From 64990394c5f224cf449e4c3657ef522f5444e48d Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 10 Mar 2026 17:09:41 +0530 Subject: [PATCH 01/11] feat(ui): add StreamMessageMetadata and StreamMessageReplies components --- .../lib/app/gallery_app.directories.g.dart | 49 ++- .../components/reaction/stream_reactions.dart | 22 +- .../lib/src/components.dart | 3 + .../message/stream_message_alignment.dart | 26 ++ .../message/stream_message_metadata.dart | 260 ++++++++++++ .../message/stream_message_replies.dart | 375 ++++++++++++++++++ .../src/factory/stream_component_factory.dart | 16 + .../stream_component_factory.g.theme.dart | 16 +- .../stream_core_flutter/lib/src/theme.dart | 2 + .../stream_message_metadata_theme.dart | 164 ++++++++ ...stream_message_metadata_theme.g.theme.dart | 162 ++++++++ .../stream_message_replies_theme.dart | 134 +++++++ .../stream_message_replies_theme.g.theme.dart | 124 ++++++ .../lib/src/theme/stream_theme.dart | 18 + .../lib/src/theme/stream_theme.g.theme.dart | 18 + .../src/theme/stream_theme_extensions.dart | 8 + 16 files changed, 1386 insertions(+), 11 deletions(-) create mode 100644 packages/stream_core_flutter/lib/src/components/message/stream_message_alignment.dart create mode 100644 packages/stream_core_flutter/lib/src/components/message/stream_message_metadata.dart create mode 100644 packages/stream_core_flutter/lib/src/components/message/stream_message_replies.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/components/stream_message_metadata_theme.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/components/stream_message_metadata_theme.g.theme.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/components/stream_message_replies_theme.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/components/stream_message_replies_theme.g.theme.dart diff --git a/apps/design_system_gallery/lib/app/gallery_app.directories.g.dart b/apps/design_system_gallery/lib/app/gallery_app.directories.g.dart index d6494aa..d05e770 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 @@ -46,6 +46,10 @@ import 'package:design_system_gallery/components/controls/stream_emoji_chip_bar. as _design_system_gallery_components_controls_stream_emoji_chip_bar; import 'package:design_system_gallery/components/emoji/stream_emoji_picker_sheet.dart' as _design_system_gallery_components_emoji_stream_emoji_picker_sheet; +import 'package:design_system_gallery/components/message/stream_message_metadata.dart' + as _design_system_gallery_components_message_stream_message_metadata; +import 'package:design_system_gallery/components/message/stream_message_replies.dart' + as _design_system_gallery_components_message_stream_message_replies; import 'package:design_system_gallery/components/message_composer/message_composer.dart' as _design_system_gallery_components_message_composer_message_composer; import 'package:design_system_gallery/components/message_composer/message_composer_attachment_link_preview.dart' @@ -55,7 +59,7 @@ import 'package:design_system_gallery/components/message_composer/message_compos 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; + as _design_system_gallery_components_reaction_stream_reactions; 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' @@ -515,6 +519,45 @@ final directories = <_widgetbook.WidgetbookNode>[ ), ], ), + _widgetbook.WidgetbookFolder( + name: 'Message', + children: [ + _widgetbook.WidgetbookComponent( + name: 'StreamMessageMetadata', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: + _design_system_gallery_components_message_stream_message_metadata + .buildStreamMessageMetadataPlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Showcase', + builder: + _design_system_gallery_components_message_stream_message_metadata + .buildStreamMessageMetadataShowcase, + ), + ], + ), + _widgetbook.WidgetbookComponent( + name: 'StreamMessageReplies', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: + _design_system_gallery_components_message_stream_message_replies + .buildStreamMessageRepliesPlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Showcase', + builder: + _design_system_gallery_components_message_stream_message_replies + .buildStreamMessageRepliesShowcase, + ), + ], + ), + ], + ), _widgetbook.WidgetbookFolder( name: 'Message Composer', children: [ @@ -579,13 +622,13 @@ final directories = <_widgetbook.WidgetbookNode>[ _widgetbook.WidgetbookUseCase( name: 'Playground', builder: - _design_system_gallery_components_reaction_stream_reaction + _design_system_gallery_components_reaction_stream_reactions .buildStreamReactionsPlayground, ), _widgetbook.WidgetbookUseCase( name: 'Showcase', builder: - _design_system_gallery_components_reaction_stream_reaction + _design_system_gallery_components_reaction_stream_reactions .buildStreamReactionsShowcase, ), ], diff --git a/apps/design_system_gallery/lib/components/reaction/stream_reactions.dart b/apps/design_system_gallery/lib/components/reaction/stream_reactions.dart index b7d906b..6340b14 100644 --- a/apps/design_system_gallery/lib/components/reaction/stream_reactions.dart +++ b/apps/design_system_gallery/lib/components/reaction/stream_reactions.dart @@ -184,6 +184,21 @@ class _ChatBubble extends StatelessWidget { ), ); + final metadata = isOutgoing + ? StreamMessageMetadataTheme( + data: StreamMessageMetadataThemeData( + statusColor: colorScheme.accentPrimary, + ), + child: StreamMessageMetadata( + timestamp: const Text('09:41'), + status: const Icon(StreamIconData.iconDoupleCheckmark1Small), + ), + ) + : StreamMessageMetadata( + timestamp: const Text('09:40'), + username: const Text('Alice'), + ); + return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: isOutgoing ? CrossAxisAlignment.end : CrossAxisAlignment.start, @@ -192,12 +207,7 @@ class _ChatBubble extends StatelessWidget { 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, - ), - ), + child: metadata, ), ], ); diff --git a/packages/stream_core_flutter/lib/src/components.dart b/packages/stream_core_flutter/lib/src/components.dart index 07ad15c..697a48d 100644 --- a/packages/stream_core_flutter/lib/src/components.dart +++ b/packages/stream_core_flutter/lib/src/components.dart @@ -22,6 +22,9 @@ export 'components/emoji/data/stream_emoji_data.dart'; 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/stream_message_alignment.dart'; +export 'components/message/stream_message_metadata.dart' hide DefaultStreamMessageMetadata; +export 'components/message/stream_message_replies.dart' hide DefaultStreamMessageReplies; export 'components/message_composer.dart'; export 'components/reaction/stream_reactions.dart' hide DefaultStreamReactions; diff --git a/packages/stream_core_flutter/lib/src/components/message/stream_message_alignment.dart b/packages/stream_core_flutter/lib/src/components/message/stream_message_alignment.dart new file mode 100644 index 0000000..b456f7c --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/message/stream_message_alignment.dart @@ -0,0 +1,26 @@ +/// Controls the semantic order of elements in message-related row components +/// based on which side the message bubble is aligned to. +/// +/// This is orthogonal to [TextDirection] (LTR/RTL). The alignment determines +/// the children order, while the ambient [TextDirection] determines the visual +/// rendering direction. They compose naturally: +/// +/// | Alignment | TextDirection | Visual result | +/// |-----------|--------------|---------------------------------------| +/// | start | LTR | start-ordered children, left-to-right | +/// | start | RTL | start-ordered children, right-to-left | +/// | end | LTR | end-ordered children, left-to-right | +/// | end | RTL | end-ordered children, right-to-left | +/// +/// The caller decides which alignment to use based on their app's message +/// layout configuration (all-start, all-end, or directional per sender). +/// +/// Each widget that accepts this enum defines its own element order for +/// [start] and [end]. See the individual widget documentation for specifics. +enum StreamMessageAlignment { + /// Elements ordered toward the start of the message bubble. + start, + + /// Elements ordered toward the end of the message bubble. + end, +} diff --git a/packages/stream_core_flutter/lib/src/components/message/stream_message_metadata.dart b/packages/stream_core_flutter/lib/src/components/message/stream_message_metadata.dart new file mode 100644 index 0000000..18c9007 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/message/stream_message_metadata.dart @@ -0,0 +1,260 @@ +import 'package:flutter/material.dart'; + +import '../../factory/stream_component_factory.dart'; +import '../../theme.dart'; +import '../../theme/components/stream_message_metadata_theme.dart'; +import '../../theme/semantics/stream_color_scheme.dart'; +import '../../theme/semantics/stream_text_theme.dart'; +import '../../theme/stream_theme_extensions.dart'; + +/// The bottom metadata row of a chat message bubble. +/// +/// Displays a [timestamp], and optional [status] icon, [username], and +/// [edited] indicator in a horizontal row with themed styling. +/// +/// All content is provided by the caller via widget slots. The provided +/// widgets are automatically styled according to +/// [StreamMessageMetadataThemeData]. +/// +/// {@tool snippet} +/// +/// Incoming message (no delivery status): +/// +/// ```dart +/// StreamMessageMetadata( +/// timestamp: Text('09:41'), +/// username: Text('Alice'), +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Outgoing message with delivery status and edited indicator: +/// +/// ```dart +/// StreamMessageMetadata( +/// timestamp: Text('09:41'), +/// status: Icon(StreamIcons.doupleCheckmark1Small), +/// edited: Text('Edited'), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageMetadataThemeData], for customizing metadata appearance. +/// * [StreamMessageMetadataTheme], for overriding theme in a widget subtree. +class StreamMessageMetadata extends StatelessWidget { + /// Creates a message metadata row. + /// + /// The [timestamp] is required; all other slots are optional and omitted + /// from the row when null. + StreamMessageMetadata({ + super.key, + required Widget timestamp, + Widget? status, + Widget? username, + Widget? edited, + double? spacing, + double? minHeight, + }) : props = .new( + timestamp: timestamp, + status: status, + username: username, + edited: edited, + spacing: spacing, + minHeight: minHeight, + ); + + /// The properties that configure this metadata row. + final StreamMessageMetadataProps props; + + @override + Widget build(BuildContext context) { + final builder = StreamComponentFactory.of(context).messageMetadata; + if (builder != null) return builder(context, props); + return DefaultStreamMessageMetadata(props: props); + } +} + +/// Properties for configuring a [StreamMessageMetadata]. +/// +/// See also: +/// +/// * [StreamMessageMetadata], which uses these properties. +class StreamMessageMetadataProps { + /// Creates properties for a message metadata row. + const StreamMessageMetadataProps({ + required this.timestamp, + this.status, + this.username, + this.edited, + this.spacing, + this.minHeight, + }); + + /// The timestamp widget, typically a [Text] displaying the message time. + /// + /// Styled by [StreamMessageMetadataThemeData.timestampTextStyle] and + /// [StreamMessageMetadataThemeData.timestampColor]. + final Widget timestamp; + + /// An optional status icon widget indicating delivery state. + /// + /// Typically an [Icon] such as a clock (sending), single checkmark (sent), + /// or double checkmark (delivered/read). + /// + /// Styled by [StreamMessageMetadataThemeData.statusColor] and + /// [StreamMessageMetadataThemeData.statusIconSize]. + final Widget? status; + + /// An optional username widget displaying the sender name. + /// + /// Styled by [StreamMessageMetadataThemeData.usernameTextStyle] and + /// [StreamMessageMetadataThemeData.usernameColor]. + final Widget? username; + + /// An optional edited indicator widget. + /// + /// Styled by [StreamMessageMetadataThemeData.editedTextStyle] and + /// [StreamMessageMetadataThemeData.editedColor]. + final Widget? edited; + + /// The gap between main elements (username, timestamp group, edited). + /// + /// When null, falls back to [StreamMessageMetadataThemeData.spacing]. + final double? spacing; + + /// The minimum height of the metadata row. + /// + /// When null, falls back to [StreamMessageMetadataThemeData.minHeight]. + final double? minHeight; +} + +/// The default implementation of [StreamMessageMetadata]. +/// +/// See also: +/// +/// * [StreamMessageMetadata], the public API widget. +/// * [StreamMessageMetadataProps], which configures this widget. +class DefaultStreamMessageMetadata extends StatelessWidget { + /// Creates a default message metadata row with the given [props]. + const DefaultStreamMessageMetadata({super.key, required this.props}); + + /// The properties that configure this metadata row. + final StreamMessageMetadataProps props; + + @override + Widget build(BuildContext context) { + final theme = context.streamMessageMetadataTheme; + final defaults = _StreamMessageMetadataThemeDefaults(context); + + final effectiveUsernameTextStyle = theme.usernameTextStyle ?? defaults.usernameTextStyle; + final effectiveUsernameColor = theme.usernameColor ?? defaults.usernameColor; + final effectiveTimestampTextStyle = theme.timestampTextStyle ?? defaults.timestampTextStyle; + final effectiveTimestampColor = theme.timestampColor ?? defaults.timestampColor; + final effectiveEditedTextStyle = theme.editedTextStyle ?? defaults.editedTextStyle; + final effectiveEditedColor = theme.editedColor ?? defaults.editedColor; + final effectiveStatusColor = theme.statusColor ?? defaults.statusColor; + final effectiveStatusIconSize = theme.statusIconSize ?? defaults.statusIconSize; + final effectiveSpacing = props.spacing ?? theme.spacing ?? defaults.spacing; + final effectiveStatusSpacing = theme.statusSpacing ?? defaults.statusSpacing; + final effectiveMinHeight = props.minHeight ?? theme.minHeight ?? defaults.minHeight; + + Widget? usernameWidget; + if (props.username case final username?) { + usernameWidget = Flexible( + child: AnimatedDefaultTextStyle( + style: effectiveUsernameTextStyle.copyWith(color: effectiveUsernameColor), + duration: kThemeChangeDuration, + child: username, + ), + ); + } + + final timestampWidget = AnimatedDefaultTextStyle( + style: effectiveTimestampTextStyle.copyWith(color: effectiveTimestampColor), + duration: kThemeChangeDuration, + child: props.timestamp, + ); + + Widget? statusWidget; + if (props.status case final status?) { + statusWidget = IconTheme.merge( + data: IconThemeData( + color: effectiveStatusColor, + size: effectiveStatusIconSize, + ), + child: status, + ); + } + + final statusTimestampWidget = Row( + mainAxisSize: MainAxisSize.min, + spacing: effectiveStatusSpacing, + children: [?statusWidget, timestampWidget], + ); + + Widget? editedWidget; + if (props.edited case final edited?) { + editedWidget = AnimatedDefaultTextStyle( + style: effectiveEditedTextStyle.copyWith(color: effectiveEditedColor), + duration: kThemeChangeDuration, + child: edited, + ); + } + + return ConstrainedBox( + constraints: .new(minHeight: effectiveMinHeight), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: effectiveSpacing, + children: [?usernameWidget, statusTimestampWidget, ?editedWidget], + ), + ); + } +} + +class _StreamMessageMetadataThemeDefaults extends StreamMessageMetadataThemeData { + _StreamMessageMetadataThemeDefaults(this._context); + + final BuildContext _context; + + late final StreamColorScheme _colorScheme = _context.streamColorScheme; + late final StreamTextTheme _textTheme = _context.streamTextTheme; + late final StreamSpacing _spacing = _context.streamSpacing; + + @override + TextStyle get usernameTextStyle => _textTheme.metadataEmphasis; + + @override + Color get usernameColor => _colorScheme.textSecondary; + + @override + TextStyle get timestampTextStyle => _textTheme.metadataDefault; + + @override + Color get timestampColor => _colorScheme.textTertiary; + + @override + TextStyle get editedTextStyle => _textTheme.metadataDefault; + + @override + Color get editedColor => _colorScheme.textTertiary; + + @override + Color get statusColor => _colorScheme.textTertiary; + + @override + double get statusIconSize => 16; + + @override + double get spacing => _spacing.xs; + + @override + double get statusSpacing => _spacing.xxs; + + @override + double get minHeight => 24; +} diff --git a/packages/stream_core_flutter/lib/src/components/message/stream_message_replies.dart b/packages/stream_core_flutter/lib/src/components/message/stream_message_replies.dart new file mode 100644 index 0000000..d7d9117 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/message/stream_message_replies.dart @@ -0,0 +1,375 @@ +import 'package:flutter/material.dart'; + +import '../../theme.dart'; +import '../avatar/stream_avatar_stack.dart'; +import 'stream_message_alignment.dart'; + +/// A tappable row showing reply count, participant avatars, and an optional +/// connector for threaded messages. +/// +/// Displays an optional [label] (typically a reply count), a list of +/// participant [avatars], and an optional connector linking the row to the +/// parent message bubble. +/// +/// The visual order of elements is controlled by [alignment]: +/// * [StreamMessageAlignment.start]: `[connector, avatars, label]` +/// * [StreamMessageAlignment.end]: `[label, avatars, connector]` +/// +/// When [showConnector] is true, a connector is displayed that visually +/// links the row to the message bubble above. The connector adapts to +/// [alignment] and the ambient [TextDirection]. +/// +/// RTL support comes from the ambient [TextDirection] automatically — the +/// alignment only controls the semantic order, not the physical direction. +/// +/// {@tool snippet} +/// +/// Incoming message with replies: +/// +/// ```dart +/// StreamMessageReplies( +/// label: Text('3 replies'), +/// avatars: [ +/// StreamAvatar(placeholder: (context) => Text('A')), +/// StreamAvatar(placeholder: (context) => Text('B')), +/// ], +/// showConnector: true, +/// onTap: () => openThread(), +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Outgoing message with replies (end alignment): +/// +/// ```dart +/// StreamMessageReplies( +/// label: Text('5 replies'), +/// avatars: [ +/// StreamAvatar(placeholder: (context) => Text('A')), +/// ], +/// showConnector: true, +/// alignment: StreamMessageAlignment.end, +/// onTap: () => openThread(), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageRepliesThemeData], for customizing replies appearance. +/// * [StreamMessageRepliesTheme], for overriding theme in a widget subtree. +/// * [StreamMessageAlignment], for controlling element order. +/// * [StreamAvatarStack], which renders the avatars internally. +class StreamMessageReplies extends StatelessWidget { + /// Creates a message replies row. + /// + /// All slots are optional — when null or empty, they are omitted from the + /// row. + StreamMessageReplies({ + super.key, + Widget? label, + Iterable? avatars, + StreamAvatarStackSize avatarSize = .sm, + int maxAvatars = 3, + bool showConnector = true, + VoidCallback? onTap, + VoidCallback? onLongPress, + StreamMessageAlignment alignment = .start, + double? spacing, + EdgeInsetsGeometry? padding, + Clip clipBehavior = .none, + }) : props = .new( + label: label, + avatars: avatars, + avatarSize: avatarSize, + maxAvatars: maxAvatars, + showConnector: showConnector, + onTap: onTap, + onLongPress: onLongPress, + alignment: alignment, + spacing: spacing, + padding: padding, + clipBehavior: clipBehavior, + ); + + /// The properties that configure this replies row. + final StreamMessageRepliesProps props; + + @override + Widget build(BuildContext context) { + final builder = StreamComponentFactory.of(context).messageReplies; + if (builder != null) return builder(context, props); + return DefaultStreamMessageReplies(props: props); + } +} + +/// Properties for configuring a [StreamMessageReplies]. +/// +/// See also: +/// +/// * [StreamMessageReplies], which uses these properties. +class StreamMessageRepliesProps { + /// Creates properties for a message replies row. + const StreamMessageRepliesProps({ + this.label, + this.avatars, + this.avatarSize = .sm, + this.maxAvatars = 3, + this.showConnector = true, + this.onTap, + this.onLongPress, + this.alignment = .start, + this.spacing, + this.padding, + this.clipBehavior = .none, + }); + + /// An optional label widget, typically a [Text] showing the reply count + /// (e.g. "3 replies"). + /// + /// Styled by [StreamMessageRepliesThemeData.labelTextStyle] and + /// [StreamMessageRepliesThemeData.labelColor]. + final Widget? label; + + /// Avatar widgets for thread participants. + /// + /// Displayed as an overlapping stack. The size of each avatar is controlled + /// by [avatarSize] and the number of visible avatars by [maxAvatars]. + final Iterable? avatars; + + /// The size of each avatar in the stack. + /// + /// Defaults to [StreamAvatarStackSize.sm] (24px). + final StreamAvatarStackSize avatarSize; + + /// Maximum number of avatars to display before showing an overflow badge. + /// + /// Defaults to 3. + final int maxAvatars; + + /// Whether to show the connector linking this row to the message bubble. + /// + /// The connector appearance is controlled by + /// [StreamMessageRepliesThemeData.connectorColor] and + /// [StreamMessageRepliesThemeData.connectorStrokeWidth]. + /// + /// The connector adapts to [alignment] and [TextDirection] to always + /// point toward the message bubble. + final bool showConnector; + + /// Called when the replies row is tapped, typically to navigate to the + /// thread view. + final VoidCallback? onTap; + + /// Called when the replies row is long-pressed. + final VoidCallback? onLongPress; + + /// Controls the semantic order of elements in the row. + /// + /// See [StreamMessageAlignment] for details on how this composes with + /// [TextDirection]. + final StreamMessageAlignment alignment; + + /// The gap between elements (connector, avatars, label). + /// + /// When null, falls back to [StreamMessageRepliesThemeData.spacing]. + final double? spacing; + + /// The padding around the replies row content. + /// + /// When null, falls back to [StreamMessageRepliesThemeData.padding]. + final EdgeInsetsGeometry? padding; + + /// How to clip the widget's content. + /// + /// Useful when the connector overflows the row bounds. Set to + /// [Clip.hardEdge] or similar to constrain the visible area. + /// + /// Defaults to [Clip.none]. + final Clip clipBehavior; +} + +/// The default implementation of [StreamMessageReplies]. +/// +/// See also: +/// +/// * [StreamMessageReplies], the public API widget. +/// * [StreamMessageRepliesProps], which configures this widget. +class DefaultStreamMessageReplies extends StatelessWidget { + /// Creates a default message replies row with the given [props]. + const DefaultStreamMessageReplies({super.key, required this.props}); + + /// The properties that configure this replies row. + final StreamMessageRepliesProps props; + + @override + Widget build(BuildContext context) { + final theme = context.streamMessageRepliesTheme; + final defaults = _StreamMessageRepliesThemeDefaults(context); + + final effectiveLabelTextStyle = theme.labelTextStyle ?? defaults.labelTextStyle; + final effectiveLabelColor = theme.labelColor ?? defaults.labelColor; + final effectiveSpacing = props.spacing ?? theme.spacing ?? defaults.spacing; + final effectivePadding = props.padding ?? theme.padding ?? defaults.padding; + + Widget? labelWidget; + if (props.label case final label?) { + labelWidget = Flexible( + child: AnimatedDefaultTextStyle( + style: effectiveLabelTextStyle.copyWith(color: effectiveLabelColor), + duration: kThemeChangeDuration, + child: label, + ), + ); + } + + Widget? avatarsWidget; + if (props.avatars case final avatars? when avatars.isNotEmpty) { + avatarsWidget = StreamAvatarStack( + size: props.avatarSize, + max: props.maxAvatars, + children: avatars, + ); + } + + Widget? connectorWidget; + if (props.showConnector) { + final effectiveConnectorColor = theme.connectorColor ?? defaults.connectorColor; + final effectiveStrokeWidth = theme.connectorStrokeWidth ?? defaults.connectorStrokeWidth; + + connectorWidget = CustomPaint( + size: const Size(_kConnectorWidth, _kConnectorHeight), + painter: _ConnectorPainter( + color: effectiveConnectorColor, + strokeWidth: effectiveStrokeWidth, + alignment: props.alignment, + textDirection: Directionality.of(context), + ), + ); + } + + final children = switch (props.alignment) { + .start => [?connectorWidget, ?avatarsWidget, ?labelWidget], + .end => [?labelWidget, ?avatarsWidget, ?connectorWidget], + }; + + Widget child = Padding( + padding: effectivePadding, + child: Row(mainAxisSize: .min, spacing: effectiveSpacing, children: children), + ); + + if (props.clipBehavior != Clip.none) { + child = ClipRect(clipBehavior: props.clipBehavior, child: child); + } + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: props.onTap, + onLongPress: props.onLongPress, + child: child, + ); + } +} + +// The layout slot size for the connector (from Figma: 16x24px). +const double _kConnectorWidth = 16; +const double _kConnectorHeight = 24; + +// The y-coordinate of the path exit point in the SVG (where the curve +// ends horizontally). Used to translate the canvas so the exit aligns +// with the vertical center of the layout slot. +const double _kConnectorExitY = 36; + +class _ConnectorPainter extends CustomPainter { + _ConnectorPainter({ + required this.color, + required this.strokeWidth, + required this.alignment, + required this.textDirection, + }); + + final Color color; + final double strokeWidth; + final StreamMessageAlignment alignment; + final TextDirection textDirection; + + @override + void paint(Canvas canvas, Size size) { + final roundedStroke = strokeWidth.roundToDouble(); + final paint = Paint() + ..color = color + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round + ..style = PaintingStyle.stroke + ..strokeWidth = roundedStroke; + + // Translate so the path exit point (y=36 in the SVG) aligns with the + // vertical center of the paint area. This lets the Row's default + // CrossAxisAlignment.center naturally align the connector exit with + // the avatar center — no hardcoded offset needed. + canvas.translate(0, (size.height / 2) - _kConnectorExitY); + + // The connector curves inward toward the content. The curve direction + // depends on which physical side the connector sits on, determined by + // both alignment and text direction. Mirror the canvas when flipped. + final isFlipped = switch ((alignment, textDirection)) { + (StreamMessageAlignment.start, TextDirection.rtl) => true, + (StreamMessageAlignment.end, TextDirection.ltr) => true, + _ => false, + }; + + if (isFlipped) { + canvas.translate(size.width, 0); + canvas.scale(-1, 1); + } + + // Snap the vertical line to pixel boundaries to avoid anti-aliasing blur. + final isOddStroke = roundedStroke.toInt().isOdd; + final lineX = isOddStroke ? 0.5 : 0.0; + + // Figma SVG path (16x37): M16 36 C7.44 36 0.5 29.06 0.5 20.5 L0.5 0 + final path = Path() + ..moveTo(16, 36) + ..cubicTo(7.43959, 36, lineX, 29.0604, lineX, 20.5) + ..lineTo(lineX, 0); + + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(_ConnectorPainter oldDelegate) => + color != oldDelegate.color || + strokeWidth != oldDelegate.strokeWidth || + alignment != oldDelegate.alignment || + textDirection != oldDelegate.textDirection; +} + +class _StreamMessageRepliesThemeDefaults extends StreamMessageRepliesThemeData { + _StreamMessageRepliesThemeDefaults(this._context); + + final BuildContext _context; + + late final StreamColorScheme _colorScheme = _context.streamColorScheme; + late final StreamTextTheme _textTheme = _context.streamTextTheme; + late final StreamSpacing _spacing = _context.streamSpacing; + + @override + double get connectorStrokeWidth => 1; + + @override + Color get connectorColor => _colorScheme.borderSubtle; + + @override + TextStyle get labelTextStyle => _textTheme.captionEmphasis; + + @override + Color get labelColor => _colorScheme.textLink; + + @override + double get spacing => _spacing.xs; + + @override + EdgeInsetsGeometry get padding => .only(top: _spacing.xs, bottom: _spacing.xxs); +} 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 a0c3a68..a02b2d5 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 @@ -145,6 +145,8 @@ class StreamComponentBuilders with _$StreamComponentBuilders { StreamComponentBuilder? emojiChipBar, StreamComponentBuilder? fileTypeIcon, StreamComponentBuilder? listTile, + StreamComponentBuilder? messageMetadata, + StreamComponentBuilder? messageReplies, StreamComponentBuilder? onlineIndicator, StreamComponentBuilder? progressBar, StreamComponentBuilder? reactions, @@ -167,6 +169,8 @@ class StreamComponentBuilders with _$StreamComponentBuilders { emojiChipBar: emojiChipBar, fileTypeIcon: fileTypeIcon, listTile: listTile, + messageMetadata: messageMetadata, + messageReplies: messageReplies, onlineIndicator: onlineIndicator, progressBar: progressBar, reactions: reactions, @@ -190,6 +194,8 @@ class StreamComponentBuilders with _$StreamComponentBuilders { required this.emojiChipBar, required this.fileTypeIcon, required this.listTile, + required this.messageMetadata, + required this.messageReplies, required this.onlineIndicator, required this.progressBar, required this.reactions, @@ -286,6 +292,16 @@ class StreamComponentBuilders with _$StreamComponentBuilders { /// When null, [StreamListTile] uses [DefaultStreamListTile]. final StreamComponentBuilder? listTile; + /// Custom builder for message metadata widgets. + /// + /// When null, [StreamMessageMetadata] uses [DefaultStreamMessageMetadata]. + final StreamComponentBuilder? messageMetadata; + + /// Custom builder for message replies widgets. + /// + /// When null, [StreamMessageReplies] uses [DefaultStreamMessageReplies]. + final StreamComponentBuilder? messageReplies; + /// Custom builder for online indicator widgets. /// /// When null, [StreamOnlineIndicator] uses [DefaultStreamOnlineIndicator]. 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 2e76e62..5767ae2 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 @@ -45,6 +45,8 @@ mixin _$StreamComponentBuilders { emojiChipBar: t < 0.5 ? a.emojiChipBar : b.emojiChipBar, fileTypeIcon: t < 0.5 ? a.fileTypeIcon : b.fileTypeIcon, listTile: t < 0.5 ? a.listTile : b.listTile, + messageMetadata: t < 0.5 ? a.messageMetadata : b.messageMetadata, + messageReplies: t < 0.5 ? a.messageReplies : b.messageReplies, onlineIndicator: t < 0.5 ? a.onlineIndicator : b.onlineIndicator, progressBar: t < 0.5 ? a.progressBar : b.progressBar, reactions: t < 0.5 ? a.reactions : b.reactions, @@ -70,6 +72,8 @@ mixin _$StreamComponentBuilders { emojiChipBar, Widget Function(BuildContext, StreamFileTypeIconProps)? fileTypeIcon, Widget Function(BuildContext, StreamListTileProps)? listTile, + Widget Function(BuildContext, StreamMessageMetadataProps)? messageMetadata, + Widget Function(BuildContext, StreamMessageRepliesProps)? messageReplies, Widget Function(BuildContext, StreamOnlineIndicatorProps)? onlineIndicator, Widget Function(BuildContext, StreamProgressBarProps)? progressBar, Widget Function(BuildContext, StreamReactionsProps)? reactions, @@ -92,6 +96,8 @@ mixin _$StreamComponentBuilders { emojiChipBar: emojiChipBar ?? _this.emojiChipBar, fileTypeIcon: fileTypeIcon ?? _this.fileTypeIcon, listTile: listTile ?? _this.listTile, + messageMetadata: messageMetadata ?? _this.messageMetadata, + messageReplies: messageReplies ?? _this.messageReplies, onlineIndicator: onlineIndicator ?? _this.onlineIndicator, progressBar: progressBar ?? _this.progressBar, reactions: reactions ?? _this.reactions, @@ -125,6 +131,8 @@ mixin _$StreamComponentBuilders { emojiChipBar: other.emojiChipBar, fileTypeIcon: other.fileTypeIcon, listTile: other.listTile, + messageMetadata: other.messageMetadata, + messageReplies: other.messageReplies, onlineIndicator: other.onlineIndicator, progressBar: other.progressBar, reactions: other.reactions, @@ -159,6 +167,8 @@ mixin _$StreamComponentBuilders { _other.emojiChipBar == _this.emojiChipBar && _other.fileTypeIcon == _this.fileTypeIcon && _other.listTile == _this.listTile && + _other.messageMetadata == _this.messageMetadata && + _other.messageReplies == _this.messageReplies && _other.onlineIndicator == _this.onlineIndicator && _other.progressBar == _this.progressBar && _other.reactions == _this.reactions; @@ -168,7 +178,7 @@ mixin _$StreamComponentBuilders { int get hashCode { final _this = (this as StreamComponentBuilders); - return Object.hash( + return Object.hashAll([ runtimeType, _this.extensions, _this.avatar, @@ -185,9 +195,11 @@ mixin _$StreamComponentBuilders { _this.emojiChipBar, _this.fileTypeIcon, _this.listTile, + _this.messageMetadata, + _this.messageReplies, _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 c4e8284..e4b91a2 100644 --- a/packages/stream_core_flutter/lib/src/theme.dart +++ b/packages/stream_core_flutter/lib/src/theme.dart @@ -12,6 +12,8 @@ export 'theme/components/stream_emoji_button_theme.dart'; export 'theme/components/stream_emoji_chip_theme.dart'; export 'theme/components/stream_input_theme.dart'; export 'theme/components/stream_list_tile_theme.dart'; +export 'theme/components/stream_message_metadata_theme.dart'; +export 'theme/components/stream_message_replies_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'; diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_metadata_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_metadata_theme.dart new file mode 100644 index 0000000..7c06191 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_metadata_theme.dart @@ -0,0 +1,164 @@ +import 'package:flutter/widgets.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import '../stream_theme.dart'; + +part 'stream_message_metadata_theme.g.theme.dart'; + +/// Applies a message metadata theme to descendant [StreamMessageMetadata] +/// widgets. +/// +/// Wrap a subtree with [StreamMessageMetadataTheme] to override metadata row +/// styling. Access the merged theme using +/// [BuildContext.streamMessageMetadataTheme]. +/// +/// {@tool snippet} +/// +/// Override metadata styling for a specific section: +/// +/// ```dart +/// StreamMessageMetadataTheme( +/// data: StreamMessageMetadataThemeData( +/// usernameColor: Colors.blue, +/// spacing: 12, +/// ), +/// child: StreamMessageMetadata( +/// timestamp: Text('09:41'), +/// username: Text('Alice'), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageMetadataThemeData], which describes the metadata theme. +/// * [StreamMessageMetadata], the widget affected by this theme. +class StreamMessageMetadataTheme extends InheritedTheme { + /// Creates a message metadata theme that controls descendant metadata rows. + const StreamMessageMetadataTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The message metadata theme data for descendant widgets. + final StreamMessageMetadataThemeData data; + + /// Returns the [StreamMessageMetadataThemeData] merged from local and global + /// themes. + /// + /// Local values from the nearest [StreamMessageMetadataTheme] ancestor take + /// precedence over global values from [StreamTheme.of]. + /// + /// This allows partial overrides — for example, overriding only + /// [StreamMessageMetadataThemeData.usernameColor] while inheriting other + /// properties from the global theme. + static StreamMessageMetadataThemeData of(BuildContext context) { + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamTheme.of(context).messageMetadataTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamMessageMetadataTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamMessageMetadataTheme oldWidget) => data != oldWidget.data; +} + +/// Theme data for customizing [StreamMessageMetadata] widgets. +/// +/// Descendant widgets obtain their values from [StreamMessageMetadataTheme.of]. +/// All properties are null by default, with fallback values applied by +/// [DefaultStreamMessageMetadata]. +/// +/// {@tool snippet} +/// +/// Customize metadata appearance globally via [StreamTheme]: +/// +/// ```dart +/// StreamTheme( +/// messageMetadataTheme: StreamMessageMetadataThemeData( +/// usernameColor: Colors.blue, +/// timestampColor: Colors.grey, +/// spacing: 12, +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageMetadataTheme], for overriding the theme in a widget +/// subtree. +/// * [StreamMessageMetadata], the widget that uses this theme data. +@themeGen +@immutable +class StreamMessageMetadataThemeData with _$StreamMessageMetadataThemeData { + /// Creates a message metadata theme data with optional property overrides. + const StreamMessageMetadataThemeData({ + this.usernameTextStyle, + this.usernameColor, + this.timestampTextStyle, + this.timestampColor, + this.editedTextStyle, + this.editedColor, + this.statusColor, + this.statusIconSize, + this.spacing, + this.statusSpacing, + this.minHeight, + }); + + /// Defines the default text style for [StreamMessageMetadata.username]. + /// + /// This only controls typography. Color comes from [usernameColor]. + final TextStyle? usernameTextStyle; + + /// Defines the default color for [StreamMessageMetadata.username]. + final Color? usernameColor; + + /// Defines the default text style for [StreamMessageMetadata.timestamp]. + /// + /// This only controls typography. Color comes from [timestampColor]. + final TextStyle? timestampTextStyle; + + /// Defines the default color for [StreamMessageMetadata.timestamp]. + final Color? timestampColor; + + /// Defines the default text style for [StreamMessageMetadata.edited]. + /// + /// This only controls typography. Color comes from [editedColor]. + final TextStyle? editedTextStyle; + + /// Defines the default color for [StreamMessageMetadata.edited]. + final Color? editedColor; + + /// Defines the default color for [StreamMessageMetadata.status]. + /// + /// Applied via [IconTheme] to the status icon slot. + final Color? statusColor; + + /// Defines the default size for the status icon. + /// + /// Applied via [IconTheme] to the status icon slot. + final double? statusIconSize; + + /// The gap between main elements (username, timestamp group, edited). + final double? spacing; + + /// The gap between the status icon and the timestamp. + final double? statusSpacing; + + /// The minimum height of the metadata row. + final double? minHeight; + + /// Linearly interpolate between two [StreamMessageMetadataThemeData] objects. + static StreamMessageMetadataThemeData? lerp( + StreamMessageMetadataThemeData? a, + StreamMessageMetadataThemeData? b, + double t, + ) => _$StreamMessageMetadataThemeData.lerp(a, b, t); +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_metadata_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_metadata_theme.g.theme.dart new file mode 100644 index 0000000..c844cd4 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_metadata_theme.g.theme.dart @@ -0,0 +1,162 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'stream_message_metadata_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamMessageMetadataThemeData { + bool get canMerge => true; + + static StreamMessageMetadataThemeData? lerp( + StreamMessageMetadataThemeData? a, + StreamMessageMetadataThemeData? 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 StreamMessageMetadataThemeData( + usernameTextStyle: TextStyle.lerp( + a.usernameTextStyle, + b.usernameTextStyle, + t, + ), + usernameColor: Color.lerp(a.usernameColor, b.usernameColor, t), + timestampTextStyle: TextStyle.lerp( + a.timestampTextStyle, + b.timestampTextStyle, + t, + ), + timestampColor: Color.lerp(a.timestampColor, b.timestampColor, t), + editedTextStyle: TextStyle.lerp(a.editedTextStyle, b.editedTextStyle, t), + editedColor: Color.lerp(a.editedColor, b.editedColor, t), + statusColor: Color.lerp(a.statusColor, b.statusColor, t), + statusIconSize: lerpDouble$(a.statusIconSize, b.statusIconSize, t), + spacing: lerpDouble$(a.spacing, b.spacing, t), + statusSpacing: lerpDouble$(a.statusSpacing, b.statusSpacing, t), + minHeight: lerpDouble$(a.minHeight, b.minHeight, t), + ); + } + + StreamMessageMetadataThemeData copyWith({ + TextStyle? usernameTextStyle, + Color? usernameColor, + TextStyle? timestampTextStyle, + Color? timestampColor, + TextStyle? editedTextStyle, + Color? editedColor, + Color? statusColor, + double? statusIconSize, + double? spacing, + double? statusSpacing, + double? minHeight, + }) { + final _this = (this as StreamMessageMetadataThemeData); + + return StreamMessageMetadataThemeData( + usernameTextStyle: usernameTextStyle ?? _this.usernameTextStyle, + usernameColor: usernameColor ?? _this.usernameColor, + timestampTextStyle: timestampTextStyle ?? _this.timestampTextStyle, + timestampColor: timestampColor ?? _this.timestampColor, + editedTextStyle: editedTextStyle ?? _this.editedTextStyle, + editedColor: editedColor ?? _this.editedColor, + statusColor: statusColor ?? _this.statusColor, + statusIconSize: statusIconSize ?? _this.statusIconSize, + spacing: spacing ?? _this.spacing, + statusSpacing: statusSpacing ?? _this.statusSpacing, + minHeight: minHeight ?? _this.minHeight, + ); + } + + StreamMessageMetadataThemeData merge(StreamMessageMetadataThemeData? other) { + final _this = (this as StreamMessageMetadataThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + usernameTextStyle: + _this.usernameTextStyle?.merge(other.usernameTextStyle) ?? + other.usernameTextStyle, + usernameColor: other.usernameColor, + timestampTextStyle: + _this.timestampTextStyle?.merge(other.timestampTextStyle) ?? + other.timestampTextStyle, + timestampColor: other.timestampColor, + editedTextStyle: + _this.editedTextStyle?.merge(other.editedTextStyle) ?? + other.editedTextStyle, + editedColor: other.editedColor, + statusColor: other.statusColor, + statusIconSize: other.statusIconSize, + spacing: other.spacing, + statusSpacing: other.statusSpacing, + minHeight: other.minHeight, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamMessageMetadataThemeData); + final _other = (other as StreamMessageMetadataThemeData); + + return _other.usernameTextStyle == _this.usernameTextStyle && + _other.usernameColor == _this.usernameColor && + _other.timestampTextStyle == _this.timestampTextStyle && + _other.timestampColor == _this.timestampColor && + _other.editedTextStyle == _this.editedTextStyle && + _other.editedColor == _this.editedColor && + _other.statusColor == _this.statusColor && + _other.statusIconSize == _this.statusIconSize && + _other.spacing == _this.spacing && + _other.statusSpacing == _this.statusSpacing && + _other.minHeight == _this.minHeight; + } + + @override + int get hashCode { + final _this = (this as StreamMessageMetadataThemeData); + + return Object.hash( + runtimeType, + _this.usernameTextStyle, + _this.usernameColor, + _this.timestampTextStyle, + _this.timestampColor, + _this.editedTextStyle, + _this.editedColor, + _this.statusColor, + _this.statusIconSize, + _this.spacing, + _this.statusSpacing, + _this.minHeight, + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_replies_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_replies_theme.dart new file mode 100644 index 0000000..35ca394 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_replies_theme.dart @@ -0,0 +1,134 @@ +import 'package:flutter/widgets.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import '../stream_theme.dart'; + +part 'stream_message_replies_theme.g.theme.dart'; + +/// Applies a message replies theme to descendant [StreamMessageReplies] +/// widgets. +/// +/// Wrap a subtree with [StreamMessageRepliesTheme] to override replies row +/// styling. Access the merged theme using +/// [BuildContext.streamMessageRepliesTheme]. +/// +/// {@tool snippet} +/// +/// Override replies styling for a specific section: +/// +/// ```dart +/// StreamMessageRepliesTheme( +/// data: StreamMessageRepliesThemeData( +/// labelColor: Colors.purple, +/// spacing: 12, +/// ), +/// child: StreamMessageReplies( +/// label: Text('3 replies'), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageRepliesThemeData], which describes the replies theme. +/// * [StreamMessageReplies], the widget affected by this theme. +class StreamMessageRepliesTheme extends InheritedTheme { + /// Creates a message replies theme that controls descendant replies rows. + const StreamMessageRepliesTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The message replies theme data for descendant widgets. + final StreamMessageRepliesThemeData data; + + /// Returns the [StreamMessageRepliesThemeData] merged from local and global + /// themes. + /// + /// Local values from the nearest [StreamMessageRepliesTheme] ancestor take + /// precedence over global values from [StreamTheme.of]. + /// + /// This allows partial overrides — for example, overriding only + /// [StreamMessageRepliesThemeData.labelColor] while inheriting other + /// properties from the global theme. + static StreamMessageRepliesThemeData of(BuildContext context) { + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamTheme.of(context).messageRepliesTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamMessageRepliesTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamMessageRepliesTheme oldWidget) => data != oldWidget.data; +} + +/// Theme data for customizing [StreamMessageReplies] widgets. +/// +/// Descendant widgets obtain their values from [StreamMessageRepliesTheme.of]. +/// All properties are null by default, with fallback values applied by +/// [DefaultStreamMessageReplies]. +/// +/// {@tool snippet} +/// +/// Customize replies appearance globally via [StreamTheme]: +/// +/// ```dart +/// StreamTheme( +/// messageRepliesTheme: StreamMessageRepliesThemeData( +/// labelColor: Colors.blue, +/// spacing: 12, +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageRepliesTheme], for overriding the theme in a widget +/// subtree. +/// * [StreamMessageReplies], the widget that uses this theme data. +@themeGen +@immutable +class StreamMessageRepliesThemeData with _$StreamMessageRepliesThemeData { + /// Creates a message replies theme data with optional property overrides. + const StreamMessageRepliesThemeData({ + this.labelTextStyle, + this.labelColor, + this.spacing, + this.padding, + this.connectorColor, + this.connectorStrokeWidth, + }); + + /// Defines the default text style for [StreamMessageReplies.label]. + /// + /// This only controls typography. Color comes from [labelColor]. + final TextStyle? labelTextStyle; + + /// Defines the default color for [StreamMessageReplies.label]. + final Color? labelColor; + + /// The gap between elements (connector, avatars, label). + final double? spacing; + + /// The padding around the replies row content. + final EdgeInsetsGeometry? padding; + + /// The color of the connector path linking the row to the message bubble. + final Color? connectorColor; + + /// The stroke width of the connector path. + final double? connectorStrokeWidth; + + /// Linearly interpolate between two [StreamMessageRepliesThemeData] objects. + static StreamMessageRepliesThemeData? lerp( + StreamMessageRepliesThemeData? a, + StreamMessageRepliesThemeData? b, + double t, + ) => _$StreamMessageRepliesThemeData.lerp(a, b, t); +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_replies_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_replies_theme.g.theme.dart new file mode 100644 index 0000000..bf4ffe9 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_replies_theme.g.theme.dart @@ -0,0 +1,124 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'stream_message_replies_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamMessageRepliesThemeData { + bool get canMerge => true; + + static StreamMessageRepliesThemeData? lerp( + StreamMessageRepliesThemeData? a, + StreamMessageRepliesThemeData? 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 StreamMessageRepliesThemeData( + labelTextStyle: TextStyle.lerp(a.labelTextStyle, b.labelTextStyle, t), + labelColor: Color.lerp(a.labelColor, b.labelColor, t), + spacing: lerpDouble$(a.spacing, b.spacing, t), + padding: EdgeInsetsGeometry.lerp(a.padding, b.padding, t), + connectorColor: Color.lerp(a.connectorColor, b.connectorColor, t), + connectorStrokeWidth: lerpDouble$( + a.connectorStrokeWidth, + b.connectorStrokeWidth, + t, + ), + ); + } + + StreamMessageRepliesThemeData copyWith({ + TextStyle? labelTextStyle, + Color? labelColor, + double? spacing, + EdgeInsetsGeometry? padding, + Color? connectorColor, + double? connectorStrokeWidth, + }) { + final _this = (this as StreamMessageRepliesThemeData); + + return StreamMessageRepliesThemeData( + labelTextStyle: labelTextStyle ?? _this.labelTextStyle, + labelColor: labelColor ?? _this.labelColor, + spacing: spacing ?? _this.spacing, + padding: padding ?? _this.padding, + connectorColor: connectorColor ?? _this.connectorColor, + connectorStrokeWidth: connectorStrokeWidth ?? _this.connectorStrokeWidth, + ); + } + + StreamMessageRepliesThemeData merge(StreamMessageRepliesThemeData? other) { + final _this = (this as StreamMessageRepliesThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + labelTextStyle: + _this.labelTextStyle?.merge(other.labelTextStyle) ?? + other.labelTextStyle, + labelColor: other.labelColor, + spacing: other.spacing, + padding: other.padding, + connectorColor: other.connectorColor, + connectorStrokeWidth: other.connectorStrokeWidth, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamMessageRepliesThemeData); + final _other = (other as StreamMessageRepliesThemeData); + + return _other.labelTextStyle == _this.labelTextStyle && + _other.labelColor == _this.labelColor && + _other.spacing == _this.spacing && + _other.padding == _this.padding && + _other.connectorColor == _this.connectorColor && + _other.connectorStrokeWidth == _this.connectorStrokeWidth; + } + + @override + int get hashCode { + final _this = (this as StreamMessageRepliesThemeData); + + return Object.hash( + runtimeType, + _this.labelTextStyle, + _this.labelColor, + _this.spacing, + _this.padding, + _this.connectorColor, + _this.connectorStrokeWidth, + ); + } +} 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 f0d35e2..f9bf797 100644 --- a/packages/stream_core_flutter/lib/src/theme/stream_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/stream_theme.dart @@ -16,6 +16,8 @@ import 'components/stream_emoji_button_theme.dart'; import 'components/stream_emoji_chip_theme.dart'; import 'components/stream_input_theme.dart'; import 'components/stream_list_tile_theme.dart'; +import 'components/stream_message_metadata_theme.dart'; +import 'components/stream_message_replies_theme.dart'; import 'components/stream_message_theme.dart'; import 'components/stream_online_indicator_theme.dart'; import 'components/stream_progress_bar_theme.dart'; @@ -105,6 +107,8 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { StreamEmojiButtonThemeData? emojiButtonTheme, StreamEmojiChipThemeData? emojiChipTheme, StreamListTileThemeData? listTileTheme, + StreamMessageMetadataThemeData? messageMetadataTheme, + StreamMessageRepliesThemeData? messageRepliesTheme, StreamMessageThemeData? messageTheme, StreamInputThemeData? inputTheme, StreamOnlineIndicatorThemeData? onlineIndicatorTheme, @@ -137,6 +141,8 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { emojiButtonTheme ??= const StreamEmojiButtonThemeData(); emojiChipTheme ??= const StreamEmojiChipThemeData(); listTileTheme ??= const StreamListTileThemeData(); + messageMetadataTheme ??= const StreamMessageMetadataThemeData(); + messageRepliesTheme ??= const StreamMessageRepliesThemeData(); messageTheme ??= const StreamMessageThemeData(); inputTheme ??= const StreamInputThemeData(); onlineIndicatorTheme ??= const StreamOnlineIndicatorThemeData(); @@ -163,6 +169,8 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { emojiButtonTheme: emojiButtonTheme, emojiChipTheme: emojiChipTheme, listTileTheme: listTileTheme, + messageMetadataTheme: messageMetadataTheme, + messageRepliesTheme: messageRepliesTheme, messageTheme: messageTheme, inputTheme: inputTheme, onlineIndicatorTheme: onlineIndicatorTheme, @@ -203,6 +211,8 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { required this.emojiButtonTheme, required this.emojiChipTheme, required this.listTileTheme, + required this.messageMetadataTheme, + required this.messageRepliesTheme, required this.messageTheme, required this.inputTheme, required this.onlineIndicatorTheme, @@ -301,6 +311,12 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { /// The list tile theme for this theme. final StreamListTileThemeData listTileTheme; + /// The message metadata theme for this theme. + final StreamMessageMetadataThemeData messageMetadataTheme; + + /// The message replies theme for this theme. + final StreamMessageRepliesThemeData messageRepliesTheme; + /// The message theme for this theme. final StreamMessageThemeData messageTheme; @@ -356,6 +372,8 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { emojiButtonTheme: emojiButtonTheme, emojiChipTheme: emojiChipTheme, listTileTheme: listTileTheme, + messageMetadataTheme: messageMetadataTheme, + messageRepliesTheme: messageRepliesTheme, messageTheme: messageTheme, inputTheme: inputTheme, onlineIndicatorTheme: onlineIndicatorTheme, 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 46d74ea..bfbdd67 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 @@ -31,6 +31,8 @@ mixin _$StreamTheme on ThemeExtension { StreamEmojiButtonThemeData? emojiButtonTheme, StreamEmojiChipThemeData? emojiChipTheme, StreamListTileThemeData? listTileTheme, + StreamMessageMetadataThemeData? messageMetadataTheme, + StreamMessageRepliesThemeData? messageRepliesTheme, StreamMessageThemeData? messageTheme, StreamInputThemeData? inputTheme, StreamOnlineIndicatorThemeData? onlineIndicatorTheme, @@ -61,6 +63,8 @@ mixin _$StreamTheme on ThemeExtension { emojiButtonTheme: emojiButtonTheme ?? _this.emojiButtonTheme, emojiChipTheme: emojiChipTheme ?? _this.emojiChipTheme, listTileTheme: listTileTheme ?? _this.listTileTheme, + messageMetadataTheme: messageMetadataTheme ?? _this.messageMetadataTheme, + messageRepliesTheme: messageRepliesTheme ?? _this.messageRepliesTheme, messageTheme: messageTheme ?? _this.messageTheme, inputTheme: inputTheme ?? _this.inputTheme, onlineIndicatorTheme: onlineIndicatorTheme ?? _this.onlineIndicatorTheme, @@ -145,6 +149,16 @@ mixin _$StreamTheme on ThemeExtension { other.listTileTheme, t, )!, + messageMetadataTheme: StreamMessageMetadataThemeData.lerp( + _this.messageMetadataTheme, + other.messageMetadataTheme, + t, + )!, + messageRepliesTheme: StreamMessageRepliesThemeData.lerp( + _this.messageRepliesTheme, + other.messageRepliesTheme, + t, + )!, messageTheme: t < 0.5 ? _this.messageTheme : other.messageTheme, inputTheme: t < 0.5 ? _this.inputTheme : other.inputTheme, onlineIndicatorTheme: StreamOnlineIndicatorThemeData.lerp( @@ -197,6 +211,8 @@ mixin _$StreamTheme on ThemeExtension { _other.emojiButtonTheme == _this.emojiButtonTheme && _other.emojiChipTheme == _this.emojiChipTheme && _other.listTileTheme == _this.listTileTheme && + _other.messageMetadataTheme == _this.messageMetadataTheme && + _other.messageRepliesTheme == _this.messageRepliesTheme && _other.messageTheme == _this.messageTheme && _other.inputTheme == _this.inputTheme && _other.onlineIndicatorTheme == _this.onlineIndicatorTheme && @@ -229,6 +245,8 @@ mixin _$StreamTheme on ThemeExtension { _this.emojiButtonTheme, _this.emojiChipTheme, _this.listTileTheme, + _this.messageMetadataTheme, + _this.messageRepliesTheme, _this.messageTheme, _this.inputTheme, _this.onlineIndicatorTheme, diff --git a/packages/stream_core_flutter/lib/src/theme/stream_theme_extensions.dart b/packages/stream_core_flutter/lib/src/theme/stream_theme_extensions.dart index 3321b72..abc31c8 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 @@ -12,6 +12,8 @@ import 'components/stream_emoji_button_theme.dart'; import 'components/stream_emoji_chip_theme.dart'; import 'components/stream_input_theme.dart'; import 'components/stream_list_tile_theme.dart'; +import 'components/stream_message_metadata_theme.dart'; +import 'components/stream_message_replies_theme.dart'; import 'components/stream_message_theme.dart'; import 'components/stream_online_indicator_theme.dart'; import 'components/stream_progress_bar_theme.dart'; @@ -103,6 +105,12 @@ extension StreamThemeExtension on BuildContext { /// Returns the [StreamListTileThemeData] from the nearest ancestor. StreamListTileThemeData get streamListTileTheme => StreamListTileTheme.of(this); + /// Returns the [StreamMessageMetadataThemeData] from the nearest ancestor. + StreamMessageMetadataThemeData get streamMessageMetadataTheme => StreamMessageMetadataTheme.of(this); + + /// Returns the [StreamMessageRepliesThemeData] from the nearest ancestor. + StreamMessageRepliesThemeData get streamMessageRepliesTheme => StreamMessageRepliesTheme.of(this); + /// Returns the [StreamMessageThemeData] from the nearest ancestor. StreamMessageThemeData get streamMessageTheme => StreamMessageTheme.of(this); From 2000bd46296b1732495685f1c4d6c3f732e0e7a2 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 10 Mar 2026 18:11:39 +0530 Subject: [PATCH 02/11] feat(ui): add StreamMessageMetadata and StreamMessageReplies components --- .../message/stream_message_metadata.dart | 598 ++++++++++++++++ .../message/stream_message_replies.dart | 637 ++++++++++++++++++ 2 files changed, 1235 insertions(+) create mode 100644 apps/design_system_gallery/lib/components/message/stream_message_metadata.dart create mode 100644 apps/design_system_gallery/lib/components/message/stream_message_replies.dart diff --git a/apps/design_system_gallery/lib/components/message/stream_message_metadata.dart b/apps/design_system_gallery/lib/components/message/stream_message_metadata.dart new file mode 100644 index 0000000..a850afe --- /dev/null +++ b/apps/design_system_gallery/lib/components/message/stream_message_metadata.dart @@ -0,0 +1,598 @@ +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: StreamMessageMetadata, + path: '[Components]/Message', +) +Widget buildStreamMessageMetadataPlayground(BuildContext context) { + final timestamp = context.knobs.string( + label: 'Timestamp', + initialValue: '09:41', + description: 'The timestamp text displayed in the metadata row.', + ); + + final showStatus = context.knobs.boolean( + label: 'Show Status', + initialValue: true, + description: 'Whether to show a delivery status icon.', + ); + + final statusOption = context.knobs.object.dropdown<_StatusOption>( + label: 'Status', + options: _StatusOption.values, + initialOption: _StatusOption.delivered, + labelBuilder: (option) => option.label, + description: 'The delivery status to display.', + ); + + final showUsername = context.knobs.boolean( + label: 'Show Username', + initialValue: true, + description: 'Whether to show the sender username.', + ); + + final username = context.knobs.string( + label: 'Username', + initialValue: 'Alice', + description: 'The username text.', + ); + + final showEdited = context.knobs.boolean( + label: 'Show Edited', + initialValue: true, + description: 'Whether to show the edited indicator.', + ); + + final editedText = context.knobs.string( + label: 'Edited Text', + initialValue: 'Edited', + description: 'The edited indicator text.', + ); + + final spacing = context.knobs.double.slider( + label: 'Spacing', + initialValue: 8, + max: 24, + divisions: 24, + description: 'Gap between main elements. Overrides theme when set.', + ); + + final minHeight = context.knobs.double.slider( + label: 'Min Height', + initialValue: 24, + min: 16, + max: 48, + divisions: 32, + description: 'Minimum height of the metadata row.', + ); + + final accentPrimary = context.streamColorScheme.accentPrimary; + + Widget child = StreamMessageMetadata( + timestamp: Text(timestamp), + status: showStatus ? Icon(statusOption.iconData) : null, + username: showUsername ? Text(username) : null, + edited: showEdited ? Text(editedText) : null, + spacing: spacing, + minHeight: minHeight, + ); + + if (showStatus && statusOption == _StatusOption.read) { + child = StreamMessageMetadataTheme( + data: StreamMessageMetadataThemeData(statusColor: accentPrimary), + child: child, + ); + } + + return Center(child: child); +} + +// ============================================================================= +// Showcase +// ============================================================================= + +@widgetbook.UseCase( + name: 'Showcase', + type: StreamMessageMetadata, + path: '[Components]/Message', +) +Widget buildStreamMessageMetadataShowcase(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + + return DefaultTextStyle( + style: textTheme.bodyDefault.copyWith(color: colorScheme.textPrimary), + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 32, + children: [ + _SlotCombinationsSection(), + _DeliveryStatusSection(), + _RealWorldSection(), + _ThemeOverrideSection(), + ], + ), + ), + ); +} + +// ============================================================================= +// Showcase Sections +// ============================================================================= + +class _SlotCombinationsSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + return _Section( + label: 'SLOT COMBINATIONS', + description: 'Each slot can be shown or hidden independently.', + children: [ + _ExampleCard( + label: 'Timestamp only', + child: StreamMessageMetadata(timestamp: const Text('09:41')), + ), + _ExampleCard( + label: 'Timestamp + username', + child: StreamMessageMetadata( + timestamp: const Text('09:41'), + username: const Text('Alice'), + ), + ), + _ExampleCard( + label: 'Timestamp + status', + child: StreamMessageMetadata( + timestamp: const Text('09:41'), + status: const Icon(StreamIconData.iconDoupleCheckmark1Small), + ), + ), + _ExampleCard( + label: 'Timestamp + edited', + child: StreamMessageMetadata( + timestamp: const Text('09:41'), + edited: const Text('Edited'), + ), + ), + _ExampleCard( + label: 'All slots', + child: StreamMessageMetadata( + timestamp: const Text('09:41'), + status: const Icon(StreamIconData.iconDoupleCheckmark1Small), + username: const Text('Alice'), + edited: const Text('Edited'), + ), + ), + ], + ); + } +} + +class _DeliveryStatusSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final accentPrimary = context.streamColorScheme.accentPrimary; + + return _Section( + label: 'DELIVERY STATUS', + description: 'Status progresses from sending → sent → delivered → read.', + children: [ + _ExampleCard( + label: 'Sending', + subtitle: 'Clock icon while message is in transit.', + child: StreamMessageMetadata( + timestamp: const Text('09:41'), + status: const Icon(StreamIconData.iconClock), + ), + ), + _ExampleCard( + label: 'Sent', + subtitle: 'Single checkmark after server acknowledgement.', + child: StreamMessageMetadata( + timestamp: const Text('09:41'), + status: const Icon(StreamIconData.iconCheckmark1Small), + ), + ), + _ExampleCard( + label: 'Delivered', + subtitle: 'Double checkmark when received by recipient.', + child: StreamMessageMetadata( + timestamp: const Text('09:41'), + status: const Icon(StreamIconData.iconDoupleCheckmark1Small), + ), + ), + _ExampleCard( + label: 'Read', + subtitle: 'Accent-colored double checkmark when read.', + child: StreamMessageMetadataTheme( + data: StreamMessageMetadataThemeData(statusColor: accentPrimary), + child: StreamMessageMetadata( + timestamp: const Text('09:41'), + status: const Icon(StreamIconData.iconDoupleCheckmark1Small), + ), + ), + ), + ], + ); + } +} + +class _RealWorldSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final radius = context.streamRadius; + + return _Section( + label: 'REAL-WORLD EXAMPLES', + description: 'Metadata shown beneath message bubbles.', + children: [ + _ExampleCard( + label: 'Incoming message', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + _MessageBubble( + text: 'Has anyone tried the new Flutter update?', + color: colorScheme.backgroundSurface, + borderRadius: BorderRadius.only( + topLeft: radius.lg, + topRight: radius.lg, + bottomRight: radius.lg, + bottomLeft: radius.xs, + ), + ), + StreamMessageMetadata( + timestamp: const Text('09:41'), + username: const Text('Bob'), + ), + ], + ), + ), + _ExampleCard( + label: 'Incoming message (edited)', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + _MessageBubble( + text: 'I think the new APIs are much better now', + color: colorScheme.backgroundSurface, + borderRadius: BorderRadius.only( + topLeft: radius.lg, + topRight: radius.lg, + bottomRight: radius.lg, + bottomLeft: radius.xs, + ), + ), + StreamMessageMetadata( + timestamp: const Text('09:38'), + username: const Text('Charlie'), + edited: const Text('Edited'), + ), + ], + ), + ), + _ExampleCard( + label: 'Outgoing message (sending)', + child: Align( + alignment: AlignmentDirectional.centerEnd, + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + spacing: 4, + children: [ + _MessageBubble( + text: 'Let me check that real quick', + color: colorScheme.accentPrimary, + textColor: Colors.white, + borderRadius: BorderRadius.only( + topLeft: radius.lg, + topRight: radius.lg, + bottomLeft: radius.lg, + bottomRight: radius.xs, + ), + ), + StreamMessageMetadata( + timestamp: const Text('09:42'), + status: const Icon(StreamIconData.iconClock), + ), + ], + ), + ), + ), + _ExampleCard( + label: 'Outgoing message (read)', + child: Align( + alignment: AlignmentDirectional.centerEnd, + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + spacing: 4, + children: [ + _MessageBubble( + text: 'Sure, I can help with that!', + color: colorScheme.accentPrimary, + textColor: Colors.white, + borderRadius: BorderRadius.only( + topLeft: radius.lg, + topRight: radius.lg, + bottomLeft: radius.lg, + bottomRight: radius.xs, + ), + ), + StreamMessageMetadataTheme( + data: StreamMessageMetadataThemeData( + statusColor: colorScheme.accentPrimary, + ), + child: StreamMessageMetadata( + timestamp: const Text('09:40'), + status: const Icon(StreamIconData.iconDoupleCheckmark1Small), + ), + ), + ], + ), + ), + ), + _ExampleCard( + label: 'Outgoing message (read + edited)', + child: Align( + alignment: AlignmentDirectional.centerEnd, + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + spacing: 4, + children: [ + _MessageBubble( + text: 'Actually, let me rephrase that', + color: colorScheme.accentPrimary, + textColor: Colors.white, + borderRadius: BorderRadius.only( + topLeft: radius.lg, + topRight: radius.lg, + bottomLeft: radius.lg, + bottomRight: radius.xs, + ), + ), + StreamMessageMetadataTheme( + data: StreamMessageMetadataThemeData( + statusColor: colorScheme.accentPrimary, + ), + child: StreamMessageMetadata( + timestamp: const Text('09:40'), + status: const Icon(StreamIconData.iconDoupleCheckmark1Small), + edited: const Text('Edited'), + ), + ), + ], + ), + ), + ), + ], + ); + } +} + +class _ThemeOverrideSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + return _Section( + label: 'THEME OVERRIDES', + description: 'Per-instance overrides via StreamMessageMetadataTheme.', + children: [ + _ExampleCard( + label: 'Custom username color', + child: StreamMessageMetadataTheme( + data: const StreamMessageMetadataThemeData( + usernameColor: Colors.deepPurple, + ), + child: StreamMessageMetadata( + timestamp: const Text('09:41'), + username: const Text('Alice'), + ), + ), + ), + _ExampleCard( + label: 'Custom spacing', + subtitle: 'Wider gap (16) between elements.', + child: StreamMessageMetadata( + timestamp: const Text('09:41'), + username: const Text('Alice'), + status: const Icon(StreamIconData.iconCheckmark1Small), + edited: const Text('Edited'), + spacing: 16, + ), + ), + _ExampleCard( + label: 'Compact', + subtitle: 'Tighter spacing (4) and smaller min height (20).', + child: StreamMessageMetadata( + timestamp: const Text('09:41'), + username: const Text('Alice'), + spacing: 4, + minHeight: 20, + ), + ), + ], + ); + } +} + +// ============================================================================= +// Helper Widgets +// ============================================================================= + +class _Section extends StatelessWidget { + const _Section({ + required this.label, + required this.children, + this.description, + }); + + final String label; + final String? description; + final List children; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 16, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + _SectionLabel(label: label), + if (description case final desc?) + Text( + desc, + style: TextStyle( + fontSize: 13, + color: colorScheme.textTertiary, + ), + ), + ], + ), + ...children, + ], + ); + } +} + +class _SectionLabel extends StatelessWidget { + const _SectionLabel({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + return Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 1.2, + color: colorScheme.accentPrimary, + ), + ); + } +} + +class _ExampleCard extends StatelessWidget { + const _ExampleCard({ + required this.label, + required this.child, + this.subtitle, + }); + + final String label; + final String? subtitle; + final Widget child; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final radius = context.streamRadius; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.backgroundApp, + borderRadius: BorderRadius.all(radius.md), + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.all(radius.md), + border: Border.all(color: colorScheme.borderSubtle), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 2, + children: [ + Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: colorScheme.textSecondary, + ), + ), + if (subtitle case final sub?) + Text( + sub, + style: TextStyle( + fontSize: 12, + color: colorScheme.textTertiary, + ), + ), + ], + ), + child, + ], + ), + ); + } +} + +class _MessageBubble extends StatelessWidget { + const _MessageBubble({ + required this.text, + required this.color, + required this.borderRadius, + this.textColor, + }); + + final String text; + final Color color; + final Color? textColor; + final BorderRadius borderRadius; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: color, + borderRadius: borderRadius, + ), + child: Text( + text, + style: TextStyle( + fontSize: 15, + color: textColor ?? colorScheme.textPrimary, + ), + ), + ); + } +} + +// ============================================================================= +// Status Icon Options (for Playground knobs) +// ============================================================================= + +enum _StatusOption { + sending('Sending', StreamIconData.iconClock), + sent('Sent', StreamIconData.iconCheckmark1Small), + delivered('Delivered', StreamIconData.iconDoupleCheckmark1Small), + read('Read', StreamIconData.iconDoupleCheckmark1Small); + + const _StatusOption(this.label, this.iconData); + + final String label; + final IconData iconData; +} diff --git a/apps/design_system_gallery/lib/components/message/stream_message_replies.dart b/apps/design_system_gallery/lib/components/message/stream_message_replies.dart new file mode 100644 index 0000000..a618dbd --- /dev/null +++ b/apps/design_system_gallery/lib/components/message/stream_message_replies.dart @@ -0,0 +1,637 @@ +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: StreamMessageReplies, + path: '[Components]/Message', +) +Widget buildStreamMessageRepliesPlayground(BuildContext context) { + final showLabel = context.knobs.boolean( + label: 'Show Label', + initialValue: true, + description: 'Whether to show the reply count label.', + ); + + final labelText = context.knobs.string( + label: 'Label Text', + initialValue: '3 replies', + description: 'The reply count text.', + ); + + final avatarCount = context.knobs.double.slider( + label: 'Avatar Count', + initialValue: 3, + max: 6, + divisions: 6, + description: 'Number of participant avatars.', + ); + + final maxAvatars = context.knobs.double.slider( + label: 'Max Avatars', + initialValue: 3, + min: 2, + max: 5, + divisions: 3, + description: 'Max visible avatars before +N overflow badge.', + ); + + final avatarSize = context.knobs.object.dropdown( + label: 'Avatar Size', + options: StreamAvatarStackSize.values, + initialOption: StreamAvatarStackSize.sm, + labelBuilder: (v) => v.name, + description: 'Size of each avatar in the stack.', + ); + + final showConnector = context.knobs.boolean( + label: 'Show Connector', + initialValue: true, + description: 'Whether to show the connector widget.', + ); + + final alignment = context.knobs.object.dropdown( + label: 'Alignment', + options: StreamMessageAlignment.values, + initialOption: StreamMessageAlignment.start, + labelBuilder: (v) => v.name, + description: 'Semantic element order in the row.', + ); + + final spacing = context.knobs.double.slider( + label: 'Spacing', + initialValue: 8, + max: 24, + divisions: 24, + description: 'Gap between elements. Overrides theme when set.', + ); + + final verticalPadding = context.knobs.double.slider( + label: 'Vertical Padding', + initialValue: 4, + max: 24, + divisions: 24, + description: 'Vertical padding around the row content.', + ); + + final clipBehavior = context.knobs.object.dropdown( + label: 'Clip Behavior', + options: Clip.values, + initialOption: Clip.none, + labelBuilder: (v) => v.name, + description: 'How to clip overflow (e.g. connector).', + ); + + final palette = context.streamColorScheme.avatarPalette; + + return Center( + child: StreamMessageReplies( + label: showLabel ? Text(labelText) : null, + avatars: _sampleAvatars(avatarCount.toInt(), palette), + avatarSize: avatarSize, + maxAvatars: maxAvatars.toInt(), + showConnector: showConnector, + alignment: alignment, + spacing: spacing, + padding: EdgeInsets.symmetric(vertical: verticalPadding), + clipBehavior: clipBehavior, + onTap: () { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + const SnackBar( + content: Text('Tapped'), + duration: Duration(seconds: 1), + ), + ); + }, + ), + ); +} + +// ============================================================================= +// Showcase +// ============================================================================= + +@widgetbook.UseCase( + name: 'Showcase', + type: StreamMessageReplies, + path: '[Components]/Message', +) +Widget buildStreamMessageRepliesShowcase(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + + return DefaultTextStyle( + style: textTheme.bodyDefault.copyWith(color: colorScheme.textPrimary), + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 32, + children: [ + _SlotCombinationsSection(), + _AlignmentSection(), + _ConnectorOverflowSection(), + _RealWorldSection(), + _ThemeOverrideSection(), + ], + ), + ), + ); +} + +// ============================================================================= +// Showcase Sections +// ============================================================================= + +class _SlotCombinationsSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final palette = context.streamColorScheme.avatarPalette; + + return _Section( + label: 'SLOT COMBINATIONS', + description: 'Each slot can be shown or hidden independently.', + children: [ + _ExampleCard( + label: 'Label only', + child: StreamMessageReplies(label: const Text('3 replies')), + ), + _ExampleCard( + label: 'Avatars only', + child: StreamMessageReplies(avatars: _sampleAvatars(2, palette)), + ), + _ExampleCard( + label: 'Label + avatars', + child: StreamMessageReplies( + label: const Text('3 replies'), + avatars: _sampleAvatars(3, palette), + ), + ), + _ExampleCard( + label: 'With connector', + childPadding: _kConnectorOverflowPadding, + child: StreamMessageReplies( + label: const Text('5 replies'), + avatars: _sampleAvatars(2, palette), + showConnector: true, + ), + ), + _ExampleCard( + label: 'All slots with overflow', + subtitle: '5 avatars, max 2 — shows +3 badge.', + childPadding: _kConnectorOverflowPadding, + child: StreamMessageReplies( + label: const Text('8 replies'), + avatars: _sampleAvatars(5, palette), + maxAvatars: 2, + showConnector: true, + ), + ), + ], + ); + } +} + +class _AlignmentSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final palette = context.streamColorScheme.avatarPalette; + + return _Section( + label: 'ALIGNMENT', + description: + 'Controls element order. Start = [connector, avatars, label]; End = [label, avatars, connector].', + children: [ + _ExampleCard( + label: 'Start alignment (default)', + subtitle: 'Connector → avatars → label.', + childPadding: _kConnectorOverflowPadding, + child: StreamMessageReplies( + label: const Text('3 replies'), + avatars: _sampleAvatars(2, palette), + showConnector: true, + ), + ), + _ExampleCard( + label: 'End alignment', + subtitle: 'Label → avatars → connector.', + childPadding: _kConnectorOverflowPadding, + child: StreamMessageReplies( + label: const Text('3 replies'), + avatars: _sampleAvatars(2, palette), + showConnector: true, + alignment: StreamMessageAlignment.end, + ), + ), + ], + ); + } +} + +class _ConnectorOverflowSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final palette = context.streamColorScheme.avatarPalette; + final colorScheme = context.streamColorScheme; + + return _Section( + label: 'CONNECTOR OVERFLOW', + description: + 'The connector extends above the row bounds. clipBehavior controls whether it is clipped.', + children: [ + _ExampleCard( + label: 'Clip.none (default)', + subtitle: 'Connector paints outside the component bounds.', + childPadding: _kConnectorOverflowPadding, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: colorScheme.borderSubtle, width: 0.5), + ), + child: StreamMessageReplies( + label: const Text('3 replies'), + avatars: _sampleAvatars(2, palette), + showConnector: true, + onTap: () {}, + ), + ), + ), + _ExampleCard( + label: 'Clip.hardEdge', + subtitle: 'Connector overflow is clipped to the row bounds.', + childPadding: _kConnectorOverflowPadding, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: colorScheme.borderSubtle, width: 0.5), + ), + child: StreamMessageReplies( + label: const Text('3 replies'), + avatars: _sampleAvatars(2, palette), + showConnector: true, + clipBehavior: Clip.hardEdge, + onTap: () {}, + ), + ), + ), + ], + ); + } +} + +class _RealWorldSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final palette = context.streamColorScheme.avatarPalette; + final colorScheme = context.streamColorScheme; + final radius = context.streamRadius; + + return _Section( + label: 'REAL-WORLD EXAMPLES', + description: 'Message bubbles with threaded replies beneath.', + children: [ + _ExampleCard( + label: 'Incoming message with replies', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _MessageBubble( + text: 'Has anyone tried the new Flutter update?', + color: colorScheme.backgroundSurface, + borderRadius: BorderRadius.only( + topLeft: radius.lg, + topRight: radius.lg, + bottomRight: radius.lg, + bottomLeft: radius.xs, + ), + ), + StreamMessageRepliesTheme( + data: StreamMessageRepliesThemeData( + connectorColor: colorScheme.backgroundSurface, + ), + child: StreamMessageReplies( + label: const Text('3 replies'), + avatars: _sampleAvatars(2, palette), + showConnector: true, + onTap: () {}, + ), + ), + ], + ), + ), + _ExampleCard( + label: 'Outgoing message with replies', + child: Align( + alignment: AlignmentDirectional.centerEnd, + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + _MessageBubble( + text: 'Sure, I can help with that!', + color: colorScheme.accentPrimary, + textColor: Colors.white, + borderRadius: BorderRadius.only( + topLeft: radius.lg, + topRight: radius.lg, + bottomLeft: radius.lg, + bottomRight: radius.xs, + ), + ), + StreamMessageRepliesTheme( + data: StreamMessageRepliesThemeData( + connectorColor: colorScheme.accentPrimary, + ), + child: StreamMessageReplies( + label: const Text('5 replies'), + avatars: _sampleAvatars(3, palette), + showConnector: true, + alignment: StreamMessageAlignment.end, + onTap: () {}, + ), + ), + ], + ), + ), + ), + _ExampleCard( + label: 'Single reply, no connector', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _MessageBubble( + text: 'Let me check that.', + color: colorScheme.backgroundSurface, + borderRadius: BorderRadius.only( + topLeft: radius.lg, + topRight: radius.lg, + bottomRight: radius.lg, + bottomLeft: radius.xs, + ), + ), + StreamMessageReplies( + label: const Text('1 reply'), + avatars: _sampleAvatars(1, palette), + onTap: () {}, + ), + ], + ), + ), + ], + ); + } +} + +class _ThemeOverrideSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final palette = context.streamColorScheme.avatarPalette; + + return _Section( + label: 'THEME OVERRIDES', + description: 'Per-instance overrides via StreamMessageRepliesTheme.', + children: [ + _ExampleCard( + label: 'Custom label color', + child: StreamMessageRepliesTheme( + data: const StreamMessageRepliesThemeData( + labelColor: Colors.deepPurple, + ), + child: StreamMessageReplies( + label: const Text('3 replies'), + avatars: _sampleAvatars(2, palette), + ), + ), + ), + _ExampleCard( + label: 'Custom connector', + subtitle: 'Red connector with 3px stroke.', + childPadding: _kConnectorOverflowPadding, + child: StreamMessageRepliesTheme( + data: const StreamMessageRepliesThemeData( + connectorColor: Colors.red, + connectorStrokeWidth: 3, + ), + child: StreamMessageReplies( + label: const Text('3 replies'), + avatars: _sampleAvatars(2, palette), + showConnector: true, + ), + ), + ), + _ExampleCard( + label: 'Custom spacing', + subtitle: 'Wider gap (16) between elements.', + childPadding: _kConnectorOverflowPadding, + child: StreamMessageReplies( + label: const Text('3 replies'), + avatars: _sampleAvatars(2, palette), + showConnector: true, + spacing: 16, + ), + ), + _ExampleCard( + label: 'Compact', + subtitle: 'Tighter spacing (4) and no padding.', + child: StreamMessageReplies( + label: const Text('3 replies'), + avatars: _sampleAvatars(2, palette), + spacing: 4, + padding: EdgeInsets.zero, + ), + ), + ], + ); + } +} + +// ============================================================================= +// Helper Widgets & Data +// ============================================================================= + +const _kConnectorOverflowPadding = EdgeInsets.only(top: 24); + +class _MessageBubble extends StatelessWidget { + const _MessageBubble({ + required this.text, + required this.color, + required this.borderRadius, + this.textColor, + }); + + final String text; + final Color color; + final Color? textColor; + final BorderRadius borderRadius; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: color, + borderRadius: borderRadius, + ), + child: Text( + text, + style: TextStyle( + fontSize: 15, + color: textColor ?? colorScheme.textPrimary, + ), + ), + ); + } +} + +const _sampleImages = [ + 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=200', + 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=200', + 'https://images.unsplash.com/photo-1517841905240-472988babdf9?w=200', + 'https://images.unsplash.com/photo-1539571696357-5a69c17a67c6?w=200', + 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?w=200', +]; + +const _sampleInitials = ['AB', 'CD', 'EF', 'GH', 'IJ']; + +List _sampleAvatars(int count, List palette) { + return [ + for (var i = 0; i < count; i++) + StreamAvatar( + imageUrl: _sampleImages[i % _sampleImages.length], + backgroundColor: palette[i % palette.length].backgroundColor, + foregroundColor: palette[i % palette.length].foregroundColor, + placeholder: (context) => Text(_sampleInitials[i % _sampleInitials.length]), + ), + ]; +} + +class _Section extends StatelessWidget { + const _Section({ + required this.label, + required this.children, + this.description, + }); + + final String label; + final String? description; + final List children; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 16, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + _SectionLabel(label: label), + if (description case final desc?) + Text( + desc, + style: TextStyle( + fontSize: 13, + color: colorScheme.textTertiary, + ), + ), + ], + ), + ...children, + ], + ); + } +} + +class _SectionLabel extends StatelessWidget { + const _SectionLabel({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + return Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 1.2, + color: colorScheme.accentPrimary, + ), + ); + } +} + +class _ExampleCard extends StatelessWidget { + const _ExampleCard({ + required this.label, + required this.child, + this.subtitle, + this.childPadding, + }); + + final String label; + final String? subtitle; + final Widget child; + final EdgeInsetsGeometry? childPadding; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final radius = context.streamRadius; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.backgroundApp, + borderRadius: BorderRadius.all(radius.md), + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.all(radius.md), + border: Border.all(color: colorScheme.borderSubtle), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 2, + children: [ + Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: colorScheme.textSecondary, + ), + ), + if (subtitle case final sub?) + Text( + sub, + style: TextStyle( + fontSize: 12, + color: colorScheme.textTertiary, + ), + ), + ], + ), + if (childPadding case final padding?) + Padding(padding: padding, child: child) + else + child, + ], + ), + ); + } +} From f0d6cf0e7d85e02d611b5b974fef0a4f139e1188 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 11 Mar 2026 00:05:32 +0530 Subject: [PATCH 03/11] feat(ui): add StreamMessageBubble component and integrate into message views --- .../lib/app/gallery_app.directories.g.dart | 19 + .../message/stream_message_bubble.dart | 476 ++++++++++++++++++ .../message/stream_message_metadata.dart | 161 +++--- .../message/stream_message_replies.dart | 119 ++--- .../message_composer/message_composer.dart | 15 +- .../components/reaction/stream_reactions.dart | 11 +- .../lib/src/components.dart | 2 + .../message/stream_message_bubble.dart | 194 +++++++ .../message/stream_message_metadata.dart | 5 - .../src/factory/stream_component_factory.dart | 8 + .../stream_component_factory.g.theme.dart | 6 + .../stream_core_flutter/lib/src/theme.dart | 1 + .../stream_message_bubble_theme.dart | 151 ++++++ .../stream_message_bubble_theme.g.theme.dart | 187 +++++++ 14 files changed, 1192 insertions(+), 163 deletions(-) create mode 100644 apps/design_system_gallery/lib/components/message/stream_message_bubble.dart create mode 100644 packages/stream_core_flutter/lib/src/components/message/stream_message_bubble.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/components/stream_message_bubble_theme.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/components/stream_message_bubble_theme.g.theme.dart diff --git a/apps/design_system_gallery/lib/app/gallery_app.directories.g.dart b/apps/design_system_gallery/lib/app/gallery_app.directories.g.dart index d05e770..9312b12 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 @@ -46,6 +46,8 @@ import 'package:design_system_gallery/components/controls/stream_emoji_chip_bar. as _design_system_gallery_components_controls_stream_emoji_chip_bar; import 'package:design_system_gallery/components/emoji/stream_emoji_picker_sheet.dart' as _design_system_gallery_components_emoji_stream_emoji_picker_sheet; +import 'package:design_system_gallery/components/message/stream_message_bubble.dart' + as _design_system_gallery_components_message_stream_message_bubble; import 'package:design_system_gallery/components/message/stream_message_metadata.dart' as _design_system_gallery_components_message_stream_message_metadata; import 'package:design_system_gallery/components/message/stream_message_replies.dart' @@ -522,6 +524,23 @@ final directories = <_widgetbook.WidgetbookNode>[ _widgetbook.WidgetbookFolder( name: 'Message', children: [ + _widgetbook.WidgetbookComponent( + name: 'StreamMessageBubble', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: + _design_system_gallery_components_message_stream_message_bubble + .buildStreamMessageBubblePlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Showcase', + builder: + _design_system_gallery_components_message_stream_message_bubble + .buildStreamMessageBubbleShowcase, + ), + ], + ), _widgetbook.WidgetbookComponent( name: 'StreamMessageMetadata', useCases: [ diff --git a/apps/design_system_gallery/lib/components/message/stream_message_bubble.dart b/apps/design_system_gallery/lib/components/message/stream_message_bubble.dart new file mode 100644 index 0000000..1e790f4 --- /dev/null +++ b/apps/design_system_gallery/lib/components/message/stream_message_bubble.dart @@ -0,0 +1,476 @@ +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: StreamMessageBubble, + path: '[Components]/Message', +) +Widget buildStreamMessageBubblePlayground(BuildContext context) { + final text = context.knobs.string( + label: 'Text', + initialValue: 'Hello, world!', + description: 'The text content inside the bubble.', + ); + + final borderRadius = context.knobs.double.slider( + label: 'Border Radius', + initialValue: 24, + max: 32, + divisions: 32, + description: 'Corner radius of the bubble shape.', + ); + + final horizontalPadding = context.knobs.double.slider( + label: 'Horizontal Padding', + initialValue: 12, + max: 32, + divisions: 32, + description: 'Horizontal content padding inside the bubble.', + ); + + final verticalPadding = context.knobs.double.slider( + label: 'Vertical Padding', + initialValue: 8, + max: 32, + divisions: 32, + description: 'Vertical content padding inside the bubble.', + ); + + final showBorder = context.knobs.boolean( + label: 'Show Border', + initialValue: true, + description: 'Whether to show a border on the bubble.', + ); + + final useOutgoingColor = context.knobs.boolean( + label: 'Outgoing Style', + description: 'Use outgoing (brand) colors instead of incoming.', + ); + + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + + final backgroundColor = useOutgoingColor + ? colorScheme.brand.shade100 + : colorScheme.backgroundSurface; + + final borderColor = useOutgoingColor + ? colorScheme.brand.shade100 + : colorScheme.borderSubtle; + + final textColor = useOutgoingColor + ? colorScheme.brand.shade900 + : colorScheme.textPrimary; + + return Center( + child: StreamMessageBubble( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(borderRadius), + ), + side: showBorder + ? BorderSide(color: borderColor) + : BorderSide.none, + padding: EdgeInsets.symmetric( + horizontal: horizontalPadding, + vertical: verticalPadding, + ), + backgroundColor: backgroundColor, + child: Text( + text, + style: textTheme.bodyDefault.copyWith(color: textColor), + ), + ), + ); +} + +// ============================================================================= +// Showcase +// ============================================================================= + +@widgetbook.UseCase( + name: 'Showcase', + type: StreamMessageBubble, + path: '[Components]/Message', +) +Widget buildStreamMessageBubbleShowcase(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + + return DefaultTextStyle( + style: textTheme.bodyDefault.copyWith(color: colorScheme.textPrimary), + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 32, + children: [ + _IncomingOutgoingSection(), + _GroupPositionsSection(), + _RealWorldSection(), + _ThemeOverrideSection(), + ], + ), + ), + ); +} + +// ============================================================================= +// Showcase Sections +// ============================================================================= + +class _IncomingOutgoingSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final radius = context.streamRadius; + + return _Section( + label: 'INCOMING VS OUTGOING', + description: 'Bubbles use different background and border colors based ' + 'on message direction.', + children: [ + _ExampleCard( + label: 'Incoming message', + child: Align( + alignment: AlignmentDirectional.centerStart, + child: StreamMessageBubble( + backgroundColor: colorScheme.backgroundSurface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(radius.xxl), + ), + side: BorderSide(color: colorScheme.borderSubtle), + child: const Text('Has anyone tried the new Flutter update?'), + ), + ), + ), + _ExampleCard( + label: 'Outgoing message', + child: Align( + alignment: AlignmentDirectional.centerEnd, + child: StreamMessageBubble( + backgroundColor: colorScheme.brand.shade100, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(radius.xxl), + ), + side: BorderSide.none, + child: Text( + 'Sure, I can help with that!', + style: TextStyle(color: colorScheme.brand.shade900), + ), + ), + ), + ), + ], + ); + } +} + +class _GroupPositionsSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final radius = context.streamRadius; + + const tailRadius = Radius.circular(4); + + return _Section( + label: 'GROUP POSITIONS', + description: 'Corner radii change based on position within a ' + 'consecutive message group.', + children: [ + _ExampleCard( + label: 'Single (standalone)', + subtitle: 'All corners rounded', + child: StreamMessageBubble( + backgroundColor: colorScheme.backgroundSurface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(radius.xxl), + ), + side: BorderSide(color: colorScheme.borderSubtle), + child: const Text('A standalone message'), + ), + ), + _ExampleCard( + label: 'Grouped messages (top → middle → bottom)', + subtitle: 'Inner corners tighten on the sender side', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 2, + children: [ + StreamMessageBubble( + backgroundColor: colorScheme.backgroundSurface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: radius.xxl, + topRight: radius.xxl, + bottomLeft: tailRadius, + bottomRight: radius.xxl, + ), + ), + side: BorderSide(color: colorScheme.borderSubtle), + child: const Text('First message in group'), + ), + StreamMessageBubble( + backgroundColor: colorScheme.backgroundSurface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: tailRadius, + topRight: radius.xxl, + bottomLeft: tailRadius, + bottomRight: radius.xxl, + ), + ), + side: BorderSide(color: colorScheme.borderSubtle), + child: const Text('Middle message'), + ), + StreamMessageBubble( + backgroundColor: colorScheme.backgroundSurface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: tailRadius, + topRight: radius.xxl, + bottomLeft: radius.xxl, + bottomRight: radius.xxl, + ), + ), + side: BorderSide(color: colorScheme.borderSubtle), + child: const Text('Last message in group'), + ), + ], + ), + ), + ], + ); + } +} + +class _RealWorldSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final radius = context.streamRadius; + + return _Section( + label: 'REAL-WORLD EXAMPLES', + description: 'Bubbles composed with metadata and reactions.', + children: [ + _ExampleCard( + label: 'Incoming with metadata', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + StreamMessageBubble( + backgroundColor: colorScheme.backgroundSurface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(radius.xxl), + ), + side: BorderSide(color: colorScheme.borderSubtle), + child: const Text('Has anyone tried the new Flutter update?'), + ), + StreamMessageMetadata( + timestamp: const Text('09:41'), + username: const Text('Alice'), + ), + ], + ), + ), + _ExampleCard( + label: 'Outgoing with status', + child: Align( + alignment: AlignmentDirectional.centerEnd, + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + spacing: 4, + children: [ + StreamMessageBubble( + backgroundColor: colorScheme.brand.shade100, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(radius.xxl), + ), + side: BorderSide.none, + child: Text( + 'Sure, I can help with that!', + style: TextStyle(color: colorScheme.brand.shade900), + ), + ), + StreamMessageMetadata( + timestamp: const Text('09:42'), + status: const Icon(StreamIconData.iconDoupleCheckmark1Small), + ), + ], + ), + ), + ), + ], + ); + } +} + +class _ThemeOverrideSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + + return _Section( + label: 'STYLE OVERRIDE', + description: 'Pass a custom StreamMessageBubbleStyle to override ' + 'individual properties.', + children: [ + _ExampleCard( + label: 'Stadium shape with large padding', + child: StreamMessageBubble( + backgroundColor: colorScheme.backgroundSurface, + shape: const StadiumBorder(), + side: const BorderSide(color: Colors.deepPurple, width: 2), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + child: const Text('Custom shape!'), + ), + ), + _ExampleCard( + label: 'Beveled rectangle', + child: StreamMessageBubble( + backgroundColor: colorScheme.backgroundSurface, + shape: const BeveledRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + ), + side: const BorderSide(color: Colors.teal), + padding: const EdgeInsets.all(16), + child: const Text('Beveled corners'), + ), + ), + ], + ); + } +} + +// ============================================================================= +// Helper Widgets +// ============================================================================= + +class _Section extends StatelessWidget { + const _Section({ + required this.label, + required this.children, + this.description, + }); + + final String label; + final String? description; + final List children; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 16, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + _SectionLabel(label: label), + if (description case final desc?) + Text( + desc, + style: TextStyle( + fontSize: 13, + color: colorScheme.textTertiary, + ), + ), + ], + ), + ...children, + ], + ); + } +} + +class _SectionLabel extends StatelessWidget { + const _SectionLabel({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + return Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 1.2, + color: colorScheme.accentPrimary, + ), + ); + } +} + +class _ExampleCard extends StatelessWidget { + const _ExampleCard({ + required this.label, + required this.child, + this.subtitle, + }); + + final String label; + final String? subtitle; + final Widget child; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final radius = context.streamRadius; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.backgroundApp, + borderRadius: BorderRadius.all(radius.md), + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.all(radius.md), + border: Border.all(color: colorScheme.borderSubtle), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 2, + children: [ + Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: colorScheme.textSecondary, + ), + ), + if (subtitle case final sub?) + Text( + sub, + style: TextStyle( + fontSize: 12, + color: colorScheme.textTertiary, + ), + ), + ], + ), + child, + ], + ), + ); + } +} diff --git a/apps/design_system_gallery/lib/components/message/stream_message_metadata.dart b/apps/design_system_gallery/lib/components/message/stream_message_metadata.dart index a850afe..c4ef6d1 100644 --- a/apps/design_system_gallery/lib/components/message/stream_message_metadata.dart +++ b/apps/design_system_gallery/lib/components/message/stream_message_metadata.dart @@ -241,14 +241,23 @@ class _RealWorldSection extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, spacing: 4, children: [ - _MessageBubble( - text: 'Has anyone tried the new Flutter update?', - color: colorScheme.backgroundSurface, - borderRadius: BorderRadius.only( - topLeft: radius.lg, - topRight: radius.lg, - bottomRight: radius.lg, - bottomLeft: radius.xs, + StreamMessageBubble( + backgroundColor: colorScheme.backgroundSurface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: radius.lg, + topRight: radius.lg, + bottomRight: radius.lg, + bottomLeft: radius.xs, + ), + ), + side: BorderSide(color: colorScheme.borderSubtle), + child: Text( + 'Has anyone tried the new Flutter update?', + style: TextStyle( + fontSize: 15, + color: colorScheme.textPrimary, + ), ), ), StreamMessageMetadata( @@ -264,14 +273,23 @@ class _RealWorldSection extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, spacing: 4, children: [ - _MessageBubble( - text: 'I think the new APIs are much better now', - color: colorScheme.backgroundSurface, - borderRadius: BorderRadius.only( - topLeft: radius.lg, - topRight: radius.lg, - bottomRight: radius.lg, - bottomLeft: radius.xs, + StreamMessageBubble( + backgroundColor: colorScheme.backgroundSurface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: radius.lg, + topRight: radius.lg, + bottomRight: radius.lg, + bottomLeft: radius.xs, + ), + ), + side: BorderSide(color: colorScheme.borderSubtle), + child: Text( + 'I think the new APIs are much better now', + style: TextStyle( + fontSize: 15, + color: colorScheme.textPrimary, + ), ), ), StreamMessageMetadata( @@ -290,15 +308,23 @@ class _RealWorldSection extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.end, spacing: 4, children: [ - _MessageBubble( - text: 'Let me check that real quick', - color: colorScheme.accentPrimary, - textColor: Colors.white, - borderRadius: BorderRadius.only( - topLeft: radius.lg, - topRight: radius.lg, - bottomLeft: radius.lg, - bottomRight: radius.xs, + StreamMessageBubble( + backgroundColor: colorScheme.accentPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: radius.lg, + topRight: radius.lg, + bottomLeft: radius.lg, + bottomRight: radius.xs, + ), + ), + side: BorderSide.none, + child: const Text( + 'Let me check that real quick', + style: TextStyle( + fontSize: 15, + color: Colors.white, + ), ), ), StreamMessageMetadata( @@ -317,15 +343,23 @@ class _RealWorldSection extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.end, spacing: 4, children: [ - _MessageBubble( - text: 'Sure, I can help with that!', - color: colorScheme.accentPrimary, - textColor: Colors.white, - borderRadius: BorderRadius.only( - topLeft: radius.lg, - topRight: radius.lg, - bottomLeft: radius.lg, - bottomRight: radius.xs, + StreamMessageBubble( + backgroundColor: colorScheme.accentPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: radius.lg, + topRight: radius.lg, + bottomLeft: radius.lg, + bottomRight: radius.xs, + ), + ), + side: BorderSide.none, + child: const Text( + 'Sure, I can help with that!', + style: TextStyle( + fontSize: 15, + color: Colors.white, + ), ), ), StreamMessageMetadataTheme( @@ -349,15 +383,23 @@ class _RealWorldSection extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.end, spacing: 4, children: [ - _MessageBubble( - text: 'Actually, let me rephrase that', - color: colorScheme.accentPrimary, - textColor: Colors.white, - borderRadius: BorderRadius.only( - topLeft: radius.lg, - topRight: radius.lg, - bottomLeft: radius.lg, - bottomRight: radius.xs, + StreamMessageBubble( + backgroundColor: colorScheme.accentPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: radius.lg, + topRight: radius.lg, + bottomLeft: radius.lg, + bottomRight: radius.xs, + ), + ), + side: BorderSide.none, + child: const Text( + 'Actually, let me rephrase that', + style: TextStyle( + fontSize: 15, + color: Colors.white, + ), ), ), StreamMessageMetadataTheme( @@ -548,39 +590,6 @@ class _ExampleCard extends StatelessWidget { } } -class _MessageBubble extends StatelessWidget { - const _MessageBubble({ - required this.text, - required this.color, - required this.borderRadius, - this.textColor, - }); - - final String text; - final Color color; - final Color? textColor; - final BorderRadius borderRadius; - - @override - Widget build(BuildContext context) { - final colorScheme = context.streamColorScheme; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - decoration: BoxDecoration( - color: color, - borderRadius: borderRadius, - ), - child: Text( - text, - style: TextStyle( - fontSize: 15, - color: textColor ?? colorScheme.textPrimary, - ), - ), - ); - } -} - // ============================================================================= // Status Icon Options (for Playground knobs) // ============================================================================= diff --git a/apps/design_system_gallery/lib/components/message/stream_message_replies.dart b/apps/design_system_gallery/lib/components/message/stream_message_replies.dart index a618dbd..8881d04 100644 --- a/apps/design_system_gallery/lib/components/message/stream_message_replies.dart +++ b/apps/design_system_gallery/lib/components/message/stream_message_replies.dart @@ -181,7 +181,6 @@ class _SlotCombinationsSection extends StatelessWidget { child: StreamMessageReplies( label: const Text('5 replies'), avatars: _sampleAvatars(2, palette), - showConnector: true, ), ), _ExampleCard( @@ -192,7 +191,6 @@ class _SlotCombinationsSection extends StatelessWidget { label: const Text('8 replies'), avatars: _sampleAvatars(5, palette), maxAvatars: 2, - showConnector: true, ), ), ], @@ -217,7 +215,6 @@ class _AlignmentSection extends StatelessWidget { child: StreamMessageReplies( label: const Text('3 replies'), avatars: _sampleAvatars(2, palette), - showConnector: true, ), ), _ExampleCard( @@ -227,7 +224,6 @@ class _AlignmentSection extends StatelessWidget { child: StreamMessageReplies( label: const Text('3 replies'), avatars: _sampleAvatars(2, palette), - showConnector: true, alignment: StreamMessageAlignment.end, ), ), @@ -258,7 +254,6 @@ class _ConnectorOverflowSection extends StatelessWidget { child: StreamMessageReplies( label: const Text('3 replies'), avatars: _sampleAvatars(2, palette), - showConnector: true, onTap: () {}, ), ), @@ -274,7 +269,6 @@ class _ConnectorOverflowSection extends StatelessWidget { child: StreamMessageReplies( label: const Text('3 replies'), avatars: _sampleAvatars(2, palette), - showConnector: true, clipBehavior: Clip.hardEdge, onTap: () {}, ), @@ -301,14 +295,23 @@ class _RealWorldSection extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _MessageBubble( - text: 'Has anyone tried the new Flutter update?', - color: colorScheme.backgroundSurface, - borderRadius: BorderRadius.only( - topLeft: radius.lg, - topRight: radius.lg, - bottomRight: radius.lg, - bottomLeft: radius.xs, + StreamMessageBubble( + backgroundColor: colorScheme.backgroundSurface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: radius.lg, + topRight: radius.lg, + bottomRight: radius.lg, + bottomLeft: radius.xs, + ), + ), + side: BorderSide(color: colorScheme.borderSubtle), + child: Text( + 'Has anyone tried the new Flutter update?', + style: TextStyle( + fontSize: 15, + color: colorScheme.textPrimary, + ), ), ), StreamMessageRepliesTheme( @@ -318,7 +321,6 @@ class _RealWorldSection extends StatelessWidget { child: StreamMessageReplies( label: const Text('3 replies'), avatars: _sampleAvatars(2, palette), - showConnector: true, onTap: () {}, ), ), @@ -332,15 +334,23 @@ class _RealWorldSection extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ - _MessageBubble( - text: 'Sure, I can help with that!', - color: colorScheme.accentPrimary, - textColor: Colors.white, - borderRadius: BorderRadius.only( - topLeft: radius.lg, - topRight: radius.lg, - bottomLeft: radius.lg, - bottomRight: radius.xs, + StreamMessageBubble( + backgroundColor: colorScheme.accentPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: radius.lg, + topRight: radius.lg, + bottomLeft: radius.lg, + bottomRight: radius.xs, + ), + ), + side: BorderSide.none, + child: const Text( + 'Sure, I can help with that!', + style: TextStyle( + fontSize: 15, + color: Colors.white, + ), ), ), StreamMessageRepliesTheme( @@ -350,7 +360,6 @@ class _RealWorldSection extends StatelessWidget { child: StreamMessageReplies( label: const Text('5 replies'), avatars: _sampleAvatars(3, palette), - showConnector: true, alignment: StreamMessageAlignment.end, onTap: () {}, ), @@ -364,14 +373,23 @@ class _RealWorldSection extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _MessageBubble( - text: 'Let me check that.', - color: colorScheme.backgroundSurface, - borderRadius: BorderRadius.only( - topLeft: radius.lg, - topRight: radius.lg, - bottomRight: radius.lg, - bottomLeft: radius.xs, + StreamMessageBubble( + backgroundColor: colorScheme.backgroundSurface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: radius.lg, + topRight: radius.lg, + bottomRight: radius.lg, + bottomLeft: radius.xs, + ), + ), + side: BorderSide(color: colorScheme.borderSubtle), + child: Text( + 'Let me check that.', + style: TextStyle( + fontSize: 15, + color: colorScheme.textPrimary, + ), ), ), StreamMessageReplies( @@ -420,7 +438,6 @@ class _ThemeOverrideSection extends StatelessWidget { child: StreamMessageReplies( label: const Text('3 replies'), avatars: _sampleAvatars(2, palette), - showConnector: true, ), ), ), @@ -431,7 +448,6 @@ class _ThemeOverrideSection extends StatelessWidget { child: StreamMessageReplies( label: const Text('3 replies'), avatars: _sampleAvatars(2, palette), - showConnector: true, spacing: 16, ), ), @@ -456,39 +472,6 @@ class _ThemeOverrideSection extends StatelessWidget { const _kConnectorOverflowPadding = EdgeInsets.only(top: 24); -class _MessageBubble extends StatelessWidget { - const _MessageBubble({ - required this.text, - required this.color, - required this.borderRadius, - this.textColor, - }); - - final String text; - final Color color; - final Color? textColor; - final BorderRadius borderRadius; - - @override - Widget build(BuildContext context) { - final colorScheme = context.streamColorScheme; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - decoration: BoxDecoration( - color: color, - borderRadius: borderRadius, - ), - child: Text( - text, - style: TextStyle( - fontSize: 15, - color: textColor ?? colorScheme.textPrimary, - ), - ), - ); - } -} - const _sampleImages = [ 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=200', 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=200', diff --git a/apps/design_system_gallery/lib/components/message_composer/message_composer.dart b/apps/design_system_gallery/lib/components/message_composer/message_composer.dart index 07fed36..0ed9066 100644 --- a/apps/design_system_gallery/lib/components/message_composer/message_composer.dart +++ b/apps/design_system_gallery/lib/components/message_composer/message_composer.dart @@ -195,19 +195,18 @@ class _MessageBubble extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = StreamTheme.of(context); - final colorScheme = theme.colorScheme; - final textTheme = theme.textTheme; + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; return Align( alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: isMe ? colorScheme.accentPrimary : colorScheme.backgroundApp, + child: StreamMessageBubble( + backgroundColor: isMe ? colorScheme.accentPrimary : colorScheme.backgroundApp, + shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), - border: isMe ? null : Border.all(color: colorScheme.borderSubtle), ), + side: isMe ? BorderSide.none : BorderSide(color: colorScheme.borderSubtle), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Text( message, style: textTheme.bodyDefault.copyWith( diff --git a/apps/design_system_gallery/lib/components/reaction/stream_reactions.dart b/apps/design_system_gallery/lib/components/reaction/stream_reactions.dart index 6340b14..da62c64 100644 --- a/apps/design_system_gallery/lib/components/reaction/stream_reactions.dart +++ b/apps/design_system_gallery/lib/components/reaction/stream_reactions.dart @@ -168,16 +168,15 @@ class _ChatBubble extends StatelessWidget { bottomRight: bubbleRadius, ); - final bubble = Container( - constraints: const BoxConstraints(maxWidth: 280), + final bubble = StreamMessageBubble( + backgroundColor: bubbleColor, + shape: RoundedRectangleBorder(borderRadius: bubbleBorderRadius), + side: BorderSide.none, padding: EdgeInsets.symmetric( horizontal: spacing.sm, vertical: spacing.xs, ), - decoration: BoxDecoration( - color: bubbleColor, - borderRadius: bubbleBorderRadius, - ), + constraints: const BoxConstraints(maxWidth: 280), child: Text( message, style: textTheme.bodyDefault.copyWith(color: colorScheme.textPrimary), diff --git a/packages/stream_core_flutter/lib/src/components.dart b/packages/stream_core_flutter/lib/src/components.dart index 697a48d..5bce255 100644 --- a/packages/stream_core_flutter/lib/src/components.dart +++ b/packages/stream_core_flutter/lib/src/components.dart @@ -23,6 +23,8 @@ 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/stream_message_alignment.dart'; +export 'components/message/stream_message_bubble.dart' hide DefaultStreamMessageBubble; +export 'components/message/stream_message_group_position.dart'; export 'components/message/stream_message_metadata.dart' hide DefaultStreamMessageMetadata; export 'components/message/stream_message_replies.dart' hide DefaultStreamMessageReplies; export 'components/message_composer.dart'; diff --git a/packages/stream_core_flutter/lib/src/components/message/stream_message_bubble.dart b/packages/stream_core_flutter/lib/src/components/message/stream_message_bubble.dart new file mode 100644 index 0000000..82c9629 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/message/stream_message_bubble.dart @@ -0,0 +1,194 @@ +import 'package:flutter/widgets.dart'; + +import '../../theme.dart'; + +/// A styled container that wraps message content with a themed background, +/// shape, and padding. +/// +/// [StreamMessageBubble] is the visual shell of a chat message. Metadata, +/// reactions, and reply indicators compose around it at a higher level. +/// +/// Each visual property can be set directly on the widget. Unset properties +/// fall back to the inherited [StreamMessageBubbleThemeData], then to +/// built-in defaults. +/// +/// {@tool snippet} +/// +/// A simple text bubble: +/// +/// ```dart +/// StreamMessageBubble( +/// backgroundColor: Colors.blue.shade50, +/// child: Text('Hello, world!'), +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// A bubble with custom structural properties: +/// +/// ```dart +/// StreamMessageBubble( +/// shape: RoundedRectangleBorder( +/// borderRadius: BorderRadius.circular(24), +/// ), +/// side: BorderSide(color: Colors.grey), +/// padding: EdgeInsets.all(16), +/// backgroundColor: Colors.white, +/// child: Text('Styled bubble'), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageBubbleStyle], for the structural style properties. +/// * [StreamMessageBubbleThemeData], for theming the bubble. +/// * [StreamMessageGroupPosition], for adjusting shape based on message +/// grouping. +class StreamMessageBubble extends StatelessWidget { + /// Creates a message bubble. + /// + /// The [child] is required; all other parameters are optional and fall back + /// to theme or default values. + StreamMessageBubble({ + super.key, + required Widget child, + OutlinedBorder? shape, + BorderSide? side, + EdgeInsetsGeometry? padding, + BoxConstraints? constraints, + Color? backgroundColor, + }) : props = StreamMessageBubbleProps( + child: child, + shape: shape, + side: side, + padding: padding, + constraints: constraints, + backgroundColor: backgroundColor, + ); + + /// The properties that configure this bubble. + final StreamMessageBubbleProps props; + + @override + Widget build(BuildContext context) { + final builder = StreamComponentFactory.of(context).messageBubble; + if (builder != null) return builder(context, props); + return DefaultStreamMessageBubble(props: props); + } +} + +/// Properties for configuring a [StreamMessageBubble]. +/// +/// See also: +/// +/// * [StreamMessageBubble], which uses these properties. +class StreamMessageBubbleProps { + /// Creates properties for a message bubble. + const StreamMessageBubbleProps({ + required this.child, + this.shape, + this.side, + this.padding, + this.constraints, + this.backgroundColor, + }); + + /// The content widget displayed inside the bubble. + final Widget child; + + /// The shape of the bubble's container. + /// + /// If null, uses [StreamMessageBubbleThemeData], then the default + /// [RoundedRectangleBorder] with 20px radius. + final OutlinedBorder? shape; + + /// The border outline of the bubble. + /// + /// Combined with [shape] via [OutlinedBorder.copyWith]. If null, uses + /// [StreamMessageBubbleThemeData], then a 1px `borderSubtle` border. + final BorderSide? side; + + /// Content padding inside the bubble. + /// + /// If null, uses [StreamMessageBubbleThemeData], then a value derived + /// from [StreamSpacing]. + final EdgeInsetsGeometry? padding; + + /// Size constraints for the bubble. + /// + /// If null, defaults to `BoxConstraints(minHeight: 20)`. + final BoxConstraints? constraints; + + /// The background color of the bubble. + /// + /// If null, uses [StreamMessageBubbleThemeData], then the surface color + /// from [StreamColorScheme]. + final Color? backgroundColor; +} + +/// The default implementation of [StreamMessageBubble]. +/// +/// See also: +/// +/// * [StreamMessageBubble], the public API widget. +/// * [StreamMessageBubbleProps], which configures this widget. +class DefaultStreamMessageBubble extends StatelessWidget { + /// Creates a default message bubble with the given [props]. + const DefaultStreamMessageBubble({super.key, required this.props}); + + /// The properties that configure this bubble. + final StreamMessageBubbleProps props; + + @override + Widget build(BuildContext context) { + final defaults = _StreamMessageBubbleDefaults(context); + + final effectiveSide = props.side ?? defaults.side; + final effectiveShape = (props.shape ?? defaults.shape).copyWith(side: effectiveSide); + final effectivePadding = props.padding ?? defaults.padding; + final effectiveConstraints = props.constraints ?? defaults.constraints; + final effectiveBackgroundColor = props.backgroundColor ?? defaults.backgroundColor; + + return ConstrainedBox( + constraints: effectiveConstraints, + child: DecoratedBox( + decoration: ShapeDecoration( + shape: effectiveShape, + color: effectiveBackgroundColor, + ), + child: Padding( + padding: effectivePadding, + child: props.child, + ), + ), + ); + } +} + +class _StreamMessageBubbleDefaults extends StreamMessageBubbleStyle { + _StreamMessageBubbleDefaults(this._context); + + final BuildContext _context; + + late final StreamColorScheme _colorScheme = _context.streamColorScheme; + late final StreamRadius _radius = _context.streamRadius; + late final StreamSpacing _spacing = _context.streamSpacing; + + @override + Color get backgroundColor => _colorScheme.backgroundSurface; + + @override + OutlinedBorder get shape => RoundedRectangleBorder(borderRadius: .all(_radius.xxxl)); + + @override + BorderSide get side => BorderSide(color: _colorScheme.borderSubtle); + + @override + EdgeInsetsGeometry get padding => .symmetric(horizontal: _spacing.sm, vertical: _spacing.xs); + + @override + BoxConstraints get constraints => const BoxConstraints(minHeight: 20); +} diff --git a/packages/stream_core_flutter/lib/src/components/message/stream_message_metadata.dart b/packages/stream_core_flutter/lib/src/components/message/stream_message_metadata.dart index 18c9007..74e8db5 100644 --- a/packages/stream_core_flutter/lib/src/components/message/stream_message_metadata.dart +++ b/packages/stream_core_flutter/lib/src/components/message/stream_message_metadata.dart @@ -1,11 +1,6 @@ import 'package:flutter/material.dart'; -import '../../factory/stream_component_factory.dart'; import '../../theme.dart'; -import '../../theme/components/stream_message_metadata_theme.dart'; -import '../../theme/semantics/stream_color_scheme.dart'; -import '../../theme/semantics/stream_text_theme.dart'; -import '../../theme/stream_theme_extensions.dart'; /// The bottom metadata row of a chat message bubble. /// 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 a02b2d5..4342eb0 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 @@ -145,6 +145,7 @@ class StreamComponentBuilders with _$StreamComponentBuilders { StreamComponentBuilder? emojiChipBar, StreamComponentBuilder? fileTypeIcon, StreamComponentBuilder? listTile, + StreamComponentBuilder? messageBubble, StreamComponentBuilder? messageMetadata, StreamComponentBuilder? messageReplies, StreamComponentBuilder? onlineIndicator, @@ -169,6 +170,7 @@ class StreamComponentBuilders with _$StreamComponentBuilders { emojiChipBar: emojiChipBar, fileTypeIcon: fileTypeIcon, listTile: listTile, + messageBubble: messageBubble, messageMetadata: messageMetadata, messageReplies: messageReplies, onlineIndicator: onlineIndicator, @@ -194,6 +196,7 @@ class StreamComponentBuilders with _$StreamComponentBuilders { required this.emojiChipBar, required this.fileTypeIcon, required this.listTile, + required this.messageBubble, required this.messageMetadata, required this.messageReplies, required this.onlineIndicator, @@ -292,6 +295,11 @@ class StreamComponentBuilders with _$StreamComponentBuilders { /// When null, [StreamListTile] uses [DefaultStreamListTile]. final StreamComponentBuilder? listTile; + /// Custom builder for message bubble widgets. + /// + /// When null, [StreamMessageBubble] uses [DefaultStreamMessageBubble]. + final StreamComponentBuilder? messageBubble; + /// Custom builder for message metadata widgets. /// /// When null, [StreamMessageMetadata] uses [DefaultStreamMessageMetadata]. 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 5767ae2..dabf096 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 @@ -45,6 +45,7 @@ mixin _$StreamComponentBuilders { emojiChipBar: t < 0.5 ? a.emojiChipBar : b.emojiChipBar, fileTypeIcon: t < 0.5 ? a.fileTypeIcon : b.fileTypeIcon, listTile: t < 0.5 ? a.listTile : b.listTile, + messageBubble: t < 0.5 ? a.messageBubble : b.messageBubble, messageMetadata: t < 0.5 ? a.messageMetadata : b.messageMetadata, messageReplies: t < 0.5 ? a.messageReplies : b.messageReplies, onlineIndicator: t < 0.5 ? a.onlineIndicator : b.onlineIndicator, @@ -72,6 +73,7 @@ mixin _$StreamComponentBuilders { emojiChipBar, Widget Function(BuildContext, StreamFileTypeIconProps)? fileTypeIcon, Widget Function(BuildContext, StreamListTileProps)? listTile, + Widget Function(BuildContext, StreamMessageBubbleProps)? messageBubble, Widget Function(BuildContext, StreamMessageMetadataProps)? messageMetadata, Widget Function(BuildContext, StreamMessageRepliesProps)? messageReplies, Widget Function(BuildContext, StreamOnlineIndicatorProps)? onlineIndicator, @@ -96,6 +98,7 @@ mixin _$StreamComponentBuilders { emojiChipBar: emojiChipBar ?? _this.emojiChipBar, fileTypeIcon: fileTypeIcon ?? _this.fileTypeIcon, listTile: listTile ?? _this.listTile, + messageBubble: messageBubble ?? _this.messageBubble, messageMetadata: messageMetadata ?? _this.messageMetadata, messageReplies: messageReplies ?? _this.messageReplies, onlineIndicator: onlineIndicator ?? _this.onlineIndicator, @@ -131,6 +134,7 @@ mixin _$StreamComponentBuilders { emojiChipBar: other.emojiChipBar, fileTypeIcon: other.fileTypeIcon, listTile: other.listTile, + messageBubble: other.messageBubble, messageMetadata: other.messageMetadata, messageReplies: other.messageReplies, onlineIndicator: other.onlineIndicator, @@ -167,6 +171,7 @@ mixin _$StreamComponentBuilders { _other.emojiChipBar == _this.emojiChipBar && _other.fileTypeIcon == _this.fileTypeIcon && _other.listTile == _this.listTile && + _other.messageBubble == _this.messageBubble && _other.messageMetadata == _this.messageMetadata && _other.messageReplies == _this.messageReplies && _other.onlineIndicator == _this.onlineIndicator && @@ -195,6 +200,7 @@ mixin _$StreamComponentBuilders { _this.emojiChipBar, _this.fileTypeIcon, _this.listTile, + _this.messageBubble, _this.messageMetadata, _this.messageReplies, _this.onlineIndicator, diff --git a/packages/stream_core_flutter/lib/src/theme.dart b/packages/stream_core_flutter/lib/src/theme.dart index e4b91a2..32ca9e7 100644 --- a/packages/stream_core_flutter/lib/src/theme.dart +++ b/packages/stream_core_flutter/lib/src/theme.dart @@ -12,6 +12,7 @@ export 'theme/components/stream_emoji_button_theme.dart'; export 'theme/components/stream_emoji_chip_theme.dart'; export 'theme/components/stream_input_theme.dart'; export 'theme/components/stream_list_tile_theme.dart'; +export 'theme/components/stream_message_bubble_theme.dart'; export 'theme/components/stream_message_metadata_theme.dart'; export 'theme/components/stream_message_replies_theme.dart'; export 'theme/components/stream_message_theme.dart'; diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_bubble_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_bubble_theme.dart new file mode 100644 index 0000000..68e9c6b --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_bubble_theme.dart @@ -0,0 +1,151 @@ +import 'package:flutter/widgets.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +part 'stream_message_bubble_theme.g.theme.dart'; + +/// Structural style properties for a message bubble container. +/// +/// [StreamMessageBubbleStyle] is a reusable value object that can be applied +/// both as a theme value (via [StreamMessageBubbleThemeData]) and as a direct +/// widget prop on the future message bubble widget — similar to how +/// [ButtonStyle] works with [ElevatedButton]. +/// +/// Includes [backgroundColor] so that incoming/outgoing variants can each +/// carry their own fill color alongside the structural properties. +/// +/// {@tool snippet} +/// +/// Create a custom bubble style: +/// +/// ```dart +/// const style = StreamMessageBubbleStyle( +/// shape: RoundedRectangleBorder( +/// borderRadius: BorderRadius.all(Radius.circular(16)), +/// ), +/// side: BorderSide(color: Colors.grey), +/// padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), +/// ); +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageBubbleThemeData], which wraps this style for theming. +/// * [StreamMessageStyle], which provides the color properties for messages. +@themeGen +@immutable +class StreamMessageBubbleStyle with _$StreamMessageBubbleStyle { + /// Creates a message bubble style with optional property overrides. + const StreamMessageBubbleStyle({ + this.shape, + this.side, + this.padding, + this.constraints, + this.backgroundColor, + }); + + /// The shape of the bubble's container. + /// + /// This shape is combined with [side] to create a shape decorated with an + /// outline. Using [OutlinedBorder] (rather than raw [BorderRadius]) aligns + /// with Material 3 conventions and supports custom tail shapes in the future. + /// + /// Defaults to a [RoundedRectangleBorder] built from the design system's + /// `messageBubbleRadius*` tokens. + final OutlinedBorder? shape; + + /// The border outline of the bubble. + /// + /// This value is combined with [shape] to create a shape decorated with an + /// outline. Keeping this separate from [shape] allows changing border + /// color/width without reconstructing the entire shape. + /// + /// Defaults to a 1px [StreamColorScheme.borderSubtle] border. + final BorderSide? side; + + /// Content padding inside the bubble. + /// + /// Defaults to a value derived from [StreamSpacing]. + final EdgeInsetsGeometry? padding; + + /// Size constraints for the bubble. + /// + /// Defaults to `BoxConstraints(minHeight: 20)`. + /// + /// ```dart + /// constraints: BoxConstraints(minHeight: 20, maxWidth: 280) + /// ``` + final BoxConstraints? constraints; + + /// The background fill color of the bubble. + /// + /// Typically differs between incoming and outgoing messages. + /// + /// Defaults to [StreamColorScheme.backgroundSurface]. + final Color? backgroundColor; + + /// Linearly interpolate between two [StreamMessageBubbleStyle] instances. + static StreamMessageBubbleStyle? lerp( + StreamMessageBubbleStyle? a, + StreamMessageBubbleStyle? b, + double t, + ) => _$StreamMessageBubbleStyle.lerp(a, b, t); +} + +/// Theme data for the message bubble, holding a base [style]. +/// +/// Nested inside [StreamMessageStyle] so that `incoming` and `outgoing` +/// messages each get their own bubble theme automatically. +/// +/// Currently holds a single [style]. Position-specific overrides +/// (e.g. `topStyle`, `middleStyle`) for message grouping will be added in a +/// follow-up. +/// +/// {@tool snippet} +/// +/// Override bubble styling for outgoing messages via [StreamMessageTheme]: +/// +/// ```dart +/// StreamMessageTheme( +/// data: StreamMessageThemeData( +/// outgoing: StreamMessageStyle( +/// bubble: StreamMessageBubbleThemeData( +/// style: StreamMessageBubbleStyle( +/// shape: RoundedRectangleBorder( +/// borderRadius: BorderRadius.circular(24), +/// ), +/// side: BorderSide.none, +/// ), +/// ), +/// ), +/// ), +/// child: ..., +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageBubbleStyle], the value object holding structural props. +/// * [StreamMessageStyle], which nests this theme data. +@themeGen +@immutable +class StreamMessageBubbleThemeData with _$StreamMessageBubbleThemeData { + /// Creates a message bubble theme data with an optional base [style]. + const StreamMessageBubbleThemeData({ + this.style, + }); + + /// The base bubble style. + /// + /// When the bubble widget resolves its effective style, this serves + /// as the theme-level default that can be overridden by a direct widget prop. + final StreamMessageBubbleStyle? style; + + /// Linearly interpolate between two [StreamMessageBubbleThemeData] instances. + static StreamMessageBubbleThemeData? lerp( + StreamMessageBubbleThemeData? a, + StreamMessageBubbleThemeData? b, + double t, + ) => _$StreamMessageBubbleThemeData.lerp(a, b, t); +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_bubble_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_bubble_theme.g.theme.dart new file mode 100644 index 0000000..b471835 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_bubble_theme.g.theme.dart @@ -0,0 +1,187 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'stream_message_bubble_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamMessageBubbleStyle { + bool get canMerge => true; + + static StreamMessageBubbleStyle? lerp( + StreamMessageBubbleStyle? a, + StreamMessageBubbleStyle? 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 StreamMessageBubbleStyle( + shape: OutlinedBorder.lerp(a.shape, b.shape, t), + side: a.side == null + ? b.side + : b.side == null + ? a.side + : BorderSide.lerp(a.side!, b.side!, t), + padding: EdgeInsetsGeometry.lerp(a.padding, b.padding, t), + constraints: BoxConstraints.lerp(a.constraints, b.constraints, t), + backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), + ); + } + + StreamMessageBubbleStyle copyWith({ + OutlinedBorder? shape, + BorderSide? side, + EdgeInsetsGeometry? padding, + BoxConstraints? constraints, + Color? backgroundColor, + }) { + final _this = (this as StreamMessageBubbleStyle); + + return StreamMessageBubbleStyle( + shape: shape ?? _this.shape, + side: side ?? _this.side, + padding: padding ?? _this.padding, + constraints: constraints ?? _this.constraints, + backgroundColor: backgroundColor ?? _this.backgroundColor, + ); + } + + StreamMessageBubbleStyle merge(StreamMessageBubbleStyle? other) { + final _this = (this as StreamMessageBubbleStyle); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + shape: other.shape, + side: _this.side != null && other.side != null + ? BorderSide.merge(_this.side!, other.side!) + : other.side, + padding: other.padding, + constraints: other.constraints, + backgroundColor: other.backgroundColor, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamMessageBubbleStyle); + final _other = (other as StreamMessageBubbleStyle); + + return _other.shape == _this.shape && + _other.side == _this.side && + _other.padding == _this.padding && + _other.constraints == _this.constraints && + _other.backgroundColor == _this.backgroundColor; + } + + @override + int get hashCode { + final _this = (this as StreamMessageBubbleStyle); + + return Object.hash( + runtimeType, + _this.shape, + _this.side, + _this.padding, + _this.constraints, + _this.backgroundColor, + ); + } +} + +mixin _$StreamMessageBubbleThemeData { + bool get canMerge => true; + + static StreamMessageBubbleThemeData? lerp( + StreamMessageBubbleThemeData? a, + StreamMessageBubbleThemeData? 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 StreamMessageBubbleThemeData( + style: StreamMessageBubbleStyle.lerp(a.style, b.style, t), + ); + } + + StreamMessageBubbleThemeData copyWith({StreamMessageBubbleStyle? style}) { + final _this = (this as StreamMessageBubbleThemeData); + + return StreamMessageBubbleThemeData(style: style ?? _this.style); + } + + StreamMessageBubbleThemeData merge(StreamMessageBubbleThemeData? other) { + final _this = (this as StreamMessageBubbleThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith(style: _this.style?.merge(other.style) ?? other.style); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamMessageBubbleThemeData); + final _other = (other as StreamMessageBubbleThemeData); + + return _other.style == _this.style; + } + + @override + int get hashCode { + final _this = (this as StreamMessageBubbleThemeData); + + return Object.hash(runtimeType, _this.style); + } +} From 740618c053a91ec45f6e5c1600ea787c590dbdfa Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 11 Mar 2026 02:45:12 +0530 Subject: [PATCH 04/11] refactor(ui): update StreamMessageBubble to use BorderRadiusDirectional for improved layout consistency --- .../message/stream_message_bubble.dart | 55 +++++++++---------- .../message/stream_message_metadata.dart | 53 +++++++++--------- .../message/stream_message_replies.dart | 41 ++++++-------- .../message_composer/message_composer.dart | 2 +- .../components/reaction/stream_reactions.dart | 16 +++--- 5 files changed, 79 insertions(+), 88 deletions(-) diff --git a/apps/design_system_gallery/lib/components/message/stream_message_bubble.dart b/apps/design_system_gallery/lib/components/message/stream_message_bubble.dart index 1e790f4..e1bd205 100644 --- a/apps/design_system_gallery/lib/components/message/stream_message_bubble.dart +++ b/apps/design_system_gallery/lib/components/message/stream_message_bubble.dart @@ -57,26 +57,18 @@ Widget buildStreamMessageBubblePlayground(BuildContext context) { final colorScheme = context.streamColorScheme; final textTheme = context.streamTextTheme; - final backgroundColor = useOutgoingColor - ? colorScheme.brand.shade100 - : colorScheme.backgroundSurface; + final backgroundColor = useOutgoingColor ? colorScheme.brand.shade100 : colorScheme.backgroundSurface; - final borderColor = useOutgoingColor - ? colorScheme.brand.shade100 - : colorScheme.borderSubtle; + final borderColor = useOutgoingColor ? colorScheme.brand.shade100 : colorScheme.borderSubtle; - final textColor = useOutgoingColor - ? colorScheme.brand.shade900 - : colorScheme.textPrimary; + final textColor = useOutgoingColor ? colorScheme.brand.shade900 : colorScheme.textPrimary; return Center( child: StreamMessageBubble( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(borderRadius), ), - side: showBorder - ? BorderSide(color: borderColor) - : BorderSide.none, + side: showBorder ? BorderSide(color: borderColor) : BorderSide.none, padding: EdgeInsets.symmetric( horizontal: horizontalPadding, vertical: verticalPadding, @@ -133,7 +125,8 @@ class _IncomingOutgoingSection extends StatelessWidget { return _Section( label: 'INCOMING VS OUTGOING', - description: 'Bubbles use different background and border colors based ' + description: + 'Bubbles use different background and border colors based ' 'on message direction.', children: [ _ExampleCard( @@ -182,7 +175,8 @@ class _GroupPositionsSection extends StatelessWidget { return _Section( label: 'GROUP POSITIONS', - description: 'Corner radii change based on position within a ' + description: + 'Corner radii change based on position within a ' 'consecutive message group.', children: [ _ExampleCard( @@ -207,11 +201,11 @@ class _GroupPositionsSection extends StatelessWidget { StreamMessageBubble( backgroundColor: colorScheme.backgroundSurface, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: radius.xxl, - topRight: radius.xxl, - bottomLeft: tailRadius, - bottomRight: radius.xxl, + borderRadius: BorderRadiusDirectional.only( + topStart: radius.xxl, + topEnd: radius.xxl, + bottomStart: tailRadius, + bottomEnd: radius.xxl, ), ), side: BorderSide(color: colorScheme.borderSubtle), @@ -220,11 +214,11 @@ class _GroupPositionsSection extends StatelessWidget { StreamMessageBubble( backgroundColor: colorScheme.backgroundSurface, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: tailRadius, - topRight: radius.xxl, - bottomLeft: tailRadius, - bottomRight: radius.xxl, + borderRadius: BorderRadiusDirectional.only( + topStart: tailRadius, + topEnd: radius.xxl, + bottomStart: tailRadius, + bottomEnd: radius.xxl, ), ), side: BorderSide(color: colorScheme.borderSubtle), @@ -233,11 +227,11 @@ class _GroupPositionsSection extends StatelessWidget { StreamMessageBubble( backgroundColor: colorScheme.backgroundSurface, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: tailRadius, - topRight: radius.xxl, - bottomLeft: radius.xxl, - bottomRight: radius.xxl, + borderRadius: BorderRadiusDirectional.only( + topStart: tailRadius, + topEnd: radius.xxl, + bottomStart: radius.xxl, + bottomEnd: radius.xxl, ), ), side: BorderSide(color: colorScheme.borderSubtle), @@ -321,7 +315,8 @@ class _ThemeOverrideSection extends StatelessWidget { return _Section( label: 'STYLE OVERRIDE', - description: 'Pass a custom StreamMessageBubbleStyle to override ' + description: + 'Pass a custom StreamMessageBubbleStyle to override ' 'individual properties.', children: [ _ExampleCard( diff --git a/apps/design_system_gallery/lib/components/message/stream_message_metadata.dart b/apps/design_system_gallery/lib/components/message/stream_message_metadata.dart index c4ef6d1..caf8301 100644 --- a/apps/design_system_gallery/lib/components/message/stream_message_metadata.dart +++ b/apps/design_system_gallery/lib/components/message/stream_message_metadata.dart @@ -244,11 +244,11 @@ class _RealWorldSection extends StatelessWidget { StreamMessageBubble( backgroundColor: colorScheme.backgroundSurface, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: radius.lg, - topRight: radius.lg, - bottomRight: radius.lg, - bottomLeft: radius.xs, + borderRadius: BorderRadiusDirectional.only( + topStart: radius.lg, + topEnd: radius.lg, + bottomEnd: radius.lg, + bottomStart: radius.xs, ), ), side: BorderSide(color: colorScheme.borderSubtle), @@ -276,11 +276,11 @@ class _RealWorldSection extends StatelessWidget { StreamMessageBubble( backgroundColor: colorScheme.backgroundSurface, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: radius.lg, - topRight: radius.lg, - bottomRight: radius.lg, - bottomLeft: radius.xs, + borderRadius: BorderRadiusDirectional.only( + topStart: radius.lg, + topEnd: radius.lg, + bottomEnd: radius.lg, + bottomStart: radius.xs, ), ), side: BorderSide(color: colorScheme.borderSubtle), @@ -311,11 +311,11 @@ class _RealWorldSection extends StatelessWidget { StreamMessageBubble( backgroundColor: colorScheme.accentPrimary, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: radius.lg, - topRight: radius.lg, - bottomLeft: radius.lg, - bottomRight: radius.xs, + borderRadius: BorderRadiusDirectional.only( + topStart: radius.lg, + topEnd: radius.lg, + bottomStart: radius.lg, + bottomEnd: radius.xs, ), ), side: BorderSide.none, @@ -346,11 +346,11 @@ class _RealWorldSection extends StatelessWidget { StreamMessageBubble( backgroundColor: colorScheme.accentPrimary, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: radius.lg, - topRight: radius.lg, - bottomLeft: radius.lg, - bottomRight: radius.xs, + borderRadius: BorderRadiusDirectional.only( + topStart: radius.lg, + topEnd: radius.lg, + bottomStart: radius.lg, + bottomEnd: radius.xs, ), ), side: BorderSide.none, @@ -386,11 +386,11 @@ class _RealWorldSection extends StatelessWidget { StreamMessageBubble( backgroundColor: colorScheme.accentPrimary, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: radius.lg, - topRight: radius.lg, - bottomLeft: radius.lg, - bottomRight: radius.xs, + borderRadius: BorderRadiusDirectional.only( + topStart: radius.lg, + topEnd: radius.lg, + bottomStart: radius.lg, + bottomEnd: radius.xs, ), ), side: BorderSide.none, @@ -598,7 +598,8 @@ enum _StatusOption { sending('Sending', StreamIconData.iconClock), sent('Sent', StreamIconData.iconCheckmark1Small), delivered('Delivered', StreamIconData.iconDoupleCheckmark1Small), - read('Read', StreamIconData.iconDoupleCheckmark1Small); + read('Read', StreamIconData.iconDoupleCheckmark1Small) + ; const _StatusOption(this.label, this.iconData); diff --git a/apps/design_system_gallery/lib/components/message/stream_message_replies.dart b/apps/design_system_gallery/lib/components/message/stream_message_replies.dart index 8881d04..ada9e89 100644 --- a/apps/design_system_gallery/lib/components/message/stream_message_replies.dart +++ b/apps/design_system_gallery/lib/components/message/stream_message_replies.dart @@ -205,8 +205,7 @@ class _AlignmentSection extends StatelessWidget { return _Section( label: 'ALIGNMENT', - description: - 'Controls element order. Start = [connector, avatars, label]; End = [label, avatars, connector].', + description: 'Controls element order. Start = [connector, avatars, label]; End = [label, avatars, connector].', children: [ _ExampleCard( label: 'Start alignment (default)', @@ -240,8 +239,7 @@ class _ConnectorOverflowSection extends StatelessWidget { return _Section( label: 'CONNECTOR OVERFLOW', - description: - 'The connector extends above the row bounds. clipBehavior controls whether it is clipped.', + description: 'The connector extends above the row bounds. clipBehavior controls whether it is clipped.', children: [ _ExampleCard( label: 'Clip.none (default)', @@ -298,11 +296,11 @@ class _RealWorldSection extends StatelessWidget { StreamMessageBubble( backgroundColor: colorScheme.backgroundSurface, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: radius.lg, - topRight: radius.lg, - bottomRight: radius.lg, - bottomLeft: radius.xs, + borderRadius: BorderRadiusDirectional.only( + topStart: radius.lg, + topEnd: radius.lg, + bottomEnd: radius.lg, + bottomStart: radius.xs, ), ), side: BorderSide(color: colorScheme.borderSubtle), @@ -337,11 +335,11 @@ class _RealWorldSection extends StatelessWidget { StreamMessageBubble( backgroundColor: colorScheme.accentPrimary, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: radius.lg, - topRight: radius.lg, - bottomLeft: radius.lg, - bottomRight: radius.xs, + borderRadius: BorderRadiusDirectional.only( + topStart: radius.lg, + topEnd: radius.lg, + bottomStart: radius.lg, + bottomEnd: radius.xs, ), ), side: BorderSide.none, @@ -376,11 +374,11 @@ class _RealWorldSection extends StatelessWidget { StreamMessageBubble( backgroundColor: colorScheme.backgroundSurface, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: radius.lg, - topRight: radius.lg, - bottomRight: radius.lg, - bottomLeft: radius.xs, + borderRadius: BorderRadiusDirectional.only( + topStart: radius.lg, + topEnd: radius.lg, + bottomEnd: radius.lg, + bottomStart: radius.xs, ), ), side: BorderSide(color: colorScheme.borderSubtle), @@ -609,10 +607,7 @@ class _ExampleCard extends StatelessWidget { ), ], ), - if (childPadding case final padding?) - Padding(padding: padding, child: child) - else - child, + if (childPadding case final padding?) Padding(padding: padding, child: child) else child, ], ), ); diff --git a/apps/design_system_gallery/lib/components/message_composer/message_composer.dart b/apps/design_system_gallery/lib/components/message_composer/message_composer.dart index 0ed9066..bb7afc8 100644 --- a/apps/design_system_gallery/lib/components/message_composer/message_composer.dart +++ b/apps/design_system_gallery/lib/components/message_composer/message_composer.dart @@ -200,7 +200,7 @@ class _MessageBubble extends StatelessWidget { return Align( alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, - child: StreamMessageBubble( + child: StreamMessageBubble( backgroundColor: isMe ? colorScheme.accentPrimary : colorScheme.backgroundApp, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), diff --git a/apps/design_system_gallery/lib/components/reaction/stream_reactions.dart b/apps/design_system_gallery/lib/components/reaction/stream_reactions.dart index da62c64..efe171c 100644 --- a/apps/design_system_gallery/lib/components/reaction/stream_reactions.dart +++ b/apps/design_system_gallery/lib/components/reaction/stream_reactions.dart @@ -157,15 +157,15 @@ class _ChatBubble extends StatelessWidget { const bubbleRadius = Radius.circular(20); final bubbleBorderRadius = isOutgoing - ? const BorderRadius.only( - topLeft: bubbleRadius, - topRight: bubbleRadius, - bottomLeft: bubbleRadius, + ? const BorderRadiusDirectional.only( + topStart: bubbleRadius, + topEnd: bubbleRadius, + bottomStart: bubbleRadius, ) - : const BorderRadius.only( - topLeft: bubbleRadius, - topRight: bubbleRadius, - bottomRight: bubbleRadius, + : const BorderRadiusDirectional.only( + topStart: bubbleRadius, + topEnd: bubbleRadius, + bottomEnd: bubbleRadius, ); final bubble = StreamMessageBubble( From b3460622ed864cbfabdc9c9a788094a36d909503 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 11 Mar 2026 04:23:23 +0530 Subject: [PATCH 05/11] feat(ui): add StreamMessageAnnotation component and integrate into message views --- .../lib/app/gallery_app.directories.g.dart | 19 + .../message/stream_message_annotation.dart | 592 ++++++++++++++++++ .../lib/src/components.dart | 1 + .../message/stream_message_annotation.dart | 216 +++++++ .../src/factory/stream_component_factory.dart | 8 + .../stream_component_factory.g.theme.dart | 7 + .../stream_core_flutter/lib/src/theme.dart | 1 + .../stream_message_annotation_theme.dart | 189 ++++++ ...ream_message_annotation_theme.g.theme.dart | 191 ++++++ .../lib/src/theme/stream_theme.dart | 9 + .../lib/src/theme/stream_theme.g.theme.dart | 10 + .../src/theme/stream_theme_extensions.dart | 4 + 12 files changed, 1247 insertions(+) create mode 100644 apps/design_system_gallery/lib/components/message/stream_message_annotation.dart create mode 100644 packages/stream_core_flutter/lib/src/components/message/stream_message_annotation.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/components/stream_message_annotation_theme.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/components/stream_message_annotation_theme.g.theme.dart diff --git a/apps/design_system_gallery/lib/app/gallery_app.directories.g.dart b/apps/design_system_gallery/lib/app/gallery_app.directories.g.dart index 9312b12..f36a3bd 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 @@ -46,6 +46,8 @@ import 'package:design_system_gallery/components/controls/stream_emoji_chip_bar. as _design_system_gallery_components_controls_stream_emoji_chip_bar; import 'package:design_system_gallery/components/emoji/stream_emoji_picker_sheet.dart' as _design_system_gallery_components_emoji_stream_emoji_picker_sheet; +import 'package:design_system_gallery/components/message/stream_message_annotation.dart' + as _design_system_gallery_components_message_stream_message_annotation; import 'package:design_system_gallery/components/message/stream_message_bubble.dart' as _design_system_gallery_components_message_stream_message_bubble; import 'package:design_system_gallery/components/message/stream_message_metadata.dart' @@ -524,6 +526,23 @@ final directories = <_widgetbook.WidgetbookNode>[ _widgetbook.WidgetbookFolder( name: 'Message', children: [ + _widgetbook.WidgetbookComponent( + name: 'StreamMessageAnnotation', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: + _design_system_gallery_components_message_stream_message_annotation + .buildStreamMessageAnnotationPlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Showcase', + builder: + _design_system_gallery_components_message_stream_message_annotation + .buildStreamMessageAnnotationShowcase, + ), + ], + ), _widgetbook.WidgetbookComponent( name: 'StreamMessageBubble', useCases: [ diff --git a/apps/design_system_gallery/lib/components/message/stream_message_annotation.dart b/apps/design_system_gallery/lib/components/message/stream_message_annotation.dart new file mode 100644 index 0000000..d2d4f31 --- /dev/null +++ b/apps/design_system_gallery/lib/components/message/stream_message_annotation.dart @@ -0,0 +1,592 @@ +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: StreamMessageAnnotation, + path: '[Components]/Message', +) +Widget buildStreamMessageAnnotationPlayground(BuildContext context) { + final icons = context.streamIcons; + + final label = context.knobs.string( + label: 'Label', + initialValue: 'Saved for later', + description: 'The annotation label text.', + ); + + final showLeading = context.knobs.boolean( + label: 'Show Leading', + initialValue: true, + description: 'Whether to show a leading icon.', + ); + + final leadingIcon = context.knobs.object.dropdown<_IconOption>( + label: 'Leading Icon', + options: _IconOption.values, + initialOption: _IconOption.bookmark, + labelBuilder: (v) => v.label, + description: 'The leading icon to display.', + ); + + final spacing = context.knobs.double.slider( + label: 'Spacing', + initialValue: 4, + max: 16, + divisions: 16, + description: 'Gap between icon and label. Overrides theme when set.', + ); + + final verticalPadding = context.knobs.double.slider( + label: 'Vertical Padding', + initialValue: 4, + max: 16, + divisions: 16, + description: 'Vertical padding around the row content.', + ); + + final horizontalPadding = context.knobs.double.slider( + label: 'Horizontal Padding', + max: 16, + divisions: 16, + description: 'Horizontal padding around the row content.', + ); + + return Center( + child: StreamMessageAnnotation( + leading: showLeading ? Icon(leadingIcon.resolve(icons)) : null, + label: Text(label), + spacing: spacing, + padding: EdgeInsets.symmetric( + vertical: verticalPadding, + horizontal: horizontalPadding, + ), + ), + ); +} + +// ============================================================================= +// Showcase +// ============================================================================= + +@widgetbook.UseCase( + name: 'Showcase', + type: StreamMessageAnnotation, + path: '[Components]/Message', +) +Widget buildStreamMessageAnnotationShowcase(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + + return DefaultTextStyle( + style: textTheme.bodyDefault.copyWith(color: colorScheme.textPrimary), + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 32, + children: [ + _AnnotationTypesSection(), + _ThemeOverrideSection(), + _RealWorldSection(), + ], + ), + ), + ); +} + +// ============================================================================= +// Showcase Sections +// ============================================================================= + +class _AnnotationTypesSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + + return _Section( + label: 'ANNOTATION TYPES', + description: 'All annotation variants from the design system.', + children: [ + _ExampleCard( + label: 'Saved', + subtitle: 'Accent color for icon and text.', + child: StreamMessageAnnotationTheme( + data: StreamMessageAnnotationThemeData( + style: StreamMessageAnnotationStyle( + textColor: colorScheme.accentPrimary, + iconColor: colorScheme.accentPrimary, + ), + ), + child: StreamMessageAnnotation( + leading: Icon(icons.bookmark), + label: const Text('Saved for later'), + ), + ), + ), + _ExampleCard( + label: 'Pinned', + child: StreamMessageAnnotation( + leading: Icon(icons.pin), + label: const Text('Pinned by Alice'), + ), + ), + _ExampleCard( + label: 'Reminder', + subtitle: 'Mixed emphasis: bold label + regular timestamp.', + child: StreamMessageAnnotation( + leading: Icon(icons.bellNotification), + label: Text.rich( + TextSpan( + children: [ + const TextSpan(text: 'Reminder set'), + TextSpan( + text: ' · In 2 hours', + style: textTheme.metadataDefault, + ), + ], + ), + ), + ), + ), + _ExampleCard( + label: 'Translated', + subtitle: 'Regular label + bold action text.', + child: StreamMessageAnnotation( + leading: const Icon(Icons.translate), + label: Text.rich( + TextSpan( + style: textTheme.metadataDefault, + children: [ + const TextSpan(text: 'Translated '), + TextSpan( + text: '· Show original', + style: textTheme.metadataEmphasis, + ), + ], + ), + ), + ), + ), + _ExampleCard( + label: 'Also sent in channel', + subtitle: 'Primary text with inline link.', + child: StreamMessageAnnotation( + leading: Icon(icons.arrowUp), + label: Text.rich( + TextSpan( + children: [ + const TextSpan(text: 'Also sent in channel · '), + TextSpan( + text: 'View', + style: TextStyle(color: colorScheme.textLink), + ), + ], + ), + ), + ), + ), + _ExampleCard( + label: 'Replied to a thread', + subtitle: 'Primary text with inline link.', + child: StreamMessageAnnotation( + leading: Icon(icons.arrowUp), + label: Text.rich( + TextSpan( + children: [ + const TextSpan(text: 'Replied to a thread · '), + TextSpan( + text: 'View', + style: TextStyle(color: colorScheme.textLink), + ), + ], + ), + ), + ), + ), + ], + ); + } +} + +class _ThemeOverrideSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + + return _Section( + label: 'THEME OVERRIDES', + description: 'Per-instance overrides via StreamMessageAnnotationTheme.', + children: [ + _ExampleCard( + label: 'Custom colors', + subtitle: 'Purple icon and text color.', + child: StreamMessageAnnotationTheme( + data: const StreamMessageAnnotationThemeData( + style: StreamMessageAnnotationStyle( + textColor: Colors.purple, + iconColor: Colors.purple, + ), + ), + child: StreamMessageAnnotation( + leading: Icon(icons.bookmark), + label: const Text('Saved for later'), + ), + ), + ), + _ExampleCard( + label: 'Custom icon size', + subtitle: 'Larger icon (20px).', + child: StreamMessageAnnotationTheme( + data: const StreamMessageAnnotationThemeData( + style: StreamMessageAnnotationStyle( + iconSize: 20, + ), + ), + child: StreamMessageAnnotation( + leading: Icon(icons.pin), + label: const Text('Pinned by Alice'), + ), + ), + ), + _ExampleCard( + label: 'Custom spacing', + subtitle: 'Wider gap (12px) between icon and label.', + child: StreamMessageAnnotation( + leading: Icon(icons.bookmark), + label: const Text('Saved for later'), + spacing: 12, + ), + ), + _ExampleCard( + label: 'Label only', + subtitle: 'No leading icon.', + child: StreamMessageAnnotation( + label: const Text('Saved for later'), + ), + ), + ], + ); + } +} + +class _RealWorldSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final radius = context.streamRadius; + + return _Section( + label: 'REAL-WORLD EXAMPLES', + description: 'Annotations displayed above message bubbles.', + children: [ + _ExampleCard( + label: 'Saved message', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + StreamMessageAnnotationTheme( + data: StreamMessageAnnotationThemeData( + style: StreamMessageAnnotationStyle( + textColor: colorScheme.accentPrimary, + iconColor: colorScheme.accentPrimary, + ), + ), + child: StreamMessageAnnotation( + leading: Icon(icons.bookmark), + label: const Text('Saved for later'), + ), + ), + StreamMessageBubble( + backgroundColor: colorScheme.backgroundSurface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadiusDirectional.only( + topStart: radius.lg, + topEnd: radius.lg, + bottomEnd: radius.lg, + bottomStart: radius.xs, + ), + ), + side: BorderSide(color: colorScheme.borderSubtle), + child: Text( + 'Check out this new design system!', + style: textTheme.bodyDefault.copyWith( + color: colorScheme.textPrimary, + ), + ), + ), + ], + ), + ), + _ExampleCard( + label: 'Pinned message', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + StreamMessageAnnotation( + leading: Icon(icons.pin), + label: const Text('Pinned by Alice'), + ), + StreamMessageBubble( + backgroundColor: colorScheme.backgroundSurface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadiusDirectional.only( + topStart: radius.lg, + topEnd: radius.lg, + bottomEnd: radius.lg, + bottomStart: radius.xs, + ), + ), + side: BorderSide(color: colorScheme.borderSubtle), + child: Text( + 'Meeting at 3 PM today.', + style: textTheme.bodyDefault.copyWith( + color: colorScheme.textPrimary, + ), + ), + ), + ], + ), + ), + _ExampleCard( + label: 'Reminder message', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + StreamMessageAnnotation( + leading: Icon(icons.bellNotification), + label: Text.rich( + TextSpan( + children: [ + const TextSpan(text: 'Reminder set'), + TextSpan( + text: ' · In 30 minutes', + style: textTheme.metadataDefault, + ), + ], + ), + ), + ), + StreamMessageBubble( + backgroundColor: colorScheme.backgroundSurface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadiusDirectional.only( + topStart: radius.lg, + topEnd: radius.lg, + bottomEnd: radius.lg, + bottomStart: radius.xs, + ), + ), + side: BorderSide(color: colorScheme.borderSubtle), + child: Text( + 'Remember to review the PR.', + style: textTheme.bodyDefault.copyWith( + color: colorScheme.textPrimary, + ), + ), + ), + ], + ), + ), + _ExampleCard( + label: 'Also sent in channel', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + StreamMessageAnnotation( + leading: Icon(icons.arrowUp), + label: Text.rich( + TextSpan( + children: [ + const TextSpan(text: 'Also sent in channel · '), + TextSpan( + text: 'View', + style: TextStyle(color: colorScheme.textLink), + ), + ], + ), + ), + ), + StreamMessageBubble( + backgroundColor: colorScheme.backgroundSurface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadiusDirectional.only( + topStart: radius.lg, + topEnd: radius.lg, + bottomEnd: radius.lg, + bottomStart: radius.xs, + ), + ), + side: BorderSide(color: colorScheme.borderSubtle), + child: Text( + 'This was also sent to the main channel.', + style: textTheme.bodyDefault.copyWith( + color: colorScheme.textPrimary, + ), + ), + ), + ], + ), + ), + ], + ); + } +} + +// ============================================================================= +// Helper Widgets & Data +// ============================================================================= + +enum _IconOption { + bookmark('Bookmark'), + pin('Pin'), + bellNotification('Bell'), + arrowUp('Arrow Up'), + translate('Translate'); + + const _IconOption(this.label); + + final String label; + + IconData resolve(StreamIcons icons) => switch (this) { + bookmark => icons.bookmark, + pin => icons.pin, + bellNotification => icons.bellNotification, + arrowUp => icons.arrowUp, + translate => Icons.translate, + }; +} + +class _Section extends StatelessWidget { + const _Section({ + required this.label, + required this.children, + this.description, + }); + + final String label; + final String? description; + final List children; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 16, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + _SectionLabel(label: label), + if (description case final desc?) + Text( + desc, + style: TextStyle( + fontSize: 13, + color: colorScheme.textTertiary, + ), + ), + ], + ), + ...children, + ], + ); + } +} + +class _SectionLabel extends StatelessWidget { + const _SectionLabel({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + return Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 1.2, + color: colorScheme.accentPrimary, + ), + ); + } +} + +class _ExampleCard extends StatelessWidget { + const _ExampleCard({ + required this.label, + required this.child, + this.subtitle, + }); + + final String label; + final String? subtitle; + final Widget child; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final radius = context.streamRadius; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.backgroundApp, + borderRadius: BorderRadius.all(radius.md), + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.all(radius.md), + border: Border.all(color: colorScheme.borderSubtle), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 2, + children: [ + Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: colorScheme.textSecondary, + ), + ), + if (subtitle case final sub?) + Text( + sub, + style: TextStyle( + fontSize: 12, + color: colorScheme.textTertiary, + ), + ), + ], + ), + child, + ], + ), + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/components.dart b/packages/stream_core_flutter/lib/src/components.dart index 5bce255..797f73e 100644 --- a/packages/stream_core_flutter/lib/src/components.dart +++ b/packages/stream_core_flutter/lib/src/components.dart @@ -23,6 +23,7 @@ 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/stream_message_alignment.dart'; +export 'components/message/stream_message_annotation.dart' hide DefaultStreamMessageAnnotation; export 'components/message/stream_message_bubble.dart' hide DefaultStreamMessageBubble; export 'components/message/stream_message_group_position.dart'; export 'components/message/stream_message_metadata.dart' hide DefaultStreamMessageMetadata; diff --git a/packages/stream_core_flutter/lib/src/components/message/stream_message_annotation.dart b/packages/stream_core_flutter/lib/src/components/message/stream_message_annotation.dart new file mode 100644 index 0000000..deb882c --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/message/stream_message_annotation.dart @@ -0,0 +1,216 @@ +import 'package:flutter/material.dart'; + +import '../../theme.dart'; + +/// An annotation row for displaying contextual message annotations. +/// +/// Displays an optional [leading] widget (typically an icon) and a [label] +/// widget in a horizontal row with themed styling. Can be used for various +/// annotation types such as "Saved", "Pinned", "Reminder", etc. +/// +/// All content is provided by the caller via widget slots. The provided +/// widgets are automatically styled according to +/// [StreamMessageAnnotationStyle]. +/// +/// The visual order is always `[leading, label]` with configurable spacing +/// between them. When [leading] is null, only the label is shown. +/// +/// {@tool snippet} +/// +/// Basic annotation with icon and label: +/// +/// ```dart +/// StreamMessageAnnotation( +/// leading: Icon(StreamIcons.bookmark), +/// label: Text('Saved'), +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Tappable annotation: +/// +/// ```dart +/// StreamMessageAnnotation( +/// leading: Icon(StreamIcons.pin), +/// label: Text('Pinned'), +/// onTap: () => print('Annotation tapped'), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageAnnotationStyle], for the visual style properties. +/// * [StreamMessageAnnotationThemeData], for customizing annotation +/// appearance. +/// * [StreamMessageAnnotationTheme], for overriding theme in a widget +/// subtree. +class StreamMessageAnnotation extends StatelessWidget { + /// Creates a message annotation row. + /// + /// The [label] is required; [leading] is optional and omitted from the row + /// when null. + StreamMessageAnnotation({ + super.key, + Widget? leading, + required Widget label, + VoidCallback? onTap, + VoidCallback? onLongPress, + double? spacing, + EdgeInsetsGeometry? padding, + }) : props = .new( + leading: leading, + label: label, + onTap: onTap, + onLongPress: onLongPress, + spacing: spacing, + padding: padding, + ); + + /// The properties that configure this annotation row. + final StreamMessageAnnotationProps props; + + @override + Widget build(BuildContext context) { + final builder = StreamComponentFactory.of(context).messageAnnotation; + if (builder != null) return builder(context, props); + return DefaultStreamMessageAnnotation(props: props); + } +} + +/// Properties for configuring a [StreamMessageAnnotation]. +/// +/// See also: +/// +/// * [StreamMessageAnnotation], which uses these properties. +class StreamMessageAnnotationProps { + /// Creates properties for a message annotation row. + const StreamMessageAnnotationProps({ + this.leading, + required this.label, + this.onTap, + this.onLongPress, + this.spacing, + this.padding, + }); + + /// The leading widget, typically an [Icon]. + /// + /// When null, the row displays only the [label]. + /// + /// Styled by [StreamMessageAnnotationStyle.iconColor] and + /// [StreamMessageAnnotationStyle.iconSize]. + final Widget? leading; + + /// The label widget, typically a [Text] showing the annotation type. + /// + /// Styled by [StreamMessageAnnotationStyle.textStyle] and + /// [StreamMessageAnnotationStyle.textColor]. + final Widget label; + + /// Called when the annotation row is tapped. + final VoidCallback? onTap; + + /// Called when the annotation row is long-pressed. + final VoidCallback? onLongPress; + + /// The gap between the leading widget and label. + /// + /// When null, falls back to [StreamMessageAnnotationStyle.spacing]. + final double? spacing; + + /// The padding around the annotation row content. + /// + /// When null, falls back to [StreamMessageAnnotationStyle.padding]. + final EdgeInsetsGeometry? padding; +} + +/// The default implementation of [StreamMessageAnnotation]. +/// +/// See also: +/// +/// * [StreamMessageAnnotation], the public API widget. +/// * [StreamMessageAnnotationProps], which configures this widget. +class DefaultStreamMessageAnnotation extends StatelessWidget { + /// Creates a default message annotation row with the given [props]. + const DefaultStreamMessageAnnotation({super.key, required this.props}); + + /// The properties that configure this annotation row. + final StreamMessageAnnotationProps props; + + @override + Widget build(BuildContext context) { + final style = context.streamMessageAnnotationTheme.style; + final defaults = _StreamMessageAnnotationDefaults(context); + + final effectiveTextStyle = style?.textStyle ?? defaults.textStyle; + final effectiveTextColor = style?.textColor ?? defaults.textColor; + final effectiveSpacing = props.spacing ?? style?.spacing ?? defaults.spacing; + final effectivePadding = props.padding ?? style?.padding ?? defaults.padding; + + Widget? leadingWidget; + if (props.leading case final leading?) { + final effectiveIconColor = style?.iconColor ?? defaults.iconColor; + final effectiveIconSize = style?.iconSize ?? defaults.iconSize; + + leadingWidget = IconTheme.merge( + data: IconThemeData(color: effectiveIconColor, size: effectiveIconSize), + child: leading, + ); + } + + final labelWidget = Flexible( + child: AnimatedDefaultTextStyle( + style: effectiveTextStyle.copyWith(color: effectiveTextColor), + duration: kThemeChangeDuration, + child: props.label, + ), + ); + + final child = Padding( + padding: effectivePadding, + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: effectiveSpacing, + children: [?leadingWidget, labelWidget], + ), + ); + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: props.onTap, + onLongPress: props.onLongPress, + child: child, + ); + } +} + +class _StreamMessageAnnotationDefaults extends StreamMessageAnnotationStyle { + _StreamMessageAnnotationDefaults(this._context); + + final BuildContext _context; + + late final StreamColorScheme _colorScheme = _context.streamColorScheme; + late final StreamTextTheme _textTheme = _context.streamTextTheme; + late final StreamSpacing _spacing = _context.streamSpacing; + + @override + TextStyle get textStyle => _textTheme.metadataEmphasis; + + @override + Color get textColor => _colorScheme.textPrimary; + + @override + Color get iconColor => _colorScheme.textPrimary; + + @override + double get iconSize => 16; + + @override + double get spacing => _spacing.xxs; + + @override + EdgeInsetsGeometry get padding => .symmetric(vertical: _spacing.xxs); +} 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 4342eb0..ff420ba 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 @@ -145,6 +145,7 @@ class StreamComponentBuilders with _$StreamComponentBuilders { StreamComponentBuilder? emojiChipBar, StreamComponentBuilder? fileTypeIcon, StreamComponentBuilder? listTile, + StreamComponentBuilder? messageAnnotation, StreamComponentBuilder? messageBubble, StreamComponentBuilder? messageMetadata, StreamComponentBuilder? messageReplies, @@ -170,6 +171,7 @@ class StreamComponentBuilders with _$StreamComponentBuilders { emojiChipBar: emojiChipBar, fileTypeIcon: fileTypeIcon, listTile: listTile, + messageAnnotation: messageAnnotation, messageBubble: messageBubble, messageMetadata: messageMetadata, messageReplies: messageReplies, @@ -196,6 +198,7 @@ class StreamComponentBuilders with _$StreamComponentBuilders { required this.emojiChipBar, required this.fileTypeIcon, required this.listTile, + required this.messageAnnotation, required this.messageBubble, required this.messageMetadata, required this.messageReplies, @@ -295,6 +298,11 @@ class StreamComponentBuilders with _$StreamComponentBuilders { /// When null, [StreamListTile] uses [DefaultStreamListTile]. final StreamComponentBuilder? listTile; + /// Custom builder for message annotation widgets. + /// + /// When null, [StreamMessageAnnotation] uses [DefaultStreamMessageAnnotation]. + final StreamComponentBuilder? messageAnnotation; + /// Custom builder for message bubble widgets. /// /// When null, [StreamMessageBubble] uses [DefaultStreamMessageBubble]. 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 dabf096..5019a35 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 @@ -45,6 +45,7 @@ mixin _$StreamComponentBuilders { emojiChipBar: t < 0.5 ? a.emojiChipBar : b.emojiChipBar, fileTypeIcon: t < 0.5 ? a.fileTypeIcon : b.fileTypeIcon, listTile: t < 0.5 ? a.listTile : b.listTile, + messageAnnotation: t < 0.5 ? a.messageAnnotation : b.messageAnnotation, messageBubble: t < 0.5 ? a.messageBubble : b.messageBubble, messageMetadata: t < 0.5 ? a.messageMetadata : b.messageMetadata, messageReplies: t < 0.5 ? a.messageReplies : b.messageReplies, @@ -73,6 +74,8 @@ mixin _$StreamComponentBuilders { emojiChipBar, Widget Function(BuildContext, StreamFileTypeIconProps)? fileTypeIcon, Widget Function(BuildContext, StreamListTileProps)? listTile, + Widget Function(BuildContext, StreamMessageAnnotationProps)? + messageAnnotation, Widget Function(BuildContext, StreamMessageBubbleProps)? messageBubble, Widget Function(BuildContext, StreamMessageMetadataProps)? messageMetadata, Widget Function(BuildContext, StreamMessageRepliesProps)? messageReplies, @@ -98,6 +101,7 @@ mixin _$StreamComponentBuilders { emojiChipBar: emojiChipBar ?? _this.emojiChipBar, fileTypeIcon: fileTypeIcon ?? _this.fileTypeIcon, listTile: listTile ?? _this.listTile, + messageAnnotation: messageAnnotation ?? _this.messageAnnotation, messageBubble: messageBubble ?? _this.messageBubble, messageMetadata: messageMetadata ?? _this.messageMetadata, messageReplies: messageReplies ?? _this.messageReplies, @@ -134,6 +138,7 @@ mixin _$StreamComponentBuilders { emojiChipBar: other.emojiChipBar, fileTypeIcon: other.fileTypeIcon, listTile: other.listTile, + messageAnnotation: other.messageAnnotation, messageBubble: other.messageBubble, messageMetadata: other.messageMetadata, messageReplies: other.messageReplies, @@ -171,6 +176,7 @@ mixin _$StreamComponentBuilders { _other.emojiChipBar == _this.emojiChipBar && _other.fileTypeIcon == _this.fileTypeIcon && _other.listTile == _this.listTile && + _other.messageAnnotation == _this.messageAnnotation && _other.messageBubble == _this.messageBubble && _other.messageMetadata == _this.messageMetadata && _other.messageReplies == _this.messageReplies && @@ -200,6 +206,7 @@ mixin _$StreamComponentBuilders { _this.emojiChipBar, _this.fileTypeIcon, _this.listTile, + _this.messageAnnotation, _this.messageBubble, _this.messageMetadata, _this.messageReplies, diff --git a/packages/stream_core_flutter/lib/src/theme.dart b/packages/stream_core_flutter/lib/src/theme.dart index 32ca9e7..c295256 100644 --- a/packages/stream_core_flutter/lib/src/theme.dart +++ b/packages/stream_core_flutter/lib/src/theme.dart @@ -12,6 +12,7 @@ export 'theme/components/stream_emoji_button_theme.dart'; export 'theme/components/stream_emoji_chip_theme.dart'; export 'theme/components/stream_input_theme.dart'; export 'theme/components/stream_list_tile_theme.dart'; +export 'theme/components/stream_message_annotation_theme.dart'; export 'theme/components/stream_message_bubble_theme.dart'; export 'theme/components/stream_message_metadata_theme.dart'; export 'theme/components/stream_message_replies_theme.dart'; diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_annotation_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_annotation_theme.dart new file mode 100644 index 0000000..7d2db20 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_annotation_theme.dart @@ -0,0 +1,189 @@ +import 'package:flutter/widgets.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import '../stream_theme.dart'; + +part 'stream_message_annotation_theme.g.theme.dart'; + +/// Applies a message annotation theme to descendant [StreamMessageAnnotation] +/// widgets. +/// +/// Wrap a subtree with [StreamMessageAnnotationTheme] to override annotation +/// styling. Access the merged theme using +/// [BuildContext.streamMessageAnnotationTheme]. +/// +/// {@tool snippet} +/// +/// Override annotation styling for a specific section: +/// +/// ```dart +/// StreamMessageAnnotationTheme( +/// data: StreamMessageAnnotationThemeData( +/// style: StreamMessageAnnotationStyle( +/// textColor: Colors.purple, +/// spacing: 8, +/// ), +/// ), +/// child: StreamMessageAnnotation( +/// leading: Icon(Icons.bookmark), +/// label: Text('Saved'), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageAnnotationThemeData], which describes the annotation theme. +/// * [StreamMessageAnnotationStyle], the value object holding visual props. +/// * [StreamMessageAnnotation], the widget affected by this theme. +class StreamMessageAnnotationTheme extends InheritedTheme { + /// Creates a message annotation theme that controls descendant annotations. + const StreamMessageAnnotationTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The message annotation theme data for descendant widgets. + final StreamMessageAnnotationThemeData data; + + /// Returns the [StreamMessageAnnotationThemeData] merged from local and + /// global themes. + /// + /// Local values from the nearest [StreamMessageAnnotationTheme] ancestor + /// take precedence over global values from [StreamTheme.of]. + /// + /// This allows partial overrides — for example, overriding only + /// [StreamMessageAnnotationStyle.textColor] while inheriting other + /// properties from the global theme. + static StreamMessageAnnotationThemeData of(BuildContext context) { + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamTheme.of(context).messageAnnotationTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamMessageAnnotationTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamMessageAnnotationTheme oldWidget) => data != oldWidget.data; +} + +/// Theme data for customizing [StreamMessageAnnotation] widgets. +/// +/// Descendant widgets obtain their values from +/// [StreamMessageAnnotationTheme.of]. The [style] property is null by default, +/// with fallback values derived from the current [StreamTheme]. +/// +/// {@tool snippet} +/// +/// Customize annotation appearance globally via [StreamTheme]: +/// +/// ```dart +/// StreamTheme( +/// messageAnnotationTheme: StreamMessageAnnotationThemeData( +/// style: StreamMessageAnnotationStyle( +/// textColor: Colors.blue, +/// iconSize: 18, +/// ), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageAnnotationStyle], the value object holding visual props. +/// * [StreamMessageAnnotationTheme], for overriding the theme in a widget +/// subtree. +/// * [StreamMessageAnnotation], the widget that uses this theme data. +@themeGen +@immutable +class StreamMessageAnnotationThemeData with _$StreamMessageAnnotationThemeData { + /// Creates a message annotation theme data with an optional base [style]. + const StreamMessageAnnotationThemeData({ + this.style, + }); + + /// The base annotation style. + /// + /// When the annotation widget resolves its effective style, this serves + /// as the theme-level default that can be overridden by a direct widget prop. + final StreamMessageAnnotationStyle? style; + + /// Linearly interpolate between two [StreamMessageAnnotationThemeData] + /// objects. + static StreamMessageAnnotationThemeData? lerp( + StreamMessageAnnotationThemeData? a, + StreamMessageAnnotationThemeData? b, + double t, + ) => _$StreamMessageAnnotationThemeData.lerp(a, b, t); +} + +/// Visual style properties for a message annotation row. +/// +/// [StreamMessageAnnotationStyle] is a reusable value object that can be +/// applied both as a theme value (via [StreamMessageAnnotationThemeData]) and +/// as a direct widget prop — similar to how [ButtonStyle] works with +/// [ElevatedButton]. +/// +/// {@tool snippet} +/// +/// Create a custom annotation style: +/// +/// ```dart +/// const style = StreamMessageAnnotationStyle( +/// textColor: Colors.purple, +/// iconColor: Colors.purple, +/// iconSize: 18, +/// spacing: 8, +/// ); +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageAnnotationThemeData], which wraps this style for theming. +/// * [StreamMessageAnnotation], the widget that uses this style. +@themeGen +@immutable +class StreamMessageAnnotationStyle with _$StreamMessageAnnotationStyle { + /// Creates a message annotation style with optional property overrides. + const StreamMessageAnnotationStyle({ + this.textStyle, + this.textColor, + this.iconColor, + this.iconSize, + this.spacing, + this.padding, + }); + + /// The default text style for the annotation label. + /// + /// This only controls typography. Color comes from [textColor]. + final TextStyle? textStyle; + + /// The default color for the annotation label text. + final Color? textColor; + + /// The default color for the leading icon. + final Color? iconColor; + + /// The default size for the leading icon. + final double? iconSize; + + /// The gap between the leading widget and label. + final double? spacing; + + /// The padding around the annotation row content. + final EdgeInsetsGeometry? padding; + + /// Linearly interpolate between two [StreamMessageAnnotationStyle] instances. + static StreamMessageAnnotationStyle? lerp( + StreamMessageAnnotationStyle? a, + StreamMessageAnnotationStyle? b, + double t, + ) => _$StreamMessageAnnotationStyle.lerp(a, b, t); +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_annotation_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_annotation_theme.g.theme.dart new file mode 100644 index 0000000..3839511 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_annotation_theme.g.theme.dart @@ -0,0 +1,191 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'stream_message_annotation_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamMessageAnnotationStyle { + bool get canMerge => true; + + static StreamMessageAnnotationStyle? lerp( + StreamMessageAnnotationStyle? a, + StreamMessageAnnotationStyle? 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 StreamMessageAnnotationStyle( + textStyle: TextStyle.lerp(a.textStyle, b.textStyle, t), + textColor: Color.lerp(a.textColor, b.textColor, t), + iconColor: Color.lerp(a.iconColor, b.iconColor, t), + iconSize: lerpDouble$(a.iconSize, b.iconSize, t), + spacing: lerpDouble$(a.spacing, b.spacing, t), + padding: EdgeInsetsGeometry.lerp(a.padding, b.padding, t), + ); + } + + StreamMessageAnnotationStyle copyWith({ + TextStyle? textStyle, + Color? textColor, + Color? iconColor, + double? iconSize, + double? spacing, + EdgeInsetsGeometry? padding, + }) { + final _this = (this as StreamMessageAnnotationStyle); + + return StreamMessageAnnotationStyle( + textStyle: textStyle ?? _this.textStyle, + textColor: textColor ?? _this.textColor, + iconColor: iconColor ?? _this.iconColor, + iconSize: iconSize ?? _this.iconSize, + spacing: spacing ?? _this.spacing, + padding: padding ?? _this.padding, + ); + } + + StreamMessageAnnotationStyle merge(StreamMessageAnnotationStyle? other) { + final _this = (this as StreamMessageAnnotationStyle); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + textStyle: _this.textStyle?.merge(other.textStyle) ?? other.textStyle, + textColor: other.textColor, + iconColor: other.iconColor, + iconSize: other.iconSize, + spacing: other.spacing, + padding: other.padding, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamMessageAnnotationStyle); + final _other = (other as StreamMessageAnnotationStyle); + + return _other.textStyle == _this.textStyle && + _other.textColor == _this.textColor && + _other.iconColor == _this.iconColor && + _other.iconSize == _this.iconSize && + _other.spacing == _this.spacing && + _other.padding == _this.padding; + } + + @override + int get hashCode { + final _this = (this as StreamMessageAnnotationStyle); + + return Object.hash( + runtimeType, + _this.textStyle, + _this.textColor, + _this.iconColor, + _this.iconSize, + _this.spacing, + _this.padding, + ); + } +} + +mixin _$StreamMessageAnnotationThemeData { + bool get canMerge => true; + + static StreamMessageAnnotationThemeData? lerp( + StreamMessageAnnotationThemeData? a, + StreamMessageAnnotationThemeData? 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 StreamMessageAnnotationThemeData( + style: StreamMessageAnnotationStyle.lerp(a.style, b.style, t), + ); + } + + StreamMessageAnnotationThemeData copyWith({ + StreamMessageAnnotationStyle? style, + }) { + final _this = (this as StreamMessageAnnotationThemeData); + + return StreamMessageAnnotationThemeData(style: style ?? _this.style); + } + + StreamMessageAnnotationThemeData merge( + StreamMessageAnnotationThemeData? other, + ) { + final _this = (this as StreamMessageAnnotationThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith(style: _this.style?.merge(other.style) ?? other.style); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamMessageAnnotationThemeData); + final _other = (other as StreamMessageAnnotationThemeData); + + return _other.style == _this.style; + } + + @override + int get hashCode { + final _this = (this as StreamMessageAnnotationThemeData); + + return Object.hash(runtimeType, _this.style); + } +} diff --git a/packages/stream_core_flutter/lib/src/theme/stream_theme.dart b/packages/stream_core_flutter/lib/src/theme/stream_theme.dart index f9bf797..2cd95ca 100644 --- a/packages/stream_core_flutter/lib/src/theme/stream_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/stream_theme.dart @@ -16,6 +16,7 @@ import 'components/stream_emoji_button_theme.dart'; import 'components/stream_emoji_chip_theme.dart'; import 'components/stream_input_theme.dart'; import 'components/stream_list_tile_theme.dart'; +import 'components/stream_message_annotation_theme.dart'; import 'components/stream_message_metadata_theme.dart'; import 'components/stream_message_replies_theme.dart'; import 'components/stream_message_theme.dart'; @@ -107,6 +108,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { StreamEmojiButtonThemeData? emojiButtonTheme, StreamEmojiChipThemeData? emojiChipTheme, StreamListTileThemeData? listTileTheme, + StreamMessageAnnotationThemeData? messageAnnotationTheme, StreamMessageMetadataThemeData? messageMetadataTheme, StreamMessageRepliesThemeData? messageRepliesTheme, StreamMessageThemeData? messageTheme, @@ -141,6 +143,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { emojiButtonTheme ??= const StreamEmojiButtonThemeData(); emojiChipTheme ??= const StreamEmojiChipThemeData(); listTileTheme ??= const StreamListTileThemeData(); + messageAnnotationTheme ??= const StreamMessageAnnotationThemeData(); messageMetadataTheme ??= const StreamMessageMetadataThemeData(); messageRepliesTheme ??= const StreamMessageRepliesThemeData(); messageTheme ??= const StreamMessageThemeData(); @@ -169,6 +172,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { emojiButtonTheme: emojiButtonTheme, emojiChipTheme: emojiChipTheme, listTileTheme: listTileTheme, + messageAnnotationTheme: messageAnnotationTheme, messageMetadataTheme: messageMetadataTheme, messageRepliesTheme: messageRepliesTheme, messageTheme: messageTheme, @@ -211,6 +215,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { required this.emojiButtonTheme, required this.emojiChipTheme, required this.listTileTheme, + required this.messageAnnotationTheme, required this.messageMetadataTheme, required this.messageRepliesTheme, required this.messageTheme, @@ -311,6 +316,9 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { /// The list tile theme for this theme. final StreamListTileThemeData listTileTheme; + /// The message annotation theme for this theme. + final StreamMessageAnnotationThemeData messageAnnotationTheme; + /// The message metadata theme for this theme. final StreamMessageMetadataThemeData messageMetadataTheme; @@ -372,6 +380,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { emojiButtonTheme: emojiButtonTheme, emojiChipTheme: emojiChipTheme, listTileTheme: listTileTheme, + messageAnnotationTheme: messageAnnotationTheme, messageMetadataTheme: messageMetadataTheme, messageRepliesTheme: messageRepliesTheme, messageTheme: messageTheme, diff --git a/packages/stream_core_flutter/lib/src/theme/stream_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/stream_theme.g.theme.dart index bfbdd67..6ced3e1 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 @@ -31,6 +31,7 @@ mixin _$StreamTheme on ThemeExtension { StreamEmojiButtonThemeData? emojiButtonTheme, StreamEmojiChipThemeData? emojiChipTheme, StreamListTileThemeData? listTileTheme, + StreamMessageAnnotationThemeData? messageAnnotationTheme, StreamMessageMetadataThemeData? messageMetadataTheme, StreamMessageRepliesThemeData? messageRepliesTheme, StreamMessageThemeData? messageTheme, @@ -63,6 +64,8 @@ mixin _$StreamTheme on ThemeExtension { emojiButtonTheme: emojiButtonTheme ?? _this.emojiButtonTheme, emojiChipTheme: emojiChipTheme ?? _this.emojiChipTheme, listTileTheme: listTileTheme ?? _this.listTileTheme, + messageAnnotationTheme: + messageAnnotationTheme ?? _this.messageAnnotationTheme, messageMetadataTheme: messageMetadataTheme ?? _this.messageMetadataTheme, messageRepliesTheme: messageRepliesTheme ?? _this.messageRepliesTheme, messageTheme: messageTheme ?? _this.messageTheme, @@ -149,6 +152,11 @@ mixin _$StreamTheme on ThemeExtension { other.listTileTheme, t, )!, + messageAnnotationTheme: StreamMessageAnnotationThemeData.lerp( + _this.messageAnnotationTheme, + other.messageAnnotationTheme, + t, + )!, messageMetadataTheme: StreamMessageMetadataThemeData.lerp( _this.messageMetadataTheme, other.messageMetadataTheme, @@ -211,6 +219,7 @@ mixin _$StreamTheme on ThemeExtension { _other.emojiButtonTheme == _this.emojiButtonTheme && _other.emojiChipTheme == _this.emojiChipTheme && _other.listTileTheme == _this.listTileTheme && + _other.messageAnnotationTheme == _this.messageAnnotationTheme && _other.messageMetadataTheme == _this.messageMetadataTheme && _other.messageRepliesTheme == _this.messageRepliesTheme && _other.messageTheme == _this.messageTheme && @@ -245,6 +254,7 @@ mixin _$StreamTheme on ThemeExtension { _this.emojiButtonTheme, _this.emojiChipTheme, _this.listTileTheme, + _this.messageAnnotationTheme, _this.messageMetadataTheme, _this.messageRepliesTheme, _this.messageTheme, 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 abc31c8..57da7f1 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 @@ -12,6 +12,7 @@ import 'components/stream_emoji_button_theme.dart'; import 'components/stream_emoji_chip_theme.dart'; import 'components/stream_input_theme.dart'; import 'components/stream_list_tile_theme.dart'; +import 'components/stream_message_annotation_theme.dart'; import 'components/stream_message_metadata_theme.dart'; import 'components/stream_message_replies_theme.dart'; import 'components/stream_message_theme.dart'; @@ -105,6 +106,9 @@ extension StreamThemeExtension on BuildContext { /// Returns the [StreamListTileThemeData] from the nearest ancestor. StreamListTileThemeData get streamListTileTheme => StreamListTileTheme.of(this); + /// Returns the [StreamMessageAnnotationThemeData] from the nearest ancestor. + StreamMessageAnnotationThemeData get streamMessageAnnotationTheme => StreamMessageAnnotationTheme.of(this); + /// Returns the [StreamMessageMetadataThemeData] from the nearest ancestor. StreamMessageMetadataThemeData get streamMessageMetadataTheme => StreamMessageMetadataTheme.of(this); From 65cf9cb63afdbf00c07848367610f4e4f85177b4 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 11 Mar 2026 10:21:14 +0530 Subject: [PATCH 06/11] feat(ui): add StreamMessageContent component for structured message layout --- .../lib/app/gallery_app.directories.g.dart | 19 + .../message/stream_message_annotation.dart | 3 +- .../message/stream_message_content.dart | 695 ++++++++++++++++++ .../lib/src/components.dart | 1 + .../message/stream_message_content.dart | 164 +++++ .../src/factory/stream_component_factory.dart | 8 + .../stream_component_factory.g.theme.dart | 6 + ...ream_message_annotation_theme.g.theme.dart | 146 ++-- 8 files changed, 968 insertions(+), 74 deletions(-) create mode 100644 apps/design_system_gallery/lib/components/message/stream_message_content.dart create mode 100644 packages/stream_core_flutter/lib/src/components/message/stream_message_content.dart diff --git a/apps/design_system_gallery/lib/app/gallery_app.directories.g.dart b/apps/design_system_gallery/lib/app/gallery_app.directories.g.dart index f36a3bd..81c7ca0 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 @@ -50,6 +50,8 @@ import 'package:design_system_gallery/components/message/stream_message_annotati as _design_system_gallery_components_message_stream_message_annotation; import 'package:design_system_gallery/components/message/stream_message_bubble.dart' as _design_system_gallery_components_message_stream_message_bubble; +import 'package:design_system_gallery/components/message/stream_message_content.dart' + as _design_system_gallery_components_message_stream_message_content; import 'package:design_system_gallery/components/message/stream_message_metadata.dart' as _design_system_gallery_components_message_stream_message_metadata; import 'package:design_system_gallery/components/message/stream_message_replies.dart' @@ -560,6 +562,23 @@ final directories = <_widgetbook.WidgetbookNode>[ ), ], ), + _widgetbook.WidgetbookComponent( + name: 'StreamMessageContent', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: + _design_system_gallery_components_message_stream_message_content + .buildStreamMessageContentPlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Showcase', + builder: + _design_system_gallery_components_message_stream_message_content + .buildStreamMessageContentShowcase, + ), + ], + ), _widgetbook.WidgetbookComponent( name: 'StreamMessageMetadata', useCases: [ diff --git a/apps/design_system_gallery/lib/components/message/stream_message_annotation.dart b/apps/design_system_gallery/lib/components/message/stream_message_annotation.dart index d2d4f31..b392f29 100644 --- a/apps/design_system_gallery/lib/components/message/stream_message_annotation.dart +++ b/apps/design_system_gallery/lib/components/message/stream_message_annotation.dart @@ -456,7 +456,8 @@ enum _IconOption { pin('Pin'), bellNotification('Bell'), arrowUp('Arrow Up'), - translate('Translate'); + translate('Translate') + ; const _IconOption(this.label); diff --git a/apps/design_system_gallery/lib/components/message/stream_message_content.dart b/apps/design_system_gallery/lib/components/message/stream_message_content.dart new file mode 100644 index 0000000..19daaa1 --- /dev/null +++ b/apps/design_system_gallery/lib/components/message/stream_message_content.dart @@ -0,0 +1,695 @@ +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: StreamMessageContent, + path: '[Components]/Message', +) +Widget buildStreamMessageContentPlayground(BuildContext context) { + final text = context.knobs.string( + label: 'Message Text', + initialValue: + 'Has anyone tried the new Flutter update? ' + 'The performance improvements are amazing!', + ); + + final showHeader = context.knobs.boolean( + label: 'Show Header', + description: 'Toggle the reminder annotation header.', + ); + + final showReplies = context.knobs.boolean( + label: 'Show Replies', + description: 'Toggle the reply indicator with avatars.', + ); + + final showReactions = context.knobs.boolean( + label: 'Show Reactions', + description: 'Wrap the bubble and replies with reactions.', + ); + + final reactionType = showReactions + ? context.knobs.object.dropdown( + label: 'Reaction Type', + options: StreamReactionsType.values, + initialOption: StreamReactionsType.segmented, + labelBuilder: (v) => v.name, + description: 'Segmented shows individual chips, clustered groups them.', + ) + : StreamReactionsType.segmented; + + final reactionCount = showReactions + ? context.knobs.int.slider( + label: 'Reaction Count', + initialValue: 3, + min: 1, + max: _allReactions.length, + description: 'Number of distinct reaction types to show.', + ) + : 0; + + final reactionPosition = showReactions + ? context.knobs.object.dropdown( + label: 'Reaction Position', + options: StreamReactionsPosition.values, + initialOption: StreamReactionsPosition.header, + labelBuilder: (v) => v.name, + description: 'Where reactions sit relative to the bubble.', + ) + : StreamReactionsPosition.footer; + + final reactionOverlap = reactionPosition == StreamReactionsPosition.header; + + final showFooter = context.knobs.boolean( + label: 'Show Footer', + initialValue: true, + description: 'Toggle the metadata footer.', + ); + + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final palette = colorScheme.avatarPalette; + + final bubble = StreamMessageBubble( + child: Text( + text, + style: textTheme.bodyDefault.copyWith( + color: colorScheme.textPrimary, + ), + ), + ); + + final replies = showReplies + ? StreamMessageReplies( + label: const Text('3 replies'), + avatars: _sampleAvatars(3, palette), + ) + : null; + + Widget body; + if (showReactions) { + final items = _allReactions.take(reactionCount).toList(); + + Widget buildReactions({required Widget child}) => switch (reactionType) { + StreamReactionsType.segmented => StreamReactions.segmented( + items: items, + position: reactionPosition, + overlap: reactionOverlap, + alignment: reactionOverlap ? .end : .start, + indent: reactionOverlap ? 8 : null, + onPressed: () {}, + child: child, + ), + StreamReactionsType.clustered => StreamReactions.clustered( + items: items, + position: reactionPosition, + overlap: reactionOverlap, + alignment: reactionOverlap ? .end : .start, + indent: reactionOverlap ? 8 : null, + onPressed: () {}, + child: child, + ), + }; + + if (reactionOverlap) { + // Top: reactions wrap only the bubble, replies sit below. + body = Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + buildReactions(child: bubble), + ?replies, + ], + ); + } else { + // Bottom: reactions wrap bubble + replies together. + final inner = Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [bubble, ?replies], + ); + body = buildReactions(child: inner); + } + } else { + body = Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [bubble, ?replies], + ); + } + + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 320), + child: StreamMessageContent( + header: showHeader + ? StreamMessageAnnotation( + leading: const Icon(StreamIconData.iconBellNotification), + label: Text.rich( + TextSpan( + children: [ + const TextSpan(text: 'Reminder set'), + TextSpan( + text: ' · In 2 hours', + style: textTheme.metadataDefault, + ), + ], + ), + ), + ) + : null, + footer: showFooter + ? StreamMessageMetadata( + timestamp: const Text('09:41'), + username: const Text('Alice'), + status: const Icon(StreamIconData.iconDoupleCheckmark1Small), + edited: const Text('Edited'), + ) + : null, + child: body, + ), + ), + ); +} + +const _allReactions = [ + 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), +]; + +const _sampleImages = [ + 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=200', + 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=200', + 'https://images.unsplash.com/photo-1517841905240-472988babdf9?w=200', +]; + +const _sampleInitials = ['AB', 'CD', 'EF']; + +List _sampleAvatars(int count, List palette) { + return [ + for (var i = 0; i < count; i++) + StreamAvatar( + imageUrl: _sampleImages[i % _sampleImages.length], + backgroundColor: palette[i % palette.length].backgroundColor, + foregroundColor: palette[i % palette.length].foregroundColor, + placeholder: (context) => Text(_sampleInitials[i % _sampleInitials.length]), + ), + ]; +} + +// ============================================================================= +// Showcase +// ============================================================================= + +@widgetbook.UseCase( + name: 'Showcase', + type: StreamMessageContent, + path: '[Components]/Message', +) +Widget buildStreamMessageContentShowcase(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + + return DefaultTextStyle( + style: textTheme.bodyDefault.copyWith(color: colorScheme.textPrimary), + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 32, + children: [ + _SlotCombinationsSection(), + _ReactionVariantsSection(), + _FullCompositionSection(), + _MinimalSection(), + ], + ), + ), + ); +} + +// ============================================================================= +// Showcase Sections +// ============================================================================= + +class _SlotCombinationsSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final palette = colorScheme.avatarPalette; + + return _Section( + label: 'SLOT COMBINATIONS', + description: + 'StreamMessageContent stacks header, child, and footer. ' + 'Each slot is optional except child.', + children: [ + _ExampleCard( + label: 'Header + child + footer', + child: StreamMessageContent( + header: StreamMessageAnnotation( + leading: const Icon(StreamIconData.iconBellNotification), + label: Text.rich( + TextSpan( + children: [ + const TextSpan(text: 'Reminder set'), + TextSpan( + text: ' · In 2 hours', + style: textTheme.metadataDefault, + ), + ], + ), + ), + ), + footer: StreamMessageMetadata( + timestamp: const Text('09:41'), + username: const Text('Alice'), + status: const Icon(StreamIconData.iconDoupleCheckmark1Small), + ), + child: StreamMessageBubble( + backgroundColor: colorScheme.backgroundSurface, + side: BorderSide(color: colorScheme.borderSubtle), + child: const Text('Has anyone tried the new Flutter update?'), + ), + ), + ), + _ExampleCard( + label: 'Child + footer (edited)', + child: StreamMessageContent( + footer: StreamMessageMetadata( + timestamp: const Text('09:42'), + status: const Icon(StreamIconData.iconDoupleCheckmark1Small), + edited: const Text('Edited'), + ), + child: StreamMessageBubble( + backgroundColor: colorScheme.backgroundSurface, + side: BorderSide(color: colorScheme.borderSubtle), + child: const Text('A message with just metadata below.'), + ), + ), + ), + _ExampleCard( + label: 'Header + child (no footer)', + child: StreamMessageContent( + header: StreamMessageAnnotation( + leading: const Icon(StreamIconData.iconBookmark), + label: const Text('Saved for later'), + ), + child: StreamMessageBubble( + backgroundColor: colorScheme.backgroundSurface, + side: BorderSide(color: colorScheme.borderSubtle), + child: const Text('Saved message, no footer.'), + ), + ), + ), + _ExampleCard( + label: 'Child with replies + footer', + child: StreamMessageContent( + footer: StreamMessageMetadata( + timestamp: const Text('09:43'), + username: const Text('Bob'), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + StreamMessageBubble( + backgroundColor: colorScheme.backgroundSurface, + side: BorderSide(color: colorScheme.borderSubtle), + child: const Text('Let me know what you think!'), + ), + StreamMessageReplies( + label: const Text('5 replies'), + avatars: _sampleAvatars(3, palette), + ), + ], + ), + ), + ), + ], + ); + } +} + +class _ReactionVariantsSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + + return _Section( + label: 'REACTION VARIANTS', + description: + 'Reactions can overlap or sit below the bubble, and be ' + 'positioned at the header or footer of the child.', + children: [ + _ExampleCard( + label: 'Overlapping (header)', + child: StreamMessageContent( + footer: StreamMessageMetadata( + timestamp: const Text('09:44'), + username: const Text('Alice'), + ), + child: StreamReactions.segmented( + items: const [ + StreamReactionsItem(emoji: Text('👍'), count: 3), + StreamReactionsItem(emoji: Text('❤'), count: 2), + ], + position: StreamReactionsPosition.header, + alignment: StreamReactionsAlignment.end, + indent: 8, + child: StreamMessageBubble( + backgroundColor: colorScheme.backgroundSurface, + side: BorderSide(color: colorScheme.borderSubtle), + child: const Text('Reactions overlap the bubble edge.'), + ), + ), + ), + ), + _ExampleCard( + label: 'Non-overlapping', + child: StreamMessageContent( + footer: StreamMessageMetadata( + timestamp: const Text('09:45'), + username: const Text('Bob'), + ), + child: StreamReactions.segmented( + items: const [ + StreamReactionsItem(emoji: Text('😂'), count: 5), + StreamReactionsItem(emoji: Text('🔥'), count: 3), + StreamReactionsItem(emoji: Text('🎉'), count: 2), + ], + overlap: false, + child: StreamMessageBubble( + backgroundColor: colorScheme.backgroundSurface, + side: BorderSide(color: colorScheme.borderSubtle), + child: const Text('Reactions with a gap below.'), + ), + ), + ), + ), + _ExampleCard( + label: 'Header position', + child: StreamMessageContent( + footer: StreamMessageMetadata( + timestamp: const Text('09:46'), + ), + child: StreamReactions.segmented( + items: const [ + StreamReactionsItem(emoji: Text('👏'), count: 7), + ], + position: StreamReactionsPosition.header, + alignment: StreamReactionsAlignment.end, + indent: 8, + child: StreamMessageBubble( + backgroundColor: colorScheme.backgroundSurface, + side: BorderSide(color: colorScheme.borderSubtle), + child: const Text('Reaction sits above the bubble.'), + ), + ), + ), + ), + _ExampleCard( + label: 'Many reactions (wrapping)', + child: StreamMessageContent( + footer: StreamMessageMetadata( + timestamp: const Text('09:47'), + username: const Text('Charlie'), + ), + child: StreamReactions.segmented( + items: const [ + 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), + ], + overlap: false, + child: StreamMessageBubble( + backgroundColor: colorScheme.backgroundSurface, + side: BorderSide(color: colorScheme.borderSubtle), + child: const Text('Short text.'), + ), + ), + ), + ), + ], + ); + } +} + +class _FullCompositionSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final palette = colorScheme.avatarPalette; + + return _Section( + label: 'FULL COMPOSITION', + description: + 'All slots populated with annotations, reactions, replies, ' + 'and metadata demonstrating the intended layout.', + children: [ + _ExampleCard( + label: 'Incoming — all slots', + child: StreamMessageContent( + header: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + StreamMessageAnnotation( + leading: const Icon(StreamIconData.iconPin), + label: const Text('Pinned'), + ), + StreamMessageAnnotation( + leading: const Icon(StreamIconData.iconBellNotification), + label: Text.rich( + TextSpan( + children: [ + const TextSpan(text: 'Reminder set'), + TextSpan( + text: ' · In 30 minutes', + style: textTheme.metadataDefault, + ), + ], + ), + ), + ), + ], + ), + footer: StreamMessageMetadata( + timestamp: const Text('09:41'), + username: const Text('Alice'), + status: const Icon(StreamIconData.iconDoupleCheckmark1Small), + edited: const Text('Edited'), + ), + child: StreamReactions.segmented( + items: const [ + StreamReactionsItem(emoji: Text('👍'), count: 3), + StreamReactionsItem(emoji: Text('😂'), count: 1), + StreamReactionsItem(emoji: Text('❤'), count: 5), + ], + overlap: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + StreamMessageBubble( + backgroundColor: colorScheme.backgroundSurface, + side: BorderSide(color: colorScheme.borderSubtle), + child: const Text( + 'This message has multiple annotations, ' + 'reactions, a reply indicator, and full metadata.', + ), + ), + StreamMessageReplies( + label: const Text('5 replies'), + avatars: _sampleAvatars(3, palette), + ), + ], + ), + ), + ), + ), + _ExampleCard( + label: 'Outgoing — reactions + status', + child: StreamMessageContent( + footer: StreamMessageMetadata( + timestamp: const Text('09:42'), + status: const Icon(StreamIconData.iconDoupleCheckmark1Small), + ), + child: StreamReactions.segmented( + items: const [ + StreamReactionsItem(emoji: Text('👍'), count: 2), + ], + overlap: false, + child: StreamMessageBubble( + child: const Text('Sure, I can help with that!'), + ), + ), + ), + ), + ], + ); + } +} + +class _MinimalSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + + return _Section( + label: 'MINIMAL', + description: 'Only the required child slot — no header or footer.', + children: [ + _ExampleCard( + label: 'Bubble only', + child: StreamMessageContent( + child: StreamMessageBubble( + backgroundColor: colorScheme.backgroundSurface, + side: BorderSide(color: colorScheme.borderSubtle), + child: const Text('Just a bubble, nothing else.'), + ), + ), + ), + _ExampleCard( + label: 'Bubble + footer only', + child: StreamMessageContent( + footer: StreamMessageMetadata( + timestamp: const Text('09:50'), + ), + child: StreamMessageBubble( + backgroundColor: colorScheme.backgroundSurface, + side: BorderSide(color: colorScheme.borderSubtle), + child: const Text('Hey!'), + ), + ), + ), + ], + ); + } +} + +// ============================================================================= +// Helper Widgets +// ============================================================================= + +class _Section extends StatelessWidget { + const _Section({ + required this.label, + required this.children, + this.description, + }); + + final String label; + final String? description; + final List children; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 16, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + _SectionLabel(label: label), + if (description case final desc?) + Text( + desc, + style: TextStyle( + fontSize: 13, + color: colorScheme.textTertiary, + ), + ), + ], + ), + ...children, + ], + ); + } +} + +class _SectionLabel extends StatelessWidget { + const _SectionLabel({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + return Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 1.2, + color: colorScheme.accentPrimary, + ), + ); + } +} + +class _ExampleCard extends StatelessWidget { + const _ExampleCard({ + required this.label, + required this.child, + }); + + final String label; + final Widget child; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final radius = context.streamRadius; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.backgroundApp, + borderRadius: BorderRadius.all(radius.md), + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.all(radius.md), + border: Border.all(color: colorScheme.borderSubtle), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: colorScheme.textSecondary, + ), + ), + child, + ], + ), + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/components.dart b/packages/stream_core_flutter/lib/src/components.dart index 797f73e..21a33dc 100644 --- a/packages/stream_core_flutter/lib/src/components.dart +++ b/packages/stream_core_flutter/lib/src/components.dart @@ -25,6 +25,7 @@ export 'components/list/stream_list_tile.dart' hide DefaultStreamListTile; export 'components/message/stream_message_alignment.dart'; export 'components/message/stream_message_annotation.dart' hide DefaultStreamMessageAnnotation; export 'components/message/stream_message_bubble.dart' hide DefaultStreamMessageBubble; +export 'components/message/stream_message_content.dart' hide DefaultStreamMessageContent; export 'components/message/stream_message_group_position.dart'; export 'components/message/stream_message_metadata.dart' hide DefaultStreamMessageMetadata; export 'components/message/stream_message_replies.dart' hide DefaultStreamMessageReplies; diff --git a/packages/stream_core_flutter/lib/src/components/message/stream_message_content.dart b/packages/stream_core_flutter/lib/src/components/message/stream_message_content.dart new file mode 100644 index 0000000..b62fb88 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/message/stream_message_content.dart @@ -0,0 +1,164 @@ +import 'package:flutter/widgets.dart'; + +import '../../theme.dart'; + +/// A composite layout container that arranges message primitives into the +/// full message content structure. +/// +/// [StreamMessageContent] composes three vertical sections — [header], +/// [child], and [footer] — stacked in a [Column]. It does not render any +/// visual decoration itself; each section is an opaque widget slot that +/// the caller fills with pre-composed primitives. +/// +/// The typical composition is: +/// +/// * **[header]** — annotation rows (pinned, saved, reminder, etc.) +/// * **[child]** — the message bubble and reply indicator, optionally +/// wrapped with reactions +/// * **[footer]** — metadata (timestamp, delivery status, etc.) +/// +/// {@tool snippet} +/// +/// Incoming message with annotations, reactions, replies, and metadata: +/// +/// ```dart +/// StreamMessageContent( +/// header: Column( +/// crossAxisAlignment: CrossAxisAlignment.start, +/// children: [ +/// StreamMessageAnnotation( +/// leading: Icon(StreamIcons.pin), +/// label: Text('Pinned'), +/// ), +/// ], +/// ), +/// footer: StreamMessageMetadata(timestamp: Text('09:41')), +/// child: StreamReactions.clustered( +/// items: [StreamReactionsItem(emoji: Text('😂'))], +/// child: Column( +/// crossAxisAlignment: CrossAxisAlignment.start, +/// children: [ +/// StreamMessageBubble(child: Text('Hello, world!')), +/// StreamMessageReplies(label: Text('3 replies')), +/// ], +/// ), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Minimal message with just a bubble: +/// +/// ```dart +/// StreamMessageContent( +/// child: StreamMessageBubble(child: Text('Hey!')), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageBubble], the visual shell of a message. +/// * [StreamMessageAnnotation], for annotation rows. +/// * [StreamMessageReplies], for thread reply indicators. +/// * [StreamMessageMetadata], for timestamp and delivery status. +/// * [StreamReactions], for wrapping content with reaction chips. +class StreamMessageContent extends StatelessWidget { + /// Creates a message content layout. + /// + /// The [child] is required; [header] and [footer] are optional and + /// omitted from the layout when null. + StreamMessageContent({ + super.key, + Widget? header, + required Widget child, + Widget? footer, + double? spacing, + }) : props = .new( + header: header, + child: child, + footer: footer, + spacing: spacing, + ); + + /// The properties that configure this content layout. + final StreamMessageContentProps props; + + @override + Widget build(BuildContext context) { + final builder = StreamComponentFactory.of(context).messageContent; + if (builder != null) return builder(context, props); + return DefaultStreamMessageContent(props: props); + } +} + +/// Properties for configuring a [StreamMessageContent]. +/// +/// See also: +/// +/// * [StreamMessageContent], which uses these properties. +class StreamMessageContentProps { + /// Creates properties for a message content layout. + const StreamMessageContentProps({ + this.header, + required this.child, + this.footer, + this.spacing, + }); + + /// Content displayed above the body. + /// + /// Typically a column of [StreamMessageAnnotation] widgets showing + /// pinned, saved, reminder, or other message annotations. + /// + /// When null, no header is shown. + final Widget? header; + + /// The body content of the message. + /// + /// Typically a [StreamMessageBubble] and [StreamMessageReplies] composed + /// in a [Column] and optionally wrapped with [StreamReactions]. + final Widget child; + + /// Content displayed below the body. + /// + /// Typically a [StreamMessageMetadata] widget showing timestamp and + /// delivery status. + /// + /// When null, no footer is shown. + final Widget? footer; + + /// The vertical spacing between the header, child, and footer sections. + /// + /// If null, defaults to [StreamSpacing.xxs]. + final double? spacing; +} + +/// The default implementation of [StreamMessageContent]. +/// +/// See also: +/// +/// * [StreamMessageContent], the public API widget. +/// * [StreamMessageContentProps], which configures this widget. +class DefaultStreamMessageContent extends StatelessWidget { + /// Creates a default message content layout with the given [props]. + const DefaultStreamMessageContent({super.key, required this.props}); + + /// The properties that configure this content layout. + final StreamMessageContentProps props; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final effectiveSpacing = props.spacing ?? spacing.xxs; + + return Column( + mainAxisSize: .min, + spacing: effectiveSpacing, + crossAxisAlignment: .stretch, + children: [?props.header, props.child, ?props.footer], + ); + } +} 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 ff420ba..6502cd3 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? messageAnnotation, StreamComponentBuilder? messageBubble, + StreamComponentBuilder? messageContent, StreamComponentBuilder? messageMetadata, StreamComponentBuilder? messageReplies, StreamComponentBuilder? onlineIndicator, @@ -173,6 +174,7 @@ class StreamComponentBuilders with _$StreamComponentBuilders { listTile: listTile, messageAnnotation: messageAnnotation, messageBubble: messageBubble, + messageContent: messageContent, messageMetadata: messageMetadata, messageReplies: messageReplies, onlineIndicator: onlineIndicator, @@ -200,6 +202,7 @@ class StreamComponentBuilders with _$StreamComponentBuilders { required this.listTile, required this.messageAnnotation, required this.messageBubble, + required this.messageContent, required this.messageMetadata, required this.messageReplies, required this.onlineIndicator, @@ -308,6 +311,11 @@ class StreamComponentBuilders with _$StreamComponentBuilders { /// When null, [StreamMessageBubble] uses [DefaultStreamMessageBubble]. final StreamComponentBuilder? messageBubble; + /// Custom builder for message content layout widgets. + /// + /// When null, [StreamMessageContent] uses [DefaultStreamMessageContent]. + final StreamComponentBuilder? messageContent; + /// Custom builder for message metadata widgets. /// /// When null, [StreamMessageMetadata] uses [DefaultStreamMessageMetadata]. 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 5019a35..4a2df20 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, messageAnnotation: t < 0.5 ? a.messageAnnotation : b.messageAnnotation, messageBubble: t < 0.5 ? a.messageBubble : b.messageBubble, + messageContent: t < 0.5 ? a.messageContent : b.messageContent, messageMetadata: t < 0.5 ? a.messageMetadata : b.messageMetadata, messageReplies: t < 0.5 ? a.messageReplies : b.messageReplies, onlineIndicator: t < 0.5 ? a.onlineIndicator : b.onlineIndicator, @@ -77,6 +78,7 @@ mixin _$StreamComponentBuilders { Widget Function(BuildContext, StreamMessageAnnotationProps)? messageAnnotation, Widget Function(BuildContext, StreamMessageBubbleProps)? messageBubble, + Widget Function(BuildContext, StreamMessageContentProps)? messageContent, Widget Function(BuildContext, StreamMessageMetadataProps)? messageMetadata, Widget Function(BuildContext, StreamMessageRepliesProps)? messageReplies, Widget Function(BuildContext, StreamOnlineIndicatorProps)? onlineIndicator, @@ -103,6 +105,7 @@ mixin _$StreamComponentBuilders { listTile: listTile ?? _this.listTile, messageAnnotation: messageAnnotation ?? _this.messageAnnotation, messageBubble: messageBubble ?? _this.messageBubble, + messageContent: messageContent ?? _this.messageContent, messageMetadata: messageMetadata ?? _this.messageMetadata, messageReplies: messageReplies ?? _this.messageReplies, onlineIndicator: onlineIndicator ?? _this.onlineIndicator, @@ -140,6 +143,7 @@ mixin _$StreamComponentBuilders { listTile: other.listTile, messageAnnotation: other.messageAnnotation, messageBubble: other.messageBubble, + messageContent: other.messageContent, messageMetadata: other.messageMetadata, messageReplies: other.messageReplies, onlineIndicator: other.onlineIndicator, @@ -178,6 +182,7 @@ mixin _$StreamComponentBuilders { _other.listTile == _this.listTile && _other.messageAnnotation == _this.messageAnnotation && _other.messageBubble == _this.messageBubble && + _other.messageContent == _this.messageContent && _other.messageMetadata == _this.messageMetadata && _other.messageReplies == _this.messageReplies && _other.onlineIndicator == _this.onlineIndicator && @@ -208,6 +213,7 @@ mixin _$StreamComponentBuilders { _this.listTile, _this.messageAnnotation, _this.messageBubble, + _this.messageContent, _this.messageMetadata, _this.messageReplies, _this.onlineIndicator, diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_annotation_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_annotation_theme.g.theme.dart index 3839511..9141773 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_message_annotation_theme.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_annotation_theme.g.theme.dart @@ -9,6 +9,79 @@ part of 'stream_message_annotation_theme.dart'; // ThemeGenGenerator // ************************************************************************** +mixin _$StreamMessageAnnotationThemeData { + bool get canMerge => true; + + static StreamMessageAnnotationThemeData? lerp( + StreamMessageAnnotationThemeData? a, + StreamMessageAnnotationThemeData? 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 StreamMessageAnnotationThemeData( + style: StreamMessageAnnotationStyle.lerp(a.style, b.style, t), + ); + } + + StreamMessageAnnotationThemeData copyWith({ + StreamMessageAnnotationStyle? style, + }) { + final _this = (this as StreamMessageAnnotationThemeData); + + return StreamMessageAnnotationThemeData(style: style ?? _this.style); + } + + StreamMessageAnnotationThemeData merge( + StreamMessageAnnotationThemeData? other, + ) { + final _this = (this as StreamMessageAnnotationThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith(style: _this.style?.merge(other.style) ?? other.style); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamMessageAnnotationThemeData); + final _other = (other as StreamMessageAnnotationThemeData); + + return _other.style == _this.style; + } + + @override + int get hashCode { + final _this = (this as StreamMessageAnnotationThemeData); + + return Object.hash(runtimeType, _this.style); + } +} + mixin _$StreamMessageAnnotationStyle { bool get canMerge => true; @@ -116,76 +189,3 @@ mixin _$StreamMessageAnnotationStyle { ); } } - -mixin _$StreamMessageAnnotationThemeData { - bool get canMerge => true; - - static StreamMessageAnnotationThemeData? lerp( - StreamMessageAnnotationThemeData? a, - StreamMessageAnnotationThemeData? 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 StreamMessageAnnotationThemeData( - style: StreamMessageAnnotationStyle.lerp(a.style, b.style, t), - ); - } - - StreamMessageAnnotationThemeData copyWith({ - StreamMessageAnnotationStyle? style, - }) { - final _this = (this as StreamMessageAnnotationThemeData); - - return StreamMessageAnnotationThemeData(style: style ?? _this.style); - } - - StreamMessageAnnotationThemeData merge( - StreamMessageAnnotationThemeData? other, - ) { - final _this = (this as StreamMessageAnnotationThemeData); - - if (other == null || identical(_this, other)) { - return _this; - } - - if (!other.canMerge) { - return other; - } - - return copyWith(style: _this.style?.merge(other.style) ?? other.style); - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - - if (other.runtimeType != runtimeType) { - return false; - } - - final _this = (this as StreamMessageAnnotationThemeData); - final _other = (other as StreamMessageAnnotationThemeData); - - return _other.style == _this.style; - } - - @override - int get hashCode { - final _this = (this as StreamMessageAnnotationThemeData); - - return Object.hash(runtimeType, _this.style); - } -} From ca8a44ec0774931f2976271d3212e654dd184525 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 11 Mar 2026 10:58:15 +0530 Subject: [PATCH 07/11] refactor(ui): wrap StreamMessageContent in SizedBox for full-width layout --- .../components/message/stream_message_content.dart | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/stream_core_flutter/lib/src/components/message/stream_message_content.dart b/packages/stream_core_flutter/lib/src/components/message/stream_message_content.dart index b62fb88..a72015a 100644 --- a/packages/stream_core_flutter/lib/src/components/message/stream_message_content.dart +++ b/packages/stream_core_flutter/lib/src/components/message/stream_message_content.dart @@ -154,11 +154,14 @@ class DefaultStreamMessageContent extends StatelessWidget { final spacing = context.streamSpacing; final effectiveSpacing = props.spacing ?? spacing.xxs; - return Column( - mainAxisSize: .min, - spacing: effectiveSpacing, - crossAxisAlignment: .stretch, - children: [?props.header, props.child, ?props.footer], + return SizedBox( + width: double.infinity, + child: Column( + mainAxisSize: .min, + spacing: effectiveSpacing, + crossAxisAlignment: .start, + children: [?props.header, props.child, ?props.footer], + ), ); } } From bea181da5db86ffb9bf2e74bbd61313576656497 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 13 Mar 2026 16:34:38 +0530 Subject: [PATCH 08/11] feat(ui): add StreamMessageText and StreamMessageWidget components for enhanced message display --- .../lib/app/gallery_app.directories.g.dart | 38 + .../message/stream_message_annotation.dart | 107 +- .../message/stream_message_bubble.dart | 436 +++---- .../message/stream_message_content.dart | 289 +++-- .../message/stream_message_metadata.dart | 227 ++-- .../message/stream_message_replies.dart | 261 ++-- .../message/stream_message_text.dart | 1107 +++++++++++++++++ .../message/stream_message_widget.dart | 935 ++++++++++++++ .../message_composer/message_composer.dart | 29 +- .../components/reaction/stream_reactions.dart | 414 +++--- .../lib/config/theme_configuration.dart | 5 + .../theme_customization_panel.dart | 5 + apps/design_system_gallery/pubspec.yaml | 2 + .../lib/src/components.dart | 8 +- .../avatar/stream_avatar_stack.dart | 14 +- .../components/common/stream_visibility.dart | 35 + .../message/stream_message_annotation.dart | 58 +- .../message/stream_message_bubble.dart | 139 +-- .../message/stream_message_content.dart | 4 +- .../message/stream_message_metadata.dart | 101 +- .../message/stream_message_replies.dart | 113 +- .../message/stream_message_text.dart | 417 +++++++ .../message/stream_message_widget.dart | 282 +++++ .../stream_message_alignment.dart | 0 .../stream_message_placement.dart | 213 ++++ .../stream_message_stack_position.dart | 37 + .../components/reaction/stream_reactions.dart | 75 +- .../src/factory/stream_component_factory.dart | 16 + .../stream_component_factory.g.theme.dart | 12 + .../stream_core_flutter/lib/src/theme.dart | 3 + .../stream_message_annotation_theme.dart | 201 +-- ...ream_message_annotation_theme.g.theme.dart | 129 +- .../stream_message_bubble_theme.dart | 175 ++- .../stream_message_bubble_theme.g.theme.dart | 117 +- .../components/stream_message_item_theme.dart | 152 +++ .../stream_message_item_theme.g.theme.dart | 150 +++ .../stream_message_metadata_theme.dart | 196 ++- ...stream_message_metadata_theme.g.theme.dart | 129 +- .../stream_message_replies_theme.dart | 160 ++- .../stream_message_replies_theme.g.theme.dart | 92 +- .../stream_message_style_property.dart | 358 ++++++ .../components/stream_message_text_theme.dart | 135 ++ .../stream_message_text_theme.g.theme.dart | 181 +++ .../components/stream_message_theme.dart | 37 + .../components/stream_reactions_theme.dart | 9 - .../stream_reactions_theme.g.theme.dart | 8 +- .../theme/semantics/stream_color_scheme.dart | 10 + .../stream_color_scheme.g.theme.dart | 10 + .../lib/src/theme/stream_theme.dart | 36 +- .../lib/src/theme/stream_theme.g.theme.dart | 33 +- .../src/theme/stream_theme_extensions.dart | 14 +- packages/stream_core_flutter/pubspec.yaml | 2 + 52 files changed, 5890 insertions(+), 1826 deletions(-) create mode 100644 apps/design_system_gallery/lib/components/message/stream_message_text.dart create mode 100644 apps/design_system_gallery/lib/components/message/stream_message_widget.dart create mode 100644 packages/stream_core_flutter/lib/src/components/common/stream_visibility.dart create mode 100644 packages/stream_core_flutter/lib/src/components/message/stream_message_text.dart create mode 100644 packages/stream_core_flutter/lib/src/components/message/stream_message_widget.dart rename packages/stream_core_flutter/lib/src/components/{message => message_placement}/stream_message_alignment.dart (100%) create mode 100644 packages/stream_core_flutter/lib/src/components/message_placement/stream_message_placement.dart create mode 100644 packages/stream_core_flutter/lib/src/components/message_placement/stream_message_stack_position.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/components/stream_message_item_theme.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/components/stream_message_item_theme.g.theme.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/components/stream_message_style_property.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/components/stream_message_text_theme.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/components/stream_message_text_theme.g.theme.dart diff --git a/apps/design_system_gallery/lib/app/gallery_app.directories.g.dart b/apps/design_system_gallery/lib/app/gallery_app.directories.g.dart index 81c7ca0..d3a8f24 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 @@ -56,6 +56,10 @@ import 'package:design_system_gallery/components/message/stream_message_metadata as _design_system_gallery_components_message_stream_message_metadata; import 'package:design_system_gallery/components/message/stream_message_replies.dart' as _design_system_gallery_components_message_stream_message_replies; +import 'package:design_system_gallery/components/message/stream_message_text.dart' + as _design_system_gallery_components_message_stream_message_text; +import 'package:design_system_gallery/components/message/stream_message_widget.dart' + as _design_system_gallery_components_message_stream_message_widget; import 'package:design_system_gallery/components/message_composer/message_composer.dart' as _design_system_gallery_components_message_composer_message_composer; import 'package:design_system_gallery/components/message_composer/message_composer_attachment_link_preview.dart' @@ -613,6 +617,40 @@ final directories = <_widgetbook.WidgetbookNode>[ ), ], ), + _widgetbook.WidgetbookComponent( + name: 'StreamMessageText', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: + _design_system_gallery_components_message_stream_message_text + .buildStreamMessageTextPlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Showcase', + builder: + _design_system_gallery_components_message_stream_message_text + .buildStreamMessageTextShowcase, + ), + ], + ), + _widgetbook.WidgetbookComponent( + name: 'StreamMessageWidget', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: + _design_system_gallery_components_message_stream_message_widget + .buildStreamMessageWidgetPlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Showcase', + builder: + _design_system_gallery_components_message_stream_message_widget + .buildStreamMessageWidgetShowcase, + ), + ], + ), ], ), _widgetbook.WidgetbookFolder( diff --git a/apps/design_system_gallery/lib/components/message/stream_message_annotation.dart b/apps/design_system_gallery/lib/components/message/stream_message_annotation.dart index b392f29..09c18a6 100644 --- a/apps/design_system_gallery/lib/components/message/stream_message_annotation.dart +++ b/apps/design_system_gallery/lib/components/message/stream_message_annotation.dart @@ -62,10 +62,12 @@ Widget buildStreamMessageAnnotationPlayground(BuildContext context) { child: StreamMessageAnnotation( leading: showLeading ? Icon(leadingIcon.resolve(icons)) : null, label: Text(label), - spacing: spacing, - padding: EdgeInsets.symmetric( - vertical: verticalPadding, - horizontal: horizontalPadding, + style: StreamMessageAnnotationStyle.from( + spacing: spacing, + padding: EdgeInsets.symmetric( + vertical: verticalPadding, + horizontal: horizontalPadding, + ), ), ), ); @@ -119,9 +121,9 @@ class _AnnotationTypesSection extends StatelessWidget { _ExampleCard( label: 'Saved', subtitle: 'Accent color for icon and text.', - child: StreamMessageAnnotationTheme( - data: StreamMessageAnnotationThemeData( - style: StreamMessageAnnotationStyle( + child: StreamMessageItemTheme( + data: StreamMessageItemThemeData( + annotation: StreamMessageAnnotationStyle.from( textColor: colorScheme.accentPrimary, iconColor: colorScheme.accentPrimary, ), @@ -224,14 +226,14 @@ class _ThemeOverrideSection extends StatelessWidget { return _Section( label: 'THEME OVERRIDES', - description: 'Per-instance overrides via StreamMessageAnnotationTheme.', + description: 'Per-instance overrides via StreamMessageItemTheme.', children: [ _ExampleCard( label: 'Custom colors', subtitle: 'Purple icon and text color.', - child: StreamMessageAnnotationTheme( - data: const StreamMessageAnnotationThemeData( - style: StreamMessageAnnotationStyle( + child: StreamMessageItemTheme( + data: StreamMessageItemThemeData( + annotation: StreamMessageAnnotationStyle.from( textColor: Colors.purple, iconColor: Colors.purple, ), @@ -245,9 +247,9 @@ class _ThemeOverrideSection extends StatelessWidget { _ExampleCard( label: 'Custom icon size', subtitle: 'Larger icon (20px).', - child: StreamMessageAnnotationTheme( - data: const StreamMessageAnnotationThemeData( - style: StreamMessageAnnotationStyle( + child: StreamMessageItemTheme( + data: StreamMessageItemThemeData( + annotation: StreamMessageAnnotationStyle.from( iconSize: 20, ), ), @@ -263,7 +265,7 @@ class _ThemeOverrideSection extends StatelessWidget { child: StreamMessageAnnotation( leading: Icon(icons.bookmark), label: const Text('Saved for later'), - spacing: 12, + style: StreamMessageAnnotationStyle.from(spacing: 12), ), ), _ExampleCard( @@ -284,7 +286,6 @@ class _RealWorldSection extends StatelessWidget { final icons = context.streamIcons; final colorScheme = context.streamColorScheme; final textTheme = context.streamTextTheme; - final radius = context.streamRadius; return _Section( label: 'REAL-WORLD EXAMPLES', @@ -296,9 +297,9 @@ class _RealWorldSection extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, spacing: 4, children: [ - StreamMessageAnnotationTheme( - data: StreamMessageAnnotationThemeData( - style: StreamMessageAnnotationStyle( + StreamMessageItemTheme( + data: StreamMessageItemThemeData( + annotation: StreamMessageAnnotationStyle.from( textColor: colorScheme.accentPrimary, iconColor: colorScheme.accentPrimary, ), @@ -309,22 +310,7 @@ class _RealWorldSection extends StatelessWidget { ), ), StreamMessageBubble( - backgroundColor: colorScheme.backgroundSurface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadiusDirectional.only( - topStart: radius.lg, - topEnd: radius.lg, - bottomEnd: radius.lg, - bottomStart: radius.xs, - ), - ), - side: BorderSide(color: colorScheme.borderSubtle), - child: Text( - 'Check out this new design system!', - style: textTheme.bodyDefault.copyWith( - color: colorScheme.textPrimary, - ), - ), + child: StreamMessageText('Check out this new design system!'), ), ], ), @@ -340,22 +326,7 @@ class _RealWorldSection extends StatelessWidget { label: const Text('Pinned by Alice'), ), StreamMessageBubble( - backgroundColor: colorScheme.backgroundSurface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadiusDirectional.only( - topStart: radius.lg, - topEnd: radius.lg, - bottomEnd: radius.lg, - bottomStart: radius.xs, - ), - ), - side: BorderSide(color: colorScheme.borderSubtle), - child: Text( - 'Meeting at 3 PM today.', - style: textTheme.bodyDefault.copyWith( - color: colorScheme.textPrimary, - ), - ), + child: StreamMessageText('Meeting at 3 PM today.'), ), ], ), @@ -381,22 +352,7 @@ class _RealWorldSection extends StatelessWidget { ), ), StreamMessageBubble( - backgroundColor: colorScheme.backgroundSurface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadiusDirectional.only( - topStart: radius.lg, - topEnd: radius.lg, - bottomEnd: radius.lg, - bottomStart: radius.xs, - ), - ), - side: BorderSide(color: colorScheme.borderSubtle), - child: Text( - 'Remember to review the PR.', - style: textTheme.bodyDefault.copyWith( - color: colorScheme.textPrimary, - ), - ), + child: StreamMessageText('Remember to review the PR.'), ), ], ), @@ -422,22 +378,7 @@ class _RealWorldSection extends StatelessWidget { ), ), StreamMessageBubble( - backgroundColor: colorScheme.backgroundSurface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadiusDirectional.only( - topStart: radius.lg, - topEnd: radius.lg, - bottomEnd: radius.lg, - bottomStart: radius.xs, - ), - ), - side: BorderSide(color: colorScheme.borderSubtle), - child: Text( - 'This was also sent to the main channel.', - style: textTheme.bodyDefault.copyWith( - color: colorScheme.textPrimary, - ), - ), + child: StreamMessageText('This was also sent to the main channel.'), ), ], ), diff --git a/apps/design_system_gallery/lib/components/message/stream_message_bubble.dart b/apps/design_system_gallery/lib/components/message/stream_message_bubble.dart index e1bd205..8cceca4 100644 --- a/apps/design_system_gallery/lib/components/message/stream_message_bubble.dart +++ b/apps/design_system_gallery/lib/components/message/stream_message_bubble.dart @@ -19,64 +19,30 @@ Widget buildStreamMessageBubblePlayground(BuildContext context) { description: 'The text content inside the bubble.', ); - final borderRadius = context.knobs.double.slider( - label: 'Border Radius', - initialValue: 24, - max: 32, - divisions: 32, - description: 'Corner radius of the bubble shape.', + final alignment = context.knobs.object.dropdown( + label: 'Alignment', + options: StreamMessageAlignment.values, + labelBuilder: (v) => v.name, + description: 'Start (incoming) or end (outgoing).', ); - final horizontalPadding = context.knobs.double.slider( - label: 'Horizontal Padding', - initialValue: 12, - max: 32, - divisions: 32, - description: 'Horizontal content padding inside the bubble.', + final stackPosition = context.knobs.object.dropdown( + label: 'Stack Position', + options: StreamMessageStackPosition.values, + labelBuilder: (v) => v.name, + description: 'Position within a consecutive message group.', ); - final verticalPadding = context.knobs.double.slider( - label: 'Vertical Padding', - initialValue: 8, - max: 32, - divisions: 32, - description: 'Vertical content padding inside the bubble.', + final placement = StreamMessagePlacementData( + alignment: alignment, + stackPosition: stackPosition, ); - final showBorder = context.knobs.boolean( - label: 'Show Border', - initialValue: true, - description: 'Whether to show a border on the bubble.', - ); - - final useOutgoingColor = context.knobs.boolean( - label: 'Outgoing Style', - description: 'Use outgoing (brand) colors instead of incoming.', - ); - - final colorScheme = context.streamColorScheme; - final textTheme = context.streamTextTheme; - - final backgroundColor = useOutgoingColor ? colorScheme.brand.shade100 : colorScheme.backgroundSurface; - - final borderColor = useOutgoingColor ? colorScheme.brand.shade100 : colorScheme.borderSubtle; - - final textColor = useOutgoingColor ? colorScheme.brand.shade900 : colorScheme.textPrimary; - return Center( - child: StreamMessageBubble( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(borderRadius), - ), - side: showBorder ? BorderSide(color: borderColor) : BorderSide.none, - padding: EdgeInsets.symmetric( - horizontal: horizontalPadding, - vertical: verticalPadding, - ), - backgroundColor: backgroundColor, - child: Text( - text, - style: textTheme.bodyDefault.copyWith(color: textColor), + child: StreamMessagePlacement( + placement: placement, + child: StreamMessageBubble( + child: StreamMessageText(text), ), ), ); @@ -92,23 +58,17 @@ Widget buildStreamMessageBubblePlayground(BuildContext context) { path: '[Components]/Message', ) Widget buildStreamMessageBubbleShowcase(BuildContext context) { - final colorScheme = context.streamColorScheme; - final textTheme = context.streamTextTheme; - - return DefaultTextStyle( - style: textTheme.bodyDefault.copyWith(color: colorScheme.textPrimary), - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 32, - children: [ - _IncomingOutgoingSection(), - _GroupPositionsSection(), - _RealWorldSection(), - _ThemeOverrideSection(), - ], - ), + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 32, + children: [ + _AlignmentSection(), + _StackPositionsSection(), + _ConversationSection(), + _StyleOverrideSection(), + ], ), ); } @@ -117,47 +77,31 @@ Widget buildStreamMessageBubbleShowcase(BuildContext context) { // Showcase Sections // ============================================================================= -class _IncomingOutgoingSection extends StatelessWidget { +class _AlignmentSection extends StatelessWidget { @override Widget build(BuildContext context) { - final colorScheme = context.streamColorScheme; - final radius = context.streamRadius; - - return _Section( - label: 'INCOMING VS OUTGOING', + return const _Section( + label: 'ALIGNMENT', description: - 'Bubbles use different background and border colors based ' - 'on message direction.', + 'Bubbles resolve background, border, and shape from the ' + 'placement alignment (start = incoming, end = outgoing).', children: [ _ExampleCard( - label: 'Incoming message', - child: Align( - alignment: AlignmentDirectional.centerStart, - child: StreamMessageBubble( - backgroundColor: colorScheme.backgroundSurface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.all(radius.xxl), - ), - side: BorderSide(color: colorScheme.borderSubtle), - child: const Text('Has anyone tried the new Flutter update?'), - ), + label: 'Start (incoming)', + child: _PlacedBubble( + alignment: StreamMessageAlignment.start, + stackPosition: StreamMessageStackPosition.single, + crossAlign: CrossAxisAlignment.start, + text: 'Has anyone tried the new Flutter update?', ), ), _ExampleCard( - label: 'Outgoing message', - child: Align( - alignment: AlignmentDirectional.centerEnd, - child: StreamMessageBubble( - backgroundColor: colorScheme.brand.shade100, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.all(radius.xxl), - ), - side: BorderSide.none, - child: Text( - 'Sure, I can help with that!', - style: TextStyle(color: colorScheme.brand.shade900), - ), - ), + label: 'End (outgoing)', + child: _PlacedBubble( + alignment: StreamMessageAlignment.end, + stackPosition: StreamMessageStackPosition.single, + crossAlign: CrossAxisAlignment.end, + text: 'Sure, I can help with that!', ), ), ], @@ -165,77 +109,72 @@ class _IncomingOutgoingSection extends StatelessWidget { } } -class _GroupPositionsSection extends StatelessWidget { +class _StackPositionsSection extends StatelessWidget { @override Widget build(BuildContext context) { - final colorScheme = context.streamColorScheme; - final radius = context.streamRadius; - - const tailRadius = Radius.circular(4); - - return _Section( - label: 'GROUP POSITIONS', + return const _Section( + label: 'STACK POSITIONS', description: 'Corner radii change based on position within a ' - 'consecutive message group.', + 'consecutive message stack.', children: [ _ExampleCard( label: 'Single (standalone)', - subtitle: 'All corners rounded', - child: StreamMessageBubble( - backgroundColor: colorScheme.backgroundSurface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.all(radius.xxl), - ), - side: BorderSide(color: colorScheme.borderSubtle), - child: const Text('A standalone message'), + child: _PlacedBubble( + alignment: StreamMessageAlignment.start, + stackPosition: StreamMessageStackPosition.single, + crossAlign: CrossAxisAlignment.start, + text: 'A standalone message', ), ), _ExampleCard( - label: 'Grouped messages (top → middle → bottom)', - subtitle: 'Inner corners tighten on the sender side', + label: 'Incoming stack (top → middle → bottom)', child: Column( - crossAxisAlignment: CrossAxisAlignment.start, spacing: 2, children: [ - StreamMessageBubble( - backgroundColor: colorScheme.backgroundSurface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadiusDirectional.only( - topStart: radius.xxl, - topEnd: radius.xxl, - bottomStart: tailRadius, - bottomEnd: radius.xxl, - ), - ), - side: BorderSide(color: colorScheme.borderSubtle), - child: const Text('First message in group'), + _PlacedBubble( + alignment: StreamMessageAlignment.start, + stackPosition: StreamMessageStackPosition.top, + crossAlign: CrossAxisAlignment.start, + text: 'First message in group', ), - StreamMessageBubble( - backgroundColor: colorScheme.backgroundSurface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadiusDirectional.only( - topStart: tailRadius, - topEnd: radius.xxl, - bottomStart: tailRadius, - bottomEnd: radius.xxl, - ), - ), - side: BorderSide(color: colorScheme.borderSubtle), - child: const Text('Middle message'), + _PlacedBubble( + alignment: StreamMessageAlignment.start, + stackPosition: StreamMessageStackPosition.middle, + crossAlign: CrossAxisAlignment.start, + text: 'Middle message', ), - StreamMessageBubble( - backgroundColor: colorScheme.backgroundSurface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadiusDirectional.only( - topStart: tailRadius, - topEnd: radius.xxl, - bottomStart: radius.xxl, - bottomEnd: radius.xxl, - ), - ), - side: BorderSide(color: colorScheme.borderSubtle), - child: const Text('Last message in group'), + _PlacedBubble( + alignment: StreamMessageAlignment.start, + stackPosition: StreamMessageStackPosition.bottom, + crossAlign: CrossAxisAlignment.start, + text: 'Last message in group', + ), + ], + ), + ), + _ExampleCard( + label: 'Outgoing stack (top → middle → bottom)', + child: Column( + spacing: 2, + children: [ + _PlacedBubble( + alignment: StreamMessageAlignment.end, + stackPosition: StreamMessageStackPosition.top, + crossAlign: CrossAxisAlignment.end, + text: 'First outgoing message', + ), + _PlacedBubble( + alignment: StreamMessageAlignment.end, + stackPosition: StreamMessageStackPosition.middle, + crossAlign: CrossAxisAlignment.end, + text: 'Middle outgoing', + ), + _PlacedBubble( + alignment: StreamMessageAlignment.end, + stackPosition: StreamMessageStackPosition.bottom, + crossAlign: CrossAxisAlignment.end, + text: 'Last outgoing message', ), ], ), @@ -245,100 +184,89 @@ class _GroupPositionsSection extends StatelessWidget { } } -class _RealWorldSection extends StatelessWidget { +class _ConversationSection extends StatelessWidget { @override Widget build(BuildContext context) { - final colorScheme = context.streamColorScheme; - final radius = context.streamRadius; - - return _Section( - label: 'REAL-WORLD EXAMPLES', - description: 'Bubbles composed with metadata and reactions.', + return const _Section( + label: 'CONVERSATION', + description: 'A realistic exchange showing how alignment and stack position combine.', children: [ _ExampleCard( - label: 'Incoming with metadata', + label: 'Mixed thread', child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 4, + spacing: 2, children: [ - StreamMessageBubble( - backgroundColor: colorScheme.backgroundSurface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.all(radius.xxl), - ), - side: BorderSide(color: colorScheme.borderSubtle), - child: const Text('Has anyone tried the new Flutter update?'), + _PlacedBubble( + alignment: StreamMessageAlignment.start, + stackPosition: StreamMessageStackPosition.top, + crossAlign: CrossAxisAlignment.start, + text: 'Hey, are you free this weekend?', ), - StreamMessageMetadata( - timestamp: const Text('09:41'), - username: const Text('Alice'), + _PlacedBubble( + alignment: StreamMessageAlignment.start, + stackPosition: StreamMessageStackPosition.bottom, + crossAlign: CrossAxisAlignment.start, + text: 'We could go hiking 🏔️', + ), + SizedBox(height: 8), + _PlacedBubble( + alignment: StreamMessageAlignment.end, + stackPosition: StreamMessageStackPosition.single, + crossAlign: CrossAxisAlignment.end, + text: 'Sounds great! Let me check my schedule.', + ), + SizedBox(height: 8), + _PlacedBubble( + alignment: StreamMessageAlignment.start, + stackPosition: StreamMessageStackPosition.single, + crossAlign: CrossAxisAlignment.start, + text: 'Perfect, let me know! 👍', ), ], ), ), - _ExampleCard( - label: 'Outgoing with status', - child: Align( - alignment: AlignmentDirectional.centerEnd, - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - spacing: 4, - children: [ - StreamMessageBubble( - backgroundColor: colorScheme.brand.shade100, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.all(radius.xxl), - ), - side: BorderSide.none, - child: Text( - 'Sure, I can help with that!', - style: TextStyle(color: colorScheme.brand.shade900), - ), - ), - StreamMessageMetadata( - timestamp: const Text('09:42'), - status: const Icon(StreamIconData.iconDoupleCheckmark1Small), - ), - ], - ), - ), - ), ], ); } } -class _ThemeOverrideSection extends StatelessWidget { +class _StyleOverrideSection extends StatelessWidget { @override Widget build(BuildContext context) { - final colorScheme = context.streamColorScheme; - return _Section( label: 'STYLE OVERRIDE', description: 'Pass a custom StreamMessageBubbleStyle to override ' - 'individual properties.', + 'individual properties while still using placement scope.', children: [ _ExampleCard( label: 'Stadium shape with large padding', child: StreamMessageBubble( - backgroundColor: colorScheme.backgroundSurface, - shape: const StadiumBorder(), - side: const BorderSide(color: Colors.deepPurple, width: 2), - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), - child: const Text('Custom shape!'), + style: StreamMessageBubbleStyle.from( + shape: const StadiumBorder(), + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 16, + ), + ), + child: StreamMessageText('Custom shape!'), ), ), _ExampleCard( label: 'Beveled rectangle', - child: StreamMessageBubble( - backgroundColor: colorScheme.backgroundSurface, - shape: const BeveledRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(12)), + child: StreamMessagePlacement( + placement: const StreamMessagePlacementData( + alignment: StreamMessageAlignment.end, + ), + child: StreamMessageBubble( + style: StreamMessageBubbleStyle.from( + shape: const BeveledRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + ), + padding: const EdgeInsets.all(16), + ), + child: StreamMessageText('Beveled corners'), ), - side: const BorderSide(color: Colors.teal), - padding: const EdgeInsets.all(16), - child: const Text('Beveled corners'), ), ), ], @@ -350,6 +278,44 @@ class _ThemeOverrideSection extends StatelessWidget { // Helper Widgets // ============================================================================= +class _PlacedBubble extends StatelessWidget { + const _PlacedBubble({ + required this.alignment, + required this.stackPosition, + required this.crossAlign, + required this.text, + }); + + final StreamMessageAlignment alignment; + final StreamMessageStackPosition stackPosition; + final CrossAxisAlignment crossAlign; + final String text; + + @override + Widget build(BuildContext context) { + final placement = StreamMessagePlacementData( + alignment: alignment, + stackPosition: stackPosition, + ); + final isDefault = placement == const StreamMessagePlacementData(); + + Widget child = StreamMessageBubble(child: StreamMessageText(text)); + if (!isDefault) { + child = StreamMessagePlacement( + placement: placement, + child: child, + ); + } + + return Align( + alignment: crossAlign == CrossAxisAlignment.end + ? AlignmentDirectional.centerEnd + : AlignmentDirectional.centerStart, + child: child, + ); + } +} + class _Section extends StatelessWidget { const _Section({ required this.label, @@ -414,11 +380,9 @@ class _ExampleCard extends StatelessWidget { const _ExampleCard({ required this.label, required this.child, - this.subtitle, }); final String label; - final String? subtitle; final Widget child; @override @@ -441,27 +405,13 @@ class _ExampleCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, spacing: 8, children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 2, - children: [ - Text( - label, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: colorScheme.textSecondary, - ), - ), - if (subtitle case final sub?) - Text( - sub, - style: TextStyle( - fontSize: 12, - color: colorScheme.textTertiary, - ), - ), - ], + Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: colorScheme.textSecondary, + ), ), child, ], diff --git a/apps/design_system_gallery/lib/components/message/stream_message_content.dart b/apps/design_system_gallery/lib/components/message/stream_message_content.dart index 19daaa1..563edd5 100644 --- a/apps/design_system_gallery/lib/components/message/stream_message_content.dart +++ b/apps/design_system_gallery/lib/components/message/stream_message_content.dart @@ -77,19 +77,17 @@ Widget buildStreamMessageContentPlayground(BuildContext context) { final textTheme = context.streamTextTheme; final palette = colorScheme.avatarPalette; - final bubble = StreamMessageBubble( - child: Text( - text, - style: textTheme.bodyDefault.copyWith( - color: colorScheme.textPrimary, - ), - ), - ); + final emojiCount = StreamMessageText.emojiOnlyCount(text); + final isEmojiOnly = emojiCount != null && emojiCount <= 3; + + final messageText = StreamMessageText(text); + final Widget messageWidget = isEmojiOnly ? messageText : StreamMessageBubble(child: messageText); final replies = showReplies ? StreamMessageReplies( label: const Text('3 replies'), avatars: _sampleAvatars(3, palette), + showConnector: !isEmojiOnly, ) : null; @@ -119,29 +117,25 @@ Widget buildStreamMessageContentPlayground(BuildContext context) { }; if (reactionOverlap) { - // Top: reactions wrap only the bubble, replies sit below. body = Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, - children: [ - buildReactions(child: bubble), - ?replies, - ], + children: [buildReactions(child: messageWidget), ?replies], ); } else { - // Bottom: reactions wrap bubble + replies together. - final inner = Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [bubble, ?replies], + body = buildReactions( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [messageWidget, ?replies], + ), ); - body = buildReactions(child: inner); } } else { body = Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, - children: [bubble, ?replies], + children: [messageWidget, ?replies], ); } @@ -234,6 +228,7 @@ Widget buildStreamMessageContentShowcase(BuildContext context) { _SlotCombinationsSection(), _ReactionVariantsSection(), _FullCompositionSection(), + _EmojiOnlySection(), _MinimalSection(), ], ), @@ -248,9 +243,8 @@ Widget buildStreamMessageContentShowcase(BuildContext context) { class _SlotCombinationsSection extends StatelessWidget { @override Widget build(BuildContext context) { - final colorScheme = context.streamColorScheme; final textTheme = context.streamTextTheme; - final palette = colorScheme.avatarPalette; + final palette = context.streamColorScheme.avatarPalette; return _Section( label: 'SLOT COMBINATIONS', @@ -281,9 +275,7 @@ class _SlotCombinationsSection extends StatelessWidget { status: const Icon(StreamIconData.iconDoupleCheckmark1Small), ), child: StreamMessageBubble( - backgroundColor: colorScheme.backgroundSurface, - side: BorderSide(color: colorScheme.borderSubtle), - child: const Text('Has anyone tried the new Flutter update?'), + child: StreamMessageText('Has anyone tried the new Flutter update?'), ), ), ), @@ -296,9 +288,7 @@ class _SlotCombinationsSection extends StatelessWidget { edited: const Text('Edited'), ), child: StreamMessageBubble( - backgroundColor: colorScheme.backgroundSurface, - side: BorderSide(color: colorScheme.borderSubtle), - child: const Text('A message with just metadata below.'), + child: StreamMessageText('A message with just metadata below.'), ), ), ), @@ -310,14 +300,12 @@ class _SlotCombinationsSection extends StatelessWidget { label: const Text('Saved for later'), ), child: StreamMessageBubble( - backgroundColor: colorScheme.backgroundSurface, - side: BorderSide(color: colorScheme.borderSubtle), - child: const Text('Saved message, no footer.'), + child: StreamMessageText('Saved message, no footer.'), ), ), ), _ExampleCard( - label: 'Child with replies + footer', + label: 'Children with replies + footer', child: StreamMessageContent( footer: StreamMessageMetadata( timestamp: const Text('09:43'), @@ -328,9 +316,7 @@ class _SlotCombinationsSection extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ StreamMessageBubble( - backgroundColor: colorScheme.backgroundSurface, - side: BorderSide(color: colorScheme.borderSubtle), - child: const Text('Let me know what you think!'), + child: StreamMessageText('Let me know what you think!'), ), StreamMessageReplies( label: const Text('5 replies'), @@ -348,8 +334,6 @@ class _SlotCombinationsSection extends StatelessWidget { class _ReactionVariantsSection extends StatelessWidget { @override Widget build(BuildContext context) { - final colorScheme = context.streamColorScheme; - return _Section( label: 'REACTION VARIANTS', description: @@ -372,9 +356,7 @@ class _ReactionVariantsSection extends StatelessWidget { alignment: StreamReactionsAlignment.end, indent: 8, child: StreamMessageBubble( - backgroundColor: colorScheme.backgroundSurface, - side: BorderSide(color: colorScheme.borderSubtle), - child: const Text('Reactions overlap the bubble edge.'), + child: StreamMessageText('Reactions overlap the bubble edge.'), ), ), ), @@ -394,9 +376,7 @@ class _ReactionVariantsSection extends StatelessWidget { ], overlap: false, child: StreamMessageBubble( - backgroundColor: colorScheme.backgroundSurface, - side: BorderSide(color: colorScheme.borderSubtle), - child: const Text('Reactions with a gap below.'), + child: StreamMessageText('Reactions with a gap below.'), ), ), ), @@ -415,9 +395,7 @@ class _ReactionVariantsSection extends StatelessWidget { alignment: StreamReactionsAlignment.end, indent: 8, child: StreamMessageBubble( - backgroundColor: colorScheme.backgroundSurface, - side: BorderSide(color: colorScheme.borderSubtle), - child: const Text('Reaction sits above the bubble.'), + child: StreamMessageText('Reaction sits above the bubble.'), ), ), ), @@ -440,9 +418,7 @@ class _ReactionVariantsSection extends StatelessWidget { ], overlap: false, child: StreamMessageBubble( - backgroundColor: colorScheme.backgroundSurface, - side: BorderSide(color: colorScheme.borderSubtle), - child: const Text('Short text.'), + child: StreamMessageText('Short text.'), ), ), ), @@ -455,9 +431,8 @@ class _ReactionVariantsSection extends StatelessWidget { class _FullCompositionSection extends StatelessWidget { @override Widget build(BuildContext context) { - final colorScheme = context.streamColorScheme; final textTheme = context.streamTextTheme; - final palette = colorScheme.avatarPalette; + final palette = context.streamColorScheme.avatarPalette; return _Section( label: 'FULL COMPOSITION', @@ -467,59 +442,163 @@ class _FullCompositionSection extends StatelessWidget { children: [ _ExampleCard( label: 'Incoming — all slots', + child: Align( + alignment: .centerLeft, + child: StreamMessageContent( + header: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + StreamMessageAnnotation( + leading: const Icon(StreamIconData.iconPin), + label: const Text('Pinned'), + ), + StreamMessageAnnotation( + leading: const Icon(StreamIconData.iconBellNotification), + label: Text.rich( + TextSpan( + children: [ + const TextSpan(text: 'Reminder set'), + TextSpan( + text: ' · In 30 minutes', + style: textTheme.metadataDefault, + ), + ], + ), + ), + ), + ], + ), + footer: StreamMessageMetadata( + timestamp: const Text('09:41'), + username: const Text('Alice'), + status: const Icon(StreamIconData.iconDoupleCheckmark1Small), + edited: const Text('Edited'), + ), + child: StreamReactions.segmented( + items: const [ + StreamReactionsItem(emoji: Text('👍'), count: 3), + StreamReactionsItem(emoji: Text('😂'), count: 1), + StreamReactionsItem(emoji: Text('❤'), count: 5), + ], + overlap: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + StreamMessageBubble( + child: StreamMessageText( + 'This message has multiple annotations, ' + 'reactions, a reply indicator, and full metadata.', + ), + ), + StreamMessageReplies( + label: const Text('5 replies'), + avatars: _sampleAvatars(3, palette), + ), + ], + ), + ), + ), + ), + ), + _ExampleCard( + label: 'Outgoing — reactions + status', + child: StreamMessagePlacement( + placement: const StreamMessagePlacementData( + alignment: StreamMessageAlignment.end, + ), + child: StreamMessageContent( + footer: StreamMessageMetadata( + timestamp: const Text('09:42'), + status: const Icon(StreamIconData.iconDoupleCheckmark1Small), + ), + child: StreamReactions.segmented( + alignment: .end, + overlap: false, + items: const [StreamReactionsItem(emoji: Text('👍'), count: 2)], + child: StreamMessageBubble( + child: StreamMessageText('Sure, I can help with that!'), + ), + ), + ), + ), + ), + ], + ); + } +} + +class _EmojiOnlySection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final palette = context.streamColorScheme.avatarPalette; + + return _Section( + label: 'EMOJI-ONLY MESSAGES', + description: + 'Messages with 1–3 emojis render without a bubble. ' + 'Shown with reactions, replies, and metadata.', + children: [ + _ExampleCard( + label: 'Single emoji + reactions + footer', + child: StreamMessageContent( + footer: StreamMessageMetadata( + timestamp: const Text('09:48'), + username: const Text('Alice'), + ), + child: StreamReactions.segmented( + items: const [ + StreamReactionsItem(emoji: Text('❤'), count: 4), + StreamReactionsItem(emoji: Text('😂'), count: 2), + ], + overlap: false, + child: StreamMessageText('👋'), + ), + ), + ), + _ExampleCard( + label: 'Two emojis + replies (no connector)', child: StreamMessageContent( - header: Column( + footer: StreamMessageMetadata( + timestamp: const Text('09:49'), + username: const Text('Bob'), + ), + child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - StreamMessageAnnotation( - leading: const Icon(StreamIconData.iconPin), - label: const Text('Pinned'), - ), - StreamMessageAnnotation( - leading: const Icon(StreamIconData.iconBellNotification), - label: Text.rich( - TextSpan( - children: [ - const TextSpan(text: 'Reminder set'), - TextSpan( - text: ' · In 30 minutes', - style: textTheme.metadataDefault, - ), - ], - ), - ), + StreamMessageText('❤️🔥'), + StreamMessageReplies( + label: const Text('3 replies'), + avatars: _sampleAvatars(2, palette), + showConnector: false, ), ], ), + ), + ), + _ExampleCard( + label: 'Three emojis + reactions + replies', + child: StreamMessageContent( footer: StreamMessageMetadata( - timestamp: const Text('09:41'), - username: const Text('Alice'), - status: const Icon(StreamIconData.iconDoupleCheckmark1Small), - edited: const Text('Edited'), + timestamp: const Text('09:50'), + username: const Text('Charlie'), ), child: StreamReactions.segmented( items: const [ - StreamReactionsItem(emoji: Text('👍'), count: 3), - StreamReactionsItem(emoji: Text('😂'), count: 1), - StreamReactionsItem(emoji: Text('❤'), count: 5), + StreamReactionsItem(emoji: Text('🔥'), count: 3), ], overlap: false, child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - StreamMessageBubble( - backgroundColor: colorScheme.backgroundSurface, - side: BorderSide(color: colorScheme.borderSubtle), - child: const Text( - 'This message has multiple annotations, ' - 'reactions, a reply indicator, and full metadata.', - ), - ), + StreamMessageText('🎉👏🔥'), StreamMessageReplies( - label: const Text('5 replies'), - avatars: _sampleAvatars(3, palette), + label: const Text('2 replies'), + avatars: _sampleAvatars(2, palette), + showConnector: false, ), ], ), @@ -527,19 +606,23 @@ class _FullCompositionSection extends StatelessWidget { ), ), _ExampleCard( - label: 'Outgoing — reactions + status', - child: StreamMessageContent( - footer: StreamMessageMetadata( - timestamp: const Text('09:42'), - status: const Icon(StreamIconData.iconDoupleCheckmark1Small), + label: 'Outgoing emoji + reactions', + child: StreamMessagePlacement( + placement: const StreamMessagePlacementData( + alignment: StreamMessageAlignment.end, ), - child: StreamReactions.segmented( - items: const [ - StreamReactionsItem(emoji: Text('👍'), count: 2), - ], - overlap: false, - child: StreamMessageBubble( - child: const Text('Sure, I can help with that!'), + child: StreamMessageContent( + footer: StreamMessageMetadata( + timestamp: const Text('09:51'), + status: const Icon(StreamIconData.iconDoupleCheckmark1Small), + ), + child: StreamReactions.segmented( + alignment: .end, + overlap: false, + items: const [ + StreamReactionsItem(emoji: Text('👍'), count: 5), + ], + child: StreamMessageText('😂'), ), ), ), @@ -552,8 +635,6 @@ class _FullCompositionSection extends StatelessWidget { class _MinimalSection extends StatelessWidget { @override Widget build(BuildContext context) { - final colorScheme = context.streamColorScheme; - return _Section( label: 'MINIMAL', description: 'Only the required child slot — no header or footer.', @@ -562,9 +643,7 @@ class _MinimalSection extends StatelessWidget { label: 'Bubble only', child: StreamMessageContent( child: StreamMessageBubble( - backgroundColor: colorScheme.backgroundSurface, - side: BorderSide(color: colorScheme.borderSubtle), - child: const Text('Just a bubble, nothing else.'), + child: StreamMessageText('Just a bubble, nothing else.'), ), ), ), @@ -575,9 +654,7 @@ class _MinimalSection extends StatelessWidget { timestamp: const Text('09:50'), ), child: StreamMessageBubble( - backgroundColor: colorScheme.backgroundSurface, - side: BorderSide(color: colorScheme.borderSubtle), - child: const Text('Hey!'), + child: StreamMessageText('Hey!'), ), ), ), diff --git a/apps/design_system_gallery/lib/components/message/stream_message_metadata.dart b/apps/design_system_gallery/lib/components/message/stream_message_metadata.dart index caf8301..df4c76a 100644 --- a/apps/design_system_gallery/lib/components/message/stream_message_metadata.dart +++ b/apps/design_system_gallery/lib/components/message/stream_message_metadata.dart @@ -81,13 +81,17 @@ Widget buildStreamMessageMetadataPlayground(BuildContext context) { status: showStatus ? Icon(statusOption.iconData) : null, username: showUsername ? Text(username) : null, edited: showEdited ? Text(editedText) : null, - spacing: spacing, - minHeight: minHeight, + style: StreamMessageMetadataStyle.from( + spacing: spacing, + minHeight: minHeight, + ), ); if (showStatus && statusOption == _StatusOption.read) { - child = StreamMessageMetadataTheme( - data: StreamMessageMetadataThemeData(statusColor: accentPrimary), + child = StreamMessageItemTheme( + data: StreamMessageItemThemeData( + metadata: StreamMessageMetadataStyle.from(statusColor: accentPrimary), + ), child: child, ); } @@ -212,8 +216,10 @@ class _DeliveryStatusSection extends StatelessWidget { _ExampleCard( label: 'Read', subtitle: 'Accent-colored double checkmark when read.', - child: StreamMessageMetadataTheme( - data: StreamMessageMetadataThemeData(statusColor: accentPrimary), + child: StreamMessageItemTheme( + data: StreamMessageItemThemeData( + metadata: StreamMessageMetadataStyle.from(statusColor: accentPrimary), + ), child: StreamMessageMetadata( timestamp: const Text('09:41'), status: const Icon(StreamIconData.iconDoupleCheckmark1Small), @@ -229,7 +235,6 @@ class _RealWorldSection extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = context.streamColorScheme; - final radius = context.streamRadius; return _Section( label: 'REAL-WORLD EXAMPLES', @@ -242,23 +247,7 @@ class _RealWorldSection extends StatelessWidget { spacing: 4, children: [ StreamMessageBubble( - backgroundColor: colorScheme.backgroundSurface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadiusDirectional.only( - topStart: radius.lg, - topEnd: radius.lg, - bottomEnd: radius.lg, - bottomStart: radius.xs, - ), - ), - side: BorderSide(color: colorScheme.borderSubtle), - child: Text( - 'Has anyone tried the new Flutter update?', - style: TextStyle( - fontSize: 15, - color: colorScheme.textPrimary, - ), - ), + child: StreamMessageText('Has anyone tried the new Flutter update?'), ), StreamMessageMetadata( timestamp: const Text('09:41'), @@ -274,23 +263,7 @@ class _RealWorldSection extends StatelessWidget { spacing: 4, children: [ StreamMessageBubble( - backgroundColor: colorScheme.backgroundSurface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadiusDirectional.only( - topStart: radius.lg, - topEnd: radius.lg, - bottomEnd: radius.lg, - bottomStart: radius.xs, - ), - ), - side: BorderSide(color: colorScheme.borderSubtle), - child: Text( - 'I think the new APIs are much better now', - style: TextStyle( - fontSize: 15, - color: colorScheme.textPrimary, - ), - ), + child: StreamMessageText('I think the new APIs are much better now'), ), StreamMessageMetadata( timestamp: const Text('09:38'), @@ -302,117 +275,88 @@ class _RealWorldSection extends StatelessWidget { ), _ExampleCard( label: 'Outgoing message (sending)', - child: Align( - alignment: AlignmentDirectional.centerEnd, - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - spacing: 4, - children: [ - StreamMessageBubble( - backgroundColor: colorScheme.accentPrimary, - shape: RoundedRectangleBorder( - borderRadius: BorderRadiusDirectional.only( - topStart: radius.lg, - topEnd: radius.lg, - bottomStart: radius.lg, - bottomEnd: radius.xs, - ), + child: StreamMessagePlacement( + placement: const StreamMessagePlacementData( + alignment: StreamMessageAlignment.end, + ), + child: Align( + alignment: AlignmentDirectional.centerEnd, + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + spacing: 4, + children: [ + StreamMessageBubble( + child: StreamMessageText('Let me check that real quick'), ), - side: BorderSide.none, - child: const Text( - 'Let me check that real quick', - style: TextStyle( - fontSize: 15, - color: Colors.white, - ), + StreamMessageMetadata( + timestamp: const Text('09:42'), + status: const Icon(StreamIconData.iconClock), ), - ), - StreamMessageMetadata( - timestamp: const Text('09:42'), - status: const Icon(StreamIconData.iconClock), - ), - ], + ], + ), ), ), ), _ExampleCard( label: 'Outgoing message (read)', - child: Align( - alignment: AlignmentDirectional.centerEnd, - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - spacing: 4, - children: [ - StreamMessageBubble( - backgroundColor: colorScheme.accentPrimary, - shape: RoundedRectangleBorder( - borderRadius: BorderRadiusDirectional.only( - topStart: radius.lg, - topEnd: radius.lg, - bottomStart: radius.lg, - bottomEnd: radius.xs, - ), + child: StreamMessagePlacement( + placement: const StreamMessagePlacementData( + alignment: StreamMessageAlignment.end, + ), + child: Align( + alignment: AlignmentDirectional.centerEnd, + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + spacing: 4, + children: [ + StreamMessageBubble( + child: StreamMessageText('Sure, I can help with that!'), ), - side: BorderSide.none, - child: const Text( - 'Sure, I can help with that!', - style: TextStyle( - fontSize: 15, - color: Colors.white, + StreamMessageItemTheme( + data: StreamMessageItemThemeData( + metadata: StreamMessageMetadataStyle.from( + statusColor: colorScheme.accentPrimary, + ), + ), + child: StreamMessageMetadata( + timestamp: const Text('09:40'), + status: const Icon(StreamIconData.iconDoupleCheckmark1Small), ), ), - ), - StreamMessageMetadataTheme( - data: StreamMessageMetadataThemeData( - statusColor: colorScheme.accentPrimary, - ), - child: StreamMessageMetadata( - timestamp: const Text('09:40'), - status: const Icon(StreamIconData.iconDoupleCheckmark1Small), - ), - ), - ], + ], + ), ), ), ), _ExampleCard( label: 'Outgoing message (read + edited)', - child: Align( - alignment: AlignmentDirectional.centerEnd, - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - spacing: 4, - children: [ - StreamMessageBubble( - backgroundColor: colorScheme.accentPrimary, - shape: RoundedRectangleBorder( - borderRadius: BorderRadiusDirectional.only( - topStart: radius.lg, - topEnd: radius.lg, - bottomStart: radius.lg, - bottomEnd: radius.xs, - ), + child: StreamMessagePlacement( + placement: const StreamMessagePlacementData( + alignment: StreamMessageAlignment.end, + ), + child: Align( + alignment: AlignmentDirectional.centerEnd, + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + spacing: 4, + children: [ + StreamMessageBubble( + child: StreamMessageText('Actually, let me rephrase that'), ), - side: BorderSide.none, - child: const Text( - 'Actually, let me rephrase that', - style: TextStyle( - fontSize: 15, - color: Colors.white, + StreamMessageItemTheme( + data: StreamMessageItemThemeData( + metadata: StreamMessageMetadataStyle.from( + statusColor: colorScheme.accentPrimary, + ), + ), + child: StreamMessageMetadata( + timestamp: const Text('09:40'), + status: const Icon(StreamIconData.iconDoupleCheckmark1Small), + edited: const Text('Edited'), ), ), - ), - StreamMessageMetadataTheme( - data: StreamMessageMetadataThemeData( - statusColor: colorScheme.accentPrimary, - ), - child: StreamMessageMetadata( - timestamp: const Text('09:40'), - status: const Icon(StreamIconData.iconDoupleCheckmark1Small), - edited: const Text('Edited'), - ), - ), - ], + ], + ), ), ), ), @@ -426,13 +370,15 @@ class _ThemeOverrideSection extends StatelessWidget { Widget build(BuildContext context) { return _Section( label: 'THEME OVERRIDES', - description: 'Per-instance overrides via StreamMessageMetadataTheme.', + description: 'Per-instance overrides via StreamMessageItemTheme.', children: [ _ExampleCard( label: 'Custom username color', - child: StreamMessageMetadataTheme( - data: const StreamMessageMetadataThemeData( - usernameColor: Colors.deepPurple, + child: StreamMessageItemTheme( + data: StreamMessageItemThemeData( + metadata: StreamMessageMetadataStyle.from( + usernameColor: Colors.deepPurple, + ), ), child: StreamMessageMetadata( timestamp: const Text('09:41'), @@ -448,7 +394,7 @@ class _ThemeOverrideSection extends StatelessWidget { username: const Text('Alice'), status: const Icon(StreamIconData.iconCheckmark1Small), edited: const Text('Edited'), - spacing: 16, + style: StreamMessageMetadataStyle.from(spacing: 16), ), ), _ExampleCard( @@ -457,8 +403,7 @@ class _ThemeOverrideSection extends StatelessWidget { child: StreamMessageMetadata( timestamp: const Text('09:41'), username: const Text('Alice'), - spacing: 4, - minHeight: 20, + style: StreamMessageMetadataStyle.from(spacing: 4, minHeight: 20), ), ), ], diff --git a/apps/design_system_gallery/lib/components/message/stream_message_replies.dart b/apps/design_system_gallery/lib/components/message/stream_message_replies.dart index ada9e89..0b7402d 100644 --- a/apps/design_system_gallery/lib/components/message/stream_message_replies.dart +++ b/apps/design_system_gallery/lib/components/message/stream_message_replies.dart @@ -80,12 +80,12 @@ Widget buildStreamMessageRepliesPlayground(BuildContext context) { description: 'Vertical padding around the row content.', ); - final clipBehavior = context.knobs.object.dropdown( + final clipOption = context.knobs.object.dropdown<_ClipOption>( label: 'Clip Behavior', - options: Clip.values, - initialOption: Clip.none, - labelBuilder: (v) => v.name, - description: 'How to clip overflow (e.g. connector).', + options: _ClipOption.values, + initialOption: _ClipOption.none, + labelBuilder: (v) => v.label, + description: 'How to clip overflow. Theme default clips for single/bottom, none for top/middle.', ); final palette = context.streamColorScheme.avatarPalette; @@ -98,9 +98,11 @@ Widget buildStreamMessageRepliesPlayground(BuildContext context) { maxAvatars: maxAvatars.toInt(), showConnector: showConnector, alignment: alignment, - spacing: spacing, - padding: EdgeInsets.symmetric(vertical: verticalPadding), - clipBehavior: clipBehavior, + clipBehavior: clipOption.clip, + style: StreamMessageRepliesStyle.from( + spacing: spacing, + padding: EdgeInsets.symmetric(vertical: verticalPadding), + ), onTap: () { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() @@ -140,6 +142,7 @@ Widget buildStreamMessageRepliesShowcase(BuildContext context) { _AlignmentSection(), _ConnectorOverflowSection(), _RealWorldSection(), + _EmojiOnlySection(), _ThemeOverrideSection(), ], ), @@ -177,7 +180,6 @@ class _SlotCombinationsSection extends StatelessWidget { ), _ExampleCard( label: 'With connector', - childPadding: _kConnectorOverflowPadding, child: StreamMessageReplies( label: const Text('5 replies'), avatars: _sampleAvatars(2, palette), @@ -186,7 +188,6 @@ class _SlotCombinationsSection extends StatelessWidget { _ExampleCard( label: 'All slots with overflow', subtitle: '5 avatars, max 2 — shows +3 badge.', - childPadding: _kConnectorOverflowPadding, child: StreamMessageReplies( label: const Text('8 replies'), avatars: _sampleAvatars(5, palette), @@ -210,7 +211,6 @@ class _AlignmentSection extends StatelessWidget { _ExampleCard( label: 'Start alignment (default)', subtitle: 'Connector → avatars → label.', - childPadding: _kConnectorOverflowPadding, child: StreamMessageReplies( label: const Text('3 replies'), avatars: _sampleAvatars(2, palette), @@ -219,7 +219,6 @@ class _AlignmentSection extends StatelessWidget { _ExampleCard( label: 'End alignment', subtitle: 'Label → avatars → connector.', - childPadding: _kConnectorOverflowPadding, child: StreamMessageReplies( label: const Text('3 replies'), avatars: _sampleAvatars(2, palette), @@ -239,12 +238,12 @@ class _ConnectorOverflowSection extends StatelessWidget { return _Section( label: 'CONNECTOR OVERFLOW', - description: 'The connector extends above the row bounds. clipBehavior controls whether it is clipped.', + description: + 'The connector extends above the row bounds. By default, single/bottom positions clip the overflow while top/middle positions do not.', children: [ _ExampleCard( - label: 'Clip.none (default)', - subtitle: 'Connector paints outside the component bounds.', - childPadding: _kConnectorOverflowPadding, + label: 'Theme default (single → clipped)', + subtitle: 'Connector overflow is clipped at the row boundary.', child: DecoratedBox( decoration: BoxDecoration( border: Border.all(color: colorScheme.borderSubtle, width: 0.5), @@ -257,9 +256,9 @@ class _ConnectorOverflowSection extends StatelessWidget { ), ), _ExampleCard( - label: 'Clip.hardEdge', - subtitle: 'Connector overflow is clipped to the row bounds.', - childPadding: _kConnectorOverflowPadding, + label: 'Clip.none (explicit)', + subtitle: 'Connector paints outside the component bounds.', + childPadding: const EdgeInsets.only(top: 24), child: DecoratedBox( decoration: BoxDecoration( border: Border.all(color: colorScheme.borderSubtle, width: 0.5), @@ -267,11 +266,31 @@ class _ConnectorOverflowSection extends StatelessWidget { child: StreamMessageReplies( label: const Text('3 replies'), avatars: _sampleAvatars(2, palette), - clipBehavior: Clip.hardEdge, + clipBehavior: Clip.none, onTap: () {}, ), ), ), + _ExampleCard( + label: 'Theme default (middle → unclipped)', + subtitle: 'Middle position leaves connector visible for stacked messages.', + childPadding: const EdgeInsets.only(top: 24), + child: StreamMessagePlacement( + placement: const StreamMessagePlacementData( + stackPosition: StreamMessageStackPosition.middle, + ), + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: colorScheme.borderSubtle, width: 0.5), + ), + child: StreamMessageReplies( + label: const Text('3 replies'), + avatars: _sampleAvatars(2, palette), + onTap: () {}, + ), + ), + ), + ), ], ); } @@ -281,8 +300,6 @@ class _RealWorldSection extends StatelessWidget { @override Widget build(BuildContext context) { final palette = context.streamColorScheme.avatarPalette; - final colorScheme = context.streamColorScheme; - final radius = context.streamRadius; return _Section( label: 'REAL-WORLD EXAMPLES', @@ -294,75 +311,38 @@ class _RealWorldSection extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ StreamMessageBubble( - backgroundColor: colorScheme.backgroundSurface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadiusDirectional.only( - topStart: radius.lg, - topEnd: radius.lg, - bottomEnd: radius.lg, - bottomStart: radius.xs, - ), - ), - side: BorderSide(color: colorScheme.borderSubtle), - child: Text( - 'Has anyone tried the new Flutter update?', - style: TextStyle( - fontSize: 15, - color: colorScheme.textPrimary, - ), - ), + child: StreamMessageText('Has anyone tried the new Flutter update?'), ), - StreamMessageRepliesTheme( - data: StreamMessageRepliesThemeData( - connectorColor: colorScheme.backgroundSurface, - ), - child: StreamMessageReplies( - label: const Text('3 replies'), - avatars: _sampleAvatars(2, palette), - onTap: () {}, - ), + StreamMessageReplies( + label: const Text('3 replies'), + avatars: _sampleAvatars(2, palette), + onTap: () {}, ), ], ), ), _ExampleCard( label: 'Outgoing message with replies', - child: Align( - alignment: AlignmentDirectional.centerEnd, - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - StreamMessageBubble( - backgroundColor: colorScheme.accentPrimary, - shape: RoundedRectangleBorder( - borderRadius: BorderRadiusDirectional.only( - topStart: radius.lg, - topEnd: radius.lg, - bottomStart: radius.lg, - bottomEnd: radius.xs, - ), - ), - side: BorderSide.none, - child: const Text( - 'Sure, I can help with that!', - style: TextStyle( - fontSize: 15, - color: Colors.white, - ), - ), - ), - StreamMessageRepliesTheme( - data: StreamMessageRepliesThemeData( - connectorColor: colorScheme.accentPrimary, + child: StreamMessagePlacement( + placement: const StreamMessagePlacementData( + alignment: StreamMessageAlignment.end, + ), + child: Align( + alignment: AlignmentDirectional.centerEnd, + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + StreamMessageBubble( + child: StreamMessageText('Sure, I can help with that!'), ), - child: StreamMessageReplies( + StreamMessageReplies( label: const Text('5 replies'), avatars: _sampleAvatars(3, palette), alignment: StreamMessageAlignment.end, onTap: () {}, ), - ), - ], + ], + ), ), ), ), @@ -372,27 +352,79 @@ class _RealWorldSection extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ StreamMessageBubble( - backgroundColor: colorScheme.backgroundSurface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadiusDirectional.only( - topStart: radius.lg, - topEnd: radius.lg, - bottomEnd: radius.lg, - bottomStart: radius.xs, - ), - ), - side: BorderSide(color: colorScheme.borderSubtle), - child: Text( - 'Let me check that.', - style: TextStyle( - fontSize: 15, - color: colorScheme.textPrimary, + child: StreamMessageText('Let me check that.'), + ), + StreamMessageReplies( + label: const Text('1 reply'), + avatars: _sampleAvatars(1, palette), + onTap: () {}, + ), + ], + ), + ), + ], + ); + } +} + +class _EmojiOnlySection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final palette = context.streamColorScheme.avatarPalette; + + return _Section( + label: 'EMOJI-ONLY MESSAGES', + description: 'Replies attached to emoji-only messages that render without a bubble.', + children: [ + _ExampleCard( + label: 'Single emoji with replies', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + StreamMessageText('👋'), + StreamMessageReplies( + label: const Text('2 replies'), + avatars: _sampleAvatars(2, palette), + showConnector: false, + onTap: () {}, + ), + ], + ), + ), + _ExampleCard( + label: 'Two emojis, outgoing with replies', + child: StreamMessagePlacement( + placement: const StreamMessagePlacementData( + alignment: StreamMessageAlignment.end, + ), + child: Align( + alignment: AlignmentDirectional.centerEnd, + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + StreamMessageText('❤️🔥'), + StreamMessageReplies( + label: const Text('4 replies'), + avatars: _sampleAvatars(3, palette), + alignment: StreamMessageAlignment.end, + showConnector: false, + onTap: () {}, ), - ), + ], ), + ), + ), + ), + _ExampleCard( + label: 'Three emojis with replies', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + StreamMessageText('🎉👏🔥'), StreamMessageReplies( label: const Text('1 reply'), avatars: _sampleAvatars(1, palette), + showConnector: false, onTap: () {}, ), ], @@ -410,13 +442,15 @@ class _ThemeOverrideSection extends StatelessWidget { return _Section( label: 'THEME OVERRIDES', - description: 'Per-instance overrides via StreamMessageRepliesTheme.', + description: 'Per-instance overrides via StreamMessageItemTheme.', children: [ _ExampleCard( label: 'Custom label color', - child: StreamMessageRepliesTheme( - data: const StreamMessageRepliesThemeData( - labelColor: Colors.deepPurple, + child: StreamMessageItemTheme( + data: StreamMessageItemThemeData( + replies: StreamMessageRepliesStyle.from( + labelColor: Colors.deepPurple, + ), ), child: StreamMessageReplies( label: const Text('3 replies'), @@ -427,11 +461,12 @@ class _ThemeOverrideSection extends StatelessWidget { _ExampleCard( label: 'Custom connector', subtitle: 'Red connector with 3px stroke.', - childPadding: _kConnectorOverflowPadding, - child: StreamMessageRepliesTheme( - data: const StreamMessageRepliesThemeData( - connectorColor: Colors.red, - connectorStrokeWidth: 3, + child: StreamMessageItemTheme( + data: StreamMessageItemThemeData( + replies: StreamMessageRepliesStyle.from( + connectorColor: Colors.red, + connectorStrokeWidth: 3, + ), ), child: StreamMessageReplies( label: const Text('3 replies'), @@ -442,11 +477,10 @@ class _ThemeOverrideSection extends StatelessWidget { _ExampleCard( label: 'Custom spacing', subtitle: 'Wider gap (16) between elements.', - childPadding: _kConnectorOverflowPadding, child: StreamMessageReplies( label: const Text('3 replies'), avatars: _sampleAvatars(2, palette), - spacing: 16, + style: StreamMessageRepliesStyle.from(spacing: 16), ), ), _ExampleCard( @@ -455,8 +489,7 @@ class _ThemeOverrideSection extends StatelessWidget { child: StreamMessageReplies( label: const Text('3 replies'), avatars: _sampleAvatars(2, palette), - spacing: 4, - padding: EdgeInsets.zero, + style: StreamMessageRepliesStyle.from(spacing: 4, padding: EdgeInsets.zero), ), ), ], @@ -468,7 +501,19 @@ class _ThemeOverrideSection extends StatelessWidget { // Helper Widgets & Data // ============================================================================= -const _kConnectorOverflowPadding = EdgeInsets.only(top: 24); +enum _ClipOption { + themeDefault(null, 'Theme default'), + none(Clip.none, 'none'), + hardEdge(Clip.hardEdge, 'hardEdge'), + antiAlias(Clip.antiAlias, 'antiAlias'), + antiAliasWithSaveLayer(Clip.antiAliasWithSaveLayer, 'antiAliasWithSaveLayer') + ; + + const _ClipOption(this.clip, this.label); + + final Clip? clip; + final String label; +} const _sampleImages = [ 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=200', diff --git a/apps/design_system_gallery/lib/components/message/stream_message_text.dart b/apps/design_system_gallery/lib/components/message/stream_message_text.dart new file mode 100644 index 0000000..abb6fec --- /dev/null +++ b/apps/design_system_gallery/lib/components/message/stream_message_text.dart @@ -0,0 +1,1107 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:markdown/markdown.dart' as md; +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: StreamMessageText, + path: '[Components]/Message', +) +Widget buildStreamMessageTextPlayground(BuildContext context) { + // -- Widget props ----------------------------------------------------------- + + final selectable = context.knobs.boolean( + label: 'Selectable', + description: 'Whether text can be selected.', + ); + + final softLineBreak = context.knobs.boolean( + label: 'Soft Line Break', + description: 'Treat single newlines as hard line breaks.', + ); + + final fitContent = context.knobs.boolean( + label: 'Fit Content', + initialValue: true, + description: 'Size to fit content vs expand to parent.', + ); + + // -- Theme props ------------------------------------------------------------ + + final textColor = context.knobs.object.dropdown<_ColorOption>( + label: 'Text Color', + options: _ColorOption.values, + labelBuilder: (v) => v.label, + description: 'Override paragraph text color.', + ); + + final linkColor = context.knobs.object.dropdown<_ColorOption>( + label: 'Link Color', + options: _ColorOption.values, + labelBuilder: (v) => v.label, + description: 'Override link color.', + ); + + final mentionColor = context.knobs.object.dropdown<_ColorOption>( + label: 'Mention Color', + options: _ColorOption.values, + labelBuilder: (v) => v.label, + description: 'Override mention color.', + ); + + final style = StreamMessageTextStyle.from( + textColor: textColor.color, + linkColor: linkColor.color, + mentionColor: mentionColor.color, + ); + + return StreamMessageItemTheme( + data: StreamMessageItemThemeData(text: style), + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + children: [ + for (final msg in _conversationMessages) + _ConversationMessage( + message: msg, + selectable: selectable, + softLineBreak: softLineBreak, + fitContent: fitContent, + ), + ], + ), + ); +} + +enum _ColorOption { + none('Default', null), + purple('Purple', Colors.purple), + red('Red', Colors.red), + green('Green', Colors.green), + orange('Orange', Colors.orange), + teal('Teal', Colors.teal) + ; + + const _ColorOption(this.label, this.color); + final String label; + final Color? color; +} + +// ============================================================================= +// Playground Helpers +// ============================================================================= + +class _ChatMessage { + const _ChatMessage({ + required this.text, + required this.alignment, + required this.stackPosition, + }); + + final String text; + final StreamMessageAlignment alignment; + final StreamMessageStackPosition stackPosition; +} + +const _conversationMessages = [ + _ChatMessage( + text: 'Hey [@Sarah](mention:sarah42), tried the new update?', + alignment: StreamMessageAlignment.start, + stackPosition: StreamMessageStackPosition.single, + ), + _ChatMessage( + text: 'Not yet! Got a [link](https://flutter.dev)?', + alignment: StreamMessageAlignment.end, + stackPosition: StreamMessageStackPosition.single, + ), + _ChatMessage( + text: 'The `Impeller` improvements are **amazing**', + alignment: StreamMessageAlignment.start, + stackPosition: StreamMessageStackPosition.top, + ), + _ChatMessage( + text: 'Jank is _basically_ gone', + alignment: StreamMessageAlignment.start, + stackPosition: StreamMessageStackPosition.bottom, + ), + _ChatMessage( + text: '\u{1F44D}\u{1F525}', + alignment: StreamMessageAlignment.end, + stackPosition: StreamMessageStackPosition.single, + ), + _ChatMessage( + text: + '[@Mike](mention:mike789) asked about that pattern:\n\n' + '```dart\nvoid debounce(Duration d, VoidCallback fn) {\n' + ' Timer(d, fn);\n}\n```', + alignment: StreamMessageAlignment.start, + stackPosition: StreamMessageStackPosition.single, + ), + _ChatMessage( + text: 'Clean! Will add it to shared utils', + alignment: StreamMessageAlignment.end, + stackPosition: StreamMessageStackPosition.single, + ), + _ChatMessage( + text: '> Always call `dispose()` to avoid leaks', + alignment: StreamMessageAlignment.start, + stackPosition: StreamMessageStackPosition.single, + ), + _ChatMessage( + text: '\u{1F680}', + alignment: StreamMessageAlignment.end, + stackPosition: StreamMessageStackPosition.single, + ), + _ChatMessage( + text: 'Already done! Check [the PR](https://github.com/example/pr/42)', + alignment: StreamMessageAlignment.start, + stackPosition: StreamMessageStackPosition.single, + ), + _ChatMessage( + text: 'LGTM, merging now', + alignment: StreamMessageAlignment.end, + stackPosition: StreamMessageStackPosition.single, + ), + _ChatMessage( + text: 'Btw [@Alice](mention:alice456) left a comment', + alignment: StreamMessageAlignment.start, + stackPosition: StreamMessageStackPosition.top, + ), + _ChatMessage( + text: 'Something about **null safety** migration', + alignment: StreamMessageAlignment.start, + stackPosition: StreamMessageStackPosition.bottom, + ), + _ChatMessage( + text: '\u{2764}\u{FE0F}\u{1F389}\u{1F60D}', + alignment: StreamMessageAlignment.end, + stackPosition: StreamMessageStackPosition.single, + ), + _ChatMessage( + text: "I'll take a look after lunch", + alignment: StreamMessageAlignment.start, + stackPosition: StreamMessageStackPosition.top, + ), + _ChatMessage( + text: '\u{1F60E}\u{1F44D}\u{1F525}\u{2764}\u{FE0F}\u{1F389}', + alignment: StreamMessageAlignment.start, + stackPosition: StreamMessageStackPosition.bottom, + ), + _ChatMessage( + text: 'That deserves a \u{1F680}!', + alignment: StreamMessageAlignment.end, + stackPosition: StreamMessageStackPosition.single, + ), +]; + +class _ConversationMessage extends StatelessWidget { + const _ConversationMessage({ + required this.message, + required this.selectable, + required this.softLineBreak, + required this.fitContent, + }); + + final _ChatMessage message; + final bool selectable; + final bool softLineBreak; + final bool fitContent; + + @override + Widget build(BuildContext context) { + final isEnd = message.alignment == StreamMessageAlignment.end; + final placement = StreamMessagePlacementData( + alignment: message.alignment, + stackPosition: message.stackPosition, + ); + + final showGap = + message.stackPosition == StreamMessageStackPosition.single || + message.stackPosition == StreamMessageStackPosition.top; + + return Padding( + padding: EdgeInsets.only(top: showGap ? 12 : 2), + child: Align( + alignment: isEnd ? Alignment.centerRight : Alignment.centerLeft, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: MediaQuery.sizeOf(context).width * 0.75, + ), + child: StreamMessagePlacement( + placement: placement, + child: Builder( + builder: (context) { + final emojiCount = StreamMessageText.emojiOnlyCount(message.text); + final hideBubble = emojiCount != null && emojiCount <= 3; + + Widget text = StreamMessageText( + message.text, + selectable: selectable, + softLineBreak: softLineBreak, + fitContent: fitContent, + onTapLink: (_, href, _) => _showSnack(context, 'Link: $href'), + onTapMention: (displayText, id) => _showSnack(context, 'Mention: $displayText (id: $id)'), + ); + + if (!hideBubble) { + text = StreamMessageBubble(child: text); + } + + return text; + }, + ), + ), + ), + ), + ); + } +} + +// ============================================================================= +// Showcase +// ============================================================================= + +@widgetbook.UseCase( + name: 'Showcase', + type: StreamMessageText, + path: '[Components]/Message', +) +Widget buildStreamMessageTextShowcase(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + + return DefaultTextStyle( + style: textTheme.bodyDefault.copyWith(color: colorScheme.textPrimary), + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 32, + children: [ + _MarkdownFeaturesSection(), + _EmojiSection(), + _ThemeOverridesSection(), + _RealWorldSection(), + _ExtensibilitySection(), + ], + ), + ), + ); +} + +// ============================================================================= +// Showcase Sections +// ============================================================================= + +class _MarkdownFeaturesSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + return _Section( + label: 'MARKDOWN FEATURES', + description: 'Core markdown rendering capabilities.', + children: [ + _ExampleCard( + label: 'Inline formatting', + subtitle: 'Bold, italic, strikethrough, code.', + child: StreamMessageText( + '**Bold**, _italic_, ~~strikethrough~~, and `inline code`.', + ), + ), + _ExampleCard( + label: 'Links', + subtitle: 'Clickable hyperlinks.', + child: Builder( + builder: (context) => StreamMessageText( + 'Visit [Flutter](https://flutter.dev) or [Dart](https://dart.dev).', + onTapLink: (_, href, _) => _showSnack(context, 'Link: $href'), + ), + ), + ), + _ExampleCard( + label: 'Mentions', + subtitle: 'Styled mention links using the mention: protocol.', + child: Builder( + builder: (context) => StreamMessageText( + 'Hey [@Alice](mention:alice123), have you seen ' + "[@Bob's update](mention:bob456)?", + onTapMention: (displayText, id) => _showSnack(context, 'Mention: $displayText (id: $id)'), + ), + ), + ), + _ExampleCard( + label: 'Headings', + subtitle: 'H1 through H6.', + child: StreamMessageText( + '# Heading 1\n## Heading 2\n### Heading 3\n' + '#### Heading 4\n##### Heading 5\n###### Heading 6', + ), + ), + _ExampleCard( + label: 'Lists', + subtitle: 'Ordered and unordered.', + child: StreamMessageText( + '- First item\n- Second item\n - Nested item\n\n' + '1. Step one\n2. Step two\n3. Step three', + ), + ), + _ExampleCard( + label: 'Blockquote', + subtitle: 'Quoted text with left border.', + child: StreamMessageText( + '> The best way to predict the future\n' + '> is to invent it.\n\n— Alan Kay', + ), + ), + _ExampleCard( + label: 'Code block', + subtitle: 'Fenced code with decoration.', + child: StreamMessageText( + "```dart\nvoid main() {\n print('Hello, world!');\n}\n```", + ), + ), + _ExampleCard( + label: 'Table', + subtitle: 'Bordered data table.', + child: StreamMessageText( + '| Feature | Status |\n|---------|--------|\n' + '| Markdown | Done |\n| Theming | Done |\n| Tests | Pending |', + ), + ), + _ExampleCard( + label: 'Horizontal rule', + child: StreamMessageText('Above the line\n\n---\n\nBelow the line'), + ), + ], + ); + } +} + +class _EmojiSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + return _Section( + label: 'EMOJI-ONLY MESSAGES', + description: + 'Messages containing only emojis render at larger sizes ' + 'without a bubble. Size scales by emoji count.', + children: [ + _ExampleCard( + label: '1 emoji — xxl (${StreamEmojiSize.xxl.value.toInt()}px)', + child: StreamMessageText('\u{1F680}'), + ), + _ExampleCard( + label: '2 emojis — xl (${StreamEmojiSize.xl.value.toInt()}px)', + child: StreamMessageText('\u{1F44D}\u{1F525}'), + ), + _ExampleCard( + label: '3 emojis — lg (${StreamEmojiSize.lg.value.toInt()}px)', + child: StreamMessageText('\u{2764}\u{FE0F}\u{1F389}\u{1F60D}'), + ), + _ExampleCard( + label: '4+ emojis — regular size', + child: StreamMessageBubble( + child: StreamMessageText( + '\u{1F60E}\u{1F44D}\u{1F525}\u{2764}\u{FE0F}\u{1F389}', + ), + ), + ), + _ExampleCard( + label: 'ZWJ family (1 grapheme)', + child: StreamMessageText('\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}'), + ), + _ExampleCard( + label: 'Flag (1 grapheme)', + child: StreamMessageText('\u{1F1FA}\u{1F1F8}'), + ), + _ExampleCard( + label: 'Skin tone (1 grapheme)', + child: StreamMessageText('\u{1F44D}\u{1F3FD}'), + ), + _ExampleCard( + label: 'Mixed text + emoji — regular', + child: StreamMessageBubble( + child: StreamMessageText('Great job \u{1F44D}'), + ), + ), + ], + ); + } +} + +class _ThemeOverridesSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + + return _Section( + label: 'THEME OVERRIDES', + description: 'Per-instance and placement-aware style overrides.', + children: [ + _ExampleCard( + label: 'Custom text color', + subtitle: 'Purple text via style override.', + child: StreamMessageText( + 'This text is purple via a style override.', + style: StreamMessageTextStyle.from(textColor: Colors.purple), + ), + ), + _ExampleCard( + label: 'Custom code block', + subtitle: 'Dark background with green monospace text via styleSheet.', + child: StreamMessageText( + '```\nconst greeting = "Hello!";\nconsole.log(greeting);\n```', + styleSheet: MarkdownStyleSheet( + codeblockDecoration: BoxDecoration( + color: const Color(0xFF1E1E1E), + borderRadius: BorderRadius.circular(8), + ), + code: const TextStyle( + fontFamily: 'monospace', + color: Color(0xFF4EC9B0), + ), + ), + ), + ), + _ExampleCard( + label: 'Custom blockquote', + subtitle: 'Brand-colored left border via styleSheet.', + child: StreamMessageText( + '> Design is not just what it looks like\n> and feels like.\n' + '> Design is how it works.\n\n— Steve Jobs', + styleSheet: MarkdownStyleSheet( + blockquoteDecoration: BoxDecoration( + border: Border( + left: BorderSide(color: colorScheme.accentPrimary, width: 4), + ), + ), + ), + ), + ), + _ExampleCard( + label: 'Placement-aware styling', + subtitle: 'Different text colors for start vs end alignment.', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + StreamMessagePlacement( + placement: const StreamMessagePlacementData(), + child: StreamMessageItemTheme( + data: StreamMessageItemThemeData( + text: StreamMessageTextStyle( + textColor: StreamMessageStyleProperty.resolveWith((p) { + return p.alignment == StreamMessageAlignment.end ? Colors.white : Colors.black87; + }), + ), + ), + child: StreamMessageBubble( + child: StreamMessageText('Start-aligned message (dark text)'), + ), + ), + ), + Align( + alignment: Alignment.centerRight, + child: StreamMessagePlacement( + placement: const StreamMessagePlacementData(alignment: StreamMessageAlignment.end), + child: StreamMessageItemTheme( + data: StreamMessageItemThemeData( + text: StreamMessageTextStyle( + textColor: StreamMessageStyleProperty.resolveWith((p) { + return p.alignment == StreamMessageAlignment.end ? Colors.white : Colors.black87; + }), + ), + ), + child: StreamMessageBubble( + child: StreamMessageText('End-aligned message (white text)'), + ), + ), + ), + ), + ], + ), + ), + ], + ); + } +} + +class _RealWorldSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + return _Section( + label: 'REAL-WORLD COMPOSITIONS', + description: + 'Message text inside full message layouts with bubbles, ' + 'metadata, annotations, replies, and reactions.', + children: [ + _ExampleCard( + label: 'Chat message with mention', + subtitle: 'Text in bubble with a mention and metadata.', + child: Builder( + builder: (context) => StreamMessageContent( + footer: StreamMessageMetadata( + timestamp: const Text('09:41'), + ), + child: StreamMessageBubble( + child: StreamMessageText( + 'Hey [@Sarah](mention:sarah42), have you tried the ' + '**new Flutter update**? The performance ' + 'improvements are _amazing_!', + onTapMention: (displayText, id) => _showSnack(context, 'Mention: $displayText (id: $id)'), + ), + ), + ), + ), + ), + _ExampleCard( + label: 'AI response', + subtitle: 'Rich markdown with code in bubble.', + child: StreamMessageContent( + footer: StreamMessageMetadata( + timestamp: const Text('09:43'), + ), + child: StreamMessageBubble( + child: StreamMessageText( + '## Quick Sort in Dart\n\n' + "Here's an efficient implementation:\n\n" + '```dart\n' + 'List quickSort(List list) {\n' + ' if (list.length <= 1) return list;\n' + ' final pivot = list[list.length ~/ 2];\n' + ' final less = list.where((e) => e < pivot).toList();\n' + ' final equal = list.where((e) => e == pivot).toList();\n' + ' final greater = list.where((e) => e > pivot).toList();\n' + ' return [...quickSort(less), ...equal, ...quickSort(greater)];\n' + '}\n' + '```\n\n' + 'The time complexity is **O(n log n)** on average.', + ), + ), + ), + ), + _ExampleCard( + label: 'Pinned message with replies', + subtitle: 'Annotation, bubble, metadata, and reply indicator.', + child: StreamMessageContent( + header: StreamMessageAnnotation( + leading: Icon(context.streamIcons.pin), + label: const Text('Pinned by Alice'), + ), + footer: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + StreamMessageMetadata( + timestamp: const Text('08:30'), + ), + StreamMessageReplies( + label: const Text('4 replies'), + ), + ], + ), + child: StreamMessageBubble( + child: StreamMessageText( + 'Meeting agenda:\n\n' + '1. **Sprint review** — demo the new features\n' + '2. **Retro** — what went well / what to improve\n' + '3. **Planning** — next sprint priorities\n\n' + '> Please come prepared with your updates.', + ), + ), + ), + ), + _ExampleCard( + label: 'Message with reactions', + subtitle: 'Bubble with markdown text and reaction chips.', + child: StreamMessageContent( + footer: StreamMessageMetadata( + timestamp: const Text('10:15'), + ), + child: StreamReactions.segmented( + items: const [ + StreamReactionsItem(emoji: Text('\u{1F680}'), count: 5), + StreamReactionsItem(emoji: Text('\u{1F44D}'), count: 3), + StreamReactionsItem(emoji: Text('\u{2764}\u{FE0F}'), count: 2), + ], + child: StreamMessageBubble( + child: StreamMessageText( + 'Just shipped the new **markdown component**!\n\n' + 'Features:\n' + '- Full GFM support\n' + '- Placement-aware theming\n' + '- Custom builder extensibility', + ), + ), + ), + ), + ), + ], + ); + } +} + +class _ExtensibilitySection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + + return _Section( + label: 'EXTENSIBILITY', + description: + 'Demonstrates custom builders, syntax highlighting, ' + 'link handlers, and image builders.', + children: [ + _ExampleCard( + label: 'Custom syntax highlighter', + subtitle: 'Keyword-aware highlighting via SyntaxHighlighter.', + child: StreamMessageText( + '```dart\n' + "import 'dart:async';\n\n" + 'void main() async {\n' + ' final stream = Stream.periodic(\n' + ' const Duration(seconds: 1),\n' + ' (i) => i,\n' + ' );\n\n' + ' await for (final value in stream) {\n' + ' if (value > 5) break;\n' + " print('Tick: \$value');\n" + ' }\n' + '}\n' + '```', + syntaxHighlighter: _DemoSyntaxHighlighter(colorScheme), + ), + ), + _ExampleCard( + label: 'Custom code block builder', + subtitle: 'Code blocks with a copy button and language label.', + child: StreamMessageText( + '```dart\n' + 'class Greeting {\n' + ' final String name;\n' + ' Greeting(this.name);\n\n' + " String say() => 'Hello, \$name!';\n" + '}\n' + '```', + builders: {'pre': _CopyableCodeBlockBuilder(colorScheme)}, + ), + ), + _ExampleCard( + label: 'Custom link handler', + subtitle: 'Links with a snackbar preview on tap.', + child: Builder( + builder: (context) => StreamMessageText( + 'Check out [Flutter docs](https://flutter.dev) ' + 'or [Dart packages](https://pub.dev).', + onTapLink: (_, href, _) => _showSnack(context, 'Link: $href'), + ), + ), + ), + _ExampleCard( + label: 'Mention handler', + subtitle: 'Mentions and links with separate tap handling.', + child: Builder( + builder: (context) => StreamMessageText( + '[@Alice](mention:alice123) shared ' + '[this article](https://example.com) with ' + '[@Bob](mention:bob456).', + onTapMention: (displayText, id) => _showSnack(context, 'Mention: $displayText (id: $id)'), + onTapLink: (_, href, _) => _showSnack(context, 'Link: $href'), + ), + ), + ), + _ExampleCard( + label: 'Custom image builder', + subtitle: 'Markdown images with rounded corners and placeholder.', + child: StreamMessageText( + '![Flutter logo](https://storage.googleapis.com/cms-storage-bucket/c823e53b3a1a7b0d36a9.png)', + imageBuilder: (uri, title, alt) { + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + uri.toString(), + width: 200, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => DecoratedBox( + decoration: BoxDecoration(color: colorScheme.backgroundSurface), + child: SizedBox( + width: 200, + height: 100, + child: Center( + child: Icon( + Icons.broken_image_outlined, + color: colorScheme.textTertiary, + ), + ), + ), + ), + ), + ); + }, + ), + ), + _ExampleCard( + label: 'MarkdownStyleSheet escape hatch', + subtitle: 'Fine-grained styling via the styleSheet prop.', + child: StreamMessageText( + '# Custom heading\n\n' + 'Paragraph with **bold** and a `code span`.\n\n' + '> A styled blockquote.', + styleSheet: MarkdownStyleSheet( + h1: TextStyle( + fontSize: 28, + fontWeight: FontWeight.w900, + color: colorScheme.accentPrimary, + ), + blockquoteDecoration: BoxDecoration( + color: colorScheme.accentPrimary.withValues(alpha: 0.1), + border: Border( + left: BorderSide(color: colorScheme.accentPrimary, width: 4), + ), + ), + ), + ), + ), + ], + ); + } +} + +// ============================================================================= +// Demo Syntax Highlighter +// ============================================================================= + +class _DemoSyntaxHighlighter extends SyntaxHighlighter { + _DemoSyntaxHighlighter(this._colorScheme); + + final StreamColorScheme _colorScheme; + + static final _keywords = RegExp( + r'\b(import|void|main|async|await|for|final|const|if|break|in|return|class|extends|with|mixin|enum|switch|case|default|try|catch|throw|new|this|super)\b', + ); + static final _strings = RegExp("'[^']*'"); + static final _comments = RegExp(r'//.*$', multiLine: true); + static final _types = RegExp( + r'\b(String|int|double|bool|List|Map|Set|Future|Stream|Duration|void|dynamic|var|Object)\b', + ); + + @override + TextSpan format(String source) { + final spans = []; + final matches = <_SyntaxMatch>[]; + + for (final m in _comments.allMatches(source)) { + matches.add(_SyntaxMatch(m.start, m.end, _MatchType.comment)); + } + for (final m in _strings.allMatches(source)) { + matches.add(_SyntaxMatch(m.start, m.end, _MatchType.string)); + } + for (final m in _keywords.allMatches(source)) { + if (!matches.any((s) => m.start >= s.start && m.start < s.end)) { + matches.add(_SyntaxMatch(m.start, m.end, _MatchType.keyword)); + } + } + for (final m in _types.allMatches(source)) { + if (!matches.any((s) => m.start >= s.start && m.start < s.end)) { + matches.add(_SyntaxMatch(m.start, m.end, _MatchType.type)); + } + } + + matches.sort((a, b) => a.start.compareTo(b.start)); + + var lastEnd = 0; + for (final match in matches) { + if (match.start > lastEnd) { + spans.add(TextSpan(text: source.substring(lastEnd, match.start))); + } + spans.add( + TextSpan( + text: source.substring(match.start, match.end), + style: _styleFor(match.type), + ), + ); + lastEnd = match.end; + } + if (lastEnd < source.length) { + spans.add(TextSpan(text: source.substring(lastEnd))); + } + + return TextSpan(children: spans); + } + + TextStyle _styleFor(_MatchType type) => switch (type) { + _MatchType.keyword => TextStyle(color: _colorScheme.accentPrimary, fontWeight: FontWeight.bold), + _MatchType.string => TextStyle(color: Colors.green.shade700), + _MatchType.comment => TextStyle(color: _colorScheme.textTertiary, fontStyle: FontStyle.italic), + _MatchType.type => TextStyle(color: Colors.teal.shade600), + }; +} + +enum _MatchType { keyword, string, comment, type } + +class _SyntaxMatch { + const _SyntaxMatch(this.start, this.end, this.type); + final int start; + final int end; + final _MatchType type; +} + +// ============================================================================= +// Custom Code Block Builder +// ============================================================================= + +class _CopyableCodeBlockBuilder extends MarkdownElementBuilder { + _CopyableCodeBlockBuilder(this._colorScheme); + + final StreamColorScheme _colorScheme; + + @override + Widget? visitElementAfterWithContext( + BuildContext context, + md.Element element, + TextStyle? preferredStyle, + TextStyle? parentStyle, + ) { + final code = element.textContent.trimRight(); + final language = element.attributes['class']?.replaceFirst('language-', '') ?? ''; + + return _CopyableCodeBlock( + code: code, + language: language, + colorScheme: _colorScheme, + ); + } +} + +class _CopyableCodeBlock extends StatefulWidget { + const _CopyableCodeBlock({ + required this.code, + required this.language, + required this.colorScheme, + }); + + final String code; + final String language; + final StreamColorScheme colorScheme; + + @override + State<_CopyableCodeBlock> createState() => _CopyableCodeBlockState(); +} + +class _CopyableCodeBlockState extends State<_CopyableCodeBlock> { + var _copied = false; + + Future _copy() async { + await Clipboard.setData(ClipboardData(text: widget.code)); + if (!mounted) return; + setState(() => _copied = true); + await Future.delayed(const Duration(seconds: 2)); + if (!mounted) return; + setState(() => _copied = false); + } + + @override + Widget build(BuildContext context) { + final cs = widget.colorScheme; + + return DecoratedBox( + decoration: BoxDecoration( + color: cs.backgroundSurface, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: cs.borderSubtle), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: cs.borderSubtle.withValues(alpha: 0.3), + borderRadius: const BorderRadius.vertical(top: Radius.circular(7)), + ), + child: Row( + children: [ + if (widget.language.isNotEmpty) + Text( + widget.language, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: cs.textSecondary, + ), + ), + const Spacer(), + GestureDetector( + onTap: _copy, + child: Row( + spacing: 4, + children: [ + Icon( + _copied ? Icons.check : Icons.copy, + size: 14, + color: _copied ? Colors.green : cs.textTertiary, + ), + Text( + _copied ? 'Copied!' : 'Copy', + style: TextStyle( + fontSize: 12, + color: _copied ? Colors.green : cs.textTertiary, + ), + ), + ], + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.all(12), + child: SelectableText( + widget.code, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 13, + color: cs.textPrimary, + ), + ), + ), + ], + ), + ); + } +} + +// ============================================================================= +// Helpers +// ============================================================================= + +void _showSnack(BuildContext context, String message) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text(message), duration: const Duration(seconds: 2)), + ); +} + +// ============================================================================= +// Helper Widgets +// ============================================================================= + +class _Section extends StatelessWidget { + const _Section({ + required this.label, + required this.children, + this.description, + }); + + final String label; + final String? description; + final List children; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 16, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + _SectionLabel(label: label), + if (description case final desc?) + Text( + desc, + style: TextStyle(fontSize: 13, color: colorScheme.textTertiary), + ), + ], + ), + ...children, + ], + ); + } +} + +class _SectionLabel extends StatelessWidget { + const _SectionLabel({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + return Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 1.2, + color: colorScheme.accentPrimary, + ), + ); + } +} + +class _ExampleCard extends StatelessWidget { + const _ExampleCard({ + required this.label, + required this.child, + this.subtitle, + }); + + final String label; + final String? subtitle; + final Widget child; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final radius = context.streamRadius; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.backgroundApp, + borderRadius: BorderRadius.all(radius.md), + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.all(radius.md), + border: Border.all(color: colorScheme.borderSubtle), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 2, + children: [ + Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: colorScheme.textSecondary, + ), + ), + if (subtitle case final sub?) + Text( + sub, + style: TextStyle(fontSize: 12, color: colorScheme.textTertiary), + ), + ], + ), + child, + ], + ), + ); + } +} diff --git a/apps/design_system_gallery/lib/components/message/stream_message_widget.dart b/apps/design_system_gallery/lib/components/message/stream_message_widget.dart new file mode 100644 index 0000000..a6a5685 --- /dev/null +++ b/apps/design_system_gallery/lib/components/message/stream_message_widget.dart @@ -0,0 +1,935 @@ +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: StreamMessageWidget, + path: '[Components]/Message', +) +Widget buildStreamMessageWidgetPlayground(BuildContext context) { + // -- Widget props ----------------------------------------------------------- + + final text = context.knobs.string( + label: 'Message Text', + initialValue: + 'Has anyone tried the new Flutter update? ' + 'The performance improvements are amazing!', + description: 'The text content inside the message bubble.', + ); + + final alignment = context.knobs.object.dropdown( + label: 'Alignment', + options: StreamMessageAlignment.values, + labelBuilder: (v) => v.name, + description: 'Start (incoming) or end (outgoing).', + ); + + final messageCount = context.knobs.int.slider( + label: 'Message Count', + initialValue: 1, + min: 1, + max: 5, + description: 'Number of messages in the stack. Positions are assigned automatically.', + ); + + // -- Content slots ---------------------------------------------------------- + + final showHeader = context.knobs.boolean( + label: 'Show Header', + description: 'Toggle the annotation header.', + ); + + final showReplies = context.knobs.boolean( + label: 'Show Replies', + description: 'Toggle the reply indicator with avatars.', + ); + + final showReactions = context.knobs.boolean( + label: 'Show Reactions', + description: 'Wrap the bubble with reactions.', + ); + + final reactionCount = showReactions + ? context.knobs.int.slider( + label: 'Reaction Count', + initialValue: 3, + min: 1, + max: _allReactions.length, + description: 'Number of distinct reaction types to show.', + ) + : 0; + + final reactionPosition = showReactions + ? context.knobs.object.dropdown( + label: 'Reaction Position', + options: StreamReactionsPosition.values, + initialOption: StreamReactionsPosition.header, + labelBuilder: (v) => v.name, + description: 'Where reactions sit relative to the bubble.', + ) + : StreamReactionsPosition.footer; + + final reactionOverlap = + showReactions && + context.knobs.boolean( + label: 'Reaction Overlap', + initialValue: true, + description: 'Whether reactions overlap the bubble edge.', + ); + + final showFooter = context.knobs.boolean( + label: 'Show Footer', + initialValue: true, + description: 'Toggle the metadata footer.', + ); + + // -- Build ------------------------------------------------------------------ + + final textTheme = context.streamTextTheme; + final palette = context.streamColorScheme.avatarPalette; + + final crossAlign = switch (alignment) { + StreamMessageAlignment.start => CrossAxisAlignment.start, + StreamMessageAlignment.end => CrossAxisAlignment.end, + }; + + Widget buildBody(String messageText) { + final textWidget = StreamMessageText(messageText); + final eCount = StreamMessageText.emojiOnlyCount(messageText); + final emoji = eCount != null && eCount <= 3; + final Widget messageBody = emoji ? textWidget : StreamMessageBubble(child: textWidget); + + final replies = showReplies + ? StreamMessageReplies( + label: const Text('3 replies'), + avatars: _sampleAvatars(3, palette), + showConnector: !emoji, + onTap: () {}, + ) + : null; + + Widget body = Column( + crossAxisAlignment: crossAlign, + mainAxisSize: MainAxisSize.min, + children: [messageBody, ?replies], + ); + + if (showReactions) { + final items = _allReactions.take(reactionCount).toList(); + + Widget buildReactions({required Widget child}) => StreamReactions.segmented( + items: items, + position: reactionPosition, + overlap: reactionOverlap, + onPressed: () {}, + child: child, + ); + + if (reactionOverlap) { + body = Column( + crossAxisAlignment: crossAlign, + mainAxisSize: MainAxisSize.min, + children: [ + buildReactions(child: messageBody), + ?replies, + ], + ); + } else { + body = buildReactions(child: body); + } + } + + return body; + } + + StreamMessageStackPosition stackPositionFor(int index, int count) { + if (count == 1) return StreamMessageStackPosition.single; + if (index == 0) return StreamMessageStackPosition.top; + if (index == count - 1) return StreamMessageStackPosition.bottom; + return StreamMessageStackPosition.middle; + } + + final avatar = StreamAvatar( + imageUrl: _avatarImages[0], + backgroundColor: palette[0].backgroundColor, + foregroundColor: palette[0].foregroundColor, + placeholder: (context) => const Text('AJ'), + ); + + final messages = [ + for (var i = 0; i < messageCount; i++) + StreamMessageWidget( + alignment: alignment, + stackPosition: stackPositionFor(i, messageCount), + onTap: () => _showSnack(context, 'Message tapped'), + onLongPress: () => _showSnack(context, 'Message long-pressed'), + leading: avatar, + child: i == messageCount - 1 + ? StreamMessageContent( + header: showHeader + ? StreamMessageAnnotation( + leading: const Icon(StreamIconData.iconBellNotification), + label: Text.rich( + TextSpan( + children: [ + const TextSpan(text: 'Reminder set'), + TextSpan( + text: ' · In 2 hours', + style: textTheme.metadataDefault, + ), + ], + ), + ), + ) + : null, + footer: showFooter + ? StreamMessageMetadata( + timestamp: const Text('09:41'), + username: const Text('Alice'), + status: const Icon(StreamIconData.iconDoupleCheckmark1Small), + ) + : null, + child: buildBody(text), + ) + : StreamMessageContent( + child: StreamMessageBubble( + child: StreamMessageText(_stackMessages[i % _stackMessages.length]), + ), + ), + ), + ]; + + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 2, + children: messages, + ), + ); +} + +// ============================================================================= +// Showcase +// ============================================================================= + +@widgetbook.UseCase( + name: 'Showcase', + type: StreamMessageWidget, + path: '[Components]/Message', +) +Widget buildStreamMessageWidgetShowcase(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + + return DefaultTextStyle( + style: textTheme.bodyDefault.copyWith(color: colorScheme.textPrimary), + child: const SingleChildScrollView( + padding: EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 32, + children: [ + _AlignmentSection(), + _StackPositionsSection(), + _VisibilitySection(), + _FullCompositionSection(), + _EmojiOnlySection(), + _ConversationSection(), + _ThemeOverrideSection(), + _MinimalSection(), + ], + ), + ), + ); +} + +// ============================================================================= +// Showcase Sections +// ============================================================================= + +class _AlignmentSection extends StatelessWidget { + const _AlignmentSection(); + + @override + Widget build(BuildContext context) { + return const _Section( + label: 'ALIGNMENT', + description: + 'The leading avatar is placed at the start or end of the row ' + 'depending on alignment. Descendants receive placement context.', + children: [ + _ExampleCard( + label: 'Start (incoming)', + child: _MessageItem( + avatarIndex: 0, + text: 'Has anyone tried the new Flutter update?', + timestamp: '09:41', + username: 'Alice', + ), + ), + _ExampleCard( + label: 'End (outgoing)', + child: _MessageItem( + alignment: StreamMessageAlignment.end, + avatarIndex: 1, + text: 'Sure, I can help with that!', + timestamp: '09:42', + status: Icon(StreamIconData.iconDoupleCheckmark1Small), + ), + ), + _ExampleCard( + label: 'Start — no avatar', + child: _MessageItem( + text: 'A message without a leading avatar.', + timestamp: '09:43', + ), + ), + ], + ); + } +} + +class _StackPositionsSection extends StatelessWidget { + const _StackPositionsSection(); + + @override + Widget build(BuildContext context) { + return const _Section( + label: 'STACK POSITIONS', + description: + 'In a consecutive group, only the bottom message shows the ' + 'avatar. Middle and top messages hide it while preserving spacing. ' + 'This is driven by the default leadingVisibility in the theme.', + children: [ + _ExampleCard( + label: 'Incoming stack (top → middle → bottom)', + child: Column( + spacing: 2, + children: [ + _MessageItem( + stackPosition: StreamMessageStackPosition.top, + avatarIndex: 0, + text: 'The keynote was incredible this year', + ), + _MessageItem( + stackPosition: StreamMessageStackPosition.middle, + avatarIndex: 0, + text: 'Especially the Impeller demo', + ), + _MessageItem( + stackPosition: StreamMessageStackPosition.bottom, + avatarIndex: 0, + text: 'Did you catch the part about Wasm support?', + timestamp: '09:41', + username: 'Alice', + ), + ], + ), + ), + _ExampleCard( + label: 'Outgoing stack (top → middle → bottom)', + child: Column( + spacing: 2, + children: [ + _MessageItem( + alignment: StreamMessageAlignment.end, + stackPosition: StreamMessageStackPosition.top, + avatarIndex: 1, + text: 'Yes! The performance charts were wild', + ), + _MessageItem( + alignment: StreamMessageAlignment.end, + stackPosition: StreamMessageStackPosition.middle, + avatarIndex: 1, + text: '60fps on low-end devices', + ), + _MessageItem( + alignment: StreamMessageAlignment.end, + stackPosition: StreamMessageStackPosition.bottom, + avatarIndex: 1, + text: "Can't wait to try it in our app", + timestamp: '09:42', + status: Icon(StreamIconData.iconDoupleCheckmark1Small), + ), + ], + ), + ), + ], + ); + } +} + +class _VisibilitySection extends StatelessWidget { + const _VisibilitySection(); + + @override + Widget build(BuildContext context) { + return _Section( + label: 'LEADING VISIBILITY', + description: + 'Leading visibility is theme-driven via StreamMessageItemTheme. ' + 'It supports three modes: visible (shown), hidden (space reserved), ' + 'and gone (no space).', + children: [ + const _ExampleCard( + label: 'Visible — avatar fully shown (default)', + child: _MessageItem( + avatarIndex: 0, + text: 'Avatar is visible.', + timestamp: '10:00', + username: 'Alice', + ), + ), + _ExampleCard( + label: 'Hidden — space reserved, avatar invisible', + child: StreamMessageItemTheme( + data: StreamMessageItemThemeData( + leadingVisibility: StreamMessageStyleVisibility.all(StreamVisibility.hidden), + ), + child: const _MessageItem( + avatarIndex: 0, + text: 'Avatar space is preserved for alignment.', + timestamp: '10:01', + ), + ), + ), + _ExampleCard( + label: 'Gone — no space reserved', + child: StreamMessageItemTheme( + data: StreamMessageItemThemeData( + leadingVisibility: StreamMessageStyleVisibility.all(StreamVisibility.gone), + ), + child: const _MessageItem( + avatarIndex: 0, + text: 'Avatar is removed entirely from the layout.', + timestamp: '10:02', + ), + ), + ), + ], + ); + } +} + +class _FullCompositionSection extends StatelessWidget { + const _FullCompositionSection(); + + @override + Widget build(BuildContext context) { + final textTheme = context.streamTextTheme; + final palette = context.streamColorScheme.avatarPalette; + + return _Section( + label: 'FULL COMPOSITION', + description: + 'All slots populated with annotations, reactions, replies, ' + 'and metadata demonstrating the intended layout.', + children: [ + _ExampleCard( + label: 'Incoming — all slots', + child: _MessageItem( + avatarIndex: 0, + text: + 'This message has an annotation, ' + 'reactions, a reply indicator, and full metadata.', + timestamp: '09:41', + username: 'Alice', + status: const Icon(StreamIconData.iconDoupleCheckmark1Small), + edited: const Text('Edited'), + annotation: StreamMessageAnnotation( + leading: const Icon(StreamIconData.iconBellNotification), + label: Text.rich( + TextSpan( + children: [ + const TextSpan(text: 'Reminder set'), + TextSpan( + text: ' · In 30 minutes', + style: textTheme.metadataDefault, + ), + ], + ), + ), + ), + reactions: _allReactions.take(4).toList(), + replies: StreamMessageReplies( + label: const Text('5 replies'), + avatars: _sampleAvatars(3, palette), + ), + ), + ), + const _ExampleCard( + label: 'Outgoing — reactions + status', + child: _MessageItem( + alignment: StreamMessageAlignment.end, + avatarIndex: 1, + text: 'Looks great, merging the PR now!', + timestamp: '09:42', + status: Icon(StreamIconData.iconDoupleCheckmark1Small), + reactions: [ + StreamReactionsItem(emoji: Text('👍'), count: 2), + StreamReactionsItem(emoji: Text('🎉'), count: 1), + ], + ), + ), + ], + ); + } +} + +class _EmojiOnlySection extends StatelessWidget { + const _EmojiOnlySection(); + + @override + Widget build(BuildContext context) { + final palette = context.streamColorScheme.avatarPalette; + + return _Section( + label: 'EMOJI-ONLY MESSAGES', + description: + 'Messages with 1–3 emojis render without a bubble. ' + 'Shown with the full message widget layout.', + children: [ + const _ExampleCard( + label: 'Single emoji', + child: _MessageItem( + avatarIndex: 0, + text: '👋', + timestamp: '11:00', + username: 'Alice', + isEmojiOnly: true, + ), + ), + const _ExampleCard( + label: 'Multi-emoji with reactions', + child: _MessageItem( + avatarIndex: 2, + text: '🎉👏🔥', + timestamp: '11:01', + username: 'Charlie', + isEmojiOnly: true, + reactions: [ + StreamReactionsItem(emoji: Text('❤'), count: 4), + StreamReactionsItem(emoji: Text('😂'), count: 2), + ], + ), + ), + _ExampleCard( + label: 'Outgoing emoji with replies', + child: _MessageItem( + alignment: StreamMessageAlignment.end, + avatarIndex: 1, + text: '👍🔥', + timestamp: '11:02', + isEmojiOnly: true, + replies: StreamMessageReplies( + label: const Text('2 replies'), + avatars: _sampleAvatars(2, palette), + showConnector: false, + ), + ), + ), + ], + ); + } +} + +class _ConversationSection extends StatelessWidget { + const _ConversationSection(); + + @override + Widget build(BuildContext context) { + return const _Section( + label: 'CONVERSATION', + description: + 'A realistic exchange showing how alignment, stack position, ' + 'and avatar visibility combine in a typical chat thread.', + children: [ + _ExampleCard( + label: 'Mixed thread', + child: Column( + spacing: 2, + children: [ + _MessageItem( + stackPosition: StreamMessageStackPosition.top, + avatarIndex: 0, + text: 'Hey, are you free this weekend?', + ), + _MessageItem( + stackPosition: StreamMessageStackPosition.bottom, + avatarIndex: 0, + text: 'We could go hiking 🏔️', + timestamp: '09:41', + username: 'Alice', + ), + SizedBox(height: 8), + _MessageItem( + alignment: StreamMessageAlignment.end, + avatarIndex: 1, + text: 'Sounds great! Let me check my schedule.', + timestamp: '09:42', + status: Icon(StreamIconData.iconDoupleCheckmark1Small), + ), + SizedBox(height: 8), + _MessageItem( + avatarIndex: 2, + text: 'Count me in! I know a great trail near the lake 🌲', + timestamp: '09:43', + username: 'Charlie', + ), + SizedBox(height: 8), + _MessageItem( + alignment: StreamMessageAlignment.end, + stackPosition: StreamMessageStackPosition.top, + avatarIndex: 1, + text: 'Perfect, Saturday morning works?', + ), + _MessageItem( + alignment: StreamMessageAlignment.end, + stackPosition: StreamMessageStackPosition.bottom, + avatarIndex: 1, + text: "I'll bring coffee ☕", + timestamp: '09:44', + status: Icon(StreamIconData.iconDoupleCheckmark1Small), + ), + SizedBox(height: 8), + _MessageItem( + avatarIndex: 0, + text: '👍', + timestamp: '09:45', + username: 'Alice', + isEmojiOnly: true, + ), + ], + ), + ), + ], + ); + } +} + +class _ThemeOverrideSection extends StatelessWidget { + const _ThemeOverrideSection(); + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + + return _Section( + label: 'THEME OVERRIDE', + description: + 'Use StreamMessageItemTheme to override item-level properties ' + 'like background color and avatar size.', + children: [ + _ExampleCard( + label: 'Pinned message with highlight background', + child: StreamMessageItemTheme( + data: StreamMessageItemThemeData( + backgroundColor: colorScheme.backgroundHighlight, + ), + child: const _MessageItem( + avatarIndex: 0, + text: 'This message is pinned to the conversation.', + timestamp: '09:41', + username: 'Alice', + ), + ), + ), + const _ExampleCard( + label: 'Smaller avatar override', + child: StreamMessageItemTheme( + data: StreamMessageItemThemeData( + avatarSize: StreamAvatarSize.sm, + ), + child: _MessageItem( + avatarIndex: 1, + text: 'A message with a smaller avatar.', + timestamp: '09:42', + username: 'Bob', + ), + ), + ), + ], + ); + } +} + +class _MinimalSection extends StatelessWidget { + const _MinimalSection(); + + @override + Widget build(BuildContext context) { + return const _Section( + label: 'MINIMAL', + description: 'Stripped-down messages with only essential content.', + children: [ + _ExampleCard( + label: 'Bubble only — no avatar, no footer', + child: _MessageItem( + text: 'Just a bubble, nothing else.', + ), + ), + _ExampleCard( + label: 'Bubble + footer only', + child: _MessageItem( + text: 'Hey!', + timestamp: '09:50', + ), + ), + _ExampleCard( + label: 'Avatar + bubble only', + child: _MessageItem( + avatarIndex: 0, + text: 'Message with avatar, no metadata.', + ), + ), + ], + ); + } +} + +// ============================================================================= +// Sample Data +// ============================================================================= + +const _avatarImages = [ + 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=200', + 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=200', + 'https://images.unsplash.com/photo-1517841905240-472988babdf9?w=200', + 'https://images.unsplash.com/photo-1539571696357-5a69c17a67c6?w=200', +]; + +const _avatarInitials = ['AJ', 'BK', 'CL', 'DM']; + +const _stackMessages = [ + 'Hey everyone!', + 'Just got back from lunch 🍕', + 'Did you see the latest PR?', + 'Looks great, nice work!', +]; + +const _allReactions = [ + 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), +]; + +List _sampleAvatars(int count, List palette) { + return [ + for (var i = 0; i < count; i++) + StreamAvatar( + imageUrl: _avatarImages[i % _avatarImages.length], + backgroundColor: palette[i % palette.length].backgroundColor, + foregroundColor: palette[i % palette.length].foregroundColor, + placeholder: (context) => Text(_avatarInitials[i % _avatarInitials.length]), + ), + ]; +} + +// ============================================================================= +// Helper Widgets +// ============================================================================= + +class _MessageItem extends StatelessWidget { + const _MessageItem({ + this.alignment = StreamMessageAlignment.start, + this.stackPosition = StreamMessageStackPosition.single, + this.avatarIndex, + required this.text, + this.timestamp, + this.username, + this.edited, + this.status, + this.replies, + this.annotation, + this.reactions, + this.isEmojiOnly = false, + }); + + final StreamMessageAlignment alignment; + final StreamMessageStackPosition stackPosition; + final int? avatarIndex; + final String text; + final String? timestamp; + final String? username; + final Widget? edited; + final Widget? status; + final Widget? replies; + final Widget? annotation; + final List? reactions; + final bool isEmojiOnly; + + @override + Widget build(BuildContext context) { + final palette = context.streamColorScheme.avatarPalette; + + Widget? leading; + if (avatarIndex case final i?) { + leading = StreamAvatar( + imageUrl: _avatarImages[i % _avatarImages.length], + backgroundColor: palette[i % palette.length].backgroundColor, + foregroundColor: palette[i % palette.length].foregroundColor, + placeholder: (context) => Text(_avatarInitials[i % _avatarInitials.length]), + ); + } + + final messageText = StreamMessageText(text); + final crossAlign = switch (alignment) { + StreamMessageAlignment.start => CrossAxisAlignment.start, + StreamMessageAlignment.end => CrossAxisAlignment.end, + }; + + final Widget messageBody = isEmojiOnly ? messageText : StreamMessageBubble(child: messageText); + + Widget body = Column( + crossAxisAlignment: crossAlign, + mainAxisSize: MainAxisSize.min, + children: [messageBody, ?replies], + ); + + if (reactions case final items?) { + body = StreamReactions.segmented(items: items, overlap: false, child: body); + } + + return StreamMessageWidget( + padding: .zero, + alignment: alignment, + stackPosition: stackPosition, + leading: leading, + child: StreamMessageContent( + header: annotation, + footer: timestamp != null + ? StreamMessageMetadata( + timestamp: Text(timestamp!), + username: username != null ? Text(username!) : null, + edited: edited, + status: status, + ) + : null, + child: body, + ), + ); + } +} + +class _Section extends StatelessWidget { + const _Section({ + required this.label, + required this.children, + this.description, + }); + + final String label; + final String? description; + final List children; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 16, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + _SectionLabel(label: label), + if (description case final desc?) + Text( + desc, + style: TextStyle( + fontSize: 13, + color: colorScheme.textTertiary, + ), + ), + ], + ), + ...children, + ], + ); + } +} + +class _SectionLabel extends StatelessWidget { + const _SectionLabel({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + return Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 1.2, + color: colorScheme.accentPrimary, + ), + ); + } +} + +class _ExampleCard extends StatelessWidget { + const _ExampleCard({ + required this.label, + required this.child, + }); + + final String label; + final Widget child; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final radius = context.streamRadius; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.backgroundApp, + borderRadius: BorderRadius.all(radius.md), + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.all(radius.md), + border: Border.all(color: colorScheme.borderSubtle), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: colorScheme.textSecondary, + ), + ), + child, + ], + ), + ); + } +} + +void _showSnack(BuildContext context, String message) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text(message), duration: const Duration(seconds: 2)), + ); +} diff --git a/apps/design_system_gallery/lib/components/message_composer/message_composer.dart b/apps/design_system_gallery/lib/components/message_composer/message_composer.dart index bb7afc8..6f9f15d 100644 --- a/apps/design_system_gallery/lib/components/message_composer/message_composer.dart +++ b/apps/design_system_gallery/lib/components/message_composer/message_composer.dart @@ -195,25 +195,22 @@ class _MessageBubble extends StatelessWidget { @override Widget build(BuildContext context) { - final colorScheme = context.streamColorScheme; - final textTheme = context.streamTextTheme; - - return Align( + Widget child = Align( alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, child: StreamMessageBubble( - backgroundColor: isMe ? colorScheme.accentPrimary : colorScheme.backgroundApp, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - side: isMe ? BorderSide.none : BorderSide(color: colorScheme.borderSubtle), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Text( - message, - style: textTheme.bodyDefault.copyWith( - color: isMe ? colorScheme.textOnAccent : colorScheme.textPrimary, - ), - ), + child: Text(message), ), ); + + if (isMe) { + child = StreamMessagePlacement( + placement: const StreamMessagePlacementData( + alignment: StreamMessageAlignment.end, + ), + child: child, + ); + } + + return child; } } diff --git a/apps/design_system_gallery/lib/components/reaction/stream_reactions.dart b/apps/design_system_gallery/lib/components/reaction/stream_reactions.dart index efe171c..1a04586 100644 --- a/apps/design_system_gallery/lib/components/reaction/stream_reactions.dart +++ b/apps/design_system_gallery/lib/components/reaction/stream_reactions.dart @@ -27,26 +27,39 @@ Widget buildStreamReactionsPlayground(BuildContext context) { labelBuilder: (option) => option.name, description: 'Where reactions sit relative to the bubble.', ); - final alignment = context.knobs.object.dropdown( + final alignmentOption = 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.', + options: _AlignmentOption.values, + initialOption: _AlignmentOption.defaultValue, + labelBuilder: (option) => option.label, + description: 'Horizontal alignment of reactions. Default derives from message alignment.', + ); + final crossAxisOption = context.knobs.object.dropdown( + label: 'Cross Axis Alignment', + options: _CrossAxisOption.values, + initialOption: _CrossAxisOption.defaultValue, + labelBuilder: (option) => option.label, + description: 'Cross-axis alignment of the column. Default derives from message alignment.', ); 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 useIndent = context.knobs.boolean( + label: 'Override Indent', + description: 'Enable to set a custom indent. When off, uses the default (null).', ); + final indent = useIndent + ? context.knobs.double.slider( + label: 'Indent', + initialValue: 8, + min: -8, + max: 8, + divisions: 8, + description: 'Horizontal shift applied to the reaction strip.', + ) + : null; final max = overlap ? context.knobs.int.slider( label: 'Max Visible', @@ -74,15 +87,16 @@ Widget buildStreamReactionsPlayground(BuildContext context) { final items = _allReactionItems.take(reactionCount).toList(); final spacing = context.streamSpacing; - final isOutgoing = direction.isOutgoing; - final crossAxis = isOutgoing ? CrossAxisAlignment.end : CrossAxisAlignment.start; + + final alignment = alignmentOption.value; + final crossAxisAlignment = crossAxisOption.value; Widget buildReaction({required Widget bubble}) => switch (type) { StreamReactionsType.segmented => StreamReactions.segmented( items: items, position: position, alignment: alignment, - crossAxisAlignment: crossAxis, + crossAxisAlignment: crossAxisAlignment, max: max, overlap: overlap, indent: indent, @@ -93,7 +107,7 @@ Widget buildStreamReactionsPlayground(BuildContext context) { items: items, position: position, alignment: alignment, - crossAxisAlignment: crossAxis, + crossAxisAlignment: crossAxisAlignment, max: max, overlap: overlap, indent: indent, @@ -114,17 +128,17 @@ Widget buildStreamReactionsPlayground(BuildContext context) { children: [ _ChatBubble( message: _mediumMessage, - direction: direction, + alignment: direction.alignment, reactionBuilder: buildReaction, ), _ChatBubble( message: _shortMessage, - direction: direction, + alignment: direction.alignment, reactionBuilder: buildReaction, ), _ChatBubble( message: _longMessage, - direction: direction, + alignment: direction.alignment, reactionBuilder: buildReaction, ), ], @@ -138,77 +152,23 @@ Widget buildStreamReactionsPlayground(BuildContext context) { class _ChatBubble extends StatelessWidget { const _ChatBubble({ required this.message, - required this.direction, + required this.alignment, required this.reactionBuilder, }); final String message; - final _MessageDirection direction; + final StreamMessageAlignment alignment; 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 BorderRadiusDirectional.only( - topStart: bubbleRadius, - topEnd: bubbleRadius, - bottomStart: bubbleRadius, - ) - : const BorderRadiusDirectional.only( - topStart: bubbleRadius, - topEnd: bubbleRadius, - bottomEnd: bubbleRadius, - ); - - final bubble = StreamMessageBubble( - backgroundColor: bubbleColor, - shape: RoundedRectangleBorder(borderRadius: bubbleBorderRadius), - side: BorderSide.none, - padding: EdgeInsets.symmetric( - horizontal: spacing.sm, - vertical: spacing.xs, - ), - constraints: const BoxConstraints(maxWidth: 280), - child: Text( - message, - style: textTheme.bodyDefault.copyWith(color: colorScheme.textPrimary), - ), - ); - - final metadata = isOutgoing - ? StreamMessageMetadataTheme( - data: StreamMessageMetadataThemeData( - statusColor: colorScheme.accentPrimary, - ), - child: StreamMessageMetadata( - timestamp: const Text('09:41'), - status: const Icon(StreamIconData.iconDoupleCheckmark1Small), - ), - ) - : StreamMessageMetadata( - timestamp: const Text('09:40'), - username: const Text('Alice'), - ); - - 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: metadata, + return StreamMessagePlacement( + placement: StreamMessagePlacementData(alignment: alignment), + child: StreamMessageContent( + child: reactionBuilder( + bubble: StreamMessageBubble(child: StreamMessageText(message)), ), - ], + ), ); } } @@ -233,8 +193,8 @@ Widget buildStreamReactionsShowcase(BuildContext context) { child: Column( crossAxisAlignment: CrossAxisAlignment.start, spacing: spacing.xl, - children: const [ - _ShowcaseSection( + children: [ + const _ShowcaseSection( title: 'SEGMENTED — FOOTER', description: 'Individual pills per reaction, positioned as a footer ' @@ -242,7 +202,7 @@ Widget buildStreamReactionsShowcase(BuildContext context) { threads: [ _ThreadMessage( message: _mediumMessage, - direction: _MessageDirection.incoming, + alignment: StreamMessageAlignment.start, type: StreamReactionsType.segmented, position: StreamReactionsPosition.footer, items: _allReactionItems, @@ -250,7 +210,7 @@ Widget buildStreamReactionsShowcase(BuildContext context) { ), _ThreadMessage( message: _shortMessage, - direction: _MessageDirection.outgoing, + alignment: StreamMessageAlignment.end, type: StreamReactionsType.segmented, position: StreamReactionsPosition.footer, items: [ @@ -260,7 +220,7 @@ Widget buildStreamReactionsShowcase(BuildContext context) { ), _ThreadMessage( message: _longMessage, - direction: _MessageDirection.incoming, + alignment: StreamMessageAlignment.start, type: StreamReactionsType.segmented, position: StreamReactionsPosition.footer, items: [ @@ -272,7 +232,7 @@ Widget buildStreamReactionsShowcase(BuildContext context) { ), ], ), - _ShowcaseSection( + const _ShowcaseSection( title: 'SEGMENTED — HEADER', description: 'Individual pills as a header. Reactions paint on top ' @@ -280,7 +240,7 @@ Widget buildStreamReactionsShowcase(BuildContext context) { threads: [ _ThreadMessage( message: _shortMessage, - direction: _MessageDirection.incoming, + alignment: StreamMessageAlignment.start, type: StreamReactionsType.segmented, position: StreamReactionsPosition.header, items: [ @@ -291,7 +251,7 @@ Widget buildStreamReactionsShowcase(BuildContext context) { ), _ThreadMessage( message: _longMessage, - direction: _MessageDirection.outgoing, + alignment: StreamMessageAlignment.end, type: StreamReactionsType.segmented, position: StreamReactionsPosition.header, items: _allReactionItems, @@ -299,7 +259,7 @@ Widget buildStreamReactionsShowcase(BuildContext context) { ), ], ), - _ShowcaseSection( + const _ShowcaseSection( title: 'CLUSTERED', description: 'All reactions grouped into a single chip. Shown in both ' @@ -307,14 +267,14 @@ Widget buildStreamReactionsShowcase(BuildContext context) { threads: [ _ThreadMessage( message: _longMessage, - direction: _MessageDirection.incoming, + alignment: StreamMessageAlignment.start, type: StreamReactionsType.clustered, position: StreamReactionsPosition.footer, items: _allReactionItems, ), _ThreadMessage( message: _shortMessage, - direction: _MessageDirection.outgoing, + alignment: StreamMessageAlignment.end, type: StreamReactionsType.clustered, position: StreamReactionsPosition.header, items: [ @@ -325,7 +285,7 @@ Widget buildStreamReactionsShowcase(BuildContext context) { ), _ThreadMessage( message: _mediumMessage, - direction: _MessageDirection.incoming, + alignment: StreamMessageAlignment.start, type: StreamReactionsType.clustered, position: StreamReactionsPosition.footer, items: [ @@ -335,7 +295,7 @@ Widget buildStreamReactionsShowcase(BuildContext context) { ), ], ), - _ShowcaseSection( + const _ShowcaseSection( title: 'OVERFLOW', description: 'When reactions exceed the max visible limit, extras are ' @@ -343,7 +303,7 @@ Widget buildStreamReactionsShowcase(BuildContext context) { threads: [ _ThreadMessage( message: _mediumMessage, - direction: _MessageDirection.incoming, + alignment: StreamMessageAlignment.start, type: StreamReactionsType.segmented, position: StreamReactionsPosition.footer, items: _allReactionItems, @@ -351,7 +311,7 @@ Widget buildStreamReactionsShowcase(BuildContext context) { ), _ThreadMessage( message: _longMessage, - direction: _MessageDirection.outgoing, + alignment: StreamMessageAlignment.end, type: StreamReactionsType.segmented, position: StreamReactionsPosition.footer, items: _allReactionItems, @@ -359,7 +319,7 @@ Widget buildStreamReactionsShowcase(BuildContext context) { ), ], ), - _ShowcaseSection( + const _ShowcaseSection( title: 'COUNT RULES', description: 'If any reaction has count > 1, all chips show counts. ' @@ -367,21 +327,21 @@ Widget buildStreamReactionsShowcase(BuildContext context) { threads: [ _ThreadMessage( message: 'Single emoji, count 1 — no count shown.', - direction: _MessageDirection.incoming, + alignment: StreamMessageAlignment.start, type: StreamReactionsType.segmented, position: StreamReactionsPosition.footer, items: [StreamReactionsItem(emoji: Text('👍'))], ), _ThreadMessage( message: 'Single emoji, count 5 — count shown.', - direction: _MessageDirection.outgoing, + alignment: StreamMessageAlignment.end, type: StreamReactionsType.segmented, position: StreamReactionsPosition.footer, items: [StreamReactionsItem(emoji: Text('❤'), count: 5)], ), _ThreadMessage( message: 'Multiple emojis, all count 1 — no counts.', - direction: _MessageDirection.incoming, + alignment: StreamMessageAlignment.start, type: StreamReactionsType.segmented, position: StreamReactionsPosition.footer, items: [ @@ -392,7 +352,7 @@ Widget buildStreamReactionsShowcase(BuildContext context) { ), _ThreadMessage( message: 'Mixed counts — all show counts.', - direction: _MessageDirection.outgoing, + alignment: StreamMessageAlignment.end, type: StreamReactionsType.segmented, position: StreamReactionsPosition.footer, items: [ @@ -404,7 +364,7 @@ Widget buildStreamReactionsShowcase(BuildContext context) { ), _ThreadMessage( message: 'Clustered — total count shown when > 1.', - direction: _MessageDirection.incoming, + alignment: StreamMessageAlignment.start, type: StreamReactionsType.clustered, position: StreamReactionsPosition.footer, items: [ @@ -415,7 +375,7 @@ Widget buildStreamReactionsShowcase(BuildContext context) { ), ], ), - _ShowcaseSection( + const _ShowcaseSection( title: 'DETACHED', description: 'Reactions with overlap disabled — separated from the bubble ' @@ -423,7 +383,7 @@ Widget buildStreamReactionsShowcase(BuildContext context) { threads: [ _ThreadMessage( message: _mediumMessage, - direction: _MessageDirection.incoming, + alignment: StreamMessageAlignment.start, type: StreamReactionsType.segmented, position: StreamReactionsPosition.footer, items: [ @@ -435,7 +395,7 @@ Widget buildStreamReactionsShowcase(BuildContext context) { ), _ThreadMessage( message: _shortMessage, - direction: _MessageDirection.outgoing, + alignment: StreamMessageAlignment.end, type: StreamReactionsType.clustered, position: StreamReactionsPosition.footer, items: [ @@ -446,7 +406,7 @@ Widget buildStreamReactionsShowcase(BuildContext context) { ), _ThreadMessage( message: _longMessage, - direction: _MessageDirection.incoming, + alignment: StreamMessageAlignment.start, type: StreamReactionsType.segmented, position: StreamReactionsPosition.footer, items: _allReactionItems, @@ -454,6 +414,7 @@ Widget buildStreamReactionsShowcase(BuildContext context) { ), ], ), + _EmojiOnlyShowcaseSection(), ], ), ), @@ -469,7 +430,7 @@ Widget buildStreamReactionsShowcase(BuildContext context) { class _ThreadMessage { const _ThreadMessage({ required this.message, - required this.direction, + required this.alignment, required this.type, required this.position, required this.items, @@ -478,7 +439,7 @@ class _ThreadMessage { }); final String message; - final _MessageDirection direction; + final StreamMessageAlignment alignment; final StreamReactionsType type; final StreamReactionsPosition position; final List items; @@ -547,36 +508,24 @@ class _ShowcaseSection extends StatelessWidget { 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: () {}, - ), - }; + alignment: t.alignment, + reactionBuilder: ({required bubble}) => switch (t.type) { + StreamReactionsType.segmented => StreamReactions.segmented( + items: t.items, + position: t.position, + max: t.max, + overlap: t.overlap, + child: bubble, + onPressed: () {}, + ), + StreamReactionsType.clustered => StreamReactions.clustered( + items: t.items, + position: t.position, + max: t.max, + overlap: t.overlap, + child: bubble, + onPressed: () {}, + ), }, ), ], @@ -590,6 +539,161 @@ class _ShowcaseSection extends StatelessWidget { } } +class _EmojiOnlyShowcaseSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final radius = context.streamRadius; + final spacing = context.streamSpacing; + final palette = colorScheme.avatarPalette; + + const reactions = [ + StreamReactionsItem(emoji: Text('❤'), count: 4), + StreamReactionsItem(emoji: Text('😂'), count: 2), + ]; + + const singleReaction = [ + StreamReactionsItem(emoji: Text('👍'), count: 3), + ]; + + Widget emojiMessage({ + required String text, + required StreamMessageAlignment alignment, + required List items, + bool showReplies = false, + StreamReactionsPosition position = StreamReactionsPosition.footer, + }) { + final isEnd = alignment == StreamMessageAlignment.end; + final crossAxis = + isEnd ? CrossAxisAlignment.end : CrossAxisAlignment.start; + + final messageText = StreamMessageText(text); + + Widget body = StreamReactions.segmented( + items: items, + position: position, + overlap: position == StreamReactionsPosition.header, + alignment: position == StreamReactionsPosition.header + ? StreamReactionsAlignment.end + : StreamReactionsAlignment.start, + indent: position == StreamReactionsPosition.header ? 8 : null, + onPressed: () {}, + child: messageText, + ); + + if (showReplies) { + body = Column( + crossAxisAlignment: crossAxis, + mainAxisSize: MainAxisSize.min, + children: [ + body, + StreamMessageReplies( + label: const Text('2 replies'), + avatars: _sampleAvatars(2, palette), + alignment: alignment, + showConnector: false, + onTap: () {}, + ), + ], + ); + } + + return StreamMessagePlacement( + placement: StreamMessagePlacementData(alignment: alignment), + child: Align( + alignment: isEnd ? Alignment.centerRight : Alignment.centerLeft, + child: StreamMessageContent(child: body), + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.md, + children: [ + const _SectionLabel(label: 'EMOJI-ONLY MESSAGES'), + 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( + 'Emoji-only messages (1–3 emojis) render without a bubble. ' + 'Shown with reactions and replies.', + 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: CrossAxisAlignment.stretch, + children: [ + emojiMessage( + text: '👋', + alignment: StreamMessageAlignment.start, + items: reactions, + ), + emojiMessage( + text: '❤️🔥', + alignment: StreamMessageAlignment.end, + items: singleReaction, + position: StreamReactionsPosition.header, + ), + emojiMessage( + text: '😂', + alignment: StreamMessageAlignment.start, + items: reactions, + showReplies: true, + ), + emojiMessage( + text: '🎉👏🔥', + alignment: StreamMessageAlignment.end, + items: reactions, + showReplies: true, + ), + ], + ), + ), + ], + ), + ), + ], + ); + } +} + +List _sampleAvatars(int count, List palette) { + const images = [ + 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=200', + 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=200', + 'https://images.unsplash.com/photo-1517841905240-472988babdf9?w=200', + ]; + const initials = ['AB', 'CD', 'EF']; + return [ + for (var i = 0; i < count; i++) + StreamAvatar( + imageUrl: images[i % images.length], + backgroundColor: palette[i % palette.length].backgroundColor, + foregroundColor: palette[i % palette.length].foregroundColor, + placeholder: (context) => Text(initials[i % initials.length]), + ), + ]; +} + class _SectionLabel extends StatelessWidget { const _SectionLabel({required this.label}); @@ -639,11 +743,39 @@ void _showSnack(BuildContext context, String message) { // ============================================================================= enum _MessageDirection { - incoming, - outgoing + incoming(StreamMessageAlignment.start), + outgoing(StreamMessageAlignment.end), ; - bool get isOutgoing => this == outgoing; + const _MessageDirection(this.alignment); + + final StreamMessageAlignment alignment; +} + +enum _AlignmentOption { + defaultValue('Default', null), + start('start', StreamReactionsAlignment.start), + end('end', StreamReactionsAlignment.end), + ; + + const _AlignmentOption(this.label, this.value); + + final String label; + final StreamReactionsAlignment? value; +} + +enum _CrossAxisOption { + defaultValue('Default', null), + start('start', CrossAxisAlignment.start), + center('center', CrossAxisAlignment.center), + end('end', CrossAxisAlignment.end), + stretch('stretch', CrossAxisAlignment.stretch), + ; + + const _CrossAxisOption(this.label, this.value); + + final String label; + final CrossAxisAlignment? value; } // ============================================================================= diff --git a/apps/design_system_gallery/lib/config/theme_configuration.dart b/apps/design_system_gallery/lib/config/theme_configuration.dart index 249fc69..80dc0c0 100644 --- a/apps/design_system_gallery/lib/config/theme_configuration.dart +++ b/apps/design_system_gallery/lib/config/theme_configuration.dart @@ -53,6 +53,7 @@ class ThemeConfiguration extends ChangeNotifier { Color? _backgroundSurfaceStrong; Color? _backgroundOverlay; Color? _backgroundDisabled; + Color? _backgroundHighlight; // ========================================================================= // Border Colors - Core @@ -129,6 +130,7 @@ class ThemeConfiguration extends ChangeNotifier { Color get backgroundSurfaceStrong => _backgroundSurfaceStrong ?? _themeData.colorScheme.backgroundSurfaceStrong; Color get backgroundOverlay => _backgroundOverlay ?? _themeData.colorScheme.backgroundOverlay; Color get backgroundDisabled => _backgroundDisabled ?? _themeData.colorScheme.backgroundDisabled; + Color get backgroundHighlight => _backgroundHighlight ?? _themeData.colorScheme.backgroundHighlight; // ========================================================================= // Getters - Border Core @@ -210,6 +212,7 @@ class ThemeConfiguration extends ChangeNotifier { void setBackgroundSurfaceStrong(Color color) => _update(() => _backgroundSurfaceStrong = color); void setBackgroundOverlay(Color color) => _update(() => _backgroundOverlay = color); void setBackgroundDisabled(Color color) => _update(() => _backgroundDisabled = color); + void setBackgroundHighlight(Color color) => _update(() => _backgroundHighlight = color); // Border Core void setBorderDefault(Color color) => _update(() => _borderDefault = color); @@ -296,6 +299,7 @@ class ThemeConfiguration extends ChangeNotifier { _backgroundSurfaceStrong = null; _backgroundOverlay = null; _backgroundDisabled = null; + _backgroundHighlight = null; // Border Core _borderDefault = null; _borderSubtle = null; @@ -379,6 +383,7 @@ class ThemeConfiguration extends ChangeNotifier { backgroundSurfaceStrong: _backgroundSurfaceStrong, backgroundOverlay: _backgroundOverlay, backgroundDisabled: _backgroundDisabled, + backgroundHighlight: _backgroundHighlight, // Border Core borderDefault: _borderDefault, borderSubtle: _borderSubtle, diff --git a/apps/design_system_gallery/lib/widgets/theme_studio/theme_customization_panel.dart b/apps/design_system_gallery/lib/widgets/theme_studio/theme_customization_panel.dart index 942be4a..0f55aae 100644 --- a/apps/design_system_gallery/lib/widgets/theme_studio/theme_customization_panel.dart +++ b/apps/design_system_gallery/lib/widgets/theme_studio/theme_customization_panel.dart @@ -348,6 +348,11 @@ class _ThemeCustomizationPanelState extends State { color: config.backgroundDisabled, onColorChanged: config.setBackgroundDisabled, ), + ColorPickerTile( + label: 'backgroundHighlight', + color: config.backgroundHighlight, + onColorChanged: config.setBackgroundHighlight, + ), ], ), ); diff --git a/apps/design_system_gallery/pubspec.yaml b/apps/design_system_gallery/pubspec.yaml index f816ad5..c4263bb 100644 --- a/apps/design_system_gallery/pubspec.yaml +++ b/apps/design_system_gallery/pubspec.yaml @@ -12,7 +12,9 @@ dependencies: flutter: sdk: flutter flutter_colorpicker: ^1.1.0 + flutter_markdown_plus: ^1.0.7 marionette_flutter: ^0.3.0 + markdown: ^7.3.0 provider: ^6.1.5+1 stream_core_flutter: path: ../../packages/stream_core_flutter diff --git a/packages/stream_core_flutter/lib/src/components.dart b/packages/stream_core_flutter/lib/src/components.dart index 21a33dc..6c7f4c1 100644 --- a/packages/stream_core_flutter/lib/src/components.dart +++ b/packages/stream_core_flutter/lib/src/components.dart @@ -13,6 +13,7 @@ export 'components/buttons/stream_emoji_button.dart' hide DefaultStreamEmojiButt 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/common/stream_visibility.dart'; export 'components/context_menu/stream_context_menu.dart'; export 'components/context_menu/stream_context_menu_action.dart' hide DefaultStreamContextMenuAction; export 'components/controls/stream_emoji_chip.dart' hide DefaultStreamEmojiChip; @@ -22,14 +23,17 @@ export 'components/emoji/data/stream_emoji_data.dart'; 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/stream_message_alignment.dart'; export 'components/message/stream_message_annotation.dart' hide DefaultStreamMessageAnnotation; export 'components/message/stream_message_bubble.dart' hide DefaultStreamMessageBubble; export 'components/message/stream_message_content.dart' hide DefaultStreamMessageContent; -export 'components/message/stream_message_group_position.dart'; export 'components/message/stream_message_metadata.dart' hide DefaultStreamMessageMetadata; export 'components/message/stream_message_replies.dart' hide DefaultStreamMessageReplies; +export 'components/message/stream_message_text.dart' hide DefaultStreamMessageText; +export 'components/message/stream_message_widget.dart' hide DefaultStreamMessageWidget; export 'components/message_composer.dart'; +export 'components/message_placement/stream_message_alignment.dart'; +export 'components/message_placement/stream_message_placement.dart'; +export 'components/message_placement/stream_message_stack_position.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 facc65b..a2c7850 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 @@ -3,6 +3,7 @@ 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 '../../theme/stream_theme_extensions.dart'; import '../badge/stream_badge_count.dart'; import '../common/stream_flex.dart'; @@ -180,6 +181,8 @@ class DefaultStreamAvatarStack extends StatelessWidget { Widget build(BuildContext context) { if (props.children.isEmpty) return const SizedBox.shrink(); + final colorScheme = context.streamColorScheme; + final effectiveSize = props.size ?? StreamAvatarStackSize.sm; final avatarSize = _avatarSizeForStackSize(effectiveSize); final extraBadgeSize = _badgeCountSizeForStackSize(effectiveSize); @@ -189,9 +192,18 @@ class DefaultStreamAvatarStack extends StatelessWidget { final visible = props.children.take(props.max).toList(); final extraCount = props.children.length - visible.length; + const avatarBorderWidth = 2.0; + return MediaQuery.withNoTextScaling( child: StreamAvatarTheme( - data: StreamAvatarThemeData(size: avatarSize), + data: StreamAvatarThemeData( + size: avatarSize, + border: Border.all( + width: avatarBorderWidth, + color: colorScheme.borderOnDark, + strokeAlign: BorderSide.strokeAlignOutside, + ), + ), child: StreamRow( spacing: -diameter * props.overlap, mainAxisSize: MainAxisSize.min, diff --git a/packages/stream_core_flutter/lib/src/components/common/stream_visibility.dart b/packages/stream_core_flutter/lib/src/components/common/stream_visibility.dart new file mode 100644 index 0000000..7d255f8 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/common/stream_visibility.dart @@ -0,0 +1,35 @@ +/// Controls the visibility and layout participation of a widget slot. +/// +/// This is typically used for optional slots (such as leading avatars in a +/// message row) where a theme needs to express whether the widget should +/// be shown, hidden while still reserving space, or removed entirely. +/// +/// {@tool snippet} +/// +/// Conditionally hide the avatar in non-bottom stack positions: +/// +/// ```dart +/// StreamMessageItemTheme( +/// data: StreamMessageItemThemeData( +/// leadingVisibility: StreamMessageStyleProperty.resolveWith( +/// (p) => switch (p.stackPosition) { +/// StreamMessageStackPosition.bottom || +/// StreamMessageStackPosition.single => StreamVisibility.visible, +/// _ => StreamVisibility.hidden, +/// }, +/// ), +/// ), +/// child: ..., +/// ) +/// ``` +/// {@end-tool} +enum StreamVisibility { + /// The widget is fully visible and participates in layout. + visible, + + /// The widget is invisible but still occupies its layout space. + hidden, + + /// The widget is removed from the layout entirely — it takes no space. + gone, +} diff --git a/packages/stream_core_flutter/lib/src/components/message/stream_message_annotation.dart b/packages/stream_core_flutter/lib/src/components/message/stream_message_annotation.dart index deb882c..274d04f 100644 --- a/packages/stream_core_flutter/lib/src/components/message/stream_message_annotation.dart +++ b/packages/stream_core_flutter/lib/src/components/message/stream_message_annotation.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../theme.dart'; +import '../message_placement/stream_message_placement.dart'; /// An annotation row for displaying contextual message annotations. /// @@ -42,11 +43,8 @@ import '../../theme.dart'; /// /// See also: /// -/// * [StreamMessageAnnotationStyle], for the visual style properties. -/// * [StreamMessageAnnotationThemeData], for customizing annotation -/// appearance. -/// * [StreamMessageAnnotationTheme], for overriding theme in a widget -/// subtree. +/// * [StreamMessageAnnotationStyle], for customizing annotation appearance. +/// * [StreamMessageItemTheme], for theming via the widget tree. class StreamMessageAnnotation extends StatelessWidget { /// Creates a message annotation row. /// @@ -58,15 +56,13 @@ class StreamMessageAnnotation extends StatelessWidget { required Widget label, VoidCallback? onTap, VoidCallback? onLongPress, - double? spacing, - EdgeInsetsGeometry? padding, + StreamMessageAnnotationStyle? style, }) : props = .new( leading: leading, label: label, onTap: onTap, onLongPress: onLongPress, - spacing: spacing, - padding: padding, + style: style, ); /// The properties that configure this annotation row. @@ -92,8 +88,7 @@ class StreamMessageAnnotationProps { required this.label, this.onTap, this.onLongPress, - this.spacing, - this.padding, + this.style, }); /// The leading widget, typically an [Icon]. @@ -116,15 +111,11 @@ class StreamMessageAnnotationProps { /// Called when the annotation row is long-pressed. final VoidCallback? onLongPress; - /// The gap between the leading widget and label. + /// Optional style overrides for placement-aware styling. /// - /// When null, falls back to [StreamMessageAnnotationStyle.spacing]. - final double? spacing; - - /// The padding around the annotation row content. - /// - /// When null, falls back to [StreamMessageAnnotationStyle.padding]. - final EdgeInsetsGeometry? padding; + /// Fields left null fall back to the inherited [StreamMessageItemTheme], + /// then to built-in defaults. + final StreamMessageAnnotationStyle? style; } /// The default implementation of [StreamMessageAnnotation]. @@ -142,18 +133,21 @@ class DefaultStreamMessageAnnotation extends StatelessWidget { @override Widget build(BuildContext context) { - final style = context.streamMessageAnnotationTheme.style; + final placement = StreamMessagePlacement.of(context); + final annotationStyle = props.style ?? StreamMessageItemTheme.of(context).annotation; final defaults = _StreamMessageAnnotationDefaults(context); - final effectiveTextStyle = style?.textStyle ?? defaults.textStyle; - final effectiveTextColor = style?.textColor ?? defaults.textColor; - final effectiveSpacing = props.spacing ?? style?.spacing ?? defaults.spacing; - final effectivePadding = props.padding ?? style?.padding ?? defaults.padding; + final resolve = StreamMessageStyleResolver(placement, [annotationStyle, defaults]); + + final effectiveTextStyle = resolve((s) => s?.textStyle); + final effectiveTextColor = resolve((s) => s?.textColor); + final effectiveSpacing = resolve((s) => s?.spacing); + final effectivePadding = resolve((s) => s?.padding); Widget? leadingWidget; if (props.leading case final leading?) { - final effectiveIconColor = style?.iconColor ?? defaults.iconColor; - final effectiveIconSize = style?.iconSize ?? defaults.iconSize; + final effectiveIconColor = resolve((s) => s?.iconColor); + final effectiveIconSize = resolve((s) => s?.iconSize); leadingWidget = IconTheme.merge( data: IconThemeData(color: effectiveIconColor, size: effectiveIconSize), @@ -197,20 +191,20 @@ class _StreamMessageAnnotationDefaults extends StreamMessageAnnotationStyle { late final StreamSpacing _spacing = _context.streamSpacing; @override - TextStyle get textStyle => _textTheme.metadataEmphasis; + StreamMessageStyleProperty get textStyle => .all(_textTheme.metadataEmphasis); @override - Color get textColor => _colorScheme.textPrimary; + StreamMessageStyleProperty get textColor => .all(_colorScheme.textPrimary); @override - Color get iconColor => _colorScheme.textPrimary; + StreamMessageStyleProperty get iconColor => .all(_colorScheme.textPrimary); @override - double get iconSize => 16; + StreamMessageStyleProperty get iconSize => .all(16); @override - double get spacing => _spacing.xxs; + StreamMessageStyleProperty get spacing => .all(_spacing.xxs); @override - EdgeInsetsGeometry get padding => .symmetric(vertical: _spacing.xxs); + StreamMessageStyleProperty get padding => .all(.symmetric(vertical: _spacing.xxs)); } diff --git a/packages/stream_core_flutter/lib/src/components/message/stream_message_bubble.dart b/packages/stream_core_flutter/lib/src/components/message/stream_message_bubble.dart index 82c9629..f89e192 100644 --- a/packages/stream_core_flutter/lib/src/components/message/stream_message_bubble.dart +++ b/packages/stream_core_flutter/lib/src/components/message/stream_message_bubble.dart @@ -1,6 +1,7 @@ import 'package:flutter/widgets.dart'; import '../../theme.dart'; +import '../message_placement/stream_message_placement.dart'; /// A styled container that wraps message content with a themed background, /// shape, and padding. @@ -8,34 +9,28 @@ import '../../theme.dart'; /// [StreamMessageBubble] is the visual shell of a chat message. Metadata, /// reactions, and reply indicators compose around it at a higher level. /// -/// Each visual property can be set directly on the widget. Unset properties -/// fall back to the inherited [StreamMessageBubbleThemeData], then to -/// built-in defaults. +/// If a [StreamMessagePlacement] is found in the ancestor tree, style +/// properties automatically adapt to the current message placement. /// /// {@tool snippet} /// -/// A simple text bubble: +/// A simple text bubble using theme defaults: /// /// ```dart -/// StreamMessageBubble( -/// backgroundColor: Colors.blue.shade50, -/// child: Text('Hello, world!'), -/// ) +/// StreamMessageBubble(child: Text('Hello, world!')) /// ``` /// {@end-tool} /// /// {@tool snippet} /// -/// A bubble with custom structural properties: +/// A bubble with uniform style overrides: /// /// ```dart /// StreamMessageBubble( -/// shape: RoundedRectangleBorder( -/// borderRadius: BorderRadius.circular(24), +/// style: StreamMessageBubbleStyle.from( +/// backgroundColor: Colors.white, +/// padding: EdgeInsets.all(16), /// ), -/// side: BorderSide(color: Colors.grey), -/// padding: EdgeInsets.all(16), -/// backgroundColor: Colors.white, /// child: Text('Styled bubble'), /// ) /// ``` @@ -43,31 +38,20 @@ import '../../theme.dart'; /// /// See also: /// -/// * [StreamMessageBubbleStyle], for the structural style properties. -/// * [StreamMessageBubbleThemeData], for theming the bubble. -/// * [StreamMessageGroupPosition], for adjusting shape based on message -/// grouping. +/// * [StreamMessageBubbleStyle], for resolver-based styling. +/// * [StreamMessageItemTheme], for theming via the widget tree. +/// * [StreamMessagePlacement], which provides the placement context +/// used to resolve styles. class StreamMessageBubble extends StatelessWidget { /// Creates a message bubble. /// - /// The [child] is required; all other parameters are optional and fall back - /// to theme or default values. + /// The [child] is required. An optional [style] can override individual + /// fields; unset fields fall back to theme, then to defaults. StreamMessageBubble({ super.key, required Widget child, - OutlinedBorder? shape, - BorderSide? side, - EdgeInsetsGeometry? padding, - BoxConstraints? constraints, - Color? backgroundColor, - }) : props = StreamMessageBubbleProps( - child: child, - shape: shape, - side: side, - padding: padding, - constraints: constraints, - backgroundColor: backgroundColor, - ); + StreamMessageBubbleStyle? style, + }) : props = .new(child: child, style: style); /// The properties that configure this bubble. final StreamMessageBubbleProps props; @@ -89,44 +73,17 @@ class StreamMessageBubbleProps { /// Creates properties for a message bubble. const StreamMessageBubbleProps({ required this.child, - this.shape, - this.side, - this.padding, - this.constraints, - this.backgroundColor, + this.style, }); /// The content widget displayed inside the bubble. final Widget child; - /// The shape of the bubble's container. - /// - /// If null, uses [StreamMessageBubbleThemeData], then the default - /// [RoundedRectangleBorder] with 20px radius. - final OutlinedBorder? shape; - - /// The border outline of the bubble. + /// Optional style overrides for placement-aware styling. /// - /// Combined with [shape] via [OutlinedBorder.copyWith]. If null, uses - /// [StreamMessageBubbleThemeData], then a 1px `borderSubtle` border. - final BorderSide? side; - - /// Content padding inside the bubble. - /// - /// If null, uses [StreamMessageBubbleThemeData], then a value derived - /// from [StreamSpacing]. - final EdgeInsetsGeometry? padding; - - /// Size constraints for the bubble. - /// - /// If null, defaults to `BoxConstraints(minHeight: 20)`. - final BoxConstraints? constraints; - - /// The background color of the bubble. - /// - /// If null, uses [StreamMessageBubbleThemeData], then the surface color - /// from [StreamColorScheme]. - final Color? backgroundColor; + /// Fields left null fall back to the inherited [StreamMessageItemTheme], + /// then to built-in defaults. + final StreamMessageBubbleStyle? style; } /// The default implementation of [StreamMessageBubble]. @@ -144,13 +101,17 @@ class DefaultStreamMessageBubble extends StatelessWidget { @override Widget build(BuildContext context) { + final placement = StreamMessagePlacement.of(context); + final bubbleStyle = props.style ?? StreamMessageItemTheme.of(context).bubble; final defaults = _StreamMessageBubbleDefaults(context); - final effectiveSide = props.side ?? defaults.side; - final effectiveShape = (props.shape ?? defaults.shape).copyWith(side: effectiveSide); - final effectivePadding = props.padding ?? defaults.padding; - final effectiveConstraints = props.constraints ?? defaults.constraints; - final effectiveBackgroundColor = props.backgroundColor ?? defaults.backgroundColor; + final resolve = StreamMessageStyleResolver(placement, [bubbleStyle, defaults]); + + final effectiveSide = resolve((s) => s?.side); + final effectiveShape = resolve((s) => s?.shape).copyWith(side: effectiveSide); + final effectivePadding = resolve((s) => s?.padding); + final effectiveConstraints = resolve((s) => s?.constraints); + final effectiveBackgroundColor = resolve((s) => s?.backgroundColor); return ConstrainedBox( constraints: effectiveConstraints, @@ -178,17 +139,45 @@ class _StreamMessageBubbleDefaults extends StreamMessageBubbleStyle { late final StreamSpacing _spacing = _context.streamSpacing; @override - Color get backgroundColor => _colorScheme.backgroundSurface; + StreamMessageStyleProperty get backgroundColor => .resolveWith( + (placement) => switch (placement.alignment) { + .start => _colorScheme.backgroundSurface, + .end => _colorScheme.brand.shade100, + }, + ); @override - OutlinedBorder get shape => RoundedRectangleBorder(borderRadius: .all(_radius.xxxl)); + StreamMessageStyleProperty get shape => .resolveWith( + (placement) => RoundedSuperellipseBorder( + borderRadius: switch ((placement.alignment, placement.stackPosition)) { + (.start, .single || .bottom) => BorderRadiusDirectional.only( + topStart: _radius.xxl, + topEnd: _radius.xxl, + bottomEnd: _radius.xxl, + ), + (.end, .single || .bottom) => BorderRadiusDirectional.only( + topStart: _radius.xxl, + topEnd: _radius.xxl, + bottomStart: _radius.xxl, + ), + _ => BorderRadiusDirectional.all(_radius.xxl), + }, + ), + ); @override - BorderSide get side => BorderSide(color: _colorScheme.borderSubtle); + StreamMessageStyleBorderSide get side => .resolveWith( + (placement) => switch (placement.alignment) { + .start => BorderSide(color: _colorScheme.borderSubtle), + .end => BorderSide(color: _colorScheme.brand.shade100), + }, + ); @override - EdgeInsetsGeometry get padding => .symmetric(horizontal: _spacing.sm, vertical: _spacing.xs); + StreamMessageStyleProperty get padding => .all( + .symmetric(horizontal: _spacing.sm, vertical: _spacing.xs), + ); @override - BoxConstraints get constraints => const BoxConstraints(minHeight: 20); + StreamMessageStyleProperty get constraints => .all(const BoxConstraints(minHeight: 20)); } diff --git a/packages/stream_core_flutter/lib/src/components/message/stream_message_content.dart b/packages/stream_core_flutter/lib/src/components/message/stream_message_content.dart index a72015a..784f1ed 100644 --- a/packages/stream_core_flutter/lib/src/components/message/stream_message_content.dart +++ b/packages/stream_core_flutter/lib/src/components/message/stream_message_content.dart @@ -1,6 +1,7 @@ import 'package:flutter/widgets.dart'; import '../../theme.dart'; +import '../message_placement/stream_message_placement.dart'; /// A composite layout container that arranges message primitives into the /// full message content structure. @@ -153,13 +154,14 @@ class DefaultStreamMessageContent extends StatelessWidget { Widget build(BuildContext context) { final spacing = context.streamSpacing; final effectiveSpacing = props.spacing ?? spacing.xxs; + final crossAxisAlignment = StreamMessagePlacement.crossAxisAlignmentOf(context); return SizedBox( width: double.infinity, child: Column( mainAxisSize: .min, spacing: effectiveSpacing, - crossAxisAlignment: .start, + crossAxisAlignment: crossAxisAlignment, children: [?props.header, props.child, ?props.footer], ), ); diff --git a/packages/stream_core_flutter/lib/src/components/message/stream_message_metadata.dart b/packages/stream_core_flutter/lib/src/components/message/stream_message_metadata.dart index 74e8db5..a47f5e8 100644 --- a/packages/stream_core_flutter/lib/src/components/message/stream_message_metadata.dart +++ b/packages/stream_core_flutter/lib/src/components/message/stream_message_metadata.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../theme.dart'; +import '../message_placement/stream_message_placement.dart'; /// The bottom metadata row of a chat message bubble. /// @@ -9,7 +10,7 @@ import '../../theme.dart'; /// /// All content is provided by the caller via widget slots. The provided /// widgets are automatically styled according to -/// [StreamMessageMetadataThemeData]. +/// [StreamMessageMetadataStyle]. /// /// {@tool snippet} /// @@ -38,8 +39,8 @@ import '../../theme.dart'; /// /// See also: /// -/// * [StreamMessageMetadataThemeData], for customizing metadata appearance. -/// * [StreamMessageMetadataTheme], for overriding theme in a widget subtree. +/// * [StreamMessageMetadataStyle], for customizing metadata appearance. +/// * [StreamMessageItemTheme], for theming via the widget tree. class StreamMessageMetadata extends StatelessWidget { /// Creates a message metadata row. /// @@ -51,15 +52,13 @@ class StreamMessageMetadata extends StatelessWidget { Widget? status, Widget? username, Widget? edited, - double? spacing, - double? minHeight, + StreamMessageMetadataStyle? style, }) : props = .new( timestamp: timestamp, status: status, username: username, edited: edited, - spacing: spacing, - minHeight: minHeight, + style: style, ); /// The properties that configure this metadata row. @@ -85,14 +84,13 @@ class StreamMessageMetadataProps { this.status, this.username, this.edited, - this.spacing, - this.minHeight, + this.style, }); /// The timestamp widget, typically a [Text] displaying the message time. /// - /// Styled by [StreamMessageMetadataThemeData.timestampTextStyle] and - /// [StreamMessageMetadataThemeData.timestampColor]. + /// Styled by [StreamMessageMetadataStyle.timestampTextStyle] and + /// [StreamMessageMetadataStyle.timestampColor]. final Widget timestamp; /// An optional status icon widget indicating delivery state. @@ -100,31 +98,27 @@ class StreamMessageMetadataProps { /// Typically an [Icon] such as a clock (sending), single checkmark (sent), /// or double checkmark (delivered/read). /// - /// Styled by [StreamMessageMetadataThemeData.statusColor] and - /// [StreamMessageMetadataThemeData.statusIconSize]. + /// Styled by [StreamMessageMetadataStyle.statusColor] and + /// [StreamMessageMetadataStyle.statusIconSize]. final Widget? status; /// An optional username widget displaying the sender name. /// - /// Styled by [StreamMessageMetadataThemeData.usernameTextStyle] and - /// [StreamMessageMetadataThemeData.usernameColor]. + /// Styled by [StreamMessageMetadataStyle.usernameTextStyle] and + /// [StreamMessageMetadataStyle.usernameColor]. final Widget? username; /// An optional edited indicator widget. /// - /// Styled by [StreamMessageMetadataThemeData.editedTextStyle] and - /// [StreamMessageMetadataThemeData.editedColor]. + /// Styled by [StreamMessageMetadataStyle.editedTextStyle] and + /// [StreamMessageMetadataStyle.editedColor]. final Widget? edited; - /// The gap between main elements (username, timestamp group, edited). + /// Optional style overrides for placement-aware styling. /// - /// When null, falls back to [StreamMessageMetadataThemeData.spacing]. - final double? spacing; - - /// The minimum height of the metadata row. - /// - /// When null, falls back to [StreamMessageMetadataThemeData.minHeight]. - final double? minHeight; + /// Fields left null fall back to the inherited [StreamMessageItemTheme], + /// then to built-in defaults. + final StreamMessageMetadataStyle? style; } /// The default implementation of [StreamMessageMetadata]. @@ -142,20 +136,23 @@ class DefaultStreamMessageMetadata extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = context.streamMessageMetadataTheme; - final defaults = _StreamMessageMetadataThemeDefaults(context); - - final effectiveUsernameTextStyle = theme.usernameTextStyle ?? defaults.usernameTextStyle; - final effectiveUsernameColor = theme.usernameColor ?? defaults.usernameColor; - final effectiveTimestampTextStyle = theme.timestampTextStyle ?? defaults.timestampTextStyle; - final effectiveTimestampColor = theme.timestampColor ?? defaults.timestampColor; - final effectiveEditedTextStyle = theme.editedTextStyle ?? defaults.editedTextStyle; - final effectiveEditedColor = theme.editedColor ?? defaults.editedColor; - final effectiveStatusColor = theme.statusColor ?? defaults.statusColor; - final effectiveStatusIconSize = theme.statusIconSize ?? defaults.statusIconSize; - final effectiveSpacing = props.spacing ?? theme.spacing ?? defaults.spacing; - final effectiveStatusSpacing = theme.statusSpacing ?? defaults.statusSpacing; - final effectiveMinHeight = props.minHeight ?? theme.minHeight ?? defaults.minHeight; + final placement = StreamMessagePlacement.of(context); + final metadataStyle = props.style ?? StreamMessageItemTheme.of(context).metadata; + final defaults = _StreamMessageMetadataDefaults(context); + + final resolve = StreamMessageStyleResolver(placement, [metadataStyle, defaults]); + + final effectiveUsernameTextStyle = resolve((s) => s?.usernameTextStyle); + final effectiveUsernameColor = resolve((s) => s?.usernameColor); + final effectiveTimestampTextStyle = resolve((s) => s?.timestampTextStyle); + final effectiveTimestampColor = resolve((s) => s?.timestampColor); + final effectiveEditedTextStyle = resolve((s) => s?.editedTextStyle); + final effectiveEditedColor = resolve((s) => s?.editedColor); + final effectiveStatusColor = resolve((s) => s?.statusColor); + final effectiveStatusIconSize = resolve((s) => s?.statusIconSize); + final effectiveSpacing = resolve((s) => s?.spacing); + final effectiveStatusSpacing = resolve((s) => s?.statusSpacing); + final effectiveMinHeight = resolve((s) => s?.minHeight); Widget? usernameWidget; if (props.username case final username?) { @@ -211,8 +208,8 @@ class DefaultStreamMessageMetadata extends StatelessWidget { } } -class _StreamMessageMetadataThemeDefaults extends StreamMessageMetadataThemeData { - _StreamMessageMetadataThemeDefaults(this._context); +class _StreamMessageMetadataDefaults extends StreamMessageMetadataStyle { + _StreamMessageMetadataDefaults(this._context); final BuildContext _context; @@ -221,35 +218,35 @@ class _StreamMessageMetadataThemeDefaults extends StreamMessageMetadataThemeData late final StreamSpacing _spacing = _context.streamSpacing; @override - TextStyle get usernameTextStyle => _textTheme.metadataEmphasis; + StreamMessageStyleProperty get usernameTextStyle => .all(_textTheme.metadataEmphasis); @override - Color get usernameColor => _colorScheme.textSecondary; + StreamMessageStyleProperty get usernameColor => .all(_colorScheme.textSecondary); @override - TextStyle get timestampTextStyle => _textTheme.metadataDefault; + StreamMessageStyleProperty get timestampTextStyle => .all(_textTheme.metadataDefault); @override - Color get timestampColor => _colorScheme.textTertiary; + StreamMessageStyleProperty get timestampColor => .all(_colorScheme.textTertiary); @override - TextStyle get editedTextStyle => _textTheme.metadataDefault; + StreamMessageStyleProperty get editedTextStyle => .all(_textTheme.metadataDefault); @override - Color get editedColor => _colorScheme.textTertiary; + StreamMessageStyleProperty get editedColor => .all(_colorScheme.textTertiary); @override - Color get statusColor => _colorScheme.textTertiary; + StreamMessageStyleProperty get statusColor => .all(_colorScheme.textTertiary); @override - double get statusIconSize => 16; + StreamMessageStyleProperty get statusIconSize => .all(16); @override - double get spacing => _spacing.xs; + StreamMessageStyleProperty get spacing => .all(_spacing.xs); @override - double get statusSpacing => _spacing.xxs; + StreamMessageStyleProperty get statusSpacing => .all(_spacing.xxs); @override - double get minHeight => 24; + StreamMessageStyleProperty get minHeight => .all(24); } diff --git a/packages/stream_core_flutter/lib/src/components/message/stream_message_replies.dart b/packages/stream_core_flutter/lib/src/components/message/stream_message_replies.dart index d7d9117..645bad0 100644 --- a/packages/stream_core_flutter/lib/src/components/message/stream_message_replies.dart +++ b/packages/stream_core_flutter/lib/src/components/message/stream_message_replies.dart @@ -2,7 +2,8 @@ import 'package:flutter/material.dart'; import '../../theme.dart'; import '../avatar/stream_avatar_stack.dart'; -import 'stream_message_alignment.dart'; +import '../message_placement/stream_message_alignment.dart'; +import '../message_placement/stream_message_placement.dart'; /// A tappable row showing reply count, participant avatars, and an optional /// connector for threaded messages. @@ -58,8 +59,8 @@ import 'stream_message_alignment.dart'; /// /// See also: /// -/// * [StreamMessageRepliesThemeData], for customizing replies appearance. -/// * [StreamMessageRepliesTheme], for overriding theme in a widget subtree. +/// * [StreamMessageRepliesStyle], for customizing replies appearance. +/// * [StreamMessageItemTheme], for theming via the widget tree. /// * [StreamMessageAlignment], for controlling element order. /// * [StreamAvatarStack], which renders the avatars internally. class StreamMessageReplies extends StatelessWidget { @@ -76,10 +77,9 @@ class StreamMessageReplies extends StatelessWidget { bool showConnector = true, VoidCallback? onTap, VoidCallback? onLongPress, - StreamMessageAlignment alignment = .start, - double? spacing, - EdgeInsetsGeometry? padding, - Clip clipBehavior = .none, + StreamMessageAlignment? alignment, + Clip? clipBehavior, + StreamMessageRepliesStyle? style, }) : props = .new( label: label, avatars: avatars, @@ -89,9 +89,8 @@ class StreamMessageReplies extends StatelessWidget { onTap: onTap, onLongPress: onLongPress, alignment: alignment, - spacing: spacing, - padding: padding, clipBehavior: clipBehavior, + style: style, ); /// The properties that configure this replies row. @@ -120,17 +119,16 @@ class StreamMessageRepliesProps { this.showConnector = true, this.onTap, this.onLongPress, - this.alignment = .start, - this.spacing, - this.padding, - this.clipBehavior = .none, + this.alignment, + this.clipBehavior, + this.style, }); /// An optional label widget, typically a [Text] showing the reply count /// (e.g. "3 replies"). /// - /// Styled by [StreamMessageRepliesThemeData.labelTextStyle] and - /// [StreamMessageRepliesThemeData.labelColor]. + /// Styled by [StreamMessageRepliesStyle.labelTextStyle] and + /// [StreamMessageRepliesStyle.labelColor]. final Widget? label; /// Avatar widgets for thread participants. @@ -152,8 +150,8 @@ class StreamMessageRepliesProps { /// Whether to show the connector linking this row to the message bubble. /// /// The connector appearance is controlled by - /// [StreamMessageRepliesThemeData.connectorColor] and - /// [StreamMessageRepliesThemeData.connectorStrokeWidth]. + /// [StreamMessageRepliesStyle.connectorColor] and + /// [StreamMessageRepliesStyle.connectorStrokeWidth]. /// /// The connector adapts to [alignment] and [TextDirection] to always /// point toward the message bubble. @@ -170,25 +168,22 @@ class StreamMessageRepliesProps { /// /// See [StreamMessageAlignment] for details on how this composes with /// [TextDirection]. - final StreamMessageAlignment alignment; - - /// The gap between elements (connector, avatars, label). - /// - /// When null, falls back to [StreamMessageRepliesThemeData.spacing]. - final double? spacing; - - /// The padding around the replies row content. - /// - /// When null, falls back to [StreamMessageRepliesThemeData.padding]. - final EdgeInsetsGeometry? padding; + final StreamMessageAlignment? alignment; /// How to clip the widget's content. /// /// Useful when the connector overflows the row bounds. Set to /// [Clip.hardEdge] or similar to constrain the visible area. /// - /// Defaults to [Clip.none]. - final Clip clipBehavior; + /// When null, falls back to the theme's [StreamMessageRepliesStyle.clipBehavior], + /// which defaults based on stack position. + final Clip? clipBehavior; + + /// Optional style overrides for placement-aware styling. + /// + /// Fields left null fall back to the inherited [StreamMessageItemTheme], + /// then to built-in defaults. + final StreamMessageRepliesStyle? style; } /// The default implementation of [StreamMessageReplies]. @@ -206,13 +201,17 @@ class DefaultStreamMessageReplies extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = context.streamMessageRepliesTheme; - final defaults = _StreamMessageRepliesThemeDefaults(context); + final placement = StreamMessagePlacement.of(context); + final repliesStyle = props.style ?? StreamMessageItemTheme.of(context).replies; + final defaults = _StreamMessageRepliesDefaults(context); + + final resolve = StreamMessageStyleResolver(placement, [repliesStyle, defaults]); - final effectiveLabelTextStyle = theme.labelTextStyle ?? defaults.labelTextStyle; - final effectiveLabelColor = theme.labelColor ?? defaults.labelColor; - final effectiveSpacing = props.spacing ?? theme.spacing ?? defaults.spacing; - final effectivePadding = props.padding ?? theme.padding ?? defaults.padding; + final effectiveLabelTextStyle = resolve((s) => s?.labelTextStyle); + final effectiveLabelColor = resolve((s) => s?.labelColor); + final effectiveSpacing = resolve((s) => s?.spacing); + final effectivePadding = resolve((s) => s?.padding); + final effectiveAlignment = props.alignment ?? placement.alignment; Widget? labelWidget; if (props.label case final label?) { @@ -236,21 +235,21 @@ class DefaultStreamMessageReplies extends StatelessWidget { Widget? connectorWidget; if (props.showConnector) { - final effectiveConnectorColor = theme.connectorColor ?? defaults.connectorColor; - final effectiveStrokeWidth = theme.connectorStrokeWidth ?? defaults.connectorStrokeWidth; + final effectiveConnectorColor = resolve((s) => s?.connectorColor); + final effectiveStrokeWidth = resolve((s) => s?.connectorStrokeWidth); connectorWidget = CustomPaint( size: const Size(_kConnectorWidth, _kConnectorHeight), painter: _ConnectorPainter( color: effectiveConnectorColor, strokeWidth: effectiveStrokeWidth, - alignment: props.alignment, + alignment: effectiveAlignment, textDirection: Directionality.of(context), ), ); } - final children = switch (props.alignment) { + final children = switch (effectiveAlignment) { .start => [?connectorWidget, ?avatarsWidget, ?labelWidget], .end => [?labelWidget, ?avatarsWidget, ?connectorWidget], }; @@ -260,8 +259,9 @@ class DefaultStreamMessageReplies extends StatelessWidget { child: Row(mainAxisSize: .min, spacing: effectiveSpacing, children: children), ); - if (props.clipBehavior != Clip.none) { - child = ClipRect(clipBehavior: props.clipBehavior, child: child); + final effectiveClip = resolve((s) => s?.clipBehavior); + if (effectiveClip != Clip.none) { + child = ClipRect(clipBehavior: effectiveClip, child: child); } return GestureDetector( @@ -346,8 +346,8 @@ class _ConnectorPainter extends CustomPainter { textDirection != oldDelegate.textDirection; } -class _StreamMessageRepliesThemeDefaults extends StreamMessageRepliesThemeData { - _StreamMessageRepliesThemeDefaults(this._context); +class _StreamMessageRepliesDefaults extends StreamMessageRepliesStyle { + _StreamMessageRepliesDefaults(this._context); final BuildContext _context; @@ -356,20 +356,33 @@ class _StreamMessageRepliesThemeDefaults extends StreamMessageRepliesThemeData { late final StreamSpacing _spacing = _context.streamSpacing; @override - double get connectorStrokeWidth => 1; + StreamMessageStyleProperty get connectorStrokeWidth => .all(1); + + @override + StreamMessageStyleProperty get connectorColor => .resolveWith( + (placement) => switch (placement.alignment) { + .start => _colorScheme.borderSubtle, + .end => _colorScheme.brand.shade150, + }, + ); @override - Color get connectorColor => _colorScheme.borderSubtle; + StreamMessageStyleProperty get labelTextStyle => .all(_textTheme.captionEmphasis); @override - TextStyle get labelTextStyle => _textTheme.captionEmphasis; + StreamMessageStyleProperty get labelColor => .all(_colorScheme.textLink); @override - Color get labelColor => _colorScheme.textLink; + StreamMessageStyleProperty get spacing => .all(_spacing.xs); @override - double get spacing => _spacing.xs; + StreamMessageStyleProperty get padding => .all(.only(top: _spacing.xs, bottom: _spacing.xxs)); @override - EdgeInsetsGeometry get padding => .only(top: _spacing.xs, bottom: _spacing.xxs); + StreamMessageStyleClip get clipBehavior => .resolveWith( + (placement) => switch (placement.stackPosition) { + .top || .middle => Clip.none, + .bottom || .single => Clip.hardEdge, + }, + ); } diff --git a/packages/stream_core_flutter/lib/src/components/message/stream_message_text.dart b/packages/stream_core_flutter/lib/src/components/message/stream_message_text.dart new file mode 100644 index 0000000..f1942fd --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/message/stream_message_text.dart @@ -0,0 +1,417 @@ +// ignore_for_file: valid_regexps + +import 'package:flutter/material.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:markdown/markdown.dart' as md; +import 'package:stream_core/stream_core.dart'; + +import '../../theme.dart'; +import '../accessories/stream_emoji.dart'; +import '../message_placement/stream_message_placement.dart'; + +/// The default protocol prefix used to identify mention links. +/// +/// Links with this scheme (e.g., `[text](mention:id)`) are treated as +/// mentions rather than regular links. +const kStreamMentionScheme = 'mention'; + +/// Callback fired when a mention link is tapped. +/// +/// [displayText] is the raw display text from the link +/// (e.g., `'@Alice'` from `[@Alice](mention:user123)`). +/// [id] is the mention identifier (the URL-decoded portion after the +/// `mention:` scheme). +typedef MarkdownTapMentionCallback = void Function(String displayText, String id); + +// Matches characters that render as emoji — either those with default emoji +// presentation, or text-default characters forced to emoji via VS16 (U+FE0F). +// +// Uses Unicode property escapes so new emoji are covered automatically when +// Dart's ICU tables update, with no hardcoded code-point ranges to maintain. +final _emojiRegex = RegExp(r'\p{Emoji_Presentation}|\p{Emoji}\uFE0F', unicode: true); + +/// Renders markdown text with themed styling. +/// +/// {@tool snippet} +/// +/// Basic markdown rendering: +/// +/// ```dart +/// StreamMessageText('**Hello** _world_') +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// With custom code handling and mention support: +/// +/// ```dart +/// StreamMessageText( +/// responseMarkdown, +/// syntaxHighlighter: mySyntaxHighlighter, +/// builders: {'pre': MyCodeBlockBuilder()}, +/// onTapLink: (text, href, title) => launchUrl(Uri.parse(href ?? '')), +/// onTapMention: (displayText, id) => navigateToProfile(id), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageTextStyle], for customizing text appearance. +/// * [StreamMessageItemTheme], for theming via the widget tree. +/// * [kStreamMentionScheme], the protocol prefix used for mention detection. +class StreamMessageText extends StatelessWidget { + /// Creates a markdown message text widget. + StreamMessageText( + String text, { + super.key, + StreamMessageTextStyle? style, + bool selectable = false, + MarkdownTapLinkCallback? onTapLink, + MarkdownTapMentionCallback? onTapMention, + VoidCallback? onTapText, + MarkdownImageBuilder? imageBuilder, + SyntaxHighlighter? syntaxHighlighter, + Map? builders, + Map? paddingBuilders, + List? blockSyntaxes, + List? inlineSyntaxes, + md.ExtensionSet? extensionSet, + bool softLineBreak = false, + bool fitContent = true, + MarkdownStyleSheet? styleSheet, + }) : props = .new( + text: text, + style: style, + selectable: selectable, + onTapLink: onTapLink, + onTapMention: onTapMention, + onTapText: onTapText, + imageBuilder: imageBuilder, + syntaxHighlighter: syntaxHighlighter, + builders: builders, + paddingBuilders: paddingBuilders, + blockSyntaxes: blockSyntaxes, + inlineSyntaxes: inlineSyntaxes, + extensionSet: extensionSet, + softLineBreak: softLineBreak, + fitContent: fitContent, + styleSheet: styleSheet, + ); + + /// The properties that configure this widget. + final StreamMessageTextProps props; + + @override + Widget build(BuildContext context) { + final builder = StreamComponentFactory.of(context).messageText; + if (builder != null) return builder(context, props); + return DefaultStreamMessageText(props: props); + } + + /// Returns the number of emoji grapheme clusters if [text] contains only + /// emojis (ignoring whitespace), or `null` if the text is empty or contains + /// any non-emoji characters. + /// + /// Useful for determining emoji-specific rendering such as larger font sizes + /// or hiding the message bubble. + /// + /// ```dart + /// StreamMessageText.emojiOnlyCount('🚀') // 1 + /// StreamMessageText.emojiOnlyCount('👍🔥') // 2 + /// StreamMessageText.emojiOnlyCount('❤️🎉😍') // 3 + /// StreamMessageText.emojiOnlyCount('🎉🎉🎉🎉') // 4 + /// StreamMessageText.emojiOnlyCount('Hello 👋') // null (mixed) + /// StreamMessageText.emojiOnlyCount('👨‍👩‍👧') // 1 (ZWJ family) + /// StreamMessageText.emojiOnlyCount('🇺🇸') // 1 (flag) + /// StreamMessageText.emojiOnlyCount('👍🏽') // 1 (skin tone) + /// ``` + static int? emojiOnlyCount(String text) { + final trimmed = text.trim(); + if (trimmed.isEmpty) return null; + + final graphemes = trimmed.characters.where((c) => c.trim().isNotEmpty); + + for (final grapheme in graphemes) { + if (!_emojiRegex.hasMatch(grapheme)) return null; + } + + return graphemes.length; + } +} + +/// Properties for configuring a [StreamMessageText]. +/// +/// See also: +/// +/// * [StreamMessageText], which uses these properties. +/// * [DefaultStreamMessageText], the default implementation. +@immutable +class StreamMessageTextProps { + /// Creates properties for a markdown message text widget. + const StreamMessageTextProps({ + required this.text, + this.style, + this.selectable = false, + this.onTapLink, + this.onTapMention, + this.onTapText, + this.imageBuilder, + this.syntaxHighlighter, + this.builders, + this.paddingBuilders, + this.blockSyntaxes, + this.inlineSyntaxes, + this.extensionSet, + this.softLineBreak = false, + this.fitContent = true, + this.styleSheet, + }); + + /// The markdown text to render. + final String text; + + /// Style override for text, links, and mentions. + final StreamMessageTextStyle? style; + + /// Whether text is selectable. + final bool selectable; + + /// Called when a link is tapped. + final MarkdownTapLinkCallback? onTapLink; + + /// Called when a mention is tapped. + /// + /// Mentions use the `[text](mention:id)` format. + final MarkdownTapMentionCallback? onTapMention; + + /// Called when non-link text is tapped. + final VoidCallback? onTapText; + + /// Custom image builder. + final MarkdownImageBuilder? imageBuilder; + + /// Syntax highlighter for code blocks. + final SyntaxHighlighter? syntaxHighlighter; + + /// Custom element builders keyed by tag name. + final Map? builders; + + /// Custom padding builders keyed by tag name. + final Map? paddingBuilders; + + /// Additional block-level syntax parsers. + final List? blockSyntaxes; + + /// Additional inline-level syntax parsers. + final List? inlineSyntaxes; + + /// Markdown extension set. + final md.ExtensionSet? extensionSet; + + /// Whether soft line breaks are treated as hard breaks. + final bool softLineBreak; + + /// Whether the widget sizes to fit its content. + final bool fitContent; + + /// Additional style sheet for customising headings, code blocks, tables, + /// and other markdown styles not exposed in [StreamMessageTextStyle]. + final MarkdownStyleSheet? styleSheet; +} + +/// The default implementation of [StreamMessageText]. +/// +/// See also: +/// +/// * [StreamMessageText], the public API widget. +/// * [StreamMessageTextProps], which configures this widget. +class DefaultStreamMessageText extends StatelessWidget { + /// Creates a default message text widget with the given [props]. + const DefaultStreamMessageText({super.key, required this.props}); + + /// The properties that configure this widget. + final StreamMessageTextProps props; + + @override + Widget build(BuildContext context) { + final placement = StreamMessagePlacement.of(context); + final textStyleFromTheme = props.style ?? StreamMessageItemTheme.of(context).text; + final defaults = _StreamMessageTextDefaults(context); + + final resolve = StreamMessageStyleResolver(placement, [textStyleFromTheme, defaults]); + + final effectiveTextColor = resolve((s) => s?.textColor); + var effectiveTextStyle = resolve((s) => s?.textStyle).copyWith(color: effectiveTextColor); + final effectiveLinkColor = resolve((s) => s?.linkColor); + final effectiveLinkStyle = resolve((s) => s?.linkStyle).copyWith(color: effectiveLinkColor); + final effectiveMentionColor = resolve((s) => s?.mentionColor); + final effectiveMentionStyle = resolve((s) => s?.mentionStyle).copyWith(color: effectiveMentionColor); + + final emojiCount = StreamMessageText.emojiOnlyCount(props.text); + if (emojiCount case final count?) { + final emojiStyle = switch (count) { + 1 => resolve((s) => s?.singleEmojiStyle), + 2 => resolve((s) => s?.doubleEmojiStyle), + 3 => resolve((s) => s?.tripleEmojiStyle), + _ => null, // No emoji style (Fallback to regular style) + }; + + effectiveTextStyle = effectiveTextStyle.merge(emojiStyle); + } + + final streamThemeData = Theme.of(context).let( + (it) => it.copyWith( + textTheme: it.textTheme.apply( + bodyColor: effectiveTextStyle.color, + decoration: effectiveTextStyle.decoration, + decorationColor: effectiveTextStyle.decorationColor, + decorationStyle: effectiveTextStyle.decorationStyle, + fontFamily: effectiveTextStyle.fontFamily, + fontFamilyFallback: effectiveTextStyle.fontFamilyFallback, + ), + ), + ); + + final markdownSheet = MarkdownStyleSheet.fromTheme( + streamThemeData, // Apply stream theme data + ).copyWith(p: effectiveTextStyle, a: effectiveLinkStyle).merge(props.styleSheet); + + // Prepend mention syntax so `[text](mention:id)` is intercepted + // before the standard LinkSyntax, producing `mention` elements. + // Regular `a` elements are never touched. + final mentionStyle = effectiveMentionStyle.copyWith(color: effectiveMentionColor); + + final effectiveInlineSyntaxes = [ + _StreamMentionSyntax(), + ...?props.inlineSyntaxes, + ]; + + final effectiveBuilders = { + kStreamMentionScheme: _StreamMentionBuilder( + style: mentionStyle, + onTap: props.onTapMention, + ), + ...?props.builders, + }; + + return MarkdownBody( + data: props.text, + selectable: props.selectable, + styleSheet: markdownSheet, + styleSheetTheme: .platform, + syntaxHighlighter: props.syntaxHighlighter, + onTapLink: props.onTapLink, + onTapText: props.onTapText, + imageBuilder: props.imageBuilder, + builders: effectiveBuilders, + paddingBuilders: props.paddingBuilders ?? const {}, + blockSyntaxes: props.blockSyntaxes, + inlineSyntaxes: effectiveInlineSyntaxes, + extensionSet: props.extensionSet, + softLineBreak: props.softLineBreak, + fitContent: props.fitContent, + ); + } +} + +// Intercepts `[text](mention:id)` patterns before the standard link parser, +// emitting a `mention` element instead of a regular link. +// +// Given `[@Alice](mention:user123)`: +// +// * Emits a `mention` element with text content `@Alice`. +// * Stores the URL-decoded id (`user123`) in the `id` attribute. +// * Regular links are never touched. +class _StreamMentionSyntax extends md.InlineSyntax { + _StreamMentionSyntax({ + String scheme = kStreamMentionScheme, + }) : super('\\[([^\\]\\n]+)\\]\\(${RegExp.escape(scheme)}:([^)\\s]+)\\)'); + + @override + bool onMatch(md.InlineParser parser, Match match) { + final displayText = match.group(1)!; + final rawId = match.group(2)!; + + final el = md.Element.text('mention', displayText); + el.attributes['id'] = Uri.decodeComponent(rawId); + parser.addNode(el); + return true; + } +} + +// Renders `mention` elements as tappable styled text with pointer cursor. +class _StreamMentionBuilder extends MarkdownElementBuilder { + _StreamMentionBuilder({required this.style, this.onTap}); + + final TextStyle style; + final MarkdownTapMentionCallback? onTap; + + @override + Widget? visitElementAfterWithContext( + BuildContext context, + md.Element element, + TextStyle? preferredStyle, + TextStyle? parentStyle, + ) { + final displayText = element.textContent; + final id = element.attributes['id'] ?? ''; + + return MouseRegion( + cursor: onTap != null ? SystemMouseCursors.click : MouseCursor.defer, + child: GestureDetector( + onTap: onTap != null ? () => onTap!(displayText, id) : null, + child: Text(displayText, style: preferredStyle?.merge(style) ?? style), + ), + ); + } +} + +// Default values for [StreamMessageTextStyle] backed by stream design tokens. +class _StreamMessageTextDefaults extends StreamMessageTextStyle { + _StreamMessageTextDefaults(this._context); + + final BuildContext _context; + + late final StreamColorScheme _colorScheme = _context.streamColorScheme; + late final StreamTextTheme _textTheme = _context.streamTextTheme; + + @override + StreamMessageStyleProperty get textStyle => .all(_textTheme.bodyDefault); + + @override + StreamMessageStyleProperty get textColor => .resolveWith( + (placement) => switch (placement.alignment) { + .start => _colorScheme.textPrimary, + .end => _colorScheme.brand.shade900, + }, + ); + + @override + StreamMessageStyleProperty get linkStyle => .all(_textTheme.bodyLink); + + @override + StreamMessageStyleProperty get linkColor => .all(_colorScheme.textLink); + + @override + StreamMessageStyleProperty get mentionStyle => .all(_textTheme.bodyLink); + + @override + StreamMessageStyleProperty get mentionColor => .all(_colorScheme.textLink); + + @override + StreamMessageStyleProperty get singleEmojiStyle { + return .all(.new(fontSize: StreamEmojiSize.xxl.value, height: 1)); + } + + @override + StreamMessageStyleProperty get doubleEmojiStyle { + return .all(.new(fontSize: StreamEmojiSize.xl.value, height: 1)); + } + + @override + StreamMessageStyleProperty get tripleEmojiStyle { + return .all(.new(fontSize: StreamEmojiSize.lg.value, height: 1)); + } +} diff --git a/packages/stream_core_flutter/lib/src/components/message/stream_message_widget.dart b/packages/stream_core_flutter/lib/src/components/message/stream_message_widget.dart new file mode 100644 index 0000000..17a5687 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/message/stream_message_widget.dart @@ -0,0 +1,282 @@ +import 'package:flutter/material.dart'; + +import '../../theme.dart'; +import '../common/stream_visibility.dart'; +import '../message_placement/stream_message_alignment.dart'; +import '../message_placement/stream_message_placement.dart'; +import '../message_placement/stream_message_stack_position.dart'; + +/// The top-level message item widget used directly by message lists. +/// +/// [StreamMessageWidget] composes a leading slot (typically an avatar) +/// alongside a content slot, and establishes the [StreamMessagePlacement] +/// that descendant message sub-components use for placement-aware styling. +/// +/// {@tool snippet} +/// +/// Incoming message with avatar and content: +/// +/// ```dart +/// StreamMessageWidget( +/// leading: StreamAvatar( +/// imageUrl: user.avatarUrl, +/// placeholder: (context) => Text(user.initials), +/// ), +/// child: StreamMessageContent( +/// footer: StreamMessageMetadata(timestamp: Text('09:41')), +/// child: StreamMessageBubble( +/// child: StreamMessageText('Hello, world!'), +/// ), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Outgoing message without avatar: +/// +/// ```dart +/// StreamMessageWidget( +/// alignment: StreamMessageAlignment.end, +/// child: StreamMessageContent( +/// footer: StreamMessageMetadata(timestamp: Text('09:42')), +/// child: StreamMessageBubble( +/// child: StreamMessageText('Hey there!'), +/// ), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageContent], for composing bubble, annotations, and metadata. +/// * [StreamMessagePlacement], the placement context this widget +/// establishes for descendants. +/// * [StreamMessageItemTheme], for theming message items. +class StreamMessageWidget extends StatelessWidget { + /// Creates a message item. + /// + /// The [child] is required. An optional [leading] widget is displayed + /// alongside the content. The [alignment] and [stackPosition] configure + /// the [StreamMessagePlacement] for descendants. + StreamMessageWidget({ + super.key, + Widget? leading, + required Widget child, + StreamMessageAlignment alignment = .start, + StreamMessageStackPosition stackPosition = .single, + StreamVisibility? leadingVisibility, + EdgeInsetsGeometry? padding, + double? spacing, + VoidCallback? onTap, + VoidCallback? onLongPress, + }) : props = .new( + leading: leading, + child: child, + alignment: alignment, + stackPosition: stackPosition, + leadingVisibility: leadingVisibility, + padding: padding, + spacing: spacing, + onTap: onTap, + onLongPress: onLongPress, + ); + + /// The properties that configure this message item. + final StreamMessageWidgetProps props; + + @override + Widget build(BuildContext context) => StreamMessagePlacement( + placement: StreamMessagePlacementData( + alignment: props.alignment, + stackPosition: props.stackPosition, + ), + child: Builder( + builder: (context) { + final builder = StreamComponentFactory.of(context).messageWidget; + if (builder != null) return builder(context, props); + return DefaultStreamMessageWidget(props: props); + }, + ), + ); +} + +/// Properties for configuring a [StreamMessageWidget]. +/// +/// See also: +/// +/// * [StreamMessageWidget], which uses these properties. +class StreamMessageWidgetProps { + /// Creates properties for a message item. + const StreamMessageWidgetProps({ + this.leading, + required this.child, + this.alignment = .start, + this.stackPosition = .single, + this.leadingVisibility, + this.padding, + this.spacing, + this.onTap, + this.onLongPress, + }); + + /// Optional widget displayed alongside the content. + /// + /// Typically an avatar. Positioned at the start or end of the row + /// depending on [alignment]. + /// + /// When null, no leading widget is shown and no space is reserved. + final Widget? leading; + + /// The main content of the message item. + /// + /// Typically a [StreamMessageContent] composing bubble, annotations, + /// metadata, and reactions. + final Widget child; + + /// The horizontal alignment of the message. + /// + /// Determines the element order in the row and establishes the + /// [StreamMessagePlacement] alignment for descendants. + /// + /// Defaults to [StreamMessageAlignment.start]. + final StreamMessageAlignment alignment; + + /// The position of this message within a consecutive stack. + /// + /// Establishes the [StreamMessagePlacement] stack position for + /// descendants, which sub-components use to adjust visual treatment + /// (e.g. corner radii). + /// + /// Defaults to [StreamMessageStackPosition.single]. + final StreamMessageStackPosition stackPosition; + + /// Overrides the leading widget visibility for this message item. + /// + /// When non-null, takes precedence over the theme-resolved value from + /// [StreamMessageItemThemeData.leadingVisibility]. + /// + /// When null (the default), the visibility is determined by the theme. + final StreamVisibility? leadingVisibility; + + /// Outer padding around the entire message item. + /// + /// When non-null, takes precedence over the theme value from + /// [StreamMessageItemThemeData.padding]. + /// + /// When null (the default), the padding is determined by the theme. + final EdgeInsetsGeometry? padding; + + /// Horizontal spacing between the leading avatar and the content. + /// + /// When non-null, takes precedence over the theme value from + /// [StreamMessageItemThemeData.spacing]. + /// + /// When null (the default), the spacing is determined by the theme. + final double? spacing; + + /// Called when the message item is tapped. + final VoidCallback? onTap; + + /// Called when the message item is long-pressed. + final VoidCallback? onLongPress; +} + +/// The default implementation of [StreamMessageWidget]. +/// +/// See also: +/// +/// * [StreamMessageWidget], the public API widget. +/// * [StreamMessageWidgetProps], which configures this widget. +class DefaultStreamMessageWidget extends StatelessWidget { + /// Creates a default message item with the given [props]. + const DefaultStreamMessageWidget({super.key, required this.props}); + + /// The properties that configure this message item. + final StreamMessageWidgetProps props; + + @override + Widget build(BuildContext context) { + final placement = StreamMessagePlacement.of(context); + final theme = StreamMessageItemTheme.of(context); + final defaults = _StreamMessageWidgetDefaults(context); + + final resolve = StreamMessageStyleResolver(placement, [theme, defaults]); + + final effectivePadding = props.padding ?? theme.padding ?? defaults.padding; + final effectiveSpacing = props.spacing ?? theme.spacing ?? defaults.spacing; + final effectiveBackgroundColor = theme.backgroundColor ?? StreamColors.transparent; + final effectiveLeadingVisibility = props.leadingVisibility ?? resolve((theme) => theme?.leadingVisibility); + + Widget? leadingWidget; + if (props.leading case final leading?) { + final effectiveAvatarSize = theme.avatarSize ?? defaults.avatarSize; + + leadingWidget = StreamAvatarTheme( + data: .new(size: effectiveAvatarSize), + child: leading, + ); + + leadingWidget = switch (effectiveLeadingVisibility) { + StreamVisibility.visible => leadingWidget, + StreamVisibility.hidden => Visibility.maintain(visible: false, child: leadingWidget), + StreamVisibility.gone => null, + }; + } + + final content = Flexible(child: props.child); + + final children = switch (props.alignment) { + StreamMessageAlignment.start => [?leadingWidget, content], + StreamMessageAlignment.end => [content, ?leadingWidget], + }; + + return StreamMessagePlacement( + placement: placement, + child: Material( + animateColor: true, + color: effectiveBackgroundColor, + child: InkWell( + onTap: props.onTap, + onLongPress: props.onLongPress, + child: Padding( + padding: effectivePadding, + child: Row( + spacing: effectiveSpacing, + crossAxisAlignment: .end, + children: children, + ), + ), + ), + ), + ); + } +} + +class _StreamMessageWidgetDefaults extends StreamMessageItemThemeData { + _StreamMessageWidgetDefaults(this._context); + + final BuildContext _context; + + late final StreamSpacing _spacing = _context.streamSpacing; + + @override + double get spacing => _spacing.xs; + + @override + StreamAvatarSize get avatarSize => .md; + + @override + EdgeInsetsGeometry get padding => .symmetric(horizontal: _spacing.md); + + @override + StreamMessageStyleVisibility get leadingVisibility => .resolveWith( + (placement) => switch ((placement.alignment, placement.stackPosition)) { + (.end, _) => .gone, + (_, .top || .middle) => .hidden, + (_, .single || .bottom) => .visible, + }, + ); +} diff --git a/packages/stream_core_flutter/lib/src/components/message/stream_message_alignment.dart b/packages/stream_core_flutter/lib/src/components/message_placement/stream_message_alignment.dart similarity index 100% rename from packages/stream_core_flutter/lib/src/components/message/stream_message_alignment.dart rename to packages/stream_core_flutter/lib/src/components/message_placement/stream_message_alignment.dart diff --git a/packages/stream_core_flutter/lib/src/components/message_placement/stream_message_placement.dart b/packages/stream_core_flutter/lib/src/components/message_placement/stream_message_placement.dart new file mode 100644 index 0000000..13ec1ce --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/message_placement/stream_message_placement.dart @@ -0,0 +1,213 @@ +import 'package:flutter/widgets.dart'; + +import 'stream_message_alignment.dart'; +import 'stream_message_stack_position.dart'; + +// The aspect of a [StreamMessagePlacementData] that a widget depends on. +// +// Used by [StreamMessagePlacement] (an [InheritedModel]) to provide +// fine-grained rebuild control. Widgets that only care about one axis can +// subscribe to just that aspect and skip rebuilds when the other changes. +enum _StreamMessagePlacementAspect { + // The horizontal alignment axis (start / end). + alignment, + + // The vertical stack position axis (single / top / middle / bottom). + stackPosition, +} + +/// Provides [StreamMessagePlacementData] to descendant widgets. +/// +/// Descendants can read the placement using one of the static methods: +/// +/// * [of] — returns the full placement (rebuilds when either axis changes). +/// * [alignmentOf] — returns only the alignment (ignores stack position +/// changes). +/// * [crossAxisAlignmentOf] — returns a [CrossAxisAlignment] derived from +/// the alignment (ignores stack position changes). +/// * [stackPositionOf] — returns only the stack position (ignores alignment +/// changes). +/// +/// When no [StreamMessagePlacement] is found in the tree, a default placement +/// of [StreamMessageAlignment.start] + [StreamMessageStackPosition.single] is +/// returned. +/// +/// {@tool snippet} +/// +/// Read the full placement in a sub-component: +/// +/// ```dart +/// @override +/// Widget build(BuildContext context) { +/// final placement = StreamMessagePlacement.of(context); +/// final shape = style?.shape?.resolve(placement) +/// ?? defaults.shape.resolve(placement); +/// // ... +/// } +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessagePlacementData], the data this widget provides. +class StreamMessagePlacement extends InheritedModel<_StreamMessagePlacementAspect> { + /// Creates a placement scope that provides [placement] to descendants. + const StreamMessagePlacement({ + super.key, + required this.placement, + required super.child, + }); + + /// The message placement provided to descendants. + final StreamMessagePlacementData placement; + + /// The data from the closest instance of this class that encloses the given + /// context. + /// + /// You can use this function to query the entire + /// [StreamMessagePlacementData]. When any of that information changes, your + /// widget will be scheduled to be rebuilt, keeping your widget up-to-date. + /// + /// Since it is typical that the widget only requires a subset of properties + /// of the [StreamMessagePlacementData], prefer using the more specific + /// methods (for example: [alignmentOf] and [stackPositionOf]), as those + /// methods will not cause a widget to rebuild when unrelated properties are + /// updated. + /// + /// If there is no [StreamMessagePlacement] in scope, a default placement of + /// [StreamMessageAlignment.start] + [StreamMessageStackPosition.single] is + /// returned. + static StreamMessagePlacementData of(BuildContext context) => _of(context); + + static StreamMessagePlacementData _of(BuildContext context, [_StreamMessagePlacementAspect? aspect]) { + final placement = InheritedModel.inheritFrom(context, aspect: aspect)?.placement; + if (placement != null) return placement; + return const StreamMessagePlacementData(); + } + + /// Returns [StreamMessagePlacementData.alignment] from the nearest + /// [StreamMessagePlacement] ancestor. + /// + /// Use of this method will cause the given [context] to rebuild any time + /// that the [StreamMessagePlacementData.alignment] property of the ancestor + /// [StreamMessagePlacement] changes. + /// + /// Prefer using this function over getting the attribute directly from the + /// [StreamMessagePlacementData] returned from [of], because using this + /// function will only rebuild the [context] when this specific attribute + /// changes, not when _any_ attribute changes. + static StreamMessageAlignment alignmentOf(BuildContext context) { + return _of(context, _StreamMessagePlacementAspect.alignment).alignment; + } + + /// Returns a [CrossAxisAlignment] derived from + /// [StreamMessagePlacementData.alignment] of the nearest + /// [StreamMessagePlacement] ancestor. + /// + /// [StreamMessageAlignment.start] maps to [CrossAxisAlignment.start] and + /// [StreamMessageAlignment.end] maps to [CrossAxisAlignment.end]. + /// + /// Use of this method will cause the given [context] to rebuild any time + /// that the [StreamMessagePlacementData.alignment] property of the ancestor + /// [StreamMessagePlacement] changes. + /// + /// Prefer using this function over getting the attribute directly from the + /// [StreamMessagePlacementData] returned from [of], because using this + /// function will only rebuild the [context] when this specific attribute + /// changes, not when _any_ attribute changes. + static CrossAxisAlignment crossAxisAlignmentOf(BuildContext context) { + return switch (alignmentOf(context)) { + StreamMessageAlignment.start => CrossAxisAlignment.start, + StreamMessageAlignment.end => CrossAxisAlignment.end, + }; + } + + /// Returns [StreamMessagePlacementData.stackPosition] from the nearest + /// [StreamMessagePlacement] ancestor. + /// + /// Use of this method will cause the given [context] to rebuild any time + /// that the [StreamMessagePlacementData.stackPosition] property of the + /// ancestor [StreamMessagePlacement] changes. + /// + /// Prefer using this function over getting the attribute directly from the + /// [StreamMessagePlacementData] returned from [of], because using this + /// function will only rebuild the [context] when this specific attribute + /// changes, not when _any_ attribute changes. + static StreamMessageStackPosition stackPositionOf(BuildContext context) { + return _of(context, _StreamMessagePlacementAspect.stackPosition).stackPosition; + } + + @override + bool updateShouldNotify(StreamMessagePlacement oldWidget) => placement != oldWidget.placement; + + @override + bool updateShouldNotifyDependent( + StreamMessagePlacement oldWidget, + Set dependencies, + ) => dependencies.any( + (dependency) { + if (dependency is! _StreamMessagePlacementAspect) return false; + return switch (dependency) { + .alignment => placement.alignment != oldWidget.placement.alignment, + .stackPosition => placement.stackPosition != oldWidget.placement.stackPosition, + }; + }, + ); +} + +/// Describes where a message sits within the message list layout. +/// +/// Combines [alignment] (start vs end) and [stackPosition] +/// (single, top, middle, bottom) into a single value that +/// [StreamMessageStyleProperty] resolvers use to compute placement-dependent +/// styling. +/// +/// {@tool snippet} +/// +/// Create a placement for an end-aligned message at the top of a stack: +/// +/// ```dart +/// const placement = StreamMessagePlacementData( +/// alignment: StreamMessageAlignment.end, +/// stackPosition: StreamMessageStackPosition.top, +/// ); +/// +/// print(placement.alignment); // StreamMessageAlignment.end +/// print(placement.stackPosition); // StreamMessageStackPosition.top +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageAlignment], the horizontal alignment axis. +/// * [StreamMessageStackPosition], the vertical stacking axis. +/// * [StreamMessageStyleProperty], which resolves values based on placement. +@immutable +class StreamMessagePlacementData { + /// Creates a message placement. + /// + /// Defaults to a start-aligned, standalone message + /// ([StreamMessageAlignment.start] + [StreamMessageStackPosition.single]). + const StreamMessagePlacementData({ + this.alignment = .start, + this.stackPosition = .single, + }); + + /// The horizontal alignment of the message. + final StreamMessageAlignment alignment; + + /// The position of the message within a consecutive stack. + final StreamMessageStackPosition stackPosition; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is StreamMessagePlacementData && other.alignment == alignment && other.stackPosition == stackPosition; + } + + @override + int get hashCode => Object.hash(alignment, stackPosition); + + @override + String toString() => 'StreamMessagePlacementData(alignment: $alignment, stackPosition: $stackPosition)'; +} diff --git a/packages/stream_core_flutter/lib/src/components/message_placement/stream_message_stack_position.dart b/packages/stream_core_flutter/lib/src/components/message_placement/stream_message_stack_position.dart new file mode 100644 index 0000000..37f2f86 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/message_placement/stream_message_stack_position.dart @@ -0,0 +1,37 @@ +/// The position of a message within a consecutive stack from the same sender. +/// +/// Chat applications commonly group consecutive messages from the same user, +/// adjusting the bubble's visual treatment (typically corner radii) based on +/// where it sits in the group. +/// +/// {@tool snippet} +/// +/// Select a bubble border radius based on stack position: +/// +/// ```dart +/// final borderRadius = switch (stackPosition) { +/// .single => BorderRadius.circular(20), +/// .top => BorderRadius.vertical(top: Radius.circular(20)), +/// .middle => BorderRadius.zero, +/// .bottom => BorderRadius.vertical(bottom: Radius.circular(20)), +/// }; +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageAlignment], which controls the horizontal placement of +/// message elements. +enum StreamMessageStackPosition { + /// A standalone message that is not part of any group. + single, + + /// The first message in a consecutive group. + top, + + /// A message in the middle of a consecutive group. + middle, + + /// The last message in a consecutive group. + bottom, +} 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 index 268c9a8..ce41308 100644 --- a/packages/stream_core_flutter/lib/src/components/reaction/stream_reactions.dart +++ b/packages/stream_core_flutter/lib/src/components/reaction/stream_reactions.dart @@ -8,6 +8,8 @@ import '../../theme/stream_theme_extensions.dart'; import '../accessories/stream_emoji.dart'; import '../common/stream_flex.dart'; import '../controls/stream_emoji_chip.dart'; +import '../message_placement/stream_message_alignment.dart'; +import '../message_placement/stream_message_placement.dart'; /// Displays reactions as either individual chips or a single grouped chip. /// @@ -18,6 +20,10 @@ import '../controls/stream_emoji_chip.dart'; /// Reactions can be displayed on their own or positioned relative to a /// [child], such as a message bubble or container. /// +/// If a [StreamMessagePlacement] is found in the ancestor tree, +/// [position], [alignment], [crossAxisAlignment], and [indent] are +/// automatically derived from the message alignment when not explicitly set. +/// /// {@tool snippet} /// /// Display segmented reactions below a child: @@ -66,8 +72,8 @@ class StreamReactions extends StatelessWidget { super.key, required List items, Widget? child, - StreamReactionsPosition position = .footer, - StreamReactionsAlignment alignment = .start, + StreamReactionsPosition? position, + StreamReactionsAlignment? alignment, int? max, bool overlap = true, double? indent, @@ -93,8 +99,8 @@ class StreamReactions extends StatelessWidget { super.key, required List items, Widget? child, - StreamReactionsPosition position = .header, - StreamReactionsAlignment alignment = .end, + StreamReactionsPosition? position, + StreamReactionsAlignment? alignment, int? max, bool overlap = true, double? indent, @@ -148,9 +154,6 @@ class StreamReactions extends StatelessWidget { /// 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. @@ -162,8 +165,8 @@ class StreamReactionsProps { required this.type, required this.items, this.child, - required this.position, - required this.alignment, + this.position, + this.alignment, this.max, this.overlap = true, this.indent, @@ -186,10 +189,10 @@ class StreamReactionsProps { final Widget? child; /// The vertical position of the reactions relative to the child. - final StreamReactionsPosition position; + final StreamReactionsPosition? position; /// The horizontal alignment of the reactions relative to the child. - final StreamReactionsAlignment alignment; + final StreamReactionsAlignment? alignment; /// Maximum number of visible items. /// @@ -247,6 +250,7 @@ class StreamReactionsItem { } const _kMaxVisibleSegments = 4; +const _kDefaultStripIndent = 8.0; /// Default implementation of [StreamReactions]. /// @@ -285,16 +289,42 @@ class DefaultStreamReactions extends StatelessWidget { // Negative spacing when overlapping makes reactions overlap the child edge. final columnSpacing = props.overlap ? -effectiveOverlapExtent : effectiveGap; - final effectiveCrossAxisAlignment = props.crossAxisAlignment ?? CrossAxisAlignment.start; + // Use the message alignment from the ancestor scope to derive sensible + // defaults for position, alignment, cross-axis alignment, and indent. + final messageAlignment = StreamMessagePlacement.alignmentOf(context); + + var effectiveCrossAxisAlignment = props.crossAxisAlignment; + effectiveCrossAxisAlignment ??= switch (messageAlignment) { + StreamMessageAlignment.start => CrossAxisAlignment.start, + StreamMessageAlignment.end => CrossAxisAlignment.end, + }; + + var effectiveAlignment = props.alignment; + effectiveAlignment ??= switch ((messageAlignment, props.overlap)) { + (StreamMessageAlignment.start, true) => StreamReactionsAlignment.end, + (StreamMessageAlignment.start, false) => StreamReactionsAlignment.start, + (StreamMessageAlignment.end, true) => StreamReactionsAlignment.start, + (StreamMessageAlignment.end, false) => StreamReactionsAlignment.end, + }; + + var effectiveIndent = props.indent; + effectiveIndent ??= switch ((effectiveAlignment, props.overlap)) { + (StreamReactionsAlignment.start, true) => effectiveIndent ?? -_kDefaultStripIndent, + (StreamReactionsAlignment.end, true) => effectiveIndent ?? _kDefaultStripIndent, + _ => effectiveIndent ?? 0, + }; - final effectiveIndent = props.indent ?? reactionTheme.indent ?? defaults.indent; - final indentedStrip = Transform.translate(offset: .new(effectiveIndent, 0), child: reactionStrip); + final effectiveIndentOffset = Offset(effectiveIndent, 0).directional(Directionality.maybeOf(context)); + final indentedStrip = Transform.translate(offset: effectiveIndentOffset, child: reactionStrip); - final alignedStrip = switch (props.alignment) { + final alignedStrip = switch (effectiveAlignment) { .start => Align(alignment: AlignmentDirectional.centerStart, child: indentedStrip), .end => Align(alignment: AlignmentDirectional.centerEnd, child: indentedStrip), }; + var effectivePosition = props.position; + effectivePosition ??= props.overlap ? StreamReactionsPosition.header : StreamReactionsPosition.footer; + // 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 @@ -305,15 +335,14 @@ class DefaultStreamReactions extends StatelessWidget { spacing: columnSpacing, crossAxisAlignment: effectiveCrossAxisAlignment, clipBehavior: props.clipBehavior, - verticalDirection: switch (props.position) { + verticalDirection: switch (effectivePosition) { .header => VerticalDirection.up, .footer => VerticalDirection.down, }, children: [props.child!, alignedStrip], ); - if (props.overlap) return IntrinsicWidth(child: column); - return column; + return IntrinsicWidth(child: column); } Widget _buildSegmented(double itemSpacing, int maxVisible) { @@ -374,7 +403,13 @@ class _StreamReactionsThemeDefaults extends StreamReactionsThemeData { @override double get overlapExtent => _spacing.xs; +} - @override - double get indent => 0; +/// Adapts an [Offset] for the current [TextDirection]. +extension on Offset { + /// Flips [dx] for RTL so a positive offset always means "toward trailing." + Offset directional([TextDirection? textDirection]) { + if (textDirection == null || textDirection == .ltr) return this; + return Offset(-dx, dy); + } } 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 6502cd3..c1c866f 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 @@ -150,6 +150,8 @@ class StreamComponentBuilders with _$StreamComponentBuilders { StreamComponentBuilder? messageContent, StreamComponentBuilder? messageMetadata, StreamComponentBuilder? messageReplies, + StreamComponentBuilder? messageText, + StreamComponentBuilder? messageWidget, StreamComponentBuilder? onlineIndicator, StreamComponentBuilder? progressBar, StreamComponentBuilder? reactions, @@ -177,6 +179,8 @@ class StreamComponentBuilders with _$StreamComponentBuilders { messageContent: messageContent, messageMetadata: messageMetadata, messageReplies: messageReplies, + messageText: messageText, + messageWidget: messageWidget, onlineIndicator: onlineIndicator, progressBar: progressBar, reactions: reactions, @@ -205,6 +209,8 @@ class StreamComponentBuilders with _$StreamComponentBuilders { required this.messageContent, required this.messageMetadata, required this.messageReplies, + required this.messageText, + required this.messageWidget, required this.onlineIndicator, required this.progressBar, required this.reactions, @@ -326,6 +332,16 @@ class StreamComponentBuilders with _$StreamComponentBuilders { /// When null, [StreamMessageReplies] uses [DefaultStreamMessageReplies]. final StreamComponentBuilder? messageReplies; + /// Custom builder for message text (markdown) widgets. + /// + /// When null, [StreamMessageText] uses [DefaultStreamMessageText]. + final StreamComponentBuilder? messageText; + + /// Custom builder for message widget (top-level message item). + /// + /// When null, [StreamMessageWidget] uses [DefaultStreamMessageWidget]. + final StreamComponentBuilder? messageWidget; + /// Custom builder for online indicator widgets. /// /// When null, [StreamOnlineIndicator] uses [DefaultStreamOnlineIndicator]. 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 4a2df20..00f330c 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 @@ -50,6 +50,8 @@ mixin _$StreamComponentBuilders { messageContent: t < 0.5 ? a.messageContent : b.messageContent, messageMetadata: t < 0.5 ? a.messageMetadata : b.messageMetadata, messageReplies: t < 0.5 ? a.messageReplies : b.messageReplies, + messageText: t < 0.5 ? a.messageText : b.messageText, + messageWidget: t < 0.5 ? a.messageWidget : b.messageWidget, onlineIndicator: t < 0.5 ? a.onlineIndicator : b.onlineIndicator, progressBar: t < 0.5 ? a.progressBar : b.progressBar, reactions: t < 0.5 ? a.reactions : b.reactions, @@ -81,6 +83,8 @@ mixin _$StreamComponentBuilders { Widget Function(BuildContext, StreamMessageContentProps)? messageContent, Widget Function(BuildContext, StreamMessageMetadataProps)? messageMetadata, Widget Function(BuildContext, StreamMessageRepliesProps)? messageReplies, + Widget Function(BuildContext, StreamMessageTextProps)? messageText, + Widget Function(BuildContext, StreamMessageWidgetProps)? messageWidget, Widget Function(BuildContext, StreamOnlineIndicatorProps)? onlineIndicator, Widget Function(BuildContext, StreamProgressBarProps)? progressBar, Widget Function(BuildContext, StreamReactionsProps)? reactions, @@ -108,6 +112,8 @@ mixin _$StreamComponentBuilders { messageContent: messageContent ?? _this.messageContent, messageMetadata: messageMetadata ?? _this.messageMetadata, messageReplies: messageReplies ?? _this.messageReplies, + messageText: messageText ?? _this.messageText, + messageWidget: messageWidget ?? _this.messageWidget, onlineIndicator: onlineIndicator ?? _this.onlineIndicator, progressBar: progressBar ?? _this.progressBar, reactions: reactions ?? _this.reactions, @@ -146,6 +152,8 @@ mixin _$StreamComponentBuilders { messageContent: other.messageContent, messageMetadata: other.messageMetadata, messageReplies: other.messageReplies, + messageText: other.messageText, + messageWidget: other.messageWidget, onlineIndicator: other.onlineIndicator, progressBar: other.progressBar, reactions: other.reactions, @@ -185,6 +193,8 @@ mixin _$StreamComponentBuilders { _other.messageContent == _this.messageContent && _other.messageMetadata == _this.messageMetadata && _other.messageReplies == _this.messageReplies && + _other.messageText == _this.messageText && + _other.messageWidget == _this.messageWidget && _other.onlineIndicator == _this.onlineIndicator && _other.progressBar == _this.progressBar && _other.reactions == _this.reactions; @@ -216,6 +226,8 @@ mixin _$StreamComponentBuilders { _this.messageContent, _this.messageMetadata, _this.messageReplies, + _this.messageText, + _this.messageWidget, _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 c295256..714baf8 100644 --- a/packages/stream_core_flutter/lib/src/theme.dart +++ b/packages/stream_core_flutter/lib/src/theme.dart @@ -14,8 +14,11 @@ export 'theme/components/stream_input_theme.dart'; export 'theme/components/stream_list_tile_theme.dart'; export 'theme/components/stream_message_annotation_theme.dart'; export 'theme/components/stream_message_bubble_theme.dart'; +export 'theme/components/stream_message_item_theme.dart'; export 'theme/components/stream_message_metadata_theme.dart'; export 'theme/components/stream_message_replies_theme.dart'; +export 'theme/components/stream_message_style_property.dart'; +export 'theme/components/stream_message_text_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'; diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_annotation_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_annotation_theme.dart index 7d2db20..cd9881e 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_message_annotation_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_annotation_theme.dart @@ -1,156 +1,54 @@ import 'package:flutter/widgets.dart'; +import 'package:stream_core/stream_core.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; -import '../stream_theme.dart'; +import 'stream_message_style_property.dart'; part 'stream_message_annotation_theme.g.theme.dart'; -/// Applies a message annotation theme to descendant [StreamMessageAnnotation] -/// widgets. +/// Visual styling properties for a message annotation row. /// -/// Wrap a subtree with [StreamMessageAnnotationTheme] to override annotation -/// styling. Access the merged theme using -/// [BuildContext.streamMessageAnnotationTheme]. +/// Defines the appearance of annotation rows including text, icons, spacing, +/// and padding. All properties use [StreamMessageStyleProperty] for +/// placement-aware resolution. Use [StreamMessageAnnotationStyle.from] +/// for uniform values across all placements. /// /// {@tool snippet} /// -/// Override annotation styling for a specific section: +/// Uniform style: /// /// ```dart -/// StreamMessageAnnotationTheme( -/// data: StreamMessageAnnotationThemeData( -/// style: StreamMessageAnnotationStyle( -/// textColor: Colors.purple, -/// spacing: 8, -/// ), -/// ), -/// child: StreamMessageAnnotation( -/// leading: Icon(Icons.bookmark), -/// label: Text('Saved'), -/// ), +/// StreamMessageAnnotationStyle.from( +/// textColor: Colors.purple, +/// iconColor: Colors.purple, +/// iconSize: 18, +/// spacing: 8, /// ) /// ``` /// {@end-tool} /// -/// See also: -/// -/// * [StreamMessageAnnotationThemeData], which describes the annotation theme. -/// * [StreamMessageAnnotationStyle], the value object holding visual props. -/// * [StreamMessageAnnotation], the widget affected by this theme. -class StreamMessageAnnotationTheme extends InheritedTheme { - /// Creates a message annotation theme that controls descendant annotations. - const StreamMessageAnnotationTheme({ - super.key, - required this.data, - required super.child, - }); - - /// The message annotation theme data for descendant widgets. - final StreamMessageAnnotationThemeData data; - - /// Returns the [StreamMessageAnnotationThemeData] merged from local and - /// global themes. - /// - /// Local values from the nearest [StreamMessageAnnotationTheme] ancestor - /// take precedence over global values from [StreamTheme.of]. - /// - /// This allows partial overrides — for example, overriding only - /// [StreamMessageAnnotationStyle.textColor] while inheriting other - /// properties from the global theme. - static StreamMessageAnnotationThemeData of(BuildContext context) { - final localTheme = context.dependOnInheritedWidgetOfExactType(); - return StreamTheme.of(context).messageAnnotationTheme.merge(localTheme?.data); - } - - @override - Widget wrap(BuildContext context, Widget child) { - return StreamMessageAnnotationTheme(data: data, child: child); - } - - @override - bool updateShouldNotify(StreamMessageAnnotationTheme oldWidget) => data != oldWidget.data; -} - -/// Theme data for customizing [StreamMessageAnnotation] widgets. -/// -/// Descendant widgets obtain their values from -/// [StreamMessageAnnotationTheme.of]. The [style] property is null by default, -/// with fallback values derived from the current [StreamTheme]. -/// /// {@tool snippet} /// -/// Customize annotation appearance globally via [StreamTheme]: +/// Placement-aware style: /// /// ```dart -/// StreamTheme( -/// messageAnnotationTheme: StreamMessageAnnotationThemeData( -/// style: StreamMessageAnnotationStyle( -/// textColor: Colors.blue, -/// iconSize: 18, -/// ), -/// ), +/// StreamMessageAnnotationStyle( +/// textColor: StreamMessageStyleProperty.resolveWith((p) { +/// final isEnd = p.alignment == StreamMessageAlignment.end; +/// return isEnd ? Colors.blue : Colors.grey; +/// }), /// ) /// ``` /// {@end-tool} /// /// See also: /// -/// * [StreamMessageAnnotationStyle], the value object holding visual props. -/// * [StreamMessageAnnotationTheme], for overriding the theme in a widget -/// subtree. -/// * [StreamMessageAnnotation], the widget that uses this theme data. -@themeGen -@immutable -class StreamMessageAnnotationThemeData with _$StreamMessageAnnotationThemeData { - /// Creates a message annotation theme data with an optional base [style]. - const StreamMessageAnnotationThemeData({ - this.style, - }); - - /// The base annotation style. - /// - /// When the annotation widget resolves its effective style, this serves - /// as the theme-level default that can be overridden by a direct widget prop. - final StreamMessageAnnotationStyle? style; - - /// Linearly interpolate between two [StreamMessageAnnotationThemeData] - /// objects. - static StreamMessageAnnotationThemeData? lerp( - StreamMessageAnnotationThemeData? a, - StreamMessageAnnotationThemeData? b, - double t, - ) => _$StreamMessageAnnotationThemeData.lerp(a, b, t); -} - -/// Visual style properties for a message annotation row. -/// -/// [StreamMessageAnnotationStyle] is a reusable value object that can be -/// applied both as a theme value (via [StreamMessageAnnotationThemeData]) and -/// as a direct widget prop — similar to how [ButtonStyle] works with -/// [ElevatedButton]. -/// -/// {@tool snippet} -/// -/// Create a custom annotation style: -/// -/// ```dart -/// const style = StreamMessageAnnotationStyle( -/// textColor: Colors.purple, -/// iconColor: Colors.purple, -/// iconSize: 18, -/// spacing: 8, -/// ); -/// ``` -/// {@end-tool} -/// -/// See also: -/// -/// * [StreamMessageAnnotationThemeData], which wraps this style for theming. -/// * [StreamMessageAnnotation], the widget that uses this style. +/// * [StreamMessageItemThemeData], which wraps this style for theming. +/// * [StreamMessageAnnotation], which uses this styling. @themeGen @immutable class StreamMessageAnnotationStyle with _$StreamMessageAnnotationStyle { - /// Creates a message annotation style with optional property overrides. + /// Creates an annotation style with optional resolver-based overrides. const StreamMessageAnnotationStyle({ this.textStyle, this.textColor, @@ -160,27 +58,60 @@ class StreamMessageAnnotationStyle with _$StreamMessageAnnotationStyle { this.padding, }); - /// The default text style for the annotation label. + /// A convenience constructor that constructs a + /// [StreamMessageAnnotationStyle] given simple values. + /// + /// All parameters default to null. By default this constructor returns + /// a [StreamMessageAnnotationStyle] that doesn't override anything. + /// + /// For example, to override the default annotation text and icon colors, + /// one could write: + /// + /// ```dart + /// StreamMessageAnnotationStyle.from( + /// textColor: Colors.purple, + /// iconColor: Colors.purple, + /// ) + /// ``` + factory StreamMessageAnnotationStyle.from({ + TextStyle? textStyle, + Color? textColor, + Color? iconColor, + double? iconSize, + double? spacing, + EdgeInsetsGeometry? padding, + }) { + return StreamMessageAnnotationStyle( + textStyle: textStyle?.let(StreamMessageStyleProperty.all), + textColor: textColor?.let(StreamMessageStyleProperty.all), + iconColor: iconColor?.let(StreamMessageStyleProperty.all), + iconSize: iconSize?.let(StreamMessageStyleProperty.all), + spacing: spacing?.let(StreamMessageStyleProperty.all), + padding: padding?.let(StreamMessageStyleProperty.all), + ); + } + + /// The text style for the annotation label. /// /// This only controls typography. Color comes from [textColor]. - final TextStyle? textStyle; + final StreamMessageStyleProperty? textStyle; - /// The default color for the annotation label text. - final Color? textColor; + /// The color for the annotation label text. + final StreamMessageStyleProperty? textColor; - /// The default color for the leading icon. - final Color? iconColor; + /// The color for the leading icon. + final StreamMessageStyleProperty? iconColor; - /// The default size for the leading icon. - final double? iconSize; + /// The size for the leading icon. + final StreamMessageStyleProperty? iconSize; /// The gap between the leading widget and label. - final double? spacing; + final StreamMessageStyleProperty? spacing; /// The padding around the annotation row content. - final EdgeInsetsGeometry? padding; + final StreamMessageStyleProperty? padding; - /// Linearly interpolate between two [StreamMessageAnnotationStyle] instances. + /// Linearly interpolate between two [StreamMessageAnnotationStyle] objects. static StreamMessageAnnotationStyle? lerp( StreamMessageAnnotationStyle? a, StreamMessageAnnotationStyle? b, diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_annotation_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_annotation_theme.g.theme.dart index 9141773..ff350fb 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_message_annotation_theme.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_annotation_theme.g.theme.dart @@ -9,79 +9,6 @@ part of 'stream_message_annotation_theme.dart'; // ThemeGenGenerator // ************************************************************************** -mixin _$StreamMessageAnnotationThemeData { - bool get canMerge => true; - - static StreamMessageAnnotationThemeData? lerp( - StreamMessageAnnotationThemeData? a, - StreamMessageAnnotationThemeData? 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 StreamMessageAnnotationThemeData( - style: StreamMessageAnnotationStyle.lerp(a.style, b.style, t), - ); - } - - StreamMessageAnnotationThemeData copyWith({ - StreamMessageAnnotationStyle? style, - }) { - final _this = (this as StreamMessageAnnotationThemeData); - - return StreamMessageAnnotationThemeData(style: style ?? _this.style); - } - - StreamMessageAnnotationThemeData merge( - StreamMessageAnnotationThemeData? other, - ) { - final _this = (this as StreamMessageAnnotationThemeData); - - if (other == null || identical(_this, other)) { - return _this; - } - - if (!other.canMerge) { - return other; - } - - return copyWith(style: _this.style?.merge(other.style) ?? other.style); - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - - if (other.runtimeType != runtimeType) { - return false; - } - - final _this = (this as StreamMessageAnnotationThemeData); - final _other = (other as StreamMessageAnnotationThemeData); - - return _other.style == _this.style; - } - - @override - int get hashCode { - final _this = (this as StreamMessageAnnotationThemeData); - - return Object.hash(runtimeType, _this.style); - } -} - mixin _$StreamMessageAnnotationStyle { bool get canMerge => true; @@ -103,22 +30,52 @@ mixin _$StreamMessageAnnotationStyle { } return StreamMessageAnnotationStyle( - textStyle: TextStyle.lerp(a.textStyle, b.textStyle, t), - textColor: Color.lerp(a.textColor, b.textColor, t), - iconColor: Color.lerp(a.iconColor, b.iconColor, t), - iconSize: lerpDouble$(a.iconSize, b.iconSize, t), - spacing: lerpDouble$(a.spacing, b.spacing, t), - padding: EdgeInsetsGeometry.lerp(a.padding, b.padding, t), + textStyle: StreamMessageStyleProperty.lerp( + a.textStyle, + b.textStyle, + t, + TextStyle.lerp, + ), + textColor: StreamMessageStyleProperty.lerp( + a.textColor, + b.textColor, + t, + Color.lerp, + ), + iconColor: StreamMessageStyleProperty.lerp( + a.iconColor, + b.iconColor, + t, + Color.lerp, + ), + iconSize: StreamMessageStyleProperty.lerp( + a.iconSize, + b.iconSize, + t, + lerpDouble$, + ), + spacing: StreamMessageStyleProperty.lerp( + a.spacing, + b.spacing, + t, + lerpDouble$, + ), + padding: StreamMessageStyleProperty.lerp( + a.padding, + b.padding, + t, + EdgeInsetsGeometry.lerp, + ), ); } StreamMessageAnnotationStyle copyWith({ - TextStyle? textStyle, - Color? textColor, - Color? iconColor, - double? iconSize, - double? spacing, - EdgeInsetsGeometry? padding, + StreamMessageStyleProperty? textStyle, + StreamMessageStyleProperty? textColor, + StreamMessageStyleProperty? iconColor, + StreamMessageStyleProperty? iconSize, + StreamMessageStyleProperty? spacing, + StreamMessageStyleProperty? padding, }) { final _this = (this as StreamMessageAnnotationStyle); @@ -144,7 +101,7 @@ mixin _$StreamMessageAnnotationStyle { } return copyWith( - textStyle: _this.textStyle?.merge(other.textStyle) ?? other.textStyle, + textStyle: other.textStyle, textColor: other.textColor, iconColor: other.iconColor, iconSize: other.iconSize, diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_bubble_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_bubble_theme.dart index 68e9c6b..9c34971 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_message_bubble_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_bubble_theme.dart @@ -1,41 +1,54 @@ +import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; +import 'package:stream_core/stream_core.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; +import 'stream_message_style_property.dart'; + part 'stream_message_bubble_theme.g.theme.dart'; -/// Structural style properties for a message bubble container. +/// Visual styling properties for the message bubble. +/// +/// Defines the appearance of message bubbles including shape, border, padding, +/// constraints, and background color. All properties use +/// [StreamMessageStyleProperty] for placement-aware resolution. +/// Use [StreamMessageBubbleStyle.from] for uniform values across all +/// placements. /// -/// [StreamMessageBubbleStyle] is a reusable value object that can be applied -/// both as a theme value (via [StreamMessageBubbleThemeData]) and as a direct -/// widget prop on the future message bubble widget — similar to how -/// [ButtonStyle] works with [ElevatedButton]. +/// {@tool snippet} /// -/// Includes [backgroundColor] so that incoming/outgoing variants can each -/// carry their own fill color alongside the structural properties. +/// Uniform style: +/// +/// ```dart +/// StreamMessageBubbleStyle.from( +/// backgroundColor: Colors.blue, +/// padding: EdgeInsets.all(12), +/// ) +/// ``` +/// {@end-tool} /// /// {@tool snippet} /// -/// Create a custom bubble style: +/// Placement-aware style: /// /// ```dart -/// const style = StreamMessageBubbleStyle( -/// shape: RoundedRectangleBorder( -/// borderRadius: BorderRadius.all(Radius.circular(16)), -/// ), -/// side: BorderSide(color: Colors.grey), -/// padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), -/// ); +/// StreamMessageBubbleStyle( +/// backgroundColor: StreamMessageStyleProperty.resolveWith((p) { +/// final isEnd = p.alignment == StreamMessageAlignment.end; +/// return isEnd ? Colors.blue.shade100 : Colors.grey.shade100; +/// }), +/// ) /// ``` /// {@end-tool} /// /// See also: /// -/// * [StreamMessageBubbleThemeData], which wraps this style for theming. -/// * [StreamMessageStyle], which provides the color properties for messages. +/// * [StreamMessageItemThemeData], which wraps this style for theming. +/// * [StreamMessageBubble], which uses this styling. @themeGen @immutable class StreamMessageBubbleStyle with _$StreamMessageBubbleStyle { - /// Creates a message bubble style with optional property overrides. + /// Creates a bubble style with optional resolver-based overrides. const StreamMessageBubbleStyle({ this.shape, this.side, @@ -44,108 +57,60 @@ class StreamMessageBubbleStyle with _$StreamMessageBubbleStyle { this.backgroundColor, }); - /// The shape of the bubble's container. + /// A convenience constructor that constructs a [StreamMessageBubbleStyle] + /// given simple values. /// - /// This shape is combined with [side] to create a shape decorated with an - /// outline. Using [OutlinedBorder] (rather than raw [BorderRadius]) aligns - /// with Material 3 conventions and supports custom tail shapes in the future. + /// All parameters default to null. By default this constructor returns + /// a [StreamMessageBubbleStyle] that doesn't override anything. /// - /// Defaults to a [RoundedRectangleBorder] built from the design system's - /// `messageBubbleRadius*` tokens. - final OutlinedBorder? shape; - - /// The border outline of the bubble. + /// For example, to override the default bubble background color and + /// padding, one could write: /// - /// This value is combined with [shape] to create a shape decorated with an - /// outline. Keeping this separate from [shape] allows changing border - /// color/width without reconstructing the entire shape. + /// ```dart + /// StreamMessageBubbleStyle.from( + /// backgroundColor: Colors.blue.shade100, + /// padding: EdgeInsets.all(12), + /// ) + /// ``` + factory StreamMessageBubbleStyle.from({ + OutlinedBorder? shape, + BorderSide? side, + EdgeInsetsGeometry? padding, + BoxConstraints? constraints, + Color? backgroundColor, + }) { + return StreamMessageBubbleStyle( + shape: shape?.let(StreamMessageStyleProperty.all), + side: side?.let(StreamMessageStyleBorderSide.all), + padding: padding?.let(StreamMessageStyleProperty.all), + constraints: constraints?.let(StreamMessageStyleProperty.all), + backgroundColor: backgroundColor?.let(StreamMessageStyleProperty.all), + ); + } + + /// The shape of the bubble. /// - /// Defaults to a 1px [StreamColorScheme.borderSubtle] border. - final BorderSide? side; + /// Typically varies by stack position and alignment (tail corner side). + final StreamMessageStyleProperty? shape; + + /// The border outline of the bubble. + final StreamMessageStyleBorderSide? side; /// Content padding inside the bubble. - /// - /// Defaults to a value derived from [StreamSpacing]. - final EdgeInsetsGeometry? padding; + final StreamMessageStyleProperty? padding; /// Size constraints for the bubble. - /// - /// Defaults to `BoxConstraints(minHeight: 20)`. - /// - /// ```dart - /// constraints: BoxConstraints(minHeight: 20, maxWidth: 280) - /// ``` - final BoxConstraints? constraints; + final StreamMessageStyleProperty? constraints; /// The background fill color of the bubble. /// - /// Typically differs between incoming and outgoing messages. - /// - /// Defaults to [StreamColorScheme.backgroundSurface]. - final Color? backgroundColor; + /// Typically differs between start-aligned and end-aligned messages. + final StreamMessageStyleProperty? backgroundColor; - /// Linearly interpolate between two [StreamMessageBubbleStyle] instances. + /// Linearly interpolate between two [StreamMessageBubbleStyle] objects. static StreamMessageBubbleStyle? lerp( StreamMessageBubbleStyle? a, StreamMessageBubbleStyle? b, double t, ) => _$StreamMessageBubbleStyle.lerp(a, b, t); } - -/// Theme data for the message bubble, holding a base [style]. -/// -/// Nested inside [StreamMessageStyle] so that `incoming` and `outgoing` -/// messages each get their own bubble theme automatically. -/// -/// Currently holds a single [style]. Position-specific overrides -/// (e.g. `topStyle`, `middleStyle`) for message grouping will be added in a -/// follow-up. -/// -/// {@tool snippet} -/// -/// Override bubble styling for outgoing messages via [StreamMessageTheme]: -/// -/// ```dart -/// StreamMessageTheme( -/// data: StreamMessageThemeData( -/// outgoing: StreamMessageStyle( -/// bubble: StreamMessageBubbleThemeData( -/// style: StreamMessageBubbleStyle( -/// shape: RoundedRectangleBorder( -/// borderRadius: BorderRadius.circular(24), -/// ), -/// side: BorderSide.none, -/// ), -/// ), -/// ), -/// ), -/// child: ..., -/// ) -/// ``` -/// {@end-tool} -/// -/// See also: -/// -/// * [StreamMessageBubbleStyle], the value object holding structural props. -/// * [StreamMessageStyle], which nests this theme data. -@themeGen -@immutable -class StreamMessageBubbleThemeData with _$StreamMessageBubbleThemeData { - /// Creates a message bubble theme data with an optional base [style]. - const StreamMessageBubbleThemeData({ - this.style, - }); - - /// The base bubble style. - /// - /// When the bubble widget resolves its effective style, this serves - /// as the theme-level default that can be overridden by a direct widget prop. - final StreamMessageBubbleStyle? style; - - /// Linearly interpolate between two [StreamMessageBubbleThemeData] instances. - static StreamMessageBubbleThemeData? lerp( - StreamMessageBubbleThemeData? a, - StreamMessageBubbleThemeData? b, - double t, - ) => _$StreamMessageBubbleThemeData.lerp(a, b, t); -} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_bubble_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_bubble_theme.g.theme.dart index b471835..8db26f2 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_message_bubble_theme.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_bubble_theme.g.theme.dart @@ -30,24 +30,40 @@ mixin _$StreamMessageBubbleStyle { } return StreamMessageBubbleStyle( - shape: OutlinedBorder.lerp(a.shape, b.shape, t), - side: a.side == null - ? b.side - : b.side == null - ? a.side - : BorderSide.lerp(a.side!, b.side!, t), - padding: EdgeInsetsGeometry.lerp(a.padding, b.padding, t), - constraints: BoxConstraints.lerp(a.constraints, b.constraints, t), - backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), + shape: StreamMessageStyleProperty.lerp( + a.shape, + b.shape, + t, + OutlinedBorder.lerp, + ), + side: StreamMessageStyleBorderSide.lerp(a.side, b.side, t), + padding: StreamMessageStyleProperty.lerp( + a.padding, + b.padding, + t, + EdgeInsetsGeometry.lerp, + ), + constraints: StreamMessageStyleProperty.lerp( + a.constraints, + b.constraints, + t, + BoxConstraints.lerp, + ), + backgroundColor: StreamMessageStyleProperty.lerp( + a.backgroundColor, + b.backgroundColor, + t, + Color.lerp, + ), ); } StreamMessageBubbleStyle copyWith({ - OutlinedBorder? shape, - BorderSide? side, - EdgeInsetsGeometry? padding, - BoxConstraints? constraints, - Color? backgroundColor, + StreamMessageStyleProperty? shape, + StreamMessageStyleBorderSide? side, + StreamMessageStyleProperty? padding, + StreamMessageStyleProperty? constraints, + StreamMessageStyleProperty? backgroundColor, }) { final _this = (this as StreamMessageBubbleStyle); @@ -73,9 +89,7 @@ mixin _$StreamMessageBubbleStyle { return copyWith( shape: other.shape, - side: _this.side != null && other.side != null - ? BorderSide.merge(_this.side!, other.side!) - : other.side, + side: other.side, padding: other.padding, constraints: other.constraints, backgroundColor: other.backgroundColor, @@ -116,72 +130,3 @@ mixin _$StreamMessageBubbleStyle { ); } } - -mixin _$StreamMessageBubbleThemeData { - bool get canMerge => true; - - static StreamMessageBubbleThemeData? lerp( - StreamMessageBubbleThemeData? a, - StreamMessageBubbleThemeData? 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 StreamMessageBubbleThemeData( - style: StreamMessageBubbleStyle.lerp(a.style, b.style, t), - ); - } - - StreamMessageBubbleThemeData copyWith({StreamMessageBubbleStyle? style}) { - final _this = (this as StreamMessageBubbleThemeData); - - return StreamMessageBubbleThemeData(style: style ?? _this.style); - } - - StreamMessageBubbleThemeData merge(StreamMessageBubbleThemeData? other) { - final _this = (this as StreamMessageBubbleThemeData); - - if (other == null || identical(_this, other)) { - return _this; - } - - if (!other.canMerge) { - return other; - } - - return copyWith(style: _this.style?.merge(other.style) ?? other.style); - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - - if (other.runtimeType != runtimeType) { - return false; - } - - final _this = (this as StreamMessageBubbleThemeData); - final _other = (other as StreamMessageBubbleThemeData); - - return _other.style == _this.style; - } - - @override - int get hashCode { - final _this = (this as StreamMessageBubbleThemeData); - - return Object.hash(runtimeType, _this.style); - } -} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_item_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_item_theme.dart new file mode 100644 index 0000000..10b4964 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_item_theme.dart @@ -0,0 +1,152 @@ +import 'package:flutter/widgets.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import '../stream_theme.dart'; +import 'stream_avatar_theme.dart'; +import 'stream_message_annotation_theme.dart'; +import 'stream_message_bubble_theme.dart'; +import 'stream_message_metadata_theme.dart'; +import 'stream_message_replies_theme.dart'; +import 'stream_message_style_property.dart'; +import 'stream_message_text_theme.dart'; + +part 'stream_message_item_theme.g.theme.dart'; + +/// Applies a message item theme to descendant message widgets. +/// +/// Wrap a subtree with [StreamMessageItemTheme] to override placement-aware +/// styling for message sub-components (bubble, annotation, metadata, replies). +/// +/// {@tool snippet} +/// +/// Override bubble colors based on placement: +/// +/// ```dart +/// StreamMessageItemTheme( +/// data: StreamMessageItemThemeData( +/// backgroundColor: Colors.blue.shade50, +/// bubble: StreamMessageBubbleStyle( +/// backgroundColor: StreamMessageStyleProperty.resolveWith((p) { +/// final isEnd = p.alignment == StreamMessageAlignment.end; +/// return isEnd ? Colors.blue.shade100 : Colors.grey.shade100; +/// }), +/// ), +/// ), +/// child: ..., +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageItemThemeData], which describes the theme data. +class StreamMessageItemTheme extends InheritedTheme { + /// Creates a message item theme that controls descendant message widgets. + const StreamMessageItemTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The message item theme data for descendant widgets. + final StreamMessageItemThemeData data; + + /// Returns the [StreamMessageItemThemeData] from the current theme context. + /// + /// This merges the local theme (if any) with the global theme from + /// [StreamTheme]. + static StreamMessageItemThemeData of(BuildContext context) { + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamTheme.of(context).messageItemTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamMessageItemTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamMessageItemTheme oldWidget) => data != oldWidget.data; +} + +/// Theme data for customizing message item appearance and sub-components. +/// +/// Properties are organized in two groups: +/// +/// 1. **Item-level** — visual and layout properties for the message item +/// itself ([backgroundColor], [padding], [spacing], [avatarSize]). +/// 2. **Sub-component styles** — grouped style overrides for each child +/// component ([text], [bubble], [annotation], [metadata], [replies]). +/// +/// A `null` field means "use defaults." +/// +/// See also: +/// +/// * [StreamMessageItemTheme], for overriding theme in a widget subtree. +@themeGen +@immutable +class StreamMessageItemThemeData with _$StreamMessageItemThemeData { + /// Creates message item theme data with optional overrides. + const StreamMessageItemThemeData({ + this.backgroundColor, + this.leadingVisibility, + this.padding, + this.spacing, + this.avatarSize, + this.text, + this.bubble, + this.annotation, + this.metadata, + this.replies, + }); + + /// Background color for the entire message item row. + /// + /// Typically used for state-driven styling (e.g. selected, reminder). + /// When null, the message item has no background. + final Color? backgroundColor; + + /// Controls the visibility of the leading widget based on placement. + /// + /// This resolves a [StreamVisibility] value from the current + /// [StreamMessagePlacementData], allowing visibility to vary by stack + /// position (e.g. only show the avatar on the bottom message of a stack). + /// + /// When null, the leading widget defaults to [StreamVisibility.visible]. + final StreamMessageStyleVisibility? leadingVisibility; + + /// Outer padding around the entire message item. + final EdgeInsetsGeometry? padding; + + /// Horizontal spacing between the leading avatar and the content. + final double? spacing; + + /// Default size for the leading avatar. + /// + /// When non-null, descendant avatars inherit this size override. + /// When null, avatars use the size from the nearest ancestor + /// avatar theme or their own default. + final StreamAvatarSize? avatarSize; + + /// Style overrides for the message text (markdown). + final StreamMessageTextStyle? text; + + /// Style overrides for the message bubble. + final StreamMessageBubbleStyle? bubble; + + /// Style overrides for the message annotation. + final StreamMessageAnnotationStyle? annotation; + + /// Style overrides for the message metadata. + final StreamMessageMetadataStyle? metadata; + + /// Style overrides for the message replies. + final StreamMessageRepliesStyle? replies; + + /// Linearly interpolate between two [StreamMessageItemThemeData] objects. + static StreamMessageItemThemeData? lerp( + StreamMessageItemThemeData? a, + StreamMessageItemThemeData? b, + double t, + ) => _$StreamMessageItemThemeData.lerp(a, b, t); +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_item_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_item_theme.g.theme.dart new file mode 100644 index 0000000..8974b65 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_item_theme.g.theme.dart @@ -0,0 +1,150 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'stream_message_item_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamMessageItemThemeData { + bool get canMerge => true; + + static StreamMessageItemThemeData? lerp( + StreamMessageItemThemeData? a, + StreamMessageItemThemeData? 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 StreamMessageItemThemeData( + backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), + leadingVisibility: StreamMessageStyleVisibility.lerp( + a.leadingVisibility, + b.leadingVisibility, + t, + ), + padding: EdgeInsetsGeometry.lerp(a.padding, b.padding, t), + spacing: lerpDouble$(a.spacing, b.spacing, t), + avatarSize: t < 0.5 ? a.avatarSize : b.avatarSize, + text: StreamMessageTextStyle.lerp(a.text, b.text, t), + bubble: StreamMessageBubbleStyle.lerp(a.bubble, b.bubble, t), + annotation: StreamMessageAnnotationStyle.lerp( + a.annotation, + b.annotation, + t, + ), + metadata: StreamMessageMetadataStyle.lerp(a.metadata, b.metadata, t), + replies: StreamMessageRepliesStyle.lerp(a.replies, b.replies, t), + ); + } + + StreamMessageItemThemeData copyWith({ + Color? backgroundColor, + StreamMessageStyleVisibility? leadingVisibility, + EdgeInsetsGeometry? padding, + double? spacing, + StreamAvatarSize? avatarSize, + StreamMessageTextStyle? text, + StreamMessageBubbleStyle? bubble, + StreamMessageAnnotationStyle? annotation, + StreamMessageMetadataStyle? metadata, + StreamMessageRepliesStyle? replies, + }) { + final _this = (this as StreamMessageItemThemeData); + + return StreamMessageItemThemeData( + backgroundColor: backgroundColor ?? _this.backgroundColor, + leadingVisibility: leadingVisibility ?? _this.leadingVisibility, + padding: padding ?? _this.padding, + spacing: spacing ?? _this.spacing, + avatarSize: avatarSize ?? _this.avatarSize, + text: text ?? _this.text, + bubble: bubble ?? _this.bubble, + annotation: annotation ?? _this.annotation, + metadata: metadata ?? _this.metadata, + replies: replies ?? _this.replies, + ); + } + + StreamMessageItemThemeData merge(StreamMessageItemThemeData? other) { + final _this = (this as StreamMessageItemThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + backgroundColor: other.backgroundColor, + leadingVisibility: other.leadingVisibility, + padding: other.padding, + spacing: other.spacing, + avatarSize: other.avatarSize, + text: _this.text?.merge(other.text) ?? other.text, + bubble: _this.bubble?.merge(other.bubble) ?? other.bubble, + annotation: _this.annotation?.merge(other.annotation) ?? other.annotation, + metadata: _this.metadata?.merge(other.metadata) ?? other.metadata, + replies: _this.replies?.merge(other.replies) ?? other.replies, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamMessageItemThemeData); + final _other = (other as StreamMessageItemThemeData); + + return _other.backgroundColor == _this.backgroundColor && + _other.leadingVisibility == _this.leadingVisibility && + _other.padding == _this.padding && + _other.spacing == _this.spacing && + _other.avatarSize == _this.avatarSize && + _other.text == _this.text && + _other.bubble == _this.bubble && + _other.annotation == _this.annotation && + _other.metadata == _this.metadata && + _other.replies == _this.replies; + } + + @override + int get hashCode { + final _this = (this as StreamMessageItemThemeData); + + return Object.hash( + runtimeType, + _this.backgroundColor, + _this.leadingVisibility, + _this.padding, + _this.spacing, + _this.avatarSize, + _this.text, + _this.bubble, + _this.annotation, + _this.metadata, + _this.replies, + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_metadata_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_metadata_theme.dart index 7c06191..33d56db 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_message_metadata_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_metadata_theme.dart @@ -1,104 +1,55 @@ import 'package:flutter/widgets.dart'; +import 'package:stream_core/stream_core.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; -import '../stream_theme.dart'; +import 'stream_message_style_property.dart'; part 'stream_message_metadata_theme.g.theme.dart'; -/// Applies a message metadata theme to descendant [StreamMessageMetadata] -/// widgets. +/// Visual styling properties for a message metadata row. /// -/// Wrap a subtree with [StreamMessageMetadataTheme] to override metadata row -/// styling. Access the merged theme using -/// [BuildContext.streamMessageMetadataTheme]. +/// Defines the appearance of metadata rows including username, timestamp, +/// edited indicator, status icon, and spacing. All properties use +/// [StreamMessageStyleProperty] for placement-aware resolution. +/// Use [StreamMessageMetadataStyle.from] for uniform values across all +/// placements. /// /// {@tool snippet} /// -/// Override metadata styling for a specific section: +/// Uniform style: /// /// ```dart -/// StreamMessageMetadataTheme( -/// data: StreamMessageMetadataThemeData( -/// usernameColor: Colors.blue, -/// spacing: 12, -/// ), -/// child: StreamMessageMetadata( -/// timestamp: Text('09:41'), -/// username: Text('Alice'), -/// ), +/// StreamMessageMetadataStyle.from( +/// usernameColor: Colors.blue, +/// timestampColor: Colors.grey, +/// spacing: 12, /// ) /// ``` /// {@end-tool} /// -/// See also: -/// -/// * [StreamMessageMetadataThemeData], which describes the metadata theme. -/// * [StreamMessageMetadata], the widget affected by this theme. -class StreamMessageMetadataTheme extends InheritedTheme { - /// Creates a message metadata theme that controls descendant metadata rows. - const StreamMessageMetadataTheme({ - super.key, - required this.data, - required super.child, - }); - - /// The message metadata theme data for descendant widgets. - final StreamMessageMetadataThemeData data; - - /// Returns the [StreamMessageMetadataThemeData] merged from local and global - /// themes. - /// - /// Local values from the nearest [StreamMessageMetadataTheme] ancestor take - /// precedence over global values from [StreamTheme.of]. - /// - /// This allows partial overrides — for example, overriding only - /// [StreamMessageMetadataThemeData.usernameColor] while inheriting other - /// properties from the global theme. - static StreamMessageMetadataThemeData of(BuildContext context) { - final localTheme = context.dependOnInheritedWidgetOfExactType(); - return StreamTheme.of(context).messageMetadataTheme.merge(localTheme?.data); - } - - @override - Widget wrap(BuildContext context, Widget child) { - return StreamMessageMetadataTheme(data: data, child: child); - } - - @override - bool updateShouldNotify(StreamMessageMetadataTheme oldWidget) => data != oldWidget.data; -} - -/// Theme data for customizing [StreamMessageMetadata] widgets. -/// -/// Descendant widgets obtain their values from [StreamMessageMetadataTheme.of]. -/// All properties are null by default, with fallback values applied by -/// [DefaultStreamMessageMetadata]. -/// /// {@tool snippet} /// -/// Customize metadata appearance globally via [StreamTheme]: +/// Placement-aware style: /// /// ```dart -/// StreamTheme( -/// messageMetadataTheme: StreamMessageMetadataThemeData( -/// usernameColor: Colors.blue, -/// timestampColor: Colors.grey, -/// spacing: 12, -/// ), +/// StreamMessageMetadataStyle( +/// usernameColor: StreamMessageStyleProperty.resolveWith((p) { +/// final isEnd = p.alignment == StreamMessageAlignment.end; +/// return isEnd ? Colors.blue : Colors.grey; +/// }), /// ) /// ``` /// {@end-tool} /// /// See also: /// -/// * [StreamMessageMetadataTheme], for overriding the theme in a widget -/// subtree. -/// * [StreamMessageMetadata], the widget that uses this theme data. +/// * [StreamMessageItemThemeData], which wraps this style for theming. +/// * [StreamMessageMetadata], which uses this styling. @themeGen @immutable -class StreamMessageMetadataThemeData with _$StreamMessageMetadataThemeData { - /// Creates a message metadata theme data with optional property overrides. - const StreamMessageMetadataThemeData({ +class StreamMessageMetadataStyle with _$StreamMessageMetadataStyle { + /// Creates a metadata style with optional resolver-based overrides. + const StreamMessageMetadataStyle({ this.usernameTextStyle, this.usernameColor, this.timestampTextStyle, @@ -112,53 +63,86 @@ class StreamMessageMetadataThemeData with _$StreamMessageMetadataThemeData { this.minHeight, }); - /// Defines the default text style for [StreamMessageMetadata.username]. + /// A convenience constructor that constructs a + /// [StreamMessageMetadataStyle] given simple values. /// - /// This only controls typography. Color comes from [usernameColor]. - final TextStyle? usernameTextStyle; + /// All parameters default to null. By default this constructor returns + /// a [StreamMessageMetadataStyle] that doesn't override anything. + /// + /// For example, to override the default username and timestamp colors, + /// one could write: + /// + /// ```dart + /// StreamMessageMetadataStyle.from( + /// usernameColor: Colors.blue, + /// timestampColor: Colors.grey, + /// ) + /// ``` + factory StreamMessageMetadataStyle.from({ + TextStyle? usernameTextStyle, + Color? usernameColor, + TextStyle? timestampTextStyle, + Color? timestampColor, + TextStyle? editedTextStyle, + Color? editedColor, + Color? statusColor, + double? statusIconSize, + double? spacing, + double? statusSpacing, + double? minHeight, + }) { + return StreamMessageMetadataStyle( + usernameTextStyle: usernameTextStyle?.let(StreamMessageStyleProperty.all), + usernameColor: usernameColor?.let(StreamMessageStyleProperty.all), + timestampTextStyle: timestampTextStyle?.let(StreamMessageStyleProperty.all), + timestampColor: timestampColor?.let(StreamMessageStyleProperty.all), + editedTextStyle: editedTextStyle?.let(StreamMessageStyleProperty.all), + editedColor: editedColor?.let(StreamMessageStyleProperty.all), + statusColor: statusColor?.let(StreamMessageStyleProperty.all), + statusIconSize: statusIconSize?.let(StreamMessageStyleProperty.all), + spacing: spacing?.let(StreamMessageStyleProperty.all), + statusSpacing: statusSpacing?.let(StreamMessageStyleProperty.all), + minHeight: minHeight?.let(StreamMessageStyleProperty.all), + ); + } - /// Defines the default color for [StreamMessageMetadata.username]. - final Color? usernameColor; + /// The text style for the username. + final StreamMessageStyleProperty? usernameTextStyle; - /// Defines the default text style for [StreamMessageMetadata.timestamp]. - /// - /// This only controls typography. Color comes from [timestampColor]. - final TextStyle? timestampTextStyle; + /// The color for the username text. + final StreamMessageStyleProperty? usernameColor; - /// Defines the default color for [StreamMessageMetadata.timestamp]. - final Color? timestampColor; + /// The text style for the timestamp. + final StreamMessageStyleProperty? timestampTextStyle; - /// Defines the default text style for [StreamMessageMetadata.edited]. - /// - /// This only controls typography. Color comes from [editedColor]. - final TextStyle? editedTextStyle; + /// The color for the timestamp text. + final StreamMessageStyleProperty? timestampColor; - /// Defines the default color for [StreamMessageMetadata.edited]. - final Color? editedColor; + /// The text style for the edited indicator. + final StreamMessageStyleProperty? editedTextStyle; - /// Defines the default color for [StreamMessageMetadata.status]. - /// - /// Applied via [IconTheme] to the status icon slot. - final Color? statusColor; + /// The color for the edited indicator text. + final StreamMessageStyleProperty? editedColor; - /// Defines the default size for the status icon. - /// - /// Applied via [IconTheme] to the status icon slot. - final double? statusIconSize; + /// The color for the status icon. + final StreamMessageStyleProperty? statusColor; + + /// The size for the status icon. + final StreamMessageStyleProperty? statusIconSize; /// The gap between main elements (username, timestamp group, edited). - final double? spacing; + final StreamMessageStyleProperty? spacing; /// The gap between the status icon and the timestamp. - final double? statusSpacing; + final StreamMessageStyleProperty? statusSpacing; /// The minimum height of the metadata row. - final double? minHeight; + final StreamMessageStyleProperty? minHeight; - /// Linearly interpolate between two [StreamMessageMetadataThemeData] objects. - static StreamMessageMetadataThemeData? lerp( - StreamMessageMetadataThemeData? a, - StreamMessageMetadataThemeData? b, + /// Linearly interpolate between two [StreamMessageMetadataStyle] objects. + static StreamMessageMetadataStyle? lerp( + StreamMessageMetadataStyle? a, + StreamMessageMetadataStyle? b, double t, - ) => _$StreamMessageMetadataThemeData.lerp(a, b, t); + ) => _$StreamMessageMetadataStyle.lerp(a, b, t); } diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_metadata_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_metadata_theme.g.theme.dart index c844cd4..2cc8ebe 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_message_metadata_theme.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_metadata_theme.g.theme.dart @@ -9,12 +9,12 @@ part of 'stream_message_metadata_theme.dart'; // ThemeGenGenerator // ************************************************************************** -mixin _$StreamMessageMetadataThemeData { +mixin _$StreamMessageMetadataStyle { bool get canMerge => true; - static StreamMessageMetadataThemeData? lerp( - StreamMessageMetadataThemeData? a, - StreamMessageMetadataThemeData? b, + static StreamMessageMetadataStyle? lerp( + StreamMessageMetadataStyle? a, + StreamMessageMetadataStyle? b, double t, ) { if (identical(a, b)) { @@ -29,45 +29,92 @@ mixin _$StreamMessageMetadataThemeData { return t == 0.0 ? a : null; } - return StreamMessageMetadataThemeData( - usernameTextStyle: TextStyle.lerp( + return StreamMessageMetadataStyle( + usernameTextStyle: StreamMessageStyleProperty.lerp( a.usernameTextStyle, b.usernameTextStyle, t, + TextStyle.lerp, ), - usernameColor: Color.lerp(a.usernameColor, b.usernameColor, t), - timestampTextStyle: TextStyle.lerp( + usernameColor: StreamMessageStyleProperty.lerp( + a.usernameColor, + b.usernameColor, + t, + Color.lerp, + ), + timestampTextStyle: StreamMessageStyleProperty.lerp( a.timestampTextStyle, b.timestampTextStyle, t, + TextStyle.lerp, + ), + timestampColor: StreamMessageStyleProperty.lerp( + a.timestampColor, + b.timestampColor, + t, + Color.lerp, + ), + editedTextStyle: StreamMessageStyleProperty.lerp( + a.editedTextStyle, + b.editedTextStyle, + t, + TextStyle.lerp, + ), + editedColor: StreamMessageStyleProperty.lerp( + a.editedColor, + b.editedColor, + t, + Color.lerp, + ), + statusColor: StreamMessageStyleProperty.lerp( + a.statusColor, + b.statusColor, + t, + Color.lerp, + ), + statusIconSize: StreamMessageStyleProperty.lerp( + a.statusIconSize, + b.statusIconSize, + t, + lerpDouble$, + ), + spacing: StreamMessageStyleProperty.lerp( + a.spacing, + b.spacing, + t, + lerpDouble$, + ), + statusSpacing: StreamMessageStyleProperty.lerp( + a.statusSpacing, + b.statusSpacing, + t, + lerpDouble$, + ), + minHeight: StreamMessageStyleProperty.lerp( + a.minHeight, + b.minHeight, + t, + lerpDouble$, ), - timestampColor: Color.lerp(a.timestampColor, b.timestampColor, t), - editedTextStyle: TextStyle.lerp(a.editedTextStyle, b.editedTextStyle, t), - editedColor: Color.lerp(a.editedColor, b.editedColor, t), - statusColor: Color.lerp(a.statusColor, b.statusColor, t), - statusIconSize: lerpDouble$(a.statusIconSize, b.statusIconSize, t), - spacing: lerpDouble$(a.spacing, b.spacing, t), - statusSpacing: lerpDouble$(a.statusSpacing, b.statusSpacing, t), - minHeight: lerpDouble$(a.minHeight, b.minHeight, t), ); } - StreamMessageMetadataThemeData copyWith({ - TextStyle? usernameTextStyle, - Color? usernameColor, - TextStyle? timestampTextStyle, - Color? timestampColor, - TextStyle? editedTextStyle, - Color? editedColor, - Color? statusColor, - double? statusIconSize, - double? spacing, - double? statusSpacing, - double? minHeight, + StreamMessageMetadataStyle copyWith({ + StreamMessageStyleProperty? usernameTextStyle, + StreamMessageStyleProperty? usernameColor, + StreamMessageStyleProperty? timestampTextStyle, + StreamMessageStyleProperty? timestampColor, + StreamMessageStyleProperty? editedTextStyle, + StreamMessageStyleProperty? editedColor, + StreamMessageStyleProperty? statusColor, + StreamMessageStyleProperty? statusIconSize, + StreamMessageStyleProperty? spacing, + StreamMessageStyleProperty? statusSpacing, + StreamMessageStyleProperty? minHeight, }) { - final _this = (this as StreamMessageMetadataThemeData); + final _this = (this as StreamMessageMetadataStyle); - return StreamMessageMetadataThemeData( + return StreamMessageMetadataStyle( usernameTextStyle: usernameTextStyle ?? _this.usernameTextStyle, usernameColor: usernameColor ?? _this.usernameColor, timestampTextStyle: timestampTextStyle ?? _this.timestampTextStyle, @@ -82,8 +129,8 @@ mixin _$StreamMessageMetadataThemeData { ); } - StreamMessageMetadataThemeData merge(StreamMessageMetadataThemeData? other) { - final _this = (this as StreamMessageMetadataThemeData); + StreamMessageMetadataStyle merge(StreamMessageMetadataStyle? other) { + final _this = (this as StreamMessageMetadataStyle); if (other == null || identical(_this, other)) { return _this; @@ -94,17 +141,11 @@ mixin _$StreamMessageMetadataThemeData { } return copyWith( - usernameTextStyle: - _this.usernameTextStyle?.merge(other.usernameTextStyle) ?? - other.usernameTextStyle, + usernameTextStyle: other.usernameTextStyle, usernameColor: other.usernameColor, - timestampTextStyle: - _this.timestampTextStyle?.merge(other.timestampTextStyle) ?? - other.timestampTextStyle, + timestampTextStyle: other.timestampTextStyle, timestampColor: other.timestampColor, - editedTextStyle: - _this.editedTextStyle?.merge(other.editedTextStyle) ?? - other.editedTextStyle, + editedTextStyle: other.editedTextStyle, editedColor: other.editedColor, statusColor: other.statusColor, statusIconSize: other.statusIconSize, @@ -124,8 +165,8 @@ mixin _$StreamMessageMetadataThemeData { return false; } - final _this = (this as StreamMessageMetadataThemeData); - final _other = (other as StreamMessageMetadataThemeData); + final _this = (this as StreamMessageMetadataStyle); + final _other = (other as StreamMessageMetadataStyle); return _other.usernameTextStyle == _this.usernameTextStyle && _other.usernameColor == _this.usernameColor && @@ -142,7 +183,7 @@ mixin _$StreamMessageMetadataThemeData { @override int get hashCode { - final _this = (this as StreamMessageMetadataThemeData); + final _this = (this as StreamMessageMetadataStyle); return Object.hash( runtimeType, diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_replies_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_replies_theme.dart index 35ca394..bed87bd 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_message_replies_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_replies_theme.dart @@ -1,134 +1,124 @@ import 'package:flutter/widgets.dart'; +import 'package:stream_core/stream_core.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; -import '../stream_theme.dart'; +import 'stream_message_style_property.dart'; part 'stream_message_replies_theme.g.theme.dart'; -/// Applies a message replies theme to descendant [StreamMessageReplies] -/// widgets. +/// Visual styling properties for a message replies row. /// -/// Wrap a subtree with [StreamMessageRepliesTheme] to override replies row -/// styling. Access the merged theme using -/// [BuildContext.streamMessageRepliesTheme]. +/// Defines the appearance of replies rows including label, spacing, padding, +/// and connector styling. All properties use [StreamMessageStyleProperty] +/// for placement-aware resolution. Use [StreamMessageRepliesStyle.from] +/// for uniform values across all placements. /// /// {@tool snippet} /// -/// Override replies styling for a specific section: +/// Uniform style: /// /// ```dart -/// StreamMessageRepliesTheme( -/// data: StreamMessageRepliesThemeData( -/// labelColor: Colors.purple, -/// spacing: 12, -/// ), -/// child: StreamMessageReplies( -/// label: Text('3 replies'), -/// ), +/// StreamMessageRepliesStyle.from( +/// labelColor: Colors.blue, +/// spacing: 12, /// ) /// ``` /// {@end-tool} /// -/// See also: -/// -/// * [StreamMessageRepliesThemeData], which describes the replies theme. -/// * [StreamMessageReplies], the widget affected by this theme. -class StreamMessageRepliesTheme extends InheritedTheme { - /// Creates a message replies theme that controls descendant replies rows. - const StreamMessageRepliesTheme({ - super.key, - required this.data, - required super.child, - }); - - /// The message replies theme data for descendant widgets. - final StreamMessageRepliesThemeData data; - - /// Returns the [StreamMessageRepliesThemeData] merged from local and global - /// themes. - /// - /// Local values from the nearest [StreamMessageRepliesTheme] ancestor take - /// precedence over global values from [StreamTheme.of]. - /// - /// This allows partial overrides — for example, overriding only - /// [StreamMessageRepliesThemeData.labelColor] while inheriting other - /// properties from the global theme. - static StreamMessageRepliesThemeData of(BuildContext context) { - final localTheme = context.dependOnInheritedWidgetOfExactType(); - return StreamTheme.of(context).messageRepliesTheme.merge(localTheme?.data); - } - - @override - Widget wrap(BuildContext context, Widget child) { - return StreamMessageRepliesTheme(data: data, child: child); - } - - @override - bool updateShouldNotify(StreamMessageRepliesTheme oldWidget) => data != oldWidget.data; -} - -/// Theme data for customizing [StreamMessageReplies] widgets. -/// -/// Descendant widgets obtain their values from [StreamMessageRepliesTheme.of]. -/// All properties are null by default, with fallback values applied by -/// [DefaultStreamMessageReplies]. -/// /// {@tool snippet} /// -/// Customize replies appearance globally via [StreamTheme]: +/// Placement-aware style: /// /// ```dart -/// StreamTheme( -/// messageRepliesTheme: StreamMessageRepliesThemeData( -/// labelColor: Colors.blue, -/// spacing: 12, -/// ), +/// StreamMessageRepliesStyle( +/// labelColor: StreamMessageStyleProperty.resolveWith((p) { +/// final isEnd = p.alignment == StreamMessageAlignment.end; +/// return isEnd ? Colors.blue : Colors.purple; +/// }), /// ) /// ``` /// {@end-tool} /// /// See also: /// -/// * [StreamMessageRepliesTheme], for overriding the theme in a widget -/// subtree. -/// * [StreamMessageReplies], the widget that uses this theme data. +/// * [StreamMessageItemThemeData], which wraps this style for theming. +/// * [StreamMessageReplies], which uses this styling. @themeGen @immutable -class StreamMessageRepliesThemeData with _$StreamMessageRepliesThemeData { - /// Creates a message replies theme data with optional property overrides. - const StreamMessageRepliesThemeData({ +class StreamMessageRepliesStyle with _$StreamMessageRepliesStyle { + /// Creates a replies style with optional resolver-based overrides. + const StreamMessageRepliesStyle({ this.labelTextStyle, this.labelColor, this.spacing, this.padding, this.connectorColor, this.connectorStrokeWidth, + this.clipBehavior, }); - /// Defines the default text style for [StreamMessageReplies.label]. + /// A convenience constructor that constructs a + /// [StreamMessageRepliesStyle] given simple values. + /// + /// All parameters default to null. By default this constructor returns + /// a [StreamMessageRepliesStyle] that doesn't override anything. /// - /// This only controls typography. Color comes from [labelColor]. - final TextStyle? labelTextStyle; + /// For example, to override the default replies label color and spacing, + /// one could write: + /// + /// ```dart + /// StreamMessageRepliesStyle.from( + /// labelColor: Colors.blue, + /// spacing: 12, + /// ) + /// ``` + factory StreamMessageRepliesStyle.from({ + TextStyle? labelTextStyle, + Color? labelColor, + double? spacing, + EdgeInsetsGeometry? padding, + Color? connectorColor, + double? connectorStrokeWidth, + Clip? clipBehavior, + }) { + return StreamMessageRepliesStyle( + labelTextStyle: labelTextStyle?.let(StreamMessageStyleProperty.all), + labelColor: labelColor?.let(StreamMessageStyleProperty.all), + spacing: spacing?.let(StreamMessageStyleProperty.all), + padding: padding?.let(StreamMessageStyleProperty.all), + connectorColor: connectorColor?.let(StreamMessageStyleProperty.all), + connectorStrokeWidth: connectorStrokeWidth?.let(StreamMessageStyleProperty.all), + clipBehavior: clipBehavior?.let(StreamMessageStyleClip.all), + ); + } + + /// The text style for the replies label. + final StreamMessageStyleProperty? labelTextStyle; - /// Defines the default color for [StreamMessageReplies.label]. - final Color? labelColor; + /// The color for the replies label text. + final StreamMessageStyleProperty? labelColor; /// The gap between elements (connector, avatars, label). - final double? spacing; + final StreamMessageStyleProperty? spacing; /// The padding around the replies row content. - final EdgeInsetsGeometry? padding; + final StreamMessageStyleProperty? padding; /// The color of the connector path linking the row to the message bubble. - final Color? connectorColor; + final StreamMessageStyleProperty? connectorColor; /// The stroke width of the connector path. - final double? connectorStrokeWidth; + final StreamMessageStyleProperty? connectorStrokeWidth; + + /// How to clip the widget's content. + /// + /// Controls whether the connector overflow is clipped at the row boundary. + final StreamMessageStyleClip? clipBehavior; - /// Linearly interpolate between two [StreamMessageRepliesThemeData] objects. - static StreamMessageRepliesThemeData? lerp( - StreamMessageRepliesThemeData? a, - StreamMessageRepliesThemeData? b, + /// Linearly interpolate between two [StreamMessageRepliesStyle] objects. + static StreamMessageRepliesStyle? lerp( + StreamMessageRepliesStyle? a, + StreamMessageRepliesStyle? b, double t, - ) => _$StreamMessageRepliesThemeData.lerp(a, b, t); + ) => _$StreamMessageRepliesStyle.lerp(a, b, t); } diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_replies_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_replies_theme.g.theme.dart index bf4ffe9..c606f79 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_message_replies_theme.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_replies_theme.g.theme.dart @@ -9,12 +9,12 @@ part of 'stream_message_replies_theme.dart'; // ThemeGenGenerator // ************************************************************************** -mixin _$StreamMessageRepliesThemeData { +mixin _$StreamMessageRepliesStyle { bool get canMerge => true; - static StreamMessageRepliesThemeData? lerp( - StreamMessageRepliesThemeData? a, - StreamMessageRepliesThemeData? b, + static StreamMessageRepliesStyle? lerp( + StreamMessageRepliesStyle? a, + StreamMessageRepliesStyle? b, double t, ) { if (identical(a, b)) { @@ -29,42 +29,75 @@ mixin _$StreamMessageRepliesThemeData { return t == 0.0 ? a : null; } - return StreamMessageRepliesThemeData( - labelTextStyle: TextStyle.lerp(a.labelTextStyle, b.labelTextStyle, t), - labelColor: Color.lerp(a.labelColor, b.labelColor, t), - spacing: lerpDouble$(a.spacing, b.spacing, t), - padding: EdgeInsetsGeometry.lerp(a.padding, b.padding, t), - connectorColor: Color.lerp(a.connectorColor, b.connectorColor, t), - connectorStrokeWidth: lerpDouble$( + return StreamMessageRepliesStyle( + labelTextStyle: StreamMessageStyleProperty.lerp( + a.labelTextStyle, + b.labelTextStyle, + t, + TextStyle.lerp, + ), + labelColor: StreamMessageStyleProperty.lerp( + a.labelColor, + b.labelColor, + t, + Color.lerp, + ), + spacing: StreamMessageStyleProperty.lerp( + a.spacing, + b.spacing, + t, + lerpDouble$, + ), + padding: StreamMessageStyleProperty.lerp( + a.padding, + b.padding, + t, + EdgeInsetsGeometry.lerp, + ), + connectorColor: StreamMessageStyleProperty.lerp( + a.connectorColor, + b.connectorColor, + t, + Color.lerp, + ), + connectorStrokeWidth: StreamMessageStyleProperty.lerp( a.connectorStrokeWidth, b.connectorStrokeWidth, t, + lerpDouble$, + ), + clipBehavior: StreamMessageStyleClip.lerp( + a.clipBehavior, + b.clipBehavior, + t, ), ); } - StreamMessageRepliesThemeData copyWith({ - TextStyle? labelTextStyle, - Color? labelColor, - double? spacing, - EdgeInsetsGeometry? padding, - Color? connectorColor, - double? connectorStrokeWidth, + StreamMessageRepliesStyle copyWith({ + StreamMessageStyleProperty? labelTextStyle, + StreamMessageStyleProperty? labelColor, + StreamMessageStyleProperty? spacing, + StreamMessageStyleProperty? padding, + StreamMessageStyleProperty? connectorColor, + StreamMessageStyleProperty? connectorStrokeWidth, + StreamMessageStyleClip? clipBehavior, }) { - final _this = (this as StreamMessageRepliesThemeData); + final _this = (this as StreamMessageRepliesStyle); - return StreamMessageRepliesThemeData( + return StreamMessageRepliesStyle( labelTextStyle: labelTextStyle ?? _this.labelTextStyle, labelColor: labelColor ?? _this.labelColor, spacing: spacing ?? _this.spacing, padding: padding ?? _this.padding, connectorColor: connectorColor ?? _this.connectorColor, connectorStrokeWidth: connectorStrokeWidth ?? _this.connectorStrokeWidth, + clipBehavior: clipBehavior ?? _this.clipBehavior, ); } - StreamMessageRepliesThemeData merge(StreamMessageRepliesThemeData? other) { - final _this = (this as StreamMessageRepliesThemeData); + StreamMessageRepliesStyle merge(StreamMessageRepliesStyle? other) { + final _this = (this as StreamMessageRepliesStyle); if (other == null || identical(_this, other)) { return _this; @@ -75,14 +108,13 @@ mixin _$StreamMessageRepliesThemeData { } return copyWith( - labelTextStyle: - _this.labelTextStyle?.merge(other.labelTextStyle) ?? - other.labelTextStyle, + labelTextStyle: other.labelTextStyle, labelColor: other.labelColor, spacing: other.spacing, padding: other.padding, connectorColor: other.connectorColor, connectorStrokeWidth: other.connectorStrokeWidth, + clipBehavior: other.clipBehavior, ); } @@ -96,20 +128,21 @@ mixin _$StreamMessageRepliesThemeData { return false; } - final _this = (this as StreamMessageRepliesThemeData); - final _other = (other as StreamMessageRepliesThemeData); + final _this = (this as StreamMessageRepliesStyle); + final _other = (other as StreamMessageRepliesStyle); return _other.labelTextStyle == _this.labelTextStyle && _other.labelColor == _this.labelColor && _other.spacing == _this.spacing && _other.padding == _this.padding && _other.connectorColor == _this.connectorColor && - _other.connectorStrokeWidth == _this.connectorStrokeWidth; + _other.connectorStrokeWidth == _this.connectorStrokeWidth && + _other.clipBehavior == _this.clipBehavior; } @override int get hashCode { - final _this = (this as StreamMessageRepliesThemeData); + final _this = (this as StreamMessageRepliesStyle); return Object.hash( runtimeType, @@ -119,6 +152,7 @@ mixin _$StreamMessageRepliesThemeData { _this.padding, _this.connectorColor, _this.connectorStrokeWidth, + _this.clipBehavior, ); } } diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_style_property.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_style_property.dart new file mode 100644 index 0000000..02f6226 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_style_property.dart @@ -0,0 +1,358 @@ +import 'dart:ui' show Clip; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart' show BorderSide; + +import '../../components/common/stream_visibility.dart'; +import '../../components/message_placement/stream_message_placement.dart'; + +/// Resolves a value of type [T] based on a message's [StreamMessagePlacementData]. +/// +/// Each field in a message style class (e.g. `StreamMessageBubbleStyle.shape`) +/// is a [StreamMessageStyleProperty] so that its value can vary by alignment +/// (start / end) and stack position (single / top / middle / bottom). +/// +/// Use the factory constructors for common patterns: +/// +/// {@tool snippet} +/// +/// Uniform value for all placements: +/// +/// ```dart +/// StreamMessageStyleProperty.all(EdgeInsets.all(12)) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Alignment-aware color: +/// +/// ```dart +/// StreamMessageStyleProperty.resolveWith((placement) { +/// final isEnd = placement.alignment == StreamMessageAlignment.end; +/// return isEnd ? brandColor : surfaceColor; +/// }) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Position + alignment aware shape: +/// +/// ```dart +/// StreamMessageStyleProperty.resolveWith((placement) { +/// final isEnd = placement.alignment == StreamMessageAlignment.end; +/// return switch (placement.stackPosition) { +/// StreamMessageStackPosition.single => +/// RoundedRectangleBorder(borderRadius: BorderRadius.all(r.xxl)), +/// StreamMessageStackPosition.top => +/// RoundedRectangleBorder(borderRadius: BorderRadiusDirectional.only( +/// topStart: r.xxl, +/// topEnd: r.xxl, +/// bottomStart: isEnd ? r.xxl : tailRadius, +/// bottomEnd: isEnd ? tailRadius : r.xxl, +/// )), +/// _ => defaultShape, +/// }; +/// }) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessagePlacementData], the context passed to [resolve]. +abstract class StreamMessageStyleProperty { + /// Const constructor for subclasses. + const StreamMessageStyleProperty(); + + /// Resolves this property for the given [placement]. + T resolve(StreamMessagePlacementData placement); + + /// Creates a property that returns [value] for every placement. + static StreamMessageStyleProperty all(T value) => _AllProperty(value); + + /// Creates a property that delegates to [callback] for resolution. + static StreamMessageStyleProperty resolveWith( + T Function(StreamMessagePlacementData placement) callback, + ) => _ResolveWithProperty(callback); + + /// Linearly interpolates between two [StreamMessageStyleProperty] values. + static StreamMessageStyleProperty? lerp( + StreamMessageStyleProperty? a, + StreamMessageStyleProperty? b, + double t, + T? Function(T?, T?, double) lerpFunction, + ) { + if (a == null && b == null) return null; + return _LerpProperty(a, b, t, lerpFunction); + } +} + +@immutable +class _AllProperty extends StreamMessageStyleProperty { + const _AllProperty(this._value); + + final T _value; + + @override + T resolve(StreamMessagePlacementData placement) => _value; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is _AllProperty && other._value == _value; + } + + @override + int get hashCode => _value.hashCode; +} + +class _ResolveWithProperty extends StreamMessageStyleProperty { + const _ResolveWithProperty(this._callback); + + final T Function(StreamMessagePlacementData placement) _callback; + + @override + T resolve(StreamMessagePlacementData placement) => _callback(placement); +} + +class _LerpProperty extends StreamMessageStyleProperty { + const _LerpProperty(this._a, this._b, this._t, this._lerpFunction); + + final StreamMessageStyleProperty? _a; + final StreamMessageStyleProperty? _b; + final double _t; + final T? Function(T?, T?, double) _lerpFunction; + + @override + T? resolve(StreamMessagePlacementData placement) { + return _lerpFunction(_a?.resolve(placement), _b?.resolve(placement), _t); + } +} + +/// A [Clip] value that can depend on [StreamMessagePlacementData]. +/// +/// {@tool snippet} +/// +/// Same clip for all placements: +/// +/// ```dart +/// StreamMessageStyleClip.all(Clip.hardEdge) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Placement-aware clip: +/// +/// ```dart +/// StreamMessageStyleClip.resolveWith((placement) { +/// return switch (placement.stackPosition) { +/// .top || .middle => Clip.none, +/// .bottom || .single => Clip.hardEdge, +/// }; +/// }) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageStyleProperty], the generic placement-aware resolver. +/// * [StreamMessagePlacementData], the context passed to [resolve]. +extension type const StreamMessageStyleClip._(StreamMessageStyleProperty _property) + implements StreamMessageStyleProperty { + /// Creates a clip that returns [clip] for every placement. + static StreamMessageStyleClip all(Clip clip) => ._(.all(clip)); + + /// Creates a clip that delegates to [callback] for resolution. + static StreamMessageStyleClip resolveWith( + Clip? Function(StreamMessagePlacementData placement) callback, + ) => ._(.resolveWith(callback)); + + /// Linearly interpolate between two [StreamMessageStyleClip] values. + static StreamMessageStyleClip? lerp( + StreamMessageStyleClip? a, + StreamMessageStyleClip? b, + double t, + ) { + if (a == null && b == null) return null; + if (identical(a, b)) return a; + return t < 0.5 ? a : b; + } +} + +/// A [StreamVisibility] value that can depend on [StreamMessagePlacementData]. +/// +/// {@tool snippet} +/// +/// Same visibility for all placements: +/// +/// ```dart +/// StreamMessageStyleVisibility.all(StreamVisibility.hidden) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Placement-aware visibility: +/// +/// ```dart +/// StreamMessageStyleVisibility.resolveWith((placement) { +/// return switch (placement.stackPosition) { +/// .top || .middle => StreamVisibility.hidden, +/// .bottom || .single => StreamVisibility.visible, +/// }; +/// }) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageStyleProperty], the generic placement-aware resolver. +/// * [StreamMessagePlacementData], the context passed to [resolve]. +extension type const StreamMessageStyleVisibility._(StreamMessageStyleProperty _property) + implements StreamMessageStyleProperty { + /// Creates a visibility that returns [visibility] for every placement. + static StreamMessageStyleVisibility all(StreamVisibility visibility) => ._(.all(visibility)); + + /// Creates a visibility that delegates to [callback] for resolution. + static StreamMessageStyleVisibility resolveWith( + StreamVisibility? Function(StreamMessagePlacementData placement) callback, + ) => ._(.resolveWith(callback)); + + /// Linearly interpolate between two [StreamMessageStyleVisibility] values. + static StreamMessageStyleVisibility? lerp( + StreamMessageStyleVisibility? a, + StreamMessageStyleVisibility? b, + double t, + ) { + if (a == null && b == null) return null; + if (identical(a, b)) return a; + return t < 0.5 ? a : b; + } +} + +/// A [BorderSide] value that can depend on [StreamMessagePlacementData]. +/// +/// {@tool snippet} +/// +/// Same border for all placements: +/// +/// ```dart +/// StreamMessageStyleBorderSide.all(BorderSide(color: Colors.grey)) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Placement-aware border: +/// +/// ```dart +/// StreamMessageStyleBorderSide.resolveWith((placement) { +/// return switch (placement.alignment) { +/// .start => BorderSide(color: Colors.grey), +/// .end => BorderSide(color: Colors.blue), +/// }; +/// }) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageStyleProperty], the generic placement-aware resolver. +/// * [StreamMessagePlacementData], the context passed to [resolve]. +extension type const StreamMessageStyleBorderSide._(StreamMessageStyleProperty _property) + implements StreamMessageStyleProperty { + /// Creates a border side that returns [side] for every placement. + static StreamMessageStyleBorderSide all(BorderSide side) => ._(.all(side)); + + /// Creates a border side that delegates to [callback] for resolution. + static StreamMessageStyleBorderSide resolveWith( + BorderSide? Function(StreamMessagePlacementData placement) callback, + ) => ._(.resolveWith(callback)); + + /// Linearly interpolate between two [StreamMessageStyleBorderSide] values. + static StreamMessageStyleBorderSide? lerp( + StreamMessageStyleBorderSide? a, + StreamMessageStyleBorderSide? b, + double t, + ) { + if (a == null && b == null) return null; + if (identical(a, b)) return a; + return ._(_LerpBorderSide(a, b, t)); + } +} + +class _LerpBorderSide extends StreamMessageStyleProperty { + const _LerpBorderSide(this._a, this._b, this._t); + + final StreamMessageStyleBorderSide? _a; + final StreamMessageStyleBorderSide? _b; + final double _t; + + @override + BorderSide? resolve(StreamMessagePlacementData placement) { + final resolvedA = _a?.resolve(placement); + final resolvedB = _b?.resolve(placement); + if (resolvedA == null && resolvedB == null) return null; + if (resolvedA == null) { + return BorderSide.lerp( + BorderSide(width: 0, color: resolvedB!.color.withAlpha(0)), + resolvedB, + _t, + ); + } + if (resolvedB == null) { + return BorderSide.lerp( + resolvedA, + BorderSide(width: 0, color: resolvedA.color.withAlpha(0)), + _t, + ); + } + return BorderSide.lerp(resolvedA, resolvedB, _t); + } +} + +/// Resolves style properties through a cascade of style sources for a given +/// [StreamMessagePlacementData]. +/// +/// Given an ordered list of style sources, returns the first non-null +/// resolved value for a requested property. +/// +/// {@tool snippet} +/// +/// ```dart +/// final resolve = StreamMessageStyleResolver( +/// placement, +/// [widgetStyle, themeStyle, defaults], +/// ); +/// +/// final color = resolve((s) => s?.backgroundColor); +/// final padding = resolve((s) => s?.padding); +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageStyleProperty], the resolver type for each field. +/// * [StreamMessagePlacementData], the context passed to each resolver. +class StreamMessageStyleResolver { + /// Creates a resolver that cascades through [styles] in order. + const StreamMessageStyleResolver(this._placement, this._styles); + + final StreamMessagePlacementData _placement; + final List _styles; + + /// Resolves the first non-null value for [getProperty] across all style + /// sources. + /// + /// The last entry in [_styles] should be a defaults object that provides + /// every property, ensuring this method always returns a value. + T call(StreamMessageStyleProperty? Function(S? style) getProperty) { + for (final style in _styles) { + final resolved = getProperty(style)?.resolve(_placement); + if (resolved != null) return resolved; + } + throw StateError('No style source provided a value for the requested property'); + } +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_text_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_text_theme.dart new file mode 100644 index 0000000..2b4d0ce --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_text_theme.dart @@ -0,0 +1,135 @@ +import 'package:flutter/widgets.dart'; +import 'package:stream_core/stream_core.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import 'stream_message_style_property.dart'; + +part 'stream_message_text_theme.g.theme.dart'; + +/// Placement-aware styling for markdown message text. +/// +/// Controls the appearance of paragraph text, links, and mentions. +/// Use [StreamMessageTextStyle.from] for uniform values across all placements. +/// +/// Additional markdown styles (headings, code blocks, blockquotes, tables, +/// layout) can be customised via [StreamMessageTextProps.styleSheet]. +/// +/// {@tool snippet} +/// +/// Uniform style: +/// +/// ```dart +/// StreamMessageTextStyle.from( +/// textColor: Colors.black, +/// linkStyle: TextStyle(color: Colors.blue, decoration: TextDecoration.underline), +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Placement-aware style: +/// +/// ```dart +/// StreamMessageTextStyle( +/// textColor: StreamMessageStyleProperty.resolveWith((p) { +/// final isEnd = p.alignment == StreamMessageAlignment.end; +/// return isEnd ? Colors.white : Colors.black; +/// }), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageItemThemeData], which wraps this style for theming. +/// * [StreamMessageText], which uses this styling. +@themeGen +@immutable +class StreamMessageTextStyle with _$StreamMessageTextStyle { + /// Creates a message text style with optional resolver-based overrides. + const StreamMessageTextStyle({ + this.textStyle, + this.textColor, + this.linkStyle, + this.linkColor, + this.mentionStyle, + this.mentionColor, + this.singleEmojiStyle, + this.doubleEmojiStyle, + this.tripleEmojiStyle, + }); + + /// A convenience constructor that constructs a [StreamMessageTextStyle] + /// given simple values. + /// + /// All parameters default to null. By default this constructor returns + /// a [StreamMessageTextStyle] that doesn't override anything. + /// + /// For example, to override the default text color and link style, one + /// could write: + /// + /// ```dart + /// StreamMessageTextStyle.from( + /// textColor: Colors.black, + /// linkStyle: TextStyle(color: Colors.blue), + /// ) + /// ``` + factory StreamMessageTextStyle.from({ + TextStyle? textStyle, + Color? textColor, + TextStyle? linkStyle, + Color? linkColor, + TextStyle? mentionStyle, + Color? mentionColor, + TextStyle? singleEmojiStyle, + TextStyle? doubleEmojiStyle, + TextStyle? tripleEmojiStyle, + }) { + return StreamMessageTextStyle( + textStyle: textStyle?.let(StreamMessageStyleProperty.all), + textColor: textColor?.let(StreamMessageStyleProperty.all), + linkStyle: linkStyle?.let(StreamMessageStyleProperty.all), + linkColor: linkColor?.let(StreamMessageStyleProperty.all), + mentionStyle: mentionStyle?.let(StreamMessageStyleProperty.all), + mentionColor: mentionColor?.let(StreamMessageStyleProperty.all), + singleEmojiStyle: singleEmojiStyle?.let(StreamMessageStyleProperty.all), + doubleEmojiStyle: doubleEmojiStyle?.let(StreamMessageStyleProperty.all), + tripleEmojiStyle: tripleEmojiStyle?.let(StreamMessageStyleProperty.all), + ); + } + + /// The base text style for paragraph content. + final StreamMessageStyleProperty? textStyle; + + /// The color for paragraph text. + final StreamMessageStyleProperty? textColor; + + /// The text style for links. + final StreamMessageStyleProperty? linkStyle; + + /// The color for link text. + final StreamMessageStyleProperty? linkColor; + + /// The text style for @mention text. + final StreamMessageStyleProperty? mentionStyle; + + /// The color for @mention text. + final StreamMessageStyleProperty? mentionColor; + + /// The text style for emoji-only messages containing exactly one emoji. + final StreamMessageStyleProperty? singleEmojiStyle; + + /// The text style for emoji-only messages containing exactly two emojis. + final StreamMessageStyleProperty? doubleEmojiStyle; + + /// The text style for emoji-only messages containing exactly three emojis. + final StreamMessageStyleProperty? tripleEmojiStyle; + + /// Linearly interpolate between two [StreamMessageTextStyle] objects. + static StreamMessageTextStyle? lerp( + StreamMessageTextStyle? a, + StreamMessageTextStyle? b, + double t, + ) => _$StreamMessageTextStyle.lerp(a, b, t); +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_text_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_text_theme.g.theme.dart new file mode 100644 index 0000000..80840ff --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_text_theme.g.theme.dart @@ -0,0 +1,181 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'stream_message_text_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamMessageTextStyle { + bool get canMerge => true; + + static StreamMessageTextStyle? lerp( + StreamMessageTextStyle? a, + StreamMessageTextStyle? 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 StreamMessageTextStyle( + textStyle: StreamMessageStyleProperty.lerp( + a.textStyle, + b.textStyle, + t, + TextStyle.lerp, + ), + textColor: StreamMessageStyleProperty.lerp( + a.textColor, + b.textColor, + t, + Color.lerp, + ), + linkStyle: StreamMessageStyleProperty.lerp( + a.linkStyle, + b.linkStyle, + t, + TextStyle.lerp, + ), + linkColor: StreamMessageStyleProperty.lerp( + a.linkColor, + b.linkColor, + t, + Color.lerp, + ), + mentionStyle: StreamMessageStyleProperty.lerp( + a.mentionStyle, + b.mentionStyle, + t, + TextStyle.lerp, + ), + mentionColor: StreamMessageStyleProperty.lerp( + a.mentionColor, + b.mentionColor, + t, + Color.lerp, + ), + singleEmojiStyle: StreamMessageStyleProperty.lerp( + a.singleEmojiStyle, + b.singleEmojiStyle, + t, + TextStyle.lerp, + ), + doubleEmojiStyle: StreamMessageStyleProperty.lerp( + a.doubleEmojiStyle, + b.doubleEmojiStyle, + t, + TextStyle.lerp, + ), + tripleEmojiStyle: StreamMessageStyleProperty.lerp( + a.tripleEmojiStyle, + b.tripleEmojiStyle, + t, + TextStyle.lerp, + ), + ); + } + + StreamMessageTextStyle copyWith({ + StreamMessageStyleProperty? textStyle, + StreamMessageStyleProperty? textColor, + StreamMessageStyleProperty? linkStyle, + StreamMessageStyleProperty? linkColor, + StreamMessageStyleProperty? mentionStyle, + StreamMessageStyleProperty? mentionColor, + StreamMessageStyleProperty? singleEmojiStyle, + StreamMessageStyleProperty? doubleEmojiStyle, + StreamMessageStyleProperty? tripleEmojiStyle, + }) { + final _this = (this as StreamMessageTextStyle); + + return StreamMessageTextStyle( + textStyle: textStyle ?? _this.textStyle, + textColor: textColor ?? _this.textColor, + linkStyle: linkStyle ?? _this.linkStyle, + linkColor: linkColor ?? _this.linkColor, + mentionStyle: mentionStyle ?? _this.mentionStyle, + mentionColor: mentionColor ?? _this.mentionColor, + singleEmojiStyle: singleEmojiStyle ?? _this.singleEmojiStyle, + doubleEmojiStyle: doubleEmojiStyle ?? _this.doubleEmojiStyle, + tripleEmojiStyle: tripleEmojiStyle ?? _this.tripleEmojiStyle, + ); + } + + StreamMessageTextStyle merge(StreamMessageTextStyle? other) { + final _this = (this as StreamMessageTextStyle); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + textStyle: other.textStyle, + textColor: other.textColor, + linkStyle: other.linkStyle, + linkColor: other.linkColor, + mentionStyle: other.mentionStyle, + mentionColor: other.mentionColor, + singleEmojiStyle: other.singleEmojiStyle, + doubleEmojiStyle: other.doubleEmojiStyle, + tripleEmojiStyle: other.tripleEmojiStyle, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamMessageTextStyle); + final _other = (other as StreamMessageTextStyle); + + return _other.textStyle == _this.textStyle && + _other.textColor == _this.textColor && + _other.linkStyle == _this.linkStyle && + _other.linkColor == _this.linkColor && + _other.mentionStyle == _this.mentionStyle && + _other.mentionColor == _this.mentionColor && + _other.singleEmojiStyle == _this.singleEmojiStyle && + _other.doubleEmojiStyle == _this.doubleEmojiStyle && + _other.tripleEmojiStyle == _this.tripleEmojiStyle; + } + + @override + int get hashCode { + final _this = (this as StreamMessageTextStyle); + + return Object.hash( + runtimeType, + _this.textStyle, + _this.textColor, + _this.linkStyle, + _this.linkColor, + _this.mentionStyle, + _this.mentionColor, + _this.singleEmojiStyle, + _this.doubleEmojiStyle, + _this.tripleEmojiStyle, + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_theme.dart index 5446156..e4d85ef 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_message_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_theme.dart @@ -5,15 +5,31 @@ import '../../../stream_core_flutter.dart'; part 'stream_message_theme.g.theme.dart'; +/// Applies a message color theme to descendant widgets. +/// +/// Wrap a subtree with [StreamMessageTheme] to override message colors. +/// Provides separate [StreamMessageStyle] values for incoming and outgoing +/// messages. +/// +/// See also: +/// +/// * [StreamMessageThemeData], which describes the theme data. +/// * [StreamMessageStyle], the color palette for a single message direction. class StreamMessageTheme extends InheritedTheme { + /// Creates a message theme that controls descendant message widget colors. const StreamMessageTheme({ super.key, required this.data, required super.child, }); + /// The message theme data for descendant widgets. final StreamMessageThemeData data; + /// Returns the [StreamMessageThemeData] from the current theme context. + /// + /// This merges the local theme (if any) with the global theme from + /// [StreamTheme]. static StreamMessageThemeData of(BuildContext context) { final localTheme = context.dependOnInheritedWidgetOfExactType(); return StreamTheme.of(context).messageTheme.merge(localTheme?.data); @@ -28,15 +44,27 @@ class StreamMessageTheme extends InheritedTheme { bool updateShouldNotify(StreamMessageTheme oldWidget) => data != oldWidget.data; } +/// Theme data for customizing message colors. +/// +/// Holds separate color palettes for incoming and outgoing messages. +/// +/// See also: +/// +/// * [StreamMessageTheme], for overriding theme in a widget subtree. +/// * [StreamMessageStyle], the color palette for a single message direction. @themeGen @immutable class StreamMessageThemeData with _$StreamMessageThemeData { + /// Creates message theme data with optional incoming/outgoing overrides. const StreamMessageThemeData({ this.incoming, this.outgoing, }); + /// Color palette for incoming (received) messages. final StreamMessageStyle? incoming; + + /// Color palette for outgoing (sent) messages. final StreamMessageStyle? outgoing; StreamMessageThemeData mergeWithDefaults(BuildContext context) { @@ -45,9 +73,18 @@ class StreamMessageThemeData with _$StreamMessageThemeData { } } +/// Color palette for a single message direction (incoming or outgoing). +/// +/// Groups all color tokens used by message-related widgets: backgrounds, text, +/// borders, progress indicators, and waveform bars. +/// +/// See also: +/// +/// * [StreamMessageThemeData], which wraps this style for theming. @themeGen @immutable class StreamMessageStyle with _$StreamMessageStyle { + /// Creates a message style with optional color overrides. const StreamMessageStyle({ this.backgroundColor, this.backgroundAttachmentColor, 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 index f8bcddd..383b139 100644 --- 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 @@ -50,7 +50,6 @@ enum StreamReactionsAlignment { /// spacing: 4, /// gap: 6, /// overlapExtent: 8, -/// indent: 4, /// ), /// child: StreamReactions.segmented( /// items: [ @@ -111,7 +110,6 @@ class StreamReactionsTheme extends InheritedTheme { /// spacing: 4, /// gap: 6, /// overlapExtent: 8, -/// indent: 4, /// ), /// ) /// ``` @@ -130,7 +128,6 @@ class StreamReactionsThemeData with _$StreamReactionsThemeData { this.spacing, this.gap, this.overlapExtent, - this.indent, }); /// The gap between adjacent reaction chips. @@ -146,12 +143,6 @@ class StreamReactionsThemeData with _$StreamReactionsThemeData { /// 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, 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 index 7b3d87c..30f7195 100644 --- 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 @@ -33,7 +33,6 @@ mixin _$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), ); } @@ -41,7 +40,6 @@ mixin _$StreamReactionsThemeData { double? spacing, double? gap, double? overlapExtent, - double? indent, }) { final _this = (this as StreamReactionsThemeData); @@ -49,7 +47,6 @@ mixin _$StreamReactionsThemeData { spacing: spacing ?? _this.spacing, gap: gap ?? _this.gap, overlapExtent: overlapExtent ?? _this.overlapExtent, - indent: indent ?? _this.indent, ); } @@ -68,7 +65,6 @@ mixin _$StreamReactionsThemeData { spacing: other.spacing, gap: other.gap, overlapExtent: other.overlapExtent, - indent: other.indent, ); } @@ -87,8 +83,7 @@ mixin _$StreamReactionsThemeData { return _other.spacing == _this.spacing && _other.gap == _this.gap && - _other.overlapExtent == _this.overlapExtent && - _other.indent == _this.indent; + _other.overlapExtent == _this.overlapExtent; } @override @@ -100,7 +95,6 @@ mixin _$StreamReactionsThemeData { _this.spacing, _this.gap, _this.overlapExtent, - _this.indent, ); } } diff --git a/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart b/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart index 1cbcbbe..754b02b 100644 --- a/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart +++ b/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart @@ -72,6 +72,7 @@ class StreamColorScheme with _$StreamColorScheme { Color? backgroundOverlayDark, Color? backgroundDisabled, Color? backgroundInverse, + Color? backgroundHighlight, // Background - Elevation Color? backgroundElevation0, @@ -139,6 +140,7 @@ class StreamColorScheme with _$StreamColorScheme { backgroundOverlayDark ??= light_tokens.StreamTokens.backgroundCoreOverlayDark; backgroundDisabled ??= light_tokens.StreamTokens.backgroundCoreDisabled; backgroundInverse ??= light_tokens.StreamTokens.backgroundCoreInverse; + backgroundHighlight ??= StreamColors.yellow.shade50; backgroundElevation0 ??= light_tokens.StreamTokens.backgroundElevationElevation0; backgroundElevation1 ??= light_tokens.StreamTokens.backgroundElevationElevation1; @@ -224,6 +226,7 @@ class StreamColorScheme with _$StreamColorScheme { backgroundOverlayDark: backgroundOverlayDark, backgroundDisabled: backgroundDisabled, backgroundInverse: backgroundInverse, + backgroundHighlight: backgroundHighlight, backgroundElevation0: backgroundElevation0, backgroundElevation1: backgroundElevation1, backgroundElevation2: backgroundElevation2, @@ -284,6 +287,7 @@ class StreamColorScheme with _$StreamColorScheme { Color? backgroundOverlayDark, Color? backgroundDisabled, Color? backgroundInverse, + Color? backgroundHighlight, // Background - Elevation Color? backgroundElevation0, Color? backgroundElevation1, @@ -349,6 +353,7 @@ class StreamColorScheme with _$StreamColorScheme { backgroundOverlayDark ??= dark_tokens.StreamTokens.backgroundCoreOverlayDark; backgroundDisabled ??= dark_tokens.StreamTokens.backgroundCoreDisabled; backgroundInverse ??= dark_tokens.StreamTokens.backgroundCoreInverse; + backgroundHighlight ??= StreamColors.yellow.shade800; backgroundElevation0 ??= dark_tokens.StreamTokens.backgroundElevationElevation0; backgroundElevation1 ??= dark_tokens.StreamTokens.backgroundElevationElevation1; @@ -434,6 +439,7 @@ class StreamColorScheme with _$StreamColorScheme { backgroundOverlayDark: backgroundOverlayDark, backgroundDisabled: backgroundDisabled, backgroundInverse: backgroundInverse, + backgroundHighlight: backgroundHighlight, backgroundElevation0: backgroundElevation0, backgroundElevation1: backgroundElevation1, backgroundElevation2: backgroundElevation2, @@ -492,6 +498,7 @@ class StreamColorScheme with _$StreamColorScheme { required this.backgroundOverlayDark, required this.backgroundDisabled, required this.backgroundInverse, + required this.backgroundHighlight, // Background - Elevation required this.backgroundElevation0, required this.backgroundElevation1, @@ -607,6 +614,9 @@ class StreamColorScheme with _$StreamColorScheme { /// The inverse background color. final Color backgroundInverse; + /// The highlight background color for pinned or accented items. + final Color backgroundHighlight; + // ---- Background - Elevation ---- /// The elevation 0 background color. diff --git a/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.g.theme.dart index 262e994..c0b8a1f 100644 --- a/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.g.theme.dart @@ -86,6 +86,11 @@ mixin _$StreamColorScheme { b.backgroundInverse, t, )!, + backgroundHighlight: Color.lerp( + a.backgroundHighlight, + b.backgroundHighlight, + t, + )!, backgroundElevation0: Color.lerp( a.backgroundElevation0, b.backgroundElevation0, @@ -161,6 +166,7 @@ mixin _$StreamColorScheme { Color? backgroundOverlayDark, Color? backgroundDisabled, Color? backgroundInverse, + Color? backgroundHighlight, Color? backgroundElevation0, Color? backgroundElevation1, Color? backgroundElevation2, @@ -220,6 +226,7 @@ mixin _$StreamColorScheme { backgroundOverlayDark ?? _this.backgroundOverlayDark, backgroundDisabled: backgroundDisabled ?? _this.backgroundDisabled, backgroundInverse: backgroundInverse ?? _this.backgroundInverse, + backgroundHighlight: backgroundHighlight ?? _this.backgroundHighlight, backgroundElevation0: backgroundElevation0 ?? _this.backgroundElevation0, backgroundElevation1: backgroundElevation1 ?? _this.backgroundElevation1, backgroundElevation2: backgroundElevation2 ?? _this.backgroundElevation2, @@ -286,6 +293,7 @@ mixin _$StreamColorScheme { backgroundOverlayDark: other.backgroundOverlayDark, backgroundDisabled: other.backgroundDisabled, backgroundInverse: other.backgroundInverse, + backgroundHighlight: other.backgroundHighlight, backgroundElevation0: other.backgroundElevation0, backgroundElevation1: other.backgroundElevation1, backgroundElevation2: other.backgroundElevation2, @@ -353,6 +361,7 @@ mixin _$StreamColorScheme { _other.backgroundOverlayDark == _this.backgroundOverlayDark && _other.backgroundDisabled == _this.backgroundDisabled && _other.backgroundInverse == _this.backgroundInverse && + _other.backgroundHighlight == _this.backgroundHighlight && _other.backgroundElevation0 == _this.backgroundElevation0 && _other.backgroundElevation1 == _this.backgroundElevation1 && _other.backgroundElevation2 == _this.backgroundElevation2 && @@ -412,6 +421,7 @@ mixin _$StreamColorScheme { _this.backgroundOverlayDark, _this.backgroundDisabled, _this.backgroundInverse, + _this.backgroundHighlight, _this.backgroundElevation0, _this.backgroundElevation1, _this.backgroundElevation2, 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 2cd95ca..2aa5591 100644 --- a/packages/stream_core_flutter/lib/src/theme/stream_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/stream_theme.dart @@ -16,9 +16,7 @@ import 'components/stream_emoji_button_theme.dart'; import 'components/stream_emoji_chip_theme.dart'; import 'components/stream_input_theme.dart'; import 'components/stream_list_tile_theme.dart'; -import 'components/stream_message_annotation_theme.dart'; -import 'components/stream_message_metadata_theme.dart'; -import 'components/stream_message_replies_theme.dart'; +import 'components/stream_message_item_theme.dart'; import 'components/stream_message_theme.dart'; import 'components/stream_online_indicator_theme.dart'; import 'components/stream_progress_bar_theme.dart'; @@ -108,9 +106,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { StreamEmojiButtonThemeData? emojiButtonTheme, StreamEmojiChipThemeData? emojiChipTheme, StreamListTileThemeData? listTileTheme, - StreamMessageAnnotationThemeData? messageAnnotationTheme, - StreamMessageMetadataThemeData? messageMetadataTheme, - StreamMessageRepliesThemeData? messageRepliesTheme, + StreamMessageItemThemeData? messageItemTheme, StreamMessageThemeData? messageTheme, StreamInputThemeData? inputTheme, StreamOnlineIndicatorThemeData? onlineIndicatorTheme, @@ -143,9 +139,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { emojiButtonTheme ??= const StreamEmojiButtonThemeData(); emojiChipTheme ??= const StreamEmojiChipThemeData(); listTileTheme ??= const StreamListTileThemeData(); - messageAnnotationTheme ??= const StreamMessageAnnotationThemeData(); - messageMetadataTheme ??= const StreamMessageMetadataThemeData(); - messageRepliesTheme ??= const StreamMessageRepliesThemeData(); + messageItemTheme ??= const StreamMessageItemThemeData(); messageTheme ??= const StreamMessageThemeData(); inputTheme ??= const StreamInputThemeData(); onlineIndicatorTheme ??= const StreamOnlineIndicatorThemeData(); @@ -172,9 +166,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { emojiButtonTheme: emojiButtonTheme, emojiChipTheme: emojiChipTheme, listTileTheme: listTileTheme, - messageAnnotationTheme: messageAnnotationTheme, - messageMetadataTheme: messageMetadataTheme, - messageRepliesTheme: messageRepliesTheme, + messageItemTheme: messageItemTheme, messageTheme: messageTheme, inputTheme: inputTheme, onlineIndicatorTheme: onlineIndicatorTheme, @@ -215,9 +207,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { required this.emojiButtonTheme, required this.emojiChipTheme, required this.listTileTheme, - required this.messageAnnotationTheme, - required this.messageMetadataTheme, - required this.messageRepliesTheme, + required this.messageItemTheme, required this.messageTheme, required this.inputTheme, required this.onlineIndicatorTheme, @@ -316,14 +306,10 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { /// The list tile theme for this theme. final StreamListTileThemeData listTileTheme; - /// The message annotation theme for this theme. - final StreamMessageAnnotationThemeData messageAnnotationTheme; - - /// The message metadata theme for this theme. - final StreamMessageMetadataThemeData messageMetadataTheme; - - /// The message replies theme for this theme. - final StreamMessageRepliesThemeData messageRepliesTheme; + /// The message item theme for this theme. + /// + /// Provides resolver-based styling for message sub-components. + final StreamMessageItemThemeData messageItemTheme; /// The message theme for this theme. final StreamMessageThemeData messageTheme; @@ -380,9 +366,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { emojiButtonTheme: emojiButtonTheme, emojiChipTheme: emojiChipTheme, listTileTheme: listTileTheme, - messageAnnotationTheme: messageAnnotationTheme, - messageMetadataTheme: messageMetadataTheme, - messageRepliesTheme: messageRepliesTheme, + messageItemTheme: messageItemTheme, messageTheme: messageTheme, inputTheme: inputTheme, onlineIndicatorTheme: onlineIndicatorTheme, 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 6ced3e1..246ec8b 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 @@ -31,9 +31,7 @@ mixin _$StreamTheme on ThemeExtension { StreamEmojiButtonThemeData? emojiButtonTheme, StreamEmojiChipThemeData? emojiChipTheme, StreamListTileThemeData? listTileTheme, - StreamMessageAnnotationThemeData? messageAnnotationTheme, - StreamMessageMetadataThemeData? messageMetadataTheme, - StreamMessageRepliesThemeData? messageRepliesTheme, + StreamMessageItemThemeData? messageItemTheme, StreamMessageThemeData? messageTheme, StreamInputThemeData? inputTheme, StreamOnlineIndicatorThemeData? onlineIndicatorTheme, @@ -64,10 +62,7 @@ mixin _$StreamTheme on ThemeExtension { emojiButtonTheme: emojiButtonTheme ?? _this.emojiButtonTheme, emojiChipTheme: emojiChipTheme ?? _this.emojiChipTheme, listTileTheme: listTileTheme ?? _this.listTileTheme, - messageAnnotationTheme: - messageAnnotationTheme ?? _this.messageAnnotationTheme, - messageMetadataTheme: messageMetadataTheme ?? _this.messageMetadataTheme, - messageRepliesTheme: messageRepliesTheme ?? _this.messageRepliesTheme, + messageItemTheme: messageItemTheme ?? _this.messageItemTheme, messageTheme: messageTheme ?? _this.messageTheme, inputTheme: inputTheme ?? _this.inputTheme, onlineIndicatorTheme: onlineIndicatorTheme ?? _this.onlineIndicatorTheme, @@ -152,19 +147,9 @@ mixin _$StreamTheme on ThemeExtension { other.listTileTheme, t, )!, - messageAnnotationTheme: StreamMessageAnnotationThemeData.lerp( - _this.messageAnnotationTheme, - other.messageAnnotationTheme, - t, - )!, - messageMetadataTheme: StreamMessageMetadataThemeData.lerp( - _this.messageMetadataTheme, - other.messageMetadataTheme, - t, - )!, - messageRepliesTheme: StreamMessageRepliesThemeData.lerp( - _this.messageRepliesTheme, - other.messageRepliesTheme, + messageItemTheme: StreamMessageItemThemeData.lerp( + _this.messageItemTheme, + other.messageItemTheme, t, )!, messageTheme: t < 0.5 ? _this.messageTheme : other.messageTheme, @@ -219,9 +204,7 @@ mixin _$StreamTheme on ThemeExtension { _other.emojiButtonTheme == _this.emojiButtonTheme && _other.emojiChipTheme == _this.emojiChipTheme && _other.listTileTheme == _this.listTileTheme && - _other.messageAnnotationTheme == _this.messageAnnotationTheme && - _other.messageMetadataTheme == _this.messageMetadataTheme && - _other.messageRepliesTheme == _this.messageRepliesTheme && + _other.messageItemTheme == _this.messageItemTheme && _other.messageTheme == _this.messageTheme && _other.inputTheme == _this.inputTheme && _other.onlineIndicatorTheme == _this.onlineIndicatorTheme && @@ -254,9 +237,7 @@ mixin _$StreamTheme on ThemeExtension { _this.emojiButtonTheme, _this.emojiChipTheme, _this.listTileTheme, - _this.messageAnnotationTheme, - _this.messageMetadataTheme, - _this.messageRepliesTheme, + _this.messageItemTheme, _this.messageTheme, _this.inputTheme, _this.onlineIndicatorTheme, diff --git a/packages/stream_core_flutter/lib/src/theme/stream_theme_extensions.dart b/packages/stream_core_flutter/lib/src/theme/stream_theme_extensions.dart index 57da7f1..72c08cb 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 @@ -12,9 +12,7 @@ import 'components/stream_emoji_button_theme.dart'; import 'components/stream_emoji_chip_theme.dart'; import 'components/stream_input_theme.dart'; import 'components/stream_list_tile_theme.dart'; -import 'components/stream_message_annotation_theme.dart'; -import 'components/stream_message_metadata_theme.dart'; -import 'components/stream_message_replies_theme.dart'; +import 'components/stream_message_item_theme.dart'; import 'components/stream_message_theme.dart'; import 'components/stream_online_indicator_theme.dart'; import 'components/stream_progress_bar_theme.dart'; @@ -106,14 +104,8 @@ extension StreamThemeExtension on BuildContext { /// Returns the [StreamListTileThemeData] from the nearest ancestor. StreamListTileThemeData get streamListTileTheme => StreamListTileTheme.of(this); - /// Returns the [StreamMessageAnnotationThemeData] from the nearest ancestor. - StreamMessageAnnotationThemeData get streamMessageAnnotationTheme => StreamMessageAnnotationTheme.of(this); - - /// Returns the [StreamMessageMetadataThemeData] from the nearest ancestor. - StreamMessageMetadataThemeData get streamMessageMetadataTheme => StreamMessageMetadataTheme.of(this); - - /// Returns the [StreamMessageRepliesThemeData] from the nearest ancestor. - StreamMessageRepliesThemeData get streamMessageRepliesTheme => StreamMessageRepliesTheme.of(this); + /// Returns the [StreamMessageItemThemeData] from the nearest ancestor. + StreamMessageItemThemeData get streamMessageItemTheme => StreamMessageItemTheme.of(this); /// Returns the [StreamMessageThemeData] from the nearest ancestor. StreamMessageThemeData get streamMessageTheme => StreamMessageTheme.of(this); diff --git a/packages/stream_core_flutter/pubspec.yaml b/packages/stream_core_flutter/pubspec.yaml index e5eb18c..9f1f817 100644 --- a/packages/stream_core_flutter/pubspec.yaml +++ b/packages/stream_core_flutter/pubspec.yaml @@ -12,7 +12,9 @@ dependencies: collection: ^1.19.0 flutter: sdk: flutter + flutter_markdown_plus: ^1.0.7 flutter_svg: ^2.2.3 + markdown: ^7.3.0 stream_core: ^0.4.0 theme_extensions_builder_annotation: ^7.1.0 From 1836018fbc37f1671568237b7d510ef57e5ce09c Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 13 Mar 2026 16:35:19 +0530 Subject: [PATCH 09/11] chore: format --- .../message/stream_message_content.dart | 5 +- .../message/stream_message_text.dart | 68 +++++++++---------- .../components/reaction/stream_reactions.dart | 3 +- 3 files changed, 39 insertions(+), 37 deletions(-) diff --git a/apps/design_system_gallery/lib/components/message/stream_message_content.dart b/apps/design_system_gallery/lib/components/message/stream_message_content.dart index 563edd5..c27eb0f 100644 --- a/apps/design_system_gallery/lib/components/message/stream_message_content.dart +++ b/apps/design_system_gallery/lib/components/message/stream_message_content.dart @@ -120,7 +120,10 @@ Widget buildStreamMessageContentPlayground(BuildContext context) { body = Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, - children: [buildReactions(child: messageWidget), ?replies], + children: [ + buildReactions(child: messageWidget), + ?replies, + ], ); } else { body = buildReactions( diff --git a/apps/design_system_gallery/lib/components/message/stream_message_text.dart b/apps/design_system_gallery/lib/components/message/stream_message_text.dart index abb6fec..96cbc16 100644 --- a/apps/design_system_gallery/lib/components/message/stream_message_text.dart +++ b/apps/design_system_gallery/lib/components/message/stream_message_text.dart @@ -551,10 +551,10 @@ class _RealWorldSection extends StatelessWidget { ), child: StreamMessageBubble( child: StreamMessageText( - 'Hey [@Sarah](mention:sarah42), have you tried the ' - '**new Flutter update**? The performance ' - 'improvements are _amazing_!', - onTapMention: (displayText, id) => _showSnack(context, 'Mention: $displayText (id: $id)'), + 'Hey [@Sarah](mention:sarah42), have you tried the ' + '**new Flutter update**? The performance ' + 'improvements are _amazing_!', + onTapMention: (displayText, id) => _showSnack(context, 'Mention: $displayText (id: $id)'), ), ), ), @@ -569,19 +569,19 @@ class _RealWorldSection extends StatelessWidget { ), child: StreamMessageBubble( child: StreamMessageText( - '## Quick Sort in Dart\n\n' - "Here's an efficient implementation:\n\n" - '```dart\n' - 'List quickSort(List list) {\n' - ' if (list.length <= 1) return list;\n' - ' final pivot = list[list.length ~/ 2];\n' - ' final less = list.where((e) => e < pivot).toList();\n' - ' final equal = list.where((e) => e == pivot).toList();\n' - ' final greater = list.where((e) => e > pivot).toList();\n' - ' return [...quickSort(less), ...equal, ...quickSort(greater)];\n' - '}\n' - '```\n\n' - 'The time complexity is **O(n log n)** on average.', + '## Quick Sort in Dart\n\n' + "Here's an efficient implementation:\n\n" + '```dart\n' + 'List quickSort(List list) {\n' + ' if (list.length <= 1) return list;\n' + ' final pivot = list[list.length ~/ 2];\n' + ' final less = list.where((e) => e < pivot).toList();\n' + ' final equal = list.where((e) => e == pivot).toList();\n' + ' final greater = list.where((e) => e > pivot).toList();\n' + ' return [...quickSort(less), ...equal, ...quickSort(greater)];\n' + '}\n' + '```\n\n' + 'The time complexity is **O(n log n)** on average.', ), ), ), @@ -607,11 +607,11 @@ class _RealWorldSection extends StatelessWidget { ), child: StreamMessageBubble( child: StreamMessageText( - 'Meeting agenda:\n\n' - '1. **Sprint review** — demo the new features\n' - '2. **Retro** — what went well / what to improve\n' - '3. **Planning** — next sprint priorities\n\n' - '> Please come prepared with your updates.', + 'Meeting agenda:\n\n' + '1. **Sprint review** — demo the new features\n' + '2. **Retro** — what went well / what to improve\n' + '3. **Planning** — next sprint priorities\n\n' + '> Please come prepared with your updates.', ), ), ), @@ -624,18 +624,18 @@ class _RealWorldSection extends StatelessWidget { timestamp: const Text('10:15'), ), child: StreamReactions.segmented( - items: const [ - StreamReactionsItem(emoji: Text('\u{1F680}'), count: 5), - StreamReactionsItem(emoji: Text('\u{1F44D}'), count: 3), - StreamReactionsItem(emoji: Text('\u{2764}\u{FE0F}'), count: 2), - ], - child: StreamMessageBubble( - child: StreamMessageText( - 'Just shipped the new **markdown component**!\n\n' - 'Features:\n' - '- Full GFM support\n' - '- Placement-aware theming\n' - '- Custom builder extensibility', + items: const [ + StreamReactionsItem(emoji: Text('\u{1F680}'), count: 5), + StreamReactionsItem(emoji: Text('\u{1F44D}'), count: 3), + StreamReactionsItem(emoji: Text('\u{2764}\u{FE0F}'), count: 2), + ], + child: StreamMessageBubble( + child: StreamMessageText( + 'Just shipped the new **markdown component**!\n\n' + 'Features:\n' + '- Full GFM support\n' + '- Placement-aware theming\n' + '- Custom builder extensibility', ), ), ), diff --git a/apps/design_system_gallery/lib/components/reaction/stream_reactions.dart b/apps/design_system_gallery/lib/components/reaction/stream_reactions.dart index 1a04586..0ae3a3a 100644 --- a/apps/design_system_gallery/lib/components/reaction/stream_reactions.dart +++ b/apps/design_system_gallery/lib/components/reaction/stream_reactions.dart @@ -565,8 +565,7 @@ class _EmojiOnlyShowcaseSection extends StatelessWidget { StreamReactionsPosition position = StreamReactionsPosition.footer, }) { final isEnd = alignment == StreamMessageAlignment.end; - final crossAxis = - isEnd ? CrossAxisAlignment.end : CrossAxisAlignment.start; + final crossAxis = isEnd ? CrossAxisAlignment.end : CrossAxisAlignment.start; final messageText = StreamMessageText(text); From f592473c673130d3de9739cf9bdd53c5a720a16b Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 13 Mar 2026 18:56:48 +0530 Subject: [PATCH 10/11] chore: fix analysis --- .../lib/src/components/message/stream_message_text.dart | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/stream_core_flutter/lib/src/components/message/stream_message_text.dart b/packages/stream_core_flutter/lib/src/components/message/stream_message_text.dart index f1942fd..db31088 100644 --- a/packages/stream_core_flutter/lib/src/components/message/stream_message_text.dart +++ b/packages/stream_core_flutter/lib/src/components/message/stream_message_text.dart @@ -1,5 +1,3 @@ -// ignore_for_file: valid_regexps - import 'package:flutter/material.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:markdown/markdown.dart' as md; From 2c6036440411f902f1ef677317c80f3ffa5c786c Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 13 Mar 2026 21:19:56 +0530 Subject: [PATCH 11/11] feat(ui): add channel kind support for message display customization --- .../message/stream_message_widget.dart | 74 +++++++++++++++++++ .../lib/src/components.dart | 1 + .../message/stream_message_widget.dart | 53 +++++++------ .../stream_channel_kind.dart | 17 +++++ .../stream_message_placement.dart | 59 ++++++++++++--- 5 files changed, 171 insertions(+), 33 deletions(-) create mode 100644 packages/stream_core_flutter/lib/src/components/message_placement/stream_channel_kind.dart diff --git a/apps/design_system_gallery/lib/components/message/stream_message_widget.dart b/apps/design_system_gallery/lib/components/message/stream_message_widget.dart index a6a5685..e4e8fe0 100644 --- a/apps/design_system_gallery/lib/components/message/stream_message_widget.dart +++ b/apps/design_system_gallery/lib/components/message/stream_message_widget.dart @@ -38,6 +38,14 @@ Widget buildStreamMessageWidgetPlayground(BuildContext context) { description: 'Number of messages in the stack. Positions are assigned automatically.', ); + final channelKind = context.knobs.object.dropdown( + label: 'Channel Kind', + options: StreamChannelKind.values, + initialOption: StreamChannelKind.group, + labelBuilder: (v) => v.name, + description: 'Direct (1-to-1) hides avatars; group shows them.', + ); + // -- Content slots ---------------------------------------------------------- final showHeader = context.knobs.boolean( @@ -167,6 +175,7 @@ Widget buildStreamMessageWidgetPlayground(BuildContext context) { StreamMessageWidget( alignment: alignment, stackPosition: stackPositionFor(i, messageCount), + channelKind: channelKind, onTap: () => _showSnack(context, 'Message tapped'), onLongPress: () => _showSnack(context, 'Message long-pressed'), leading: avatar, @@ -237,6 +246,7 @@ Widget buildStreamMessageWidgetShowcase(BuildContext context) { children: [ _AlignmentSection(), _StackPositionsSection(), + _ChannelKindSection(), _VisibilitySection(), _FullCompositionSection(), _EmojiOnlySection(), @@ -365,6 +375,67 @@ class _StackPositionsSection extends StatelessWidget { } } +class _ChannelKindSection extends StatelessWidget { + const _ChannelKindSection(); + + @override + Widget build(BuildContext context) { + return const _Section( + label: 'CHANNEL KIND', + description: + 'In direct (1-to-1) channels, avatars are hidden by default since ' + 'the sender is always known. In group channels, avatars are shown ' + 'for identification.', + children: [ + _ExampleCard( + label: 'Group channel — avatars visible', + child: Column( + spacing: 2, + children: [ + _MessageItem( + avatarIndex: 0, + text: 'In a group channel, avatars help identify the sender.', + timestamp: '09:41', + username: 'Alice', + ), + _MessageItem( + alignment: StreamMessageAlignment.end, + avatarIndex: 1, + text: 'Makes sense for multi-participant chats!', + timestamp: '09:42', + status: Icon(StreamIconData.iconDoupleCheckmark1Small), + ), + ], + ), + ), + _ExampleCard( + label: 'Direct channel — avatars hidden', + child: Column( + spacing: 2, + children: [ + _MessageItem( + channelKind: StreamChannelKind.direct, + avatarIndex: 0, + text: 'In a direct channel, you already know who is talking.', + timestamp: '09:41', + username: 'Alice', + ), + _MessageItem( + channelKind: StreamChannelKind.direct, + alignment: StreamMessageAlignment.end, + avatarIndex: 1, + text: 'So avatars are removed to save space.', + timestamp: '09:42', + status: Icon(StreamIconData.iconDoupleCheckmark1Small), + ), + ], + ), + ), + ], + ); + } +} + class _VisibilitySection extends StatelessWidget { const _VisibilitySection(); @@ -744,6 +815,7 @@ class _MessageItem extends StatelessWidget { const _MessageItem({ this.alignment = StreamMessageAlignment.start, this.stackPosition = StreamMessageStackPosition.single, + this.channelKind = StreamChannelKind.group, this.avatarIndex, required this.text, this.timestamp, @@ -758,6 +830,7 @@ class _MessageItem extends StatelessWidget { final StreamMessageAlignment alignment; final StreamMessageStackPosition stackPosition; + final StreamChannelKind channelKind; final int? avatarIndex; final String text; final String? timestamp; @@ -805,6 +878,7 @@ class _MessageItem extends StatelessWidget { padding: .zero, alignment: alignment, stackPosition: stackPosition, + channelKind: channelKind, leading: leading, child: StreamMessageContent( header: annotation, diff --git a/packages/stream_core_flutter/lib/src/components.dart b/packages/stream_core_flutter/lib/src/components.dart index 6c7f4c1..47b05f6 100644 --- a/packages/stream_core_flutter/lib/src/components.dart +++ b/packages/stream_core_flutter/lib/src/components.dart @@ -31,6 +31,7 @@ export 'components/message/stream_message_replies.dart' hide DefaultStreamMessag export 'components/message/stream_message_text.dart' hide DefaultStreamMessageText; export 'components/message/stream_message_widget.dart' hide DefaultStreamMessageWidget; export 'components/message_composer.dart'; +export 'components/message_placement/stream_channel_kind.dart'; export 'components/message_placement/stream_message_alignment.dart'; export 'components/message_placement/stream_message_placement.dart'; export 'components/message_placement/stream_message_stack_position.dart'; diff --git a/packages/stream_core_flutter/lib/src/components/message/stream_message_widget.dart b/packages/stream_core_flutter/lib/src/components/message/stream_message_widget.dart index 17a5687..b477512 100644 --- a/packages/stream_core_flutter/lib/src/components/message/stream_message_widget.dart +++ b/packages/stream_core_flutter/lib/src/components/message/stream_message_widget.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../../theme.dart'; import '../common/stream_visibility.dart'; +import '../message_placement/stream_channel_kind.dart'; import '../message_placement/stream_message_alignment.dart'; import '../message_placement/stream_message_placement.dart'; import '../message_placement/stream_message_stack_position.dart'; @@ -59,14 +60,15 @@ class StreamMessageWidget extends StatelessWidget { /// Creates a message item. /// /// The [child] is required. An optional [leading] widget is displayed - /// alongside the content. The [alignment] and [stackPosition] configure - /// the [StreamMessagePlacement] for descendants. + /// alongside the content. The [alignment], [stackPosition], and + /// [channelKind] configure the [StreamMessagePlacement] for descendants. StreamMessageWidget({ super.key, Widget? leading, required Widget child, StreamMessageAlignment alignment = .start, StreamMessageStackPosition stackPosition = .single, + StreamChannelKind channelKind = .group, StreamVisibility? leadingVisibility, EdgeInsetsGeometry? padding, double? spacing, @@ -77,6 +79,7 @@ class StreamMessageWidget extends StatelessWidget { child: child, alignment: alignment, stackPosition: stackPosition, + channelKind: channelKind, leadingVisibility: leadingVisibility, padding: padding, spacing: spacing, @@ -92,6 +95,7 @@ class StreamMessageWidget extends StatelessWidget { placement: StreamMessagePlacementData( alignment: props.alignment, stackPosition: props.stackPosition, + channelKind: props.channelKind, ), child: Builder( builder: (context) { @@ -115,6 +119,7 @@ class StreamMessageWidgetProps { required this.child, this.alignment = .start, this.stackPosition = .single, + this.channelKind = .group, this.leadingVisibility, this.padding, this.spacing, @@ -153,6 +158,15 @@ class StreamMessageWidgetProps { /// Defaults to [StreamMessageStackPosition.single]. final StreamMessageStackPosition stackPosition; + /// The kind of channel this message is displayed in. + /// + /// Establishes the [StreamMessagePlacement] channel kind for + /// descendants, which sub-components use to adapt their appearance + /// (e.g. hiding avatars in direct channels). + /// + /// Defaults to [StreamChannelKind.group]. + final StreamChannelKind channelKind; + /// Overrides the leading widget visibility for this message item. /// /// When non-null, takes precedence over the theme-resolved value from @@ -233,21 +247,18 @@ class DefaultStreamMessageWidget extends StatelessWidget { StreamMessageAlignment.end => [content, ?leadingWidget], }; - return StreamMessagePlacement( - placement: placement, - child: Material( - animateColor: true, - color: effectiveBackgroundColor, - child: InkWell( - onTap: props.onTap, - onLongPress: props.onLongPress, - child: Padding( - padding: effectivePadding, - child: Row( - spacing: effectiveSpacing, - crossAxisAlignment: .end, - children: children, - ), + return Material( + animateColor: true, + color: effectiveBackgroundColor, + child: InkWell( + onTap: props.onTap, + onLongPress: props.onLongPress, + child: Padding( + padding: effectivePadding, + child: Row( + spacing: effectiveSpacing, + crossAxisAlignment: .end, + children: children, ), ), ), @@ -273,10 +284,10 @@ class _StreamMessageWidgetDefaults extends StreamMessageItemThemeData { @override StreamMessageStyleVisibility get leadingVisibility => .resolveWith( - (placement) => switch ((placement.alignment, placement.stackPosition)) { - (.end, _) => .gone, - (_, .top || .middle) => .hidden, - (_, .single || .bottom) => .visible, + (placement) => switch ((placement.channelKind, placement.alignment, placement.stackPosition)) { + (.direct, _, _) || (_, .end, _) => .gone, + (_, _, .top || .middle) => .hidden, + (_, _, .single || .bottom) => .visible, }, ); } diff --git a/packages/stream_core_flutter/lib/src/components/message_placement/stream_channel_kind.dart b/packages/stream_core_flutter/lib/src/components/message_placement/stream_channel_kind.dart new file mode 100644 index 0000000..6295a40 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/message_placement/stream_channel_kind.dart @@ -0,0 +1,17 @@ +/// The kind of channel a message is displayed in. +/// +/// Used by [StreamMessagePlacementData] to let descendant widgets adapt their +/// appearance based on the channel type — for example, hiding avatars in +/// direct (1-to-1) conversations where the sender is always known. +/// +/// See also: +/// +/// * [StreamMessagePlacementData], which carries this value. +/// * [StreamMessagePlacement], the [InheritedModel] that provides it. +enum StreamChannelKind { + /// A direct (1-to-1) conversation between two users. + direct, + + /// A group conversation with multiple participants. + group, +} diff --git a/packages/stream_core_flutter/lib/src/components/message_placement/stream_message_placement.dart b/packages/stream_core_flutter/lib/src/components/message_placement/stream_message_placement.dart index 13ec1ce..3cb6e67 100644 --- a/packages/stream_core_flutter/lib/src/components/message_placement/stream_message_placement.dart +++ b/packages/stream_core_flutter/lib/src/components/message_placement/stream_message_placement.dart @@ -1,5 +1,6 @@ import 'package:flutter/widgets.dart'; +import 'stream_channel_kind.dart'; import 'stream_message_alignment.dart'; import 'stream_message_stack_position.dart'; @@ -14,6 +15,9 @@ enum _StreamMessagePlacementAspect { // The vertical stack position axis (single / top / middle / bottom). stackPosition, + + // The channel kind (direct / group). + channelKind, } /// Provides [StreamMessagePlacementData] to descendant widgets. @@ -27,10 +31,12 @@ enum _StreamMessagePlacementAspect { /// the alignment (ignores stack position changes). /// * [stackPositionOf] — returns only the stack position (ignores alignment /// changes). +/// * [channelKindOf] — returns only the channel kind (ignores alignment and +/// stack position changes). /// /// When no [StreamMessagePlacement] is found in the tree, a default placement -/// of [StreamMessageAlignment.start] + [StreamMessageStackPosition.single] is -/// returned. +/// of [StreamMessageAlignment.start] + [StreamMessageStackPosition.single] + +/// [StreamChannelKind.group] is returned. /// /// {@tool snippet} /// @@ -137,6 +143,21 @@ class StreamMessagePlacement extends InheritedModel<_StreamMessagePlacementAspec return _of(context, _StreamMessagePlacementAspect.stackPosition).stackPosition; } + /// Returns [StreamMessagePlacementData.channelKind] from the nearest + /// [StreamMessagePlacement] ancestor. + /// + /// Use of this method will cause the given [context] to rebuild any time + /// that the [StreamMessagePlacementData.channelKind] property of the + /// ancestor [StreamMessagePlacement] changes. + /// + /// Prefer using this function over getting the attribute directly from the + /// [StreamMessagePlacementData] returned from [of], because using this + /// function will only rebuild the [context] when this specific attribute + /// changes, not when _any_ attribute changes. + static StreamChannelKind channelKindOf(BuildContext context) { + return _of(context, _StreamMessagePlacementAspect.channelKind).channelKind; + } + @override bool updateShouldNotify(StreamMessagePlacement oldWidget) => placement != oldWidget.placement; @@ -150,6 +171,7 @@ class StreamMessagePlacement extends InheritedModel<_StreamMessagePlacementAspec return switch (dependency) { .alignment => placement.alignment != oldWidget.placement.alignment, .stackPosition => placement.stackPosition != oldWidget.placement.stackPosition, + .channelKind => placement.channelKind != oldWidget.placement.channelKind, }; }, ); @@ -157,23 +179,26 @@ class StreamMessagePlacement extends InheritedModel<_StreamMessagePlacementAspec /// Describes where a message sits within the message list layout. /// -/// Combines [alignment] (start vs end) and [stackPosition] -/// (single, top, middle, bottom) into a single value that -/// [StreamMessageStyleProperty] resolvers use to compute placement-dependent -/// styling. +/// Combines [alignment] (start vs end), [stackPosition] +/// (single, top, middle, bottom), and [channelKind] (direct vs group) into a +/// single value that [StreamMessageStyleProperty] resolvers use to compute +/// placement-dependent styling. /// /// {@tool snippet} /// -/// Create a placement for an end-aligned message at the top of a stack: +/// Create a placement for an end-aligned message at the top of a stack in a +/// group channel: /// /// ```dart /// const placement = StreamMessagePlacementData( /// alignment: StreamMessageAlignment.end, /// stackPosition: StreamMessageStackPosition.top, +/// channelKind: StreamChannelKind.group, /// ); /// /// print(placement.alignment); // StreamMessageAlignment.end /// print(placement.stackPosition); // StreamMessageStackPosition.top +/// print(placement.channelKind); // StreamChannelKind.group /// ``` /// {@end-tool} /// @@ -181,16 +206,19 @@ class StreamMessagePlacement extends InheritedModel<_StreamMessagePlacementAspec /// /// * [StreamMessageAlignment], the horizontal alignment axis. /// * [StreamMessageStackPosition], the vertical stacking axis. +/// * [StreamChannelKind], the kind of channel the message is displayed in. /// * [StreamMessageStyleProperty], which resolves values based on placement. @immutable class StreamMessagePlacementData { /// Creates a message placement. /// - /// Defaults to a start-aligned, standalone message - /// ([StreamMessageAlignment.start] + [StreamMessageStackPosition.single]). + /// Defaults to a start-aligned, standalone message in a group channel + /// ([StreamMessageAlignment.start] + [StreamMessageStackPosition.single] + + /// [StreamChannelKind.group]). const StreamMessagePlacementData({ this.alignment = .start, this.stackPosition = .single, + this.channelKind = .group, }); /// The horizontal alignment of the message. @@ -199,15 +227,22 @@ class StreamMessagePlacementData { /// The position of the message within a consecutive stack. final StreamMessageStackPosition stackPosition; + /// The kind of channel this message is displayed in. + final StreamChannelKind channelKind; + @override bool operator ==(Object other) { if (identical(this, other)) return true; - return other is StreamMessagePlacementData && other.alignment == alignment && other.stackPosition == stackPosition; + return other is StreamMessagePlacementData && + other.alignment == alignment && + other.stackPosition == stackPosition && + other.channelKind == channelKind; } @override - int get hashCode => Object.hash(alignment, stackPosition); + int get hashCode => Object.hash(alignment, stackPosition, channelKind); @override - String toString() => 'StreamMessagePlacementData(alignment: $alignment, stackPosition: $stackPosition)'; + String toString() => + 'StreamMessagePlacementData(alignment: $alignment, stackPosition: $stackPosition, channelKind: $channelKind)'; }