From 8695d69ee43c601c83ec0915727b565af3b6796d Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 4 May 2026 12:02:30 +0200 Subject: [PATCH 1/6] refactor(ui): remove StreamMessageTheme and inline composer reply/link preview colors MessageComposerReplyAttachment and MessageComposerLinkPreviewAttachment now read colors directly from StreamColorScheme (semantic where available, brand/chrome shades otherwise) instead of the removed StreamMessageTheme/StreamMessageThemeData/StreamMessageStyle. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/stream_core_flutter/CHANGELOG.md | 1 + ...sage_composer_link_preview_attachment.dart | 7 +- .../message_composer_reply_attachment.dart | 118 ++++--- .../stream_core_flutter/lib/src/theme.dart | 1 - .../components/stream_message_theme.dart | 186 ---------- .../stream_message_theme.g.theme.dart | 325 ------------------ .../lib/src/theme/stream_theme.dart | 9 - .../lib/src/theme/stream_theme.g.theme.dart | 5 - .../src/theme/stream_theme_extensions.dart | 4 - ...omposer_attachment_reply_custom_matrix.png | Bin 2063 -> 0 bytes ...composer_attachment_reply_golden_test.dart | 36 -- 11 files changed, 80 insertions(+), 612 deletions(-) delete mode 100644 packages/stream_core_flutter/lib/src/theme/components/stream_message_theme.dart delete mode 100644 packages/stream_core_flutter/lib/src/theme/components/stream_message_theme.g.theme.dart delete mode 100644 packages/stream_core_flutter/test/components/message_composer/goldens/ci/message_composer_attachment_reply_custom_matrix.png diff --git a/packages/stream_core_flutter/CHANGELOG.md b/packages/stream_core_flutter/CHANGELOG.md index c4834887..e95643c9 100644 --- a/packages/stream_core_flutter/CHANGELOG.md +++ b/packages/stream_core_flutter/CHANGELOG.md @@ -39,6 +39,7 @@ - Aligned `StreamButton` API with Flutter's built-in buttons: renamed `label` (`String?`) to required `child` (`Widget`), changed `icon`/`iconLeft`/`iconRight` from `IconData` to `Widget`, and renamed `onTap` to `onPressed`. `StreamButtonProps` mirrors the same renames. - Redesigned `StreamAppBar` with a slots-based API (`leading`/`title`/`subtitle`/`trailing`) and platform-aware auto-implied leading; replaces the previous Material `AppBar` wrapper. Adds `StreamAppBarStyle`, `StreamAppBarTheme`, and `StreamAppBarThemeData`. - `placeholder` on `StreamCoreMessageComposer`, `StreamMessageComposerInput`, and `StreamMessageComposerInputField` is now an optional `String?` (was `String` defaulting to `''`, and `required` on `StreamMessageComposerInputField`). +- Removed `StreamMessageTheme`, `StreamMessageThemeData`, and `StreamMessageStyle`; `MessageComposerReplyAttachment` and `MessageComposerLinkPreviewAttachment` now read colors directly from `StreamColorScheme`. ## 0.2.0 diff --git a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_link_preview_attachment.dart b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_link_preview_attachment.dart index 3ec5a175..eb88ab71 100644 --- a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_link_preview_attachment.dart +++ b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_link_preview_attachment.dart @@ -20,9 +20,10 @@ class MessageComposerLinkPreviewAttachment extends StatelessWidget { @override Widget build(BuildContext context) { - final messageTheme = context.streamMessageTheme.mergeWithDefaults(context); - final backgroundColor = messageTheme.outgoing?.backgroundColor; - final textColor = messageTheme.outgoing?.textColor; + final colorScheme = context.streamColorScheme; + + final textColor = colorScheme.brand.shade900; + final backgroundColor = colorScheme.brand.shade100; final titleStyle = context.streamTextTheme.metadataEmphasis.copyWith(color: textColor); final bodyStyle = context.streamTextTheme.metadataDefault.copyWith(color: textColor); diff --git a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_reply_attachment.dart b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_reply_attachment.dart index 79f723e2..bf1d60c6 100644 --- a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_reply_attachment.dart +++ b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_reply_attachment.dart @@ -3,6 +3,16 @@ import 'package:flutter/widgets.dart'; import '../../../../stream_core_flutter.dart'; +const _kDefaultConstraints = BoxConstraints(minWidth: 272, minHeight: 56); + +const _kIndicatorWidth = 2.0; +const _kIndicatorHeight = 36.0; + +enum ReplyStyle { + incoming, + outgoing, +} + class MessageComposerReplyAttachment extends StatelessWidget { const MessageComposerReplyAttachment({ super.key, @@ -10,7 +20,7 @@ class MessageComposerReplyAttachment extends StatelessWidget { required this.subtitle, this.trailing, this.onRemovePressed, - this.style = ReplyStyle.incoming, + this.style = .incoming, }); final Widget title; @@ -21,58 +31,80 @@ class MessageComposerReplyAttachment extends StatelessWidget { @override Widget build(BuildContext context) { - final messageTheme = context.streamMessageTheme.mergeWithDefaults(context); - final messageStyle = switch (style) { - ReplyStyle.incoming => messageTheme.incoming, - ReplyStyle.outgoing => messageTheme.outgoing, + final radius = context.streamRadius; + final spacing = context.streamSpacing; + + final textTheme = context.streamTextTheme; + final colorScheme = context.streamColorScheme; + + final backgroundColor = switch (style) { + ReplyStyle.incoming => colorScheme.backgroundSurface, + ReplyStyle.outgoing => colorScheme.brand.shade100, }; - final indicatorColor = messageStyle?.replyIndicatorColor; - final backgroundColor = messageStyle?.backgroundColor; - final textColor = messageStyle?.textColor; + final indicatorColor = switch (style) { + ReplyStyle.incoming => colorScheme.chrome.shade400, + ReplyStyle.outgoing => colorScheme.brand.shade400, + }; + + final textColor = switch (style) { + ReplyStyle.incoming => colorScheme.textPrimary, + ReplyStyle.outgoing => colorScheme.brand.shade900, + }; - final spacing = context.streamSpacing; return StreamMessageComposerAttachmentContainer( onRemovePressed: onRemovePressed, backgroundColor: backgroundColor, borderColor: StreamColors.transparent, - child: Padding( - padding: .all(spacing.xs), - child: Row( - spacing: spacing.xs, - children: [ - Container( - margin: const EdgeInsets.only(top: 2, bottom: 2), - color: indicatorColor, - child: const SizedBox(width: 2, height: 36), - ), - Expanded( - child: Column( - spacing: spacing.xxxs, - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - DefaultTextStyle.merge( - style: context.streamTextTheme.metadataEmphasis.copyWith(color: textColor), - child: title, - ), - DefaultTextStyle.merge( - style: context.streamTextTheme.metadataDefault.copyWith(color: textColor), - child: subtitle, - ), - ], + child: ConstrainedBox( + constraints: _kDefaultConstraints, + child: Padding( + padding: .all(spacing.xs), + child: Row( + spacing: spacing.xs, + children: [ + Expanded( + child: Row( + spacing: spacing.xs, + children: [ + Container( + width: _kIndicatorWidth, + height: _kIndicatorHeight, + decoration: BoxDecoration( + color: indicatorColor, + borderRadius: .all(radius.max), + ), + ), + Expanded( + child: Column( + mainAxisSize: .min, + spacing: spacing.xxxs, + mainAxisAlignment: .center, + crossAxisAlignment: .start, + children: [ + DefaultTextStyle.merge( + maxLines: 1, + style: textTheme.metadataEmphasis.copyWith(color: textColor), + overflow: TextOverflow.ellipsis, + child: title, + ), + DefaultTextStyle.merge( + maxLines: 1, + style: textTheme.metadataDefault.copyWith(color: textColor), + overflow: TextOverflow.ellipsis, + child: subtitle, + ), + ], + ), + ), + ], + ), ), - ), - ?trailing, - ], + ?trailing, + ], + ), ), ), ); } } - -enum ReplyStyle { - incoming, - outgoing, -} diff --git a/packages/stream_core_flutter/lib/src/theme.dart b/packages/stream_core_flutter/lib/src/theme.dart index fdaeffb2..aa4ce608 100644 --- a/packages/stream_core_flutter/lib/src/theme.dart +++ b/packages/stream_core_flutter/lib/src/theme.dart @@ -22,7 +22,6 @@ 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_playback_speed_toggle_theme.dart'; export 'theme/components/stream_progress_bar_theme.dart'; 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 deleted file mode 100644 index c51a7656..00000000 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_message_theme.dart +++ /dev/null @@ -1,186 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; - -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); - } - - @override - Widget wrap(BuildContext context, Widget child) { - return StreamMessageTheme(data: data, child: child); - } - - @override - 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) { - final defaults = _MessageThemeDefaults(context: context); - return StreamMessageThemeData(incoming: defaults.incoming, outgoing: defaults.outgoing).merge(this); - } -} - -/// 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, - this.backgroundTypingIndicatorColor, - this.textColor, - this.textUsernameColor, - this.textTimestampColor, - this.textMentionColor, - this.textLinkColor, - this.textReactionColor, - this.textSystemColor, - this.textReadColor, - this.borderColor, - this.borderOnChatColor, - this.threadConnectorColor, - this.progressTrackColor, - this.progressFillColor, - this.replyIndicatorColor, - this.waveFormBarColor, - this.waveFormBarPlayingColor, - }); - - final Color? backgroundColor; - final Color? backgroundAttachmentColor; - final Color? backgroundTypingIndicatorColor; - - final Color? textColor; - final Color? textUsernameColor; - final Color? textTimestampColor; - final Color? textMentionColor; - final Color? textLinkColor; - final Color? textReactionColor; - final Color? textSystemColor; - final Color? textReadColor; - - final Color? borderColor; - final Color? borderOnChatColor; - - final Color? threadConnectorColor; - - final Color? progressTrackColor; - final Color? progressFillColor; - - final Color? replyIndicatorColor; - - final Color? waveFormBarColor; - final Color? waveFormBarPlayingColor; -} - -class _MessageThemeDefaults { - _MessageThemeDefaults({required this.context}) : _colorScheme = context.streamColorScheme; - - final BuildContext context; - final StreamColorScheme _colorScheme; - - StreamMessageStyle get incoming => StreamMessageStyle( - backgroundColor: _colorScheme.backgroundSurface, - backgroundAttachmentColor: _colorScheme.backgroundSurfaceStrong, - backgroundTypingIndicatorColor: _colorScheme.accentNeutral, - textColor: _colorScheme.textPrimary, - textUsernameColor: _colorScheme.textSecondary, - textTimestampColor: _colorScheme.textTertiary, - textMentionColor: _colorScheme.textLink, - textLinkColor: _colorScheme.textLink, - textReactionColor: _colorScheme.textSecondary, - textSystemColor: _colorScheme.textSecondary, - textReadColor: _colorScheme.accentPrimary, - borderColor: _colorScheme.borderSubtle, - borderOnChatColor: _colorScheme.borderStrong, - threadConnectorColor: _colorScheme.borderDefault, - replyIndicatorColor: _colorScheme.chrome.shade400, - progressTrackColor: _colorScheme.backgroundSurfaceStrong, - progressFillColor: _colorScheme.accentNeutral, - waveFormBarColor: _colorScheme.borderOpacityStrong, - waveFormBarPlayingColor: _colorScheme.accentPrimary, - ); - - StreamMessageStyle get outgoing => StreamMessageStyle( - backgroundColor: _colorScheme.brand.shade100, - backgroundAttachmentColor: _colorScheme.brand.shade150, - backgroundTypingIndicatorColor: _colorScheme.accentNeutral, - textColor: _colorScheme.brand.shade900, - textUsernameColor: _colorScheme.textSecondary, - textTimestampColor: _colorScheme.textTertiary, - textMentionColor: _colorScheme.textLink, - textLinkColor: _colorScheme.textLink, - textReactionColor: _colorScheme.textSecondary, - textSystemColor: _colorScheme.textSecondary, - textReadColor: _colorScheme.accentPrimary, - borderColor: _colorScheme.brand.shade100, - borderOnChatColor: _colorScheme.brand.shade300, - threadConnectorColor: _colorScheme.brand.shade150, - replyIndicatorColor: _colorScheme.brand.shade400, - progressTrackColor: _colorScheme.brand.shade200, - progressFillColor: _colorScheme.accentPrimary, - waveFormBarColor: _colorScheme.borderOpacityStrong, - waveFormBarPlayingColor: _colorScheme.accentPrimary, - ); -} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_theme.g.theme.dart deleted file mode 100644 index 94bb2fad..00000000 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_message_theme.g.theme.dart +++ /dev/null @@ -1,325 +0,0 @@ -// 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_theme.dart'; - -// ************************************************************************** -// ThemeGenGenerator -// ************************************************************************** - -mixin _$StreamMessageThemeData { - bool get canMerge => true; - - static StreamMessageThemeData? lerp( - StreamMessageThemeData? a, - StreamMessageThemeData? 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 StreamMessageThemeData( - incoming: t < 0.5 ? a.incoming : b.incoming, - outgoing: t < 0.5 ? a.outgoing : b.outgoing, - ); - } - - StreamMessageThemeData copyWith({ - StreamMessageStyle? incoming, - StreamMessageStyle? outgoing, - }) { - final _this = (this as StreamMessageThemeData); - - return StreamMessageThemeData( - incoming: incoming ?? _this.incoming, - outgoing: outgoing ?? _this.outgoing, - ); - } - - StreamMessageThemeData merge(StreamMessageThemeData? other) { - final _this = (this as StreamMessageThemeData); - - if (other == null || identical(_this, other)) { - return _this; - } - - if (!other.canMerge) { - return other; - } - - return copyWith( - incoming: _this.incoming?.merge(other.incoming) ?? other.incoming, - outgoing: _this.outgoing?.merge(other.outgoing) ?? other.outgoing, - ); - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - - if (other.runtimeType != runtimeType) { - return false; - } - - final _this = (this as StreamMessageThemeData); - final _other = (other as StreamMessageThemeData); - - return _other.incoming == _this.incoming && - _other.outgoing == _this.outgoing; - } - - @override - int get hashCode { - final _this = (this as StreamMessageThemeData); - - return Object.hash(runtimeType, _this.incoming, _this.outgoing); - } -} - -mixin _$StreamMessageStyle { - bool get canMerge => true; - - static StreamMessageStyle? lerp( - StreamMessageStyle? a, - StreamMessageStyle? 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 StreamMessageStyle( - backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), - backgroundAttachmentColor: Color.lerp( - a.backgroundAttachmentColor, - b.backgroundAttachmentColor, - t, - ), - backgroundTypingIndicatorColor: Color.lerp( - a.backgroundTypingIndicatorColor, - b.backgroundTypingIndicatorColor, - t, - ), - textColor: Color.lerp(a.textColor, b.textColor, t), - textUsernameColor: Color.lerp( - a.textUsernameColor, - b.textUsernameColor, - t, - ), - textTimestampColor: Color.lerp( - a.textTimestampColor, - b.textTimestampColor, - t, - ), - textMentionColor: Color.lerp(a.textMentionColor, b.textMentionColor, t), - textLinkColor: Color.lerp(a.textLinkColor, b.textLinkColor, t), - textReactionColor: Color.lerp( - a.textReactionColor, - b.textReactionColor, - t, - ), - textSystemColor: Color.lerp(a.textSystemColor, b.textSystemColor, t), - textReadColor: Color.lerp(a.textReadColor, b.textReadColor, t), - borderColor: Color.lerp(a.borderColor, b.borderColor, t), - borderOnChatColor: Color.lerp( - a.borderOnChatColor, - b.borderOnChatColor, - t, - ), - threadConnectorColor: Color.lerp( - a.threadConnectorColor, - b.threadConnectorColor, - t, - ), - progressTrackColor: Color.lerp( - a.progressTrackColor, - b.progressTrackColor, - t, - ), - progressFillColor: Color.lerp( - a.progressFillColor, - b.progressFillColor, - t, - ), - replyIndicatorColor: Color.lerp( - a.replyIndicatorColor, - b.replyIndicatorColor, - t, - ), - waveFormBarColor: Color.lerp(a.waveFormBarColor, b.waveFormBarColor, t), - waveFormBarPlayingColor: Color.lerp( - a.waveFormBarPlayingColor, - b.waveFormBarPlayingColor, - t, - ), - ); - } - - StreamMessageStyle copyWith({ - Color? backgroundColor, - Color? backgroundAttachmentColor, - Color? backgroundTypingIndicatorColor, - Color? textColor, - Color? textUsernameColor, - Color? textTimestampColor, - Color? textMentionColor, - Color? textLinkColor, - Color? textReactionColor, - Color? textSystemColor, - Color? textReadColor, - Color? borderColor, - Color? borderOnChatColor, - Color? threadConnectorColor, - Color? progressTrackColor, - Color? progressFillColor, - Color? replyIndicatorColor, - Color? waveFormBarColor, - Color? waveFormBarPlayingColor, - }) { - final _this = (this as StreamMessageStyle); - - return StreamMessageStyle( - backgroundColor: backgroundColor ?? _this.backgroundColor, - backgroundAttachmentColor: - backgroundAttachmentColor ?? _this.backgroundAttachmentColor, - backgroundTypingIndicatorColor: - backgroundTypingIndicatorColor ?? - _this.backgroundTypingIndicatorColor, - textColor: textColor ?? _this.textColor, - textUsernameColor: textUsernameColor ?? _this.textUsernameColor, - textTimestampColor: textTimestampColor ?? _this.textTimestampColor, - textMentionColor: textMentionColor ?? _this.textMentionColor, - textLinkColor: textLinkColor ?? _this.textLinkColor, - textReactionColor: textReactionColor ?? _this.textReactionColor, - textSystemColor: textSystemColor ?? _this.textSystemColor, - textReadColor: textReadColor ?? _this.textReadColor, - borderColor: borderColor ?? _this.borderColor, - borderOnChatColor: borderOnChatColor ?? _this.borderOnChatColor, - threadConnectorColor: threadConnectorColor ?? _this.threadConnectorColor, - progressTrackColor: progressTrackColor ?? _this.progressTrackColor, - progressFillColor: progressFillColor ?? _this.progressFillColor, - replyIndicatorColor: replyIndicatorColor ?? _this.replyIndicatorColor, - waveFormBarColor: waveFormBarColor ?? _this.waveFormBarColor, - waveFormBarPlayingColor: - waveFormBarPlayingColor ?? _this.waveFormBarPlayingColor, - ); - } - - StreamMessageStyle merge(StreamMessageStyle? other) { - final _this = (this as StreamMessageStyle); - - if (other == null || identical(_this, other)) { - return _this; - } - - if (!other.canMerge) { - return other; - } - - return copyWith( - backgroundColor: other.backgroundColor, - backgroundAttachmentColor: other.backgroundAttachmentColor, - backgroundTypingIndicatorColor: other.backgroundTypingIndicatorColor, - textColor: other.textColor, - textUsernameColor: other.textUsernameColor, - textTimestampColor: other.textTimestampColor, - textMentionColor: other.textMentionColor, - textLinkColor: other.textLinkColor, - textReactionColor: other.textReactionColor, - textSystemColor: other.textSystemColor, - textReadColor: other.textReadColor, - borderColor: other.borderColor, - borderOnChatColor: other.borderOnChatColor, - threadConnectorColor: other.threadConnectorColor, - progressTrackColor: other.progressTrackColor, - progressFillColor: other.progressFillColor, - replyIndicatorColor: other.replyIndicatorColor, - waveFormBarColor: other.waveFormBarColor, - waveFormBarPlayingColor: other.waveFormBarPlayingColor, - ); - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - - if (other.runtimeType != runtimeType) { - return false; - } - - final _this = (this as StreamMessageStyle); - final _other = (other as StreamMessageStyle); - - return _other.backgroundColor == _this.backgroundColor && - _other.backgroundAttachmentColor == _this.backgroundAttachmentColor && - _other.backgroundTypingIndicatorColor == - _this.backgroundTypingIndicatorColor && - _other.textColor == _this.textColor && - _other.textUsernameColor == _this.textUsernameColor && - _other.textTimestampColor == _this.textTimestampColor && - _other.textMentionColor == _this.textMentionColor && - _other.textLinkColor == _this.textLinkColor && - _other.textReactionColor == _this.textReactionColor && - _other.textSystemColor == _this.textSystemColor && - _other.textReadColor == _this.textReadColor && - _other.borderColor == _this.borderColor && - _other.borderOnChatColor == _this.borderOnChatColor && - _other.threadConnectorColor == _this.threadConnectorColor && - _other.progressTrackColor == _this.progressTrackColor && - _other.progressFillColor == _this.progressFillColor && - _other.replyIndicatorColor == _this.replyIndicatorColor && - _other.waveFormBarColor == _this.waveFormBarColor && - _other.waveFormBarPlayingColor == _this.waveFormBarPlayingColor; - } - - @override - int get hashCode { - final _this = (this as StreamMessageStyle); - - return Object.hash( - runtimeType, - _this.backgroundColor, - _this.backgroundAttachmentColor, - _this.backgroundTypingIndicatorColor, - _this.textColor, - _this.textUsernameColor, - _this.textTimestampColor, - _this.textMentionColor, - _this.textLinkColor, - _this.textReactionColor, - _this.textSystemColor, - _this.textReadColor, - _this.borderColor, - _this.borderOnChatColor, - _this.threadConnectorColor, - _this.progressTrackColor, - _this.progressFillColor, - _this.replyIndicatorColor, - _this.waveFormBarColor, - _this.waveFormBarPlayingColor, - ); - } -} 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 ffd5eeb9..7aa10a5e 100644 --- a/packages/stream_core_flutter/lib/src/theme/stream_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/stream_theme.dart @@ -19,7 +19,6 @@ import 'components/stream_emoji_chip_theme.dart'; import 'components/stream_jump_to_unread_button_theme.dart'; import 'components/stream_list_tile_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_playback_speed_toggle_theme.dart'; import 'components/stream_progress_bar_theme.dart'; @@ -120,7 +119,6 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { StreamJumpToUnreadButtonThemeData? jumpToUnreadButtonTheme, StreamListTileThemeData? listTileTheme, StreamMessageItemThemeData? messageItemTheme, - StreamMessageThemeData? messageTheme, StreamTextInputThemeData? textInputTheme, StreamOnlineIndicatorThemeData? onlineIndicatorTheme, StreamPlaybackSpeedToggleThemeData? playbackSpeedToggleTheme, @@ -163,7 +161,6 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { jumpToUnreadButtonTheme ??= const StreamJumpToUnreadButtonThemeData(); listTileTheme ??= const StreamListTileThemeData(); messageItemTheme ??= const StreamMessageItemThemeData(); - messageTheme ??= const StreamMessageThemeData(); textInputTheme ??= const StreamTextInputThemeData(); onlineIndicatorTheme ??= const StreamOnlineIndicatorThemeData(); playbackSpeedToggleTheme ??= const StreamPlaybackSpeedToggleThemeData(); @@ -200,7 +197,6 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { jumpToUnreadButtonTheme: jumpToUnreadButtonTheme, listTileTheme: listTileTheme, messageItemTheme: messageItemTheme, - messageTheme: messageTheme, textInputTheme: textInputTheme, onlineIndicatorTheme: onlineIndicatorTheme, playbackSpeedToggleTheme: playbackSpeedToggleTheme, @@ -251,7 +247,6 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { required this.jumpToUnreadButtonTheme, required this.listTileTheme, required this.messageItemTheme, - required this.messageTheme, required this.textInputTheme, required this.onlineIndicatorTheme, required this.playbackSpeedToggleTheme, @@ -370,9 +365,6 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { /// Provides resolver-based styling for message sub-components. final StreamMessageItemThemeData messageItemTheme; - /// The message theme for this theme. - final StreamMessageThemeData messageTheme; - /// The text input theme for this theme. final StreamTextInputThemeData textInputTheme; @@ -449,7 +441,6 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { jumpToUnreadButtonTheme: jumpToUnreadButtonTheme, listTileTheme: listTileTheme, messageItemTheme: messageItemTheme, - messageTheme: messageTheme, textInputTheme: textInputTheme, onlineIndicatorTheme: onlineIndicatorTheme, playbackSpeedToggleTheme: playbackSpeedToggleTheme, 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 fdc48da8..cf9f9800 100644 --- a/packages/stream_core_flutter/lib/src/theme/stream_theme.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/stream_theme.g.theme.dart @@ -35,7 +35,6 @@ mixin _$StreamTheme on ThemeExtension { StreamJumpToUnreadButtonThemeData? jumpToUnreadButtonTheme, StreamListTileThemeData? listTileTheme, StreamMessageItemThemeData? messageItemTheme, - StreamMessageThemeData? messageTheme, StreamTextInputThemeData? textInputTheme, StreamOnlineIndicatorThemeData? onlineIndicatorTheme, StreamPlaybackSpeedToggleThemeData? playbackSpeedToggleTheme, @@ -77,7 +76,6 @@ mixin _$StreamTheme on ThemeExtension { jumpToUnreadButtonTheme ?? _this.jumpToUnreadButtonTheme, listTileTheme: listTileTheme ?? _this.listTileTheme, messageItemTheme: messageItemTheme ?? _this.messageItemTheme, - messageTheme: messageTheme ?? _this.messageTheme, textInputTheme: textInputTheme ?? _this.textInputTheme, onlineIndicatorTheme: onlineIndicatorTheme ?? _this.onlineIndicatorTheme, playbackSpeedToggleTheme: @@ -189,7 +187,6 @@ mixin _$StreamTheme on ThemeExtension { other.messageItemTheme, t, )!, - messageTheme: t < 0.5 ? _this.messageTheme : other.messageTheme, textInputTheme: StreamTextInputThemeData.lerp( _this.textInputTheme, other.textInputTheme, @@ -284,7 +281,6 @@ mixin _$StreamTheme on ThemeExtension { _other.jumpToUnreadButtonTheme == _this.jumpToUnreadButtonTheme && _other.listTileTheme == _this.listTileTheme && _other.messageItemTheme == _this.messageItemTheme && - _other.messageTheme == _this.messageTheme && _other.textInputTheme == _this.textInputTheme && _other.onlineIndicatorTheme == _this.onlineIndicatorTheme && _other.playbackSpeedToggleTheme == _this.playbackSpeedToggleTheme && @@ -327,7 +323,6 @@ mixin _$StreamTheme on ThemeExtension { _this.jumpToUnreadButtonTheme, _this.listTileTheme, _this.messageItemTheme, - _this.messageTheme, _this.textInputTheme, _this.onlineIndicatorTheme, _this.playbackSpeedToggleTheme, 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 97c69198..7a3778aa 100644 --- a/packages/stream_core_flutter/lib/src/theme/stream_theme_extensions.dart +++ b/packages/stream_core_flutter/lib/src/theme/stream_theme_extensions.dart @@ -15,7 +15,6 @@ import 'components/stream_emoji_chip_theme.dart'; import 'components/stream_jump_to_unread_button_theme.dart'; import 'components/stream_list_tile_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_playback_speed_toggle_theme.dart'; import 'components/stream_progress_bar_theme.dart'; @@ -126,9 +125,6 @@ extension StreamThemeExtension on BuildContext { /// 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); - /// Returns the [StreamTextInputThemeData] from the nearest ancestor. StreamTextInputThemeData get streamTextInputTheme => StreamTextInputTheme.of(this); diff --git a/packages/stream_core_flutter/test/components/message_composer/goldens/ci/message_composer_attachment_reply_custom_matrix.png b/packages/stream_core_flutter/test/components/message_composer/goldens/ci/message_composer_attachment_reply_custom_matrix.png deleted file mode 100644 index e06fd90bbb2121f493a552dee9665e5e2dc18a62..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2063 zcmbuAX*k>Y7RUeAx=Iyov@UAsMGwJ?HVYt1wwh)?!|VXUkO~t3j zTAxQeDY|)D#w5CAdm5d|C_{$i5uagX_H+cG4Tix&I+lw?;O ztV{{HE#QBEg5sX9LghrZo7wRP9d*%6+)@wTKnHuOB*l97Q1C|Y&vylPH^_B;0t;lJ z%8B&*lo)Fz-j$=r@sY-K5D7s^hG|$Grs}BT;&X<1A#jhHj5BBSvDM~HZwts>R%_^m z1y&t0K@F$H$*<;3YcP_3kgWGIecI=glDm|osEekCjGl5-1o5bON~mEm57x+N*g ziZr*5sBq`&acI+KTeoeCx=@()htDn_+O?q)+fTd3;^sQTwT66cPtW>>9eoM2*5_Ql zwQ=b0{FZlnIa@JYPBhv6k|IN_;E^SzPx;qOt*Vv@&+m78AzYm(* zYQZ8{jVd6nFDhfE7^xY#3oTupK)Q9U=Z0L~Pa&ZAG~3C5by^dFecTlFMh(q>Ft$my zPS2eTxuCC{X{|6(*gegufQYPdE2-bo^XJ>jkqE&d6Dx$xH1QL6TqZRM0($i2gMT6O z&LE`mGs#@}^v_619S0jrdG260W9A;~$Q}Q)l9~U|v{fm{JSemF#(^0R3g(S4I;27 zOK{W;iwMsq`MRhDgzerhdnDpiTt9D<^2&h4P=Vl{TawU+9tDDx!OJ6gezFVQ;QmW5 z&5WAMglbHRuV_Hs?(}wv>4;u6)9nAOQ9LqB#;e^osBHe058pyP8jtVL8i<=1N`}7X zqa-A;5WVh2+N6uf|0hj&+DvRf7}O0R7DZvK)1aAv>ATZH;Xmskc*!hu*HqRlsxJDq z>9%eaWR<}ZA9$}-B@zCj@c+9)LT&W-_pOYjK0(FARIlHAY0F~lLtzf8yr z?MJ)W{OCjS`N?uONw_I45r=yqosJ7XoICW5K6N_=q9xyI=6-Gt3Qs3-1Q>L6+_ z?%O6@_uSD9mNzG;J*ii+_Zzarp2s7H8AxUW_|boA8r_+nM9iGjacol&Lq?>9z3U-_ zw|%X2_esD6`-V%5Dp%Xj?bN-?y~&^~7z2ZUu(>fM zgS4?Y*^(ja@B(H~60B-si%-v!>5C(q^IcB89ozB0VRP`_h@J6^+YMeRCQnqMDt>t` zzXfwW=c;8B!)>WYQLm0UT57=$LE#~W7jDXX7R@Miw@2$-4PC_PQ7gOjc`v?FphkN& zh_cPzK%;|Ku=36E9m7LC7WrF>CMVg?XviI#WkJtOs_neGg?N* z7Awku`{tDf@!$^S&2JkHmj!m381``BaBrCN1pfI6>RHff4DCOC(T*gD#`P4^&1Jsuq61FQ LtM&vtzodTyP;JTG diff --git a/packages/stream_core_flutter/test/components/message_composer/message_composer_attachment_reply_golden_test.dart b/packages/stream_core_flutter/test/components/message_composer/message_composer_attachment_reply_golden_test.dart index ef2044fa..cd6a5479 100644 --- a/packages/stream_core_flutter/test/components/message_composer/message_composer_attachment_reply_golden_test.dart +++ b/packages/stream_core_flutter/test/components/message_composer/message_composer_attachment_reply_golden_test.dart @@ -76,42 +76,6 @@ void main() { ], ), ); - - goldenTest( - 'renders custom theme style matrix', - fileName: 'message_composer_attachment_reply_custom_matrix', - builder: () => GoldenTestGroup( - scenarioConstraints: const BoxConstraints(maxWidth: 360), - children: [ - for (final style in ReplyStyle.values) - GoldenTestScenario( - name: style.name, - child: _buildReplyInTheme( - StreamMessageTheme( - data: const StreamMessageThemeData( - incoming: StreamMessageStyle( - backgroundColor: Colors.red, - replyIndicatorColor: Colors.green, - textColor: Colors.white, - ), - outgoing: StreamMessageStyle( - backgroundColor: Colors.blue, - replyIndicatorColor: Colors.yellow, - textColor: Colors.white, - ), - ), - child: MessageComposerReplyAttachment( - title: const Text('Reply to John Doe'), - subtitle: const Text('We had a great time during our holiday.'), - onRemovePressed: null, - style: style, - ), - ), - ), - ), - ], - ), - ); }); } From e157ddeec76d5c438b306c30bf9f8a548759707a Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 5 May 2026 16:03:06 +0200 Subject: [PATCH 2/6] feat(ui): theme composer attachments and add edit-message/unsupported variants Adds per-widget themes and StreamComponentFactory builder slots for every composer attachment, introduces edit-message and unsupported variants, and unifies the leading/trailing media slot under a themed thumbnail across reply, edit-message, and link-preview previews. - Add StreamMessageComposerEditMessageAttachment and StreamMessageComposerUnsupportedAttachment with matching themes. - Theme reply/edit-message/link-preview thumbnails via thumbnailSize/ thumbnailShape/thumbnailSide; rename media (link-preview) and trailing (reply) parameters to thumbnail. - Drop the redundant "File" segment from StreamMessageComposerMediaFileAttachment, now StreamMessageComposerMediaAttachment. - Wire all seven attachment widgets into StreamComponentFactory and expose BuildContext extensions for each new theme. - Center StreamFileTypeIcon SVG inside its render box and add a click cursor to StreamRemoveControl on web/desktop. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/app/gallery_app.directories.g.dart | 74 +++-- .../common/stream_tap_target_padding.dart | 27 +- ...sage_composer_attachment_edit_message.dart | 291 +++++++++++++++++ ...sage_composer_attachment_link_preview.dart | 168 ++++++++-- ...=> message_composer_attachment_media.dart} | 94 ++++-- .../message_composer_attachment_reply.dart | 102 +++++- ...ssage_composer_attachment_unsupported.dart | 196 ++++++++++++ .../message_composer_file_attachment.dart | 62 +++- packages/stream_core_flutter/CHANGELOG.md | 15 +- .../accessories/stream_file_type_icon.dart | 1 + .../controls/stream_remove_control.dart | 35 +- .../lib/src/components/message_composer.dart | 19 +- ...message_composer_attachment_container.dart | 117 ------- .../message_composer_file_attachment.dart | 65 ---- ...sage_composer_link_preview_attachment.dart | 99 ------ ...essage_composer_media_file_attachment.dart | 55 ---- .../message_composer_reply_attachment.dart | 110 ------- .../stream_message_composer_attachment.dart | 193 +++++++++++ ...sage_composer_edit_message_attachment.dart | 261 +++++++++++++++ ...ream_message_composer_file_attachment.dart | 220 +++++++++++++ ...sage_composer_link_preview_attachment.dart | 283 ++++++++++++++++ ...eam_message_composer_media_attachment.dart | 187 +++++++++++ ...eam_message_composer_reply_attachment.dart | 301 ++++++++++++++++++ ...ssage_composer_unsupported_attachment.dart | 176 ++++++++++ .../src/factory/stream_component_factory.dart | 63 ++++ .../stream_component_factory.g.theme.dart | 93 ++++++ .../stream_core_flutter/lib/src/theme.dart | 7 + ...eam_message_composer_attachment_theme.dart | 134 ++++++++ ...age_composer_attachment_theme.g.theme.dart | 114 +++++++ ...omposer_edit_message_attachment_theme.dart | 155 +++++++++ ...edit_message_attachment_theme.g.theme.dart | 151 +++++++++ ...essage_composer_file_attachment_theme.dart | 134 ++++++++ ...omposer_file_attachment_theme.g.theme.dart | 116 +++++++ ...omposer_link_preview_attachment_theme.dart | 158 +++++++++ ...link_preview_attachment_theme.g.theme.dart | 145 +++++++++ ...ssage_composer_media_attachment_theme.dart | 120 +++++++ ...mposer_media_attachment_theme.g.theme.dart | 88 +++++ ...ssage_composer_reply_attachment_theme.dart | 164 ++++++++++ ...mposer_reply_attachment_theme.g.theme.dart | 150 +++++++++ ...composer_unsupported_attachment_theme.dart | 122 +++++++ ..._unsupported_attachment_theme.g.theme.dart | 105 ++++++ .../lib/src/theme/stream_theme.dart | 63 ++++ .../lib/src/theme/stream_theme.g.theme.dart | 97 ++++++ .../src/theme/stream_theme_extensions.dart | 35 ++ ...r_attachment_link_preview_golden_test.dart | 114 ++++--- ...composer_attachment_reply_golden_test.dart | 24 +- 46 files changed, 4863 insertions(+), 640 deletions(-) create mode 100644 apps/design_system_gallery/lib/components/message_composer/message_composer_attachment_edit_message.dart rename apps/design_system_gallery/lib/components/message_composer/{message_composer_attachment_media_file.dart => message_composer_attachment_media.dart} (64%) create mode 100644 apps/design_system_gallery/lib/components/message_composer/message_composer_attachment_unsupported.dart delete mode 100644 packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_attachment_container.dart delete mode 100644 packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_file_attachment.dart delete mode 100644 packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_link_preview_attachment.dart delete mode 100644 packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_media_file_attachment.dart delete mode 100644 packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_reply_attachment.dart create mode 100644 packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_attachment.dart create mode 100644 packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_edit_message_attachment.dart create mode 100644 packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_file_attachment.dart create mode 100644 packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_link_preview_attachment.dart create mode 100644 packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_media_attachment.dart create mode 100644 packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_reply_attachment.dart create mode 100644 packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_unsupported_attachment.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_attachment_theme.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_attachment_theme.g.theme.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_edit_message_attachment_theme.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_edit_message_attachment_theme.g.theme.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_file_attachment_theme.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_file_attachment_theme.g.theme.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_link_preview_attachment_theme.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_link_preview_attachment_theme.g.theme.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_media_attachment_theme.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_media_attachment_theme.g.theme.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_reply_attachment_theme.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_reply_attachment_theme.g.theme.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_unsupported_attachment_theme.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_unsupported_attachment_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 a9a5b671..e6c4db4d 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 @@ -98,12 +98,16 @@ import 'package:design_system_gallery/components/message/stream_message_text.dar as _design_system_gallery_components_message_stream_message_text; 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_edit_message.dart' + as _design_system_gallery_components_message_composer_message_composer_attachment_edit_message; import 'package:design_system_gallery/components/message_composer/message_composer_attachment_link_preview.dart' as _design_system_gallery_components_message_composer_message_composer_attachment_link_preview; -import 'package:design_system_gallery/components/message_composer/message_composer_attachment_media_file.dart' - as _design_system_gallery_components_message_composer_message_composer_attachment_media_file; +import 'package:design_system_gallery/components/message_composer/message_composer_attachment_media.dart' + as _design_system_gallery_components_message_composer_message_composer_attachment_media; 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/message_composer/message_composer_attachment_unsupported.dart' + as _design_system_gallery_components_message_composer_message_composer_attachment_unsupported; import 'package:design_system_gallery/components/message_composer/message_composer_file_attachment.dart' as _design_system_gallery_components_message_composer_message_composer_file_attachment; import 'package:design_system_gallery/components/reaction/stream_reaction_picker.dart' @@ -996,24 +1000,58 @@ final directories = <_widgetbook.WidgetbookNode>[ name: 'Message Composer', children: [ _widgetbook.WidgetbookComponent( - name: 'MessageComposerFileAttachment', + name: 'StreamCoreMessageComposer', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: + _design_system_gallery_components_message_composer_message_composer + .buildStreamMessageComposerPlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Real-world Example', + builder: + _design_system_gallery_components_message_composer_message_composer + .buildStreamMessageComposerExample, + ), + ], + ), + _widgetbook.WidgetbookComponent( + name: 'StreamMessageComposerEditMessageAttachment', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: + _design_system_gallery_components_message_composer_message_composer_attachment_edit_message + .buildMessageComposerAttachmentEditMessagePlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Showcase', + builder: + _design_system_gallery_components_message_composer_message_composer_attachment_edit_message + .buildMessageComposerAttachmentEditMessageShowcase, + ), + ], + ), + _widgetbook.WidgetbookComponent( + name: 'StreamMessageComposerFileAttachment', useCases: [ _widgetbook.WidgetbookUseCase( name: 'Playground', builder: _design_system_gallery_components_message_composer_message_composer_file_attachment - .buildMessageComposerFileAttachmentPlayground, + .buildStreamMessageComposerFileAttachmentPlayground, ), _widgetbook.WidgetbookUseCase( name: 'Showcase', builder: _design_system_gallery_components_message_composer_message_composer_file_attachment - .buildMessageComposerFileAttachmentShowcase, + .buildStreamMessageComposerFileAttachmentShowcase, ), ], ), _widgetbook.WidgetbookComponent( - name: 'MessageComposerLinkPreviewAttachment', + name: 'StreamMessageComposerLinkPreviewAttachment', useCases: [ _widgetbook.WidgetbookUseCase( name: 'Playground', @@ -1030,24 +1068,24 @@ final directories = <_widgetbook.WidgetbookNode>[ ], ), _widgetbook.WidgetbookComponent( - name: 'MessageComposerMediaFileAttachment', + name: 'StreamMessageComposerMediaAttachment', useCases: [ _widgetbook.WidgetbookUseCase( name: 'Playground', builder: - _design_system_gallery_components_message_composer_message_composer_attachment_media_file - .buildMessageComposerAttachmentMediaFilePlayground, + _design_system_gallery_components_message_composer_message_composer_attachment_media + .buildMessageComposerAttachmentMediaPlayground, ), _widgetbook.WidgetbookUseCase( name: 'Showcase', builder: - _design_system_gallery_components_message_composer_message_composer_attachment_media_file - .buildMessageComposerAttachmentMediaFileShowcase, + _design_system_gallery_components_message_composer_message_composer_attachment_media + .buildMessageComposerAttachmentMediaShowcase, ), ], ), _widgetbook.WidgetbookComponent( - name: 'MessageComposerReplyAttachment', + name: 'StreamMessageComposerReplyAttachment', useCases: [ _widgetbook.WidgetbookUseCase( name: 'Playground', @@ -1064,19 +1102,19 @@ final directories = <_widgetbook.WidgetbookNode>[ ], ), _widgetbook.WidgetbookComponent( - name: 'StreamCoreMessageComposer', + name: 'StreamMessageComposerUnsupportedAttachment', useCases: [ _widgetbook.WidgetbookUseCase( name: 'Playground', builder: - _design_system_gallery_components_message_composer_message_composer - .buildStreamMessageComposerPlayground, + _design_system_gallery_components_message_composer_message_composer_attachment_unsupported + .buildMessageComposerAttachmentUnsupportedPlayground, ), _widgetbook.WidgetbookUseCase( - name: 'Real-world Example', + name: 'Showcase', builder: - _design_system_gallery_components_message_composer_message_composer - .buildStreamMessageComposerExample, + _design_system_gallery_components_message_composer_message_composer_attachment_unsupported + .buildMessageComposerAttachmentUnsupportedShowcase, ), ], ), diff --git a/apps/design_system_gallery/lib/components/common/stream_tap_target_padding.dart b/apps/design_system_gallery/lib/components/common/stream_tap_target_padding.dart index d53a8061..0d9131fc 100644 --- a/apps/design_system_gallery/lib/components/common/stream_tap_target_padding.dart +++ b/apps/design_system_gallery/lib/components/common/stream_tap_target_padding.dart @@ -96,18 +96,21 @@ class _InteractiveDemoState extends State<_InteractiveDemo> { style: widget.showHitRegion ? BorderStyle.solid : BorderStyle.none, ), ), - child: StreamTapTargetPadding( - minSize: widget.minSize, - alignment: widget.alignment, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => setState(() => _taps++), - child: Container( - width: 20, - height: 20, - decoration: BoxDecoration( - color: colorScheme.accentPrimary, - shape: BoxShape.circle, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: StreamTapTargetPadding( + minSize: widget.minSize, + alignment: widget.alignment, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => setState(() => _taps++), + child: Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: colorScheme.accentPrimary, + shape: BoxShape.circle, + ), ), ), ), diff --git a/apps/design_system_gallery/lib/components/message_composer/message_composer_attachment_edit_message.dart b/apps/design_system_gallery/lib/components/message_composer/message_composer_attachment_edit_message.dart new file mode 100644 index 00000000..fe5d9e31 --- /dev/null +++ b/apps/design_system_gallery/lib/components/message_composer/message_composer_attachment_edit_message.dart @@ -0,0 +1,291 @@ +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: StreamMessageComposerEditMessageAttachment, + path: '[Components]/Message Composer', +) +Widget buildMessageComposerAttachmentEditMessagePlayground(BuildContext context) { + final title = context.knobs.string( + label: 'Title', + initialValue: 'Edit message', + description: 'The title line, typically a localized "Edit message" label.', + ); + + final subtitle = context.knobs.string( + label: 'Subtitle', + initialValue: 'See you at 9!', + description: 'The subtitle line, typically the body of the message being edited.', + ); + + final showThumbnail = context.knobs.boolean( + label: 'Show Thumbnail', + description: 'Toggle a trailing image thumbnail.', + ); + + final showRemoveButton = context.knobs.boolean( + label: 'Show Remove Button', + initialValue: true, + description: 'Toggle the remove attachment control.', + ); + + final maxWidth = context.knobs.double.slider( + label: 'Parent Max Width', + initialValue: 360, + min: 200, + max: 600, + description: 'Bounds the parent width. The preview fills this width.', + ); + + return Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth), + child: StreamMessageComposerEditMessageAttachment( + title: Text(title), + subtitle: Text(subtitle), + thumbnail: showThumbnail ? _Thumbnail() : null, + onRemovePressed: showRemoveButton ? () {} : null, + ), + ), + ); +} + +// ============================================================================= +// Showcase +// ============================================================================= + +@widgetbook.UseCase( + name: 'Showcase', + type: StreamMessageComposerEditMessageAttachment, + path: '[Components]/Message Composer', +) +Widget buildMessageComposerAttachmentEditMessageShowcase(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 32, + children: [ + _BasicSection(), + _ThumbnailSection(), + _FileThumbnailSection(), + _ConstrainedSection(), + ], + ), + ); +} + +// ============================================================================= +// Showcase Sections +// ============================================================================= + +class _BasicSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + return _Section( + label: 'BASIC', + description: 'Edit-message preview with brand-tinted background and indicator.', + children: [ + _ExampleCard( + label: 'Short message', + child: StreamMessageComposerEditMessageAttachment( + title: const Text('Edit message'), + subtitle: const Text('See you at 9!'), + onRemovePressed: () {}, + ), + ), + _ExampleCard( + label: 'Long message', + child: StreamMessageComposerEditMessageAttachment( + title: const Text('Edit message'), + subtitle: const Text('We had a great time during our holiday and the views were stunning all week.'), + onRemovePressed: () {}, + ), + ), + ], + ); + } +} + +class _ThumbnailSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + return _Section( + label: 'WITH THUMBNAIL', + description: 'A trailing thumbnail for edits to messages with attached media.', + children: [ + _ExampleCard( + label: 'With image thumbnail', + child: StreamMessageComposerEditMessageAttachment( + title: const Text('Edit message'), + subtitle: const Text('Here is the document you requested.'), + thumbnail: _Thumbnail(), + onRemovePressed: () {}, + ), + ), + ], + ); + } +} + +class _FileThumbnailSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + return _Section( + label: 'WITH FILE ICON', + description: 'A file-type icon as the thumbnail for editing non-media attachments.', + children: [ + _ExampleCard( + label: 'With PDF', + child: StreamMessageComposerEditMessageAttachment( + title: const Text('Edit message'), + subtitle: const Text('annual_report.pdf'), + thumbnail: StreamFileTypeIcon(type: StreamFileType.pdf), + onRemovePressed: () {}, + ), + ), + _ExampleCard( + label: 'With spreadsheet', + child: StreamMessageComposerEditMessageAttachment( + title: const Text('Edit message'), + subtitle: const Text('project_budget.xlsx'), + thumbnail: StreamFileTypeIcon(type: StreamFileType.spreadsheet), + onRemovePressed: () {}, + ), + ), + ], + ); + } +} + +class _ConstrainedSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + return _Section( + label: 'CONSTRAINED MAX WIDTH', + description: + 'When a parent bounds the width, the message body ellipsizes on a single ' + 'line rather than wrap.', + children: [ + _ExampleCard( + label: 'Bounded to 280', + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 280), + child: StreamMessageComposerEditMessageAttachment( + title: const Text('Edit message'), + subtitle: const Text( + 'A long message body that exceeds the available width and ellipsizes.', + ), + onRemovePressed: () {}, + ), + ), + ), + ], + ); + } +} + +// ============================================================================= +// Helper Widgets +// ============================================================================= + +class _Thumbnail extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Container( + width: 40, + height: 40, + decoration: BoxDecoration( + borderRadius: BorderRadius.all(context.streamRadius.md), + image: const DecorationImage( + image: AssetImage('assets/attachment_image.png'), + fit: BoxFit.cover, + ), + ), + ); + } +} + +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: [ + Text( + label, + style: context.streamTextTheme.metadataEmphasis.copyWith( + letterSpacing: 1.2, + color: colorScheme.accentPrimary, + ), + ), + if (description case final desc?) + Text(desc, style: context.streamTextTheme.metadataDefault.copyWith(color: colorScheme.textTertiary)), + ], + ), + ...children, + ], + ); + } +} + +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: context.streamTextTheme.metadataEmphasis.copyWith(color: colorScheme.textSecondary), + ), + child, + ], + ), + ); + } +} diff --git a/apps/design_system_gallery/lib/components/message_composer/message_composer_attachment_link_preview.dart b/apps/design_system_gallery/lib/components/message_composer/message_composer_attachment_link_preview.dart index 67e83d09..e9221fb6 100644 --- a/apps/design_system_gallery/lib/components/message_composer/message_composer_attachment_link_preview.dart +++ b/apps/design_system_gallery/lib/components/message_composer/message_composer_attachment_link_preview.dart @@ -9,7 +9,7 @@ import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; @widgetbook.UseCase( name: 'Playground', - type: MessageComposerLinkPreviewAttachment, + type: StreamMessageComposerLinkPreviewAttachment, path: '[Components]/Message Composer', ) Widget buildMessageComposerAttachmentLinkPreviewPlayground(BuildContext context) { @@ -26,12 +26,12 @@ Widget buildMessageComposerAttachmentLinkPreviewPlayground(BuildContext context) final url = context.knobs.string( label: 'URL', initialValue: 'https://getstream.io/chat/docs/', - description: 'The link URL displayed in the preview.', + description: 'The link URL shown in the caption row.', ); - final showImage = context.knobs.boolean( - label: 'Show Image', + final showMedia = context.knobs.boolean( + label: 'Show Media', initialValue: true, - description: 'Toggle the link preview thumbnail image.', + description: 'Toggle the link preview thumbnail media.', ); final showRemoveButton = context.knobs.boolean( label: 'Show Remove Button', @@ -39,14 +39,24 @@ Widget buildMessageComposerAttachmentLinkPreviewPlayground(BuildContext context) description: 'Toggle the remove attachment control.', ); + final maxWidth = context.knobs.double.slider( + label: 'Parent Max Width', + initialValue: 360, + min: 200, + max: 600, + description: + 'Bounds the parent width. Values below 290 force the preview to shrink ' + 'below its natural minimum.', + ); + return Center( child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 360), - child: MessageComposerLinkPreviewAttachment( - title: title.isEmpty ? null : title, - subtitle: subtitle.isEmpty ? null : subtitle, - url: url.isEmpty ? null : url, - image: showImage ? const AssetImage('assets/attachment_image.png') : null, + constraints: BoxConstraints(maxWidth: maxWidth), + child: StreamMessageComposerLinkPreviewAttachment( + title: title.isEmpty ? null : Text(title), + subtitle: subtitle.isEmpty ? null : Text(subtitle), + caption: url.isEmpty ? null : _UrlCaption(url: url), + thumbnail: showMedia ? const Image(image: AssetImage('assets/attachment_image.png'), fit: BoxFit.cover) : null, onRemovePressed: showRemoveButton ? () {} : null, ), ), @@ -59,7 +69,7 @@ Widget buildMessageComposerAttachmentLinkPreviewPlayground(BuildContext context) @widgetbook.UseCase( name: 'Showcase', - type: MessageComposerLinkPreviewAttachment, + type: StreamMessageComposerLinkPreviewAttachment, path: '[Components]/Message Composer', ) Widget buildMessageComposerAttachmentLinkPreviewShowcase(BuildContext context) { @@ -71,6 +81,8 @@ Widget buildMessageComposerAttachmentLinkPreviewShowcase(BuildContext context) { children: [ _FullPreviewSection(), _PartialPreviewSection(), + _MediaCustomizationSection(), + _ConstrainedSection(), ], ), ); @@ -83,17 +95,17 @@ Widget buildMessageComposerAttachmentLinkPreviewShowcase(BuildContext context) { class _FullPreviewSection extends StatelessWidget { @override Widget build(BuildContext context) { - return const _Section( + return _Section( label: 'FULL PREVIEW', description: 'All fields populated: image, title, subtitle, and URL.', children: [ _ExampleCard( label: 'Complete link preview', - child: MessageComposerLinkPreviewAttachment( - title: 'Stream Chat Flutter SDK', - subtitle: 'Build real-time chat with our powerful Flutter SDK.', - url: 'https://getstream.io/chat/sdk/flutter/', - image: AssetImage('assets/attachment_image.png'), + child: StreamMessageComposerLinkPreviewAttachment( + title: const Text('Stream Chat Flutter SDK'), + subtitle: const Text('Build real-time chat with our powerful Flutter SDK.'), + caption: const _UrlCaption(url: 'https://getstream.io/chat/sdk/flutter/'), + thumbnail: const Image(image: AssetImage('assets/attachment_image.png'), fit: BoxFit.cover), ), ), ], @@ -104,29 +116,103 @@ class _FullPreviewSection extends StatelessWidget { class _PartialPreviewSection extends StatelessWidget { @override Widget build(BuildContext context) { - return const _Section( + return _Section( label: 'PARTIAL PREVIEWS', - description: 'Each field is optional. The layout adapts to available content.', + description: 'Each field is optional. Null fields are simply omitted from the layout.', + children: [ + _ExampleCard( + label: 'Title + caption only', + child: StreamMessageComposerLinkPreviewAttachment( + title: const Text('Flutter Documentation'), + caption: const _UrlCaption(url: 'https://docs.flutter.dev'), + ), + ), + _ExampleCard( + label: 'Caption only', + child: StreamMessageComposerLinkPreviewAttachment( + caption: const _UrlCaption(url: 'https://getstream.io'), + ), + ), + _ExampleCard( + label: 'Media + title + subtitle (no caption)', + child: StreamMessageComposerLinkPreviewAttachment( + title: const Text('Beautiful Landscapes'), + subtitle: const Text('A collection of stunning nature photography.'), + thumbnail: const Image(image: AssetImage('assets/attachment_image.png'), fit: BoxFit.cover), + ), + ), + ], + ); + } +} + +class _MediaCustomizationSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + return _Section( + label: 'MEDIA CUSTOMIZATION', + description: + 'The thumbnail size, shape, and border side are themable. Defaults to a 40×40 ' + 'rounded-superellipse with no border.', children: [ _ExampleCard( - label: 'Title + URL only', - child: MessageComposerLinkPreviewAttachment( - title: 'Flutter Documentation', - url: 'https://docs.flutter.dev', + label: 'Larger 56×56 thumbnail', + child: StreamMessageComposerLinkPreviewAttachment( + title: const Text('Stream Chat Flutter SDK'), + subtitle: const Text('Build real-time chat with our powerful Flutter SDK.'), + caption: const _UrlCaption(url: 'https://getstream.io/chat/sdk/flutter/'), + thumbnail: const Image(image: AssetImage('assets/attachment_image.png'), fit: BoxFit.cover), + style: const StreamMessageComposerLinkPreviewAttachmentThemeData(thumbnailSize: Size.square(56)), ), ), _ExampleCard( - label: 'URL only', - child: MessageComposerLinkPreviewAttachment( - url: 'https://getstream.io', + label: 'Circular thumbnail', + child: StreamMessageComposerLinkPreviewAttachment( + title: const Text('Avatar-style favicon'), + subtitle: const Text('Custom shape via thumbnailShape.'), + caption: const _UrlCaption(url: 'https://getstream.io'), + thumbnail: const Image(image: AssetImage('assets/attachment_image.png'), fit: BoxFit.cover), + style: const StreamMessageComposerLinkPreviewAttachmentThemeData(thumbnailShape: CircleBorder()), ), ), _ExampleCard( - label: 'Image + title + subtitle (no URL)', - child: MessageComposerLinkPreviewAttachment( - title: 'Beautiful Landscapes', - subtitle: 'A collection of stunning nature photography.', - image: AssetImage('assets/attachment_image.png'), + label: 'With media border', + child: StreamMessageComposerLinkPreviewAttachment( + title: const Text('Bordered thumbnail'), + subtitle: const Text('Side composed onto the shape via thumbnailSide.'), + caption: const _UrlCaption(url: 'https://getstream.io'), + thumbnail: const Image(image: AssetImage('assets/attachment_image.png'), fit: BoxFit.cover), + style: StreamMessageComposerLinkPreviewAttachmentThemeData( + thumbnailSide: BorderSide(color: colorScheme.borderDefault), + ), + ), + ), + ], + ); + } +} + +class _ConstrainedSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + return _Section( + label: 'CONSTRAINED MAX WIDTH', + description: + 'The preview targets a minimum width of 290 so content has room to breathe. When a ' + 'parent bounds the width, the preview shrinks to fit and long text ellipsizes on a ' + 'single line rather than wrap.', + children: [ + _ExampleCard( + label: 'Bounded to 280 (ellipsizes)', + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 280), + child: StreamMessageComposerLinkPreviewAttachment( + title: const Text('A very long page title that will not fit on a single line at this width'), + subtitle: const Text('And an even longer description that also exceeds the available space.'), + caption: const _UrlCaption(url: 'https://getstream.io/chat/sdk/flutter/long-path/another-segment'), + thumbnail: const Image(image: AssetImage('assets/attachment_image.png'), fit: BoxFit.cover), + ), ), ), ], @@ -138,6 +224,24 @@ class _PartialPreviewSection extends StatelessWidget { // Helper Widgets // ============================================================================= +class _UrlCaption extends StatelessWidget { + const _UrlCaption({required this.url}); + + final String url; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + spacing: 4, + children: [ + Icon(context.streamIcons.link, size: 12), + Flexible(child: Text(url)), + ], + ); + } +} + class _Section extends StatelessWidget { const _Section({ required this.label, diff --git a/apps/design_system_gallery/lib/components/message_composer/message_composer_attachment_media_file.dart b/apps/design_system_gallery/lib/components/message_composer/message_composer_attachment_media.dart similarity index 64% rename from apps/design_system_gallery/lib/components/message_composer/message_composer_attachment_media_file.dart rename to apps/design_system_gallery/lib/components/message_composer/message_composer_attachment_media.dart index d88b64f6..1c1cdf26 100644 --- a/apps/design_system_gallery/lib/components/message_composer/message_composer_attachment_media_file.dart +++ b/apps/design_system_gallery/lib/components/message_composer/message_composer_attachment_media.dart @@ -9,10 +9,18 @@ import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; @widgetbook.UseCase( name: 'Playground', - type: MessageComposerMediaFileAttachment, + type: StreamMessageComposerMediaAttachment, path: '[Components]/Message Composer', ) -Widget buildMessageComposerAttachmentMediaFilePlayground(BuildContext context) { +Widget buildMessageComposerAttachmentMediaPlayground(BuildContext context) { + final size = context.knobs.double.slider( + label: 'Thumbnail Size', + initialValue: 72, + min: 48, + max: 160, + description: 'The square thumbnail edge length, in logical pixels.', + ); + final showBadge = context.knobs.boolean( label: 'Show Media Badge', description: 'Toggle a source badge overlay (e.g. Giphy).', @@ -34,12 +42,13 @@ Widget buildMessageComposerAttachmentMediaFilePlayground(BuildContext context) { ); return Center( - child: MessageComposerMediaFileAttachment.image( - image: const AssetImage('assets/attachment_image.png'), + child: StreamMessageComposerMediaAttachment( onRemovePressed: showRemoveButton ? () {} : null, mediaBadge: badgeType != null ? StreamMediaBadge(type: badgeType, duration: const Duration(minutes: 2, seconds: 34)) : null, + style: StreamMessageComposerMediaAttachmentThemeData(size: Size.square(size)), + child: const Image(image: AssetImage('assets/attachment_image.png'), fit: BoxFit.cover), ), ); } @@ -50,10 +59,10 @@ Widget buildMessageComposerAttachmentMediaFilePlayground(BuildContext context) { @widgetbook.UseCase( name: 'Showcase', - type: MessageComposerMediaFileAttachment, + type: StreamMessageComposerMediaAttachment, path: '[Components]/Message Composer', ) -Widget buildMessageComposerAttachmentMediaFileShowcase(BuildContext context) { +Widget buildMessageComposerAttachmentMediaShowcase(BuildContext context) { return SingleChildScrollView( padding: const EdgeInsets.all(24), child: Column( @@ -61,6 +70,7 @@ Widget buildMessageComposerAttachmentMediaFileShowcase(BuildContext context) { spacing: 32, children: [ _BasicSection(), + _SizeSection(), _BadgeSection(), _CompositionSection(), ], @@ -81,16 +91,58 @@ class _BasicSection extends StatelessWidget { children: [ _ExampleCard( label: 'Image attachment', - child: MessageComposerMediaFileAttachment.image( - image: const AssetImage('assets/attachment_image.png'), + child: StreamMessageComposerMediaAttachment( onRemovePressed: () {}, + child: const Image(image: AssetImage('assets/attachment_image.png'), fit: BoxFit.cover), ), ), _ExampleCard( label: 'Without remove button', - child: MessageComposerMediaFileAttachment.image( - image: const AssetImage('assets/attachment_image.png'), - onRemovePressed: null, + child: StreamMessageComposerMediaAttachment( + child: const Image(image: AssetImage('assets/attachment_image.png'), fit: BoxFit.cover), + ), + ), + ], + ); + } +} + +class _SizeSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + return _Section( + label: 'SIZE', + description: 'Override the thumbnail size via the per-instance style.', + children: [ + _ExampleCard( + label: 'Small (56), Default (72), Medium (96), Large (128)', + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.end, + spacing: spacing.sm, + runSpacing: spacing.sm, + children: [ + StreamMessageComposerMediaAttachment( + onRemovePressed: () {}, + style: const StreamMessageComposerMediaAttachmentThemeData(size: Size.square(56)), + child: const Image(image: AssetImage('assets/attachment_image.png'), fit: BoxFit.cover), + ), + StreamMessageComposerMediaAttachment( + onRemovePressed: () {}, + child: const Image(image: AssetImage('assets/attachment_image.png'), fit: BoxFit.cover), + ), + StreamMessageComposerMediaAttachment( + onRemovePressed: () {}, + style: const StreamMessageComposerMediaAttachmentThemeData(size: Size.square(96)), + child: const Image(image: AssetImage('assets/attachment_image.png'), fit: BoxFit.cover), + ), + StreamMessageComposerMediaAttachment( + onRemovePressed: () {}, + style: const StreamMessageComposerMediaAttachmentThemeData(size: Size.square(128)), + child: const Image(image: AssetImage('assets/attachment_image.png'), fit: BoxFit.cover), + ), + ], ), ), ], @@ -107,24 +159,24 @@ class _BadgeSection extends StatelessWidget { children: [ _ExampleCard( label: 'Video badge', - child: MessageComposerMediaFileAttachment.image( - image: const AssetImage('assets/attachment_image.png'), + child: StreamMessageComposerMediaAttachment( onRemovePressed: () {}, mediaBadge: const StreamMediaBadge( type: MediaBadgeType.video, duration: Duration(minutes: 1, seconds: 42), ), + child: const Image(image: AssetImage('assets/attachment_image.png'), fit: BoxFit.cover), ), ), _ExampleCard( label: 'Audio badge', - child: MessageComposerMediaFileAttachment.image( - image: const AssetImage('assets/attachment_image.png'), + child: StreamMessageComposerMediaAttachment( onRemovePressed: () {}, mediaBadge: const StreamMediaBadge( type: MediaBadgeType.audio, duration: Duration(seconds: 30), ), + child: const Image(image: AssetImage('assets/attachment_image.png'), fit: BoxFit.cover), ), ), ], @@ -148,23 +200,23 @@ class _CompositionSection extends StatelessWidget { child: ListView( scrollDirection: Axis.horizontal, children: [ - MessageComposerMediaFileAttachment.image( - image: const AssetImage('assets/attachment_image.png'), + StreamMessageComposerMediaAttachment( onRemovePressed: () {}, + child: const Image(image: AssetImage('assets/attachment_image.png'), fit: BoxFit.cover), ), SizedBox(width: spacing.xxs), - MessageComposerMediaFileAttachment.image( - image: const AssetImage('assets/attachment_image.png'), + StreamMessageComposerMediaAttachment( onRemovePressed: () {}, mediaBadge: const StreamMediaBadge( type: MediaBadgeType.video, duration: Duration(minutes: 3, seconds: 15), ), + child: const Image(image: AssetImage('assets/attachment_image.png'), fit: BoxFit.cover), ), SizedBox(width: spacing.xxs), - MessageComposerMediaFileAttachment.image( - image: const AssetImage('assets/attachment_image.png'), + StreamMessageComposerMediaAttachment( onRemovePressed: () {}, + child: const Image(image: AssetImage('assets/attachment_image.png'), fit: BoxFit.cover), ), ], ), diff --git a/apps/design_system_gallery/lib/components/message_composer/message_composer_attachment_reply.dart b/apps/design_system_gallery/lib/components/message_composer/message_composer_attachment_reply.dart index 08449bd3..b5f86d9d 100644 --- a/apps/design_system_gallery/lib/components/message_composer/message_composer_attachment_reply.dart +++ b/apps/design_system_gallery/lib/components/message_composer/message_composer_attachment_reply.dart @@ -9,7 +9,7 @@ import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; @widgetbook.UseCase( name: 'Playground', - type: MessageComposerReplyAttachment, + type: StreamMessageComposerReplyAttachment, path: '[Components]/Message Composer', ) Widget buildMessageComposerAttachmentReplyPlayground(BuildContext context) { @@ -25,10 +25,10 @@ Widget buildMessageComposerAttachmentReplyPlayground(BuildContext context) { description: 'The subtitle line, typically the message preview.', ); - final style = context.knobs.object.dropdown( + final style = context.knobs.object.dropdown( label: 'Style', - options: ReplyStyle.values, - initialOption: ReplyStyle.incoming, + options: StreamReplyDirection.values, + initialOption: StreamReplyDirection.incoming, labelBuilder: (option) => option.name, description: 'Incoming uses left-hand bar and incoming colors; outgoing uses right-hand bar and outgoing colors.', ); @@ -44,15 +44,23 @@ Widget buildMessageComposerAttachmentReplyPlayground(BuildContext context) { description: 'Toggle the remove attachment control.', ); + final maxWidth = context.knobs.double.slider( + label: 'Parent Max Width', + initialValue: 360, + min: 200, + max: 600, + description: 'Bounds the parent width. The preview fills this width.', + ); + return Center( child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 360), - child: MessageComposerReplyAttachment( + constraints: BoxConstraints(maxWidth: maxWidth), + child: StreamMessageComposerReplyAttachment( title: Text(title), subtitle: Text(subtitle), - trailing: showThumbnail ? _Thumbnail() : null, + thumbnail: showThumbnail ? _Thumbnail() : null, onRemovePressed: showRemoveButton ? () {} : null, - style: style, + direction: style, ), ), ); @@ -64,7 +72,7 @@ Widget buildMessageComposerAttachmentReplyPlayground(BuildContext context) { @widgetbook.UseCase( name: 'Showcase', - type: MessageComposerReplyAttachment, + type: StreamMessageComposerReplyAttachment, path: '[Components]/Message Composer', ) Widget buildMessageComposerAttachmentReplyShowcase(BuildContext context) { @@ -76,6 +84,8 @@ Widget buildMessageComposerAttachmentReplyShowcase(BuildContext context) { children: [ _ReplyStyleSection(), _ThumbnailSection(), + _FileThumbnailSection(), + _ConstrainedSection(), ], ), ); @@ -94,7 +104,7 @@ class _ReplyStyleSection extends StatelessWidget { children: [ _ExampleCard( label: 'Incoming', - child: MessageComposerReplyAttachment( + child: StreamMessageComposerReplyAttachment( title: const Text('Reply to Alice'), subtitle: const Text('Did you see the sunset yesterday?'), onRemovePressed: () {}, @@ -102,11 +112,11 @@ class _ReplyStyleSection extends StatelessWidget { ), _ExampleCard( label: 'Outgoing', - child: MessageComposerReplyAttachment( + child: StreamMessageComposerReplyAttachment( title: const Text('Reply to You'), subtitle: const Text('Sure, I can help with that!'), onRemovePressed: () {}, - style: ReplyStyle.outgoing, + direction: StreamReplyDirection.outgoing, ), ), ], @@ -123,21 +133,79 @@ class _ThumbnailSection extends StatelessWidget { children: [ _ExampleCard( label: 'Incoming with image', - child: MessageComposerReplyAttachment( + child: StreamMessageComposerReplyAttachment( title: const Text('Reply to Bob'), subtitle: const Text('Check out this photo from our trip!'), - trailing: _Thumbnail(), + thumbnail: _Thumbnail(), onRemovePressed: () {}, ), ), _ExampleCard( label: 'Outgoing with image', - child: MessageComposerReplyAttachment( + child: StreamMessageComposerReplyAttachment( title: const Text('Reply to You'), subtitle: const Text('Here is the document you requested.'), - trailing: _Thumbnail(), + thumbnail: _Thumbnail(), + onRemovePressed: () {}, + direction: StreamReplyDirection.outgoing, + ), + ), + ], + ); + } +} + +class _FileThumbnailSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + return _Section( + label: 'WITH FILE ICON', + description: 'A file-type icon as the thumbnail for replies to non-media attachments.', + children: [ + _ExampleCard( + label: 'Incoming with PDF', + child: StreamMessageComposerReplyAttachment( + title: const Text('Reply to Alice'), + subtitle: const Text('annual_report.pdf'), + thumbnail: StreamFileTypeIcon(type: StreamFileType.pdf), onRemovePressed: () {}, - style: ReplyStyle.outgoing, + ), + ), + _ExampleCard( + label: 'Outgoing with spreadsheet', + child: StreamMessageComposerReplyAttachment( + title: const Text('Reply to You'), + subtitle: const Text('project_budget.xlsx'), + thumbnail: StreamFileTypeIcon(type: StreamFileType.spreadsheet), + onRemovePressed: () {}, + direction: StreamReplyDirection.outgoing, + ), + ), + ], + ); + } +} + +class _ConstrainedSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + return _Section( + label: 'CONSTRAINED MAX WIDTH', + description: + 'When a parent bounds the width, long titles and subtitles ellipsize on a ' + 'single line rather than wrap.', + children: [ + _ExampleCard( + label: 'Bounded to 280', + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 280), + child: StreamMessageComposerReplyAttachment( + title: const Text('Reply to Charlotte Bennett-Williams'), + subtitle: const Text( + 'Here is a very long message that will not fit on a single line at this width.', + ), + onRemovePressed: () {}, + ), ), ), ], diff --git a/apps/design_system_gallery/lib/components/message_composer/message_composer_attachment_unsupported.dart b/apps/design_system_gallery/lib/components/message_composer/message_composer_attachment_unsupported.dart new file mode 100644 index 00000000..654a5ccb --- /dev/null +++ b/apps/design_system_gallery/lib/components/message_composer/message_composer_attachment_unsupported.dart @@ -0,0 +1,196 @@ +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: StreamMessageComposerUnsupportedAttachment, + path: '[Components]/Message Composer', +) +Widget buildMessageComposerAttachmentUnsupportedPlayground(BuildContext context) { + final label = context.knobs.string( + label: 'Label', + initialValue: 'Unsupported attachment', + description: 'The placeholder label, typically a localized "Unsupported attachment" string.', + ); + + final showRemoveButton = context.knobs.boolean( + label: 'Show Remove Button', + initialValue: true, + description: 'Toggle the remove attachment control.', + ); + + final maxWidth = context.knobs.double.slider( + label: 'Parent Max Width', + initialValue: 320, + min: 150, + max: 500, + description: 'Bounds the parent width. Values below 260 force the row to shrink below its natural maximum.', + ); + + return Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth), + child: StreamMessageComposerUnsupportedAttachment( + label: Text(label), + onRemovePressed: showRemoveButton ? () {} : null, + ), + ), + ); +} + +// ============================================================================= +// Showcase +// ============================================================================= + +@widgetbook.UseCase( + name: 'Showcase', + type: StreamMessageComposerUnsupportedAttachment, + path: '[Components]/Message Composer', +) +Widget buildMessageComposerAttachmentUnsupportedShowcase(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 32, + children: [ + _BasicSection(), + ], + ), + ); +} + +// ============================================================================= +// Showcase Sections +// ============================================================================= + +class _BasicSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + return _Section( + label: 'BASIC', + description: + 'A placeholder for attachments the client cannot render. Fixed at 260 wide; ' + 'height adapts to the content.', + children: [ + _ExampleCard( + label: 'With remove button', + child: StreamMessageComposerUnsupportedAttachment( + label: const Text('Unsupported attachment'), + onRemovePressed: () {}, + ), + ), + _ExampleCard( + label: 'Without remove button', + child: StreamMessageComposerUnsupportedAttachment( + label: const Text('Unsupported attachment'), + ), + ), + _ExampleCard( + label: 'Long label (ellipsizes)', + child: StreamMessageComposerUnsupportedAttachment( + label: const Text('This particular attachment type is not yet supported on this client'), + onRemovePressed: () {}, + ), + ), + _ExampleCard( + label: 'Constrained max width (220)', + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 220), + child: StreamMessageComposerUnsupportedAttachment( + label: const Text('A parent below 260 forces the row to shrink and the label ellipsizes.'), + onRemovePressed: () {}, + ), + ), + ), + ], + ); + } +} + +// ============================================================================= +// 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: [ + Text( + label, + style: context.streamTextTheme.metadataEmphasis.copyWith( + letterSpacing: 1.2, + color: colorScheme.accentPrimary, + ), + ), + if (description case final desc?) + Text(desc, style: context.streamTextTheme.metadataDefault.copyWith(color: colorScheme.textTertiary)), + ], + ), + ...children, + ], + ); + } +} + +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: context.streamTextTheme.metadataEmphasis.copyWith(color: colorScheme.textSecondary), + ), + child, + ], + ), + ); + } +} diff --git a/apps/design_system_gallery/lib/components/message_composer/message_composer_file_attachment.dart b/apps/design_system_gallery/lib/components/message_composer/message_composer_file_attachment.dart index 2db719e5..3bee69a1 100644 --- a/apps/design_system_gallery/lib/components/message_composer/message_composer_file_attachment.dart +++ b/apps/design_system_gallery/lib/components/message_composer/message_composer_file_attachment.dart @@ -9,10 +9,10 @@ import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; @widgetbook.UseCase( name: 'Playground', - type: MessageComposerFileAttachment, + type: StreamMessageComposerFileAttachment, path: '[Components]/Message Composer', ) -Widget buildMessageComposerFileAttachmentPlayground(BuildContext context) { +Widget buildStreamMessageComposerFileAttachmentPlayground(BuildContext context) { final title = context.knobs.string( label: 'Title', initialValue: 'quarterly_report.pdf', @@ -39,10 +39,18 @@ Widget buildMessageComposerFileAttachmentPlayground(BuildContext context) { description: 'Toggle the remove attachment control.', ); + final maxWidth = context.knobs.double.slider( + label: 'Parent Max Width', + initialValue: 320, + min: 150, + max: 500, + description: 'Bounds the parent width. Values below 260 force the row to shrink below its natural maximum.', + ); + return Center( child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 360), - child: MessageComposerFileAttachment( + constraints: BoxConstraints(maxWidth: maxWidth), + child: StreamMessageComposerFileAttachment( fileTypeIcon: StreamFileTypeIcon(type: fileType), title: Text(title), subtitle: showSubtitle @@ -63,10 +71,10 @@ Widget buildMessageComposerFileAttachmentPlayground(BuildContext context) { @widgetbook.UseCase( name: 'Showcase', - type: MessageComposerFileAttachment, + type: StreamMessageComposerFileAttachment, path: '[Components]/Message Composer', ) -Widget buildMessageComposerFileAttachmentShowcase(BuildContext context) { +Widget buildStreamMessageComposerFileAttachmentShowcase(BuildContext context) { return SingleChildScrollView( padding: const EdgeInsets.all(24), child: Column( @@ -75,6 +83,7 @@ Widget buildMessageComposerFileAttachmentShowcase(BuildContext context) { children: [ _FileTypesSection(), _WithSubtitleSection(), + _ConstrainedSection(), ], ), ); @@ -93,7 +102,7 @@ class _FileTypesSection extends StatelessWidget { children: [ _ExampleCard( label: 'PDF', - child: MessageComposerFileAttachment( + child: StreamMessageComposerFileAttachment( fileTypeIcon: StreamFileTypeIcon(type: StreamFileType.pdf), title: const Text('annual_report.pdf'), onRemovePressed: () {}, @@ -101,7 +110,7 @@ class _FileTypesSection extends StatelessWidget { ), _ExampleCard( label: 'Document', - child: MessageComposerFileAttachment( + child: StreamMessageComposerFileAttachment( fileTypeIcon: StreamFileTypeIcon(type: StreamFileType.text), title: const Text('meeting_notes.docx'), onRemovePressed: () {}, @@ -109,7 +118,7 @@ class _FileTypesSection extends StatelessWidget { ), _ExampleCard( label: 'Spreadsheet', - child: MessageComposerFileAttachment( + child: StreamMessageComposerFileAttachment( fileTypeIcon: StreamFileTypeIcon(type: StreamFileType.spreadsheet), title: const Text('project_budget.xlsx'), onRemovePressed: () {}, @@ -117,7 +126,7 @@ class _FileTypesSection extends StatelessWidget { ), _ExampleCard( label: 'Audio', - child: MessageComposerFileAttachment( + child: StreamMessageComposerFileAttachment( fileTypeIcon: StreamFileTypeIcon(type: StreamFileType.audio), title: const Text('podcast_episode.mp3'), onRemovePressed: () {}, @@ -140,7 +149,7 @@ class _WithSubtitleSection extends StatelessWidget { children: [ _ExampleCard( label: 'With file size', - child: MessageComposerFileAttachment( + child: StreamMessageComposerFileAttachment( fileTypeIcon: StreamFileTypeIcon(type: StreamFileType.pdf), title: const Text('design_specs.pdf'), subtitle: Text('4.2 MB', style: subtitleStyle), @@ -149,7 +158,7 @@ class _WithSubtitleSection extends StatelessWidget { ), _ExampleCard( label: 'Without remove button', - child: MessageComposerFileAttachment( + child: StreamMessageComposerFileAttachment( fileTypeIcon: StreamFileTypeIcon(type: StreamFileType.spreadsheet), title: const Text('budget_2025.xlsx'), subtitle: Text('1.8 MB', style: subtitleStyle), @@ -160,6 +169,35 @@ class _WithSubtitleSection extends StatelessWidget { } } +class _ConstrainedSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final subtitleStyle = context.streamTextTheme.metadataDefault.copyWith(color: colorScheme.textTertiary); + + return _Section( + label: 'CONSTRAINED MAX WIDTH', + description: + 'When a parent bounds the width, long file names ellipsize on a single line ' + 'rather than wrap.', + children: [ + _ExampleCard( + label: 'Bounded to 240', + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 240), + child: StreamMessageComposerFileAttachment( + fileTypeIcon: StreamFileTypeIcon(type: StreamFileType.pdf), + title: const Text('quarterly_financial_results_summary.pdf'), + subtitle: Text('12.4 MB', style: subtitleStyle), + onRemovePressed: () {}, + ), + ), + ), + ], + ); + } +} + // ============================================================================= // Helper Widgets // ============================================================================= diff --git a/packages/stream_core_flutter/CHANGELOG.md b/packages/stream_core_flutter/CHANGELOG.md index e95643c9..612bcb71 100644 --- a/packages/stream_core_flutter/CHANGELOG.md +++ b/packages/stream_core_flutter/CHANGELOG.md @@ -14,10 +14,15 @@ - `StreamEmojiPickerSheet.show` now resolves its background color and border radius from the ambient `StreamSheetTheme` so the picker visually matches other Stream-styled sheets by default. - `StreamLoadingSpinner` now renders a completion checkmark when progress reaches 100%. - `StreamCommandChip` is now tappable across its whole surface, not just the × icon. -- `StreamRemoveControl` now meets the 48 dp minimum tap target by default while keeping its 20 dp visible badge anchored to the top-end corner. Exposes `tapTargetSize`, `visualDensity`, and `semanticLabel`, and announces itself as a button to screen readers. +- `StreamRemoveControl` now meets the 48 dp minimum tap target by default while keeping its 20 dp visible badge anchored to the top-end corner. Exposes `tapTargetSize`, `visualDensity`, and `semanticLabel`, announces itself as a button to screen readers, and shows a click cursor on web/desktop when hovered. - Added `textAlignVertical` to `StreamTextInput` (and `StreamTextInputProps`) for controlling the vertical alignment of the text within the input. - Added `cursorColor`, `cursorErrorColor`, `cursorWidth`, `cursorHeight`, and `cursorRadius` to `StreamTextInputStyle` for customizing the text input cursor. `cursorErrorColor` is applied automatically when `helperState` is `StreamHelperState.error`. `StreamMessageComposerInputField` also honors these cursor properties from the theme. - Exported `DefaultStreamEmoji` so consumers can compose with or wrap the default emoji rendering when overriding via `StreamComponentFactory`. +- Added `StreamMessageComposerEditMessageAttachment`, a preview shown above the composer input while editing a message. +- Added `StreamMessageComposerUnsupportedAttachment`, a placeholder shown for attachments the client cannot render. +- Added a themed `thumbnail` slot to `StreamMessageComposerReplyAttachment` and `StreamMessageComposerEditMessageAttachment`, with matching `thumbnailSize`/`thumbnailShape`/`thumbnailSide` theme fields. +- Added per-widget themes for the message composer attachments: `StreamMessageComposerAttachmentTheme`, `StreamMessageComposerEditMessageAttachmentTheme`, `StreamMessageComposerFileAttachmentTheme`, `StreamMessageComposerLinkPreviewAttachmentTheme`, `StreamMessageComposerMediaAttachmentTheme`, `StreamMessageComposerReplyAttachmentTheme`, and `StreamMessageComposerUnsupportedAttachmentTheme`. All seven are wired into `StreamTheme` with matching `BuildContext` extensions. +- The composer attachment widgets (`StreamMessageComposerAttachment`, `StreamMessageComposerEditMessageAttachment`, `StreamMessageComposerFileAttachment`, `StreamMessageComposerLinkPreviewAttachment`, `StreamMessageComposerMediaAttachment`, `StreamMessageComposerReplyAttachment`, `StreamMessageComposerUnsupportedAttachment`) can now be customized via `StreamComponentFactory` — each exposes a matching builder slot taking its `*Props` configuration object. ### 🐞 Fixed @@ -29,6 +34,7 @@ - Updated `StreamReactionPicker` spacing to match the Figma specification. - Updated `StreamStepper` button style to match the Figma specification. - `StreamEmoji` now pins its primary `fontFamily` to the platform's native emoji font (Apple Color Emoji on iOS/macOS, Segoe UI Emoji on Windows, Noto Color Emoji elsewhere) so the existing per-platform `fontSize` correction lines up with the font that actually renders the glyph. `fontFamilyFallback` is unchanged. +- `StreamFileTypeIcon` now centers its SVG inside the icon's render box, so it stays centered when given larger constraints. ### 💥 Breaking Changes @@ -40,6 +46,13 @@ - Redesigned `StreamAppBar` with a slots-based API (`leading`/`title`/`subtitle`/`trailing`) and platform-aware auto-implied leading; replaces the previous Material `AppBar` wrapper. Adds `StreamAppBarStyle`, `StreamAppBarTheme`, and `StreamAppBarThemeData`. - `placeholder` on `StreamCoreMessageComposer`, `StreamMessageComposerInput`, and `StreamMessageComposerInputField` is now an optional `String?` (was `String` defaulting to `''`, and `required` on `StreamMessageComposerInputField`). - Removed `StreamMessageTheme`, `StreamMessageThemeData`, and `StreamMessageStyle`; `MessageComposerReplyAttachment` and `MessageComposerLinkPreviewAttachment` now read colors directly from `StreamColorScheme`. +- Renamed `StreamMessageComposerAttachmentContainer` to `StreamMessageComposerAttachment` to mirror the existing `StreamMessageAttachment` naming. +- `StreamMessageComposerAttachment` now uses `shape: OutlinedBorder?` and `side: BorderSide?` (matching `StreamMessageAttachmentStyle`) in place of the previous `borderColor` / `borderRadius` fields and constructor params. Added a `style:` constructor param taking `StreamMessageComposerAttachmentThemeData?` for per-instance overrides; the old `backgroundColor` and `borderColor` widget params are gone. +- Added `style:` per-instance override params to `MessageComposerFileAttachment`, `MessageComposerMediaFileAttachment`, `MessageComposerLinkPreviewAttachment`, and `MessageComposerReplyAttachment`. Renamed `MessageComposerReplyAttachment.style` (the `ReplyStyle` enum slot) to `direction:` so `style:` is free for the theme-data override. +- Renamed the four composer attachment widgets to add the `Stream` prefix: `MessageComposerFileAttachment` → `StreamMessageComposerFileAttachment`, `MessageComposerMediaFileAttachment` → `StreamMessageComposerMediaAttachment` (also dropped the redundant "File" segment), `MessageComposerLinkPreviewAttachment` → `StreamMessageComposerLinkPreviewAttachment`, `MessageComposerReplyAttachment` → `StreamMessageComposerReplyAttachment`. +- Replaced `ReplyStyle` with `StreamReplyDirection` (same `incoming` / `outgoing` values) used by `StreamMessageComposerReplyAttachment.direction`. +- Renamed `StreamMessageComposerLinkPreviewAttachment.media` to `thumbnail`, and `StreamMessageComposerReplyAttachment.trailing` to `thumbnail`. +- Renamed `StreamMessageComposerLinkPreviewAttachmentThemeData` fields: `mediaSize` → `thumbnailSize`, `mediaShape` → `thumbnailShape`, `mediaSide` → `thumbnailSide`. ## 0.2.0 diff --git a/packages/stream_core_flutter/lib/src/components/accessories/stream_file_type_icon.dart b/packages/stream_core_flutter/lib/src/components/accessories/stream_file_type_icon.dart index dc48c9a7..7d68ea93 100644 --- a/packages/stream_core_flutter/lib/src/components/accessories/stream_file_type_icon.dart +++ b/packages/stream_core_flutter/lib/src/components/accessories/stream_file_type_icon.dart @@ -305,6 +305,7 @@ class DefaultStreamFileTypeIcon extends StatelessWidget { final colorScheme = context.streamColorScheme; return Stack( + alignment: .center, clipBehavior: .none, children: [ SvgPicture.asset( diff --git a/packages/stream_core_flutter/lib/src/components/controls/stream_remove_control.dart b/packages/stream_core_flutter/lib/src/components/controls/stream_remove_control.dart index 282791e1..44dda7c4 100644 --- a/packages/stream_core_flutter/lib/src/components/controls/stream_remove_control.dart +++ b/packages/stream_core_flutter/lib/src/components/controls/stream_remove_control.dart @@ -75,22 +75,25 @@ class StreamRemoveControl extends StatelessWidget { child: GestureDetector( onTap: onPressed, behavior: .opaque, - child: StreamTapTargetPadding( - minSize: minSize, - alignment: AlignmentDirectional.topEnd, - child: Container( - width: _badgeSize.width, - height: _badgeSize.height, - alignment: Alignment.center, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: colorScheme.backgroundInverse, - border: Border.all(color: colorScheme.borderOnInverse, width: 2), - ), - child: Icon( - size: 16, - context.streamIcons.xmarkSmall, - color: colorScheme.textOnInverse, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: StreamTapTargetPadding( + minSize: minSize, + alignment: AlignmentDirectional.topEnd, + child: Container( + width: _badgeSize.width, + height: _badgeSize.height, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colorScheme.backgroundInverse, + border: Border.all(color: colorScheme.borderOnInverse, width: 2), + ), + child: Icon( + size: 16, + context.streamIcons.xmarkSmall, + color: colorScheme.textOnInverse, + ), ), ), ), diff --git a/packages/stream_core_flutter/lib/src/components/message_composer.dart b/packages/stream_core_flutter/lib/src/components/message_composer.dart index 9a681bc7..e85a1ec4 100644 --- a/packages/stream_core_flutter/lib/src/components/message_composer.dart +++ b/packages/stream_core_flutter/lib/src/components/message_composer.dart @@ -1,8 +1,17 @@ -export 'message_composer/attachment/message_composer_attachment_container.dart'; -export 'message_composer/attachment/message_composer_file_attachment.dart'; -export 'message_composer/attachment/message_composer_link_preview_attachment.dart'; -export 'message_composer/attachment/message_composer_media_file_attachment.dart'; -export 'message_composer/attachment/message_composer_reply_attachment.dart'; +export 'message_composer/attachment/stream_message_composer_attachment.dart' + hide DefaultStreamMessageComposerAttachment; +export 'message_composer/attachment/stream_message_composer_edit_message_attachment.dart' + hide DefaultStreamMessageComposerEditMessageAttachment; +export 'message_composer/attachment/stream_message_composer_file_attachment.dart' + hide DefaultStreamMessageComposerFileAttachment; +export 'message_composer/attachment/stream_message_composer_link_preview_attachment.dart' + hide DefaultStreamMessageComposerLinkPreviewAttachment; +export 'message_composer/attachment/stream_message_composer_media_attachment.dart' + hide DefaultStreamMessageComposerMediaAttachment; +export 'message_composer/attachment/stream_message_composer_reply_attachment.dart' + hide DefaultStreamMessageComposerReplyAttachment; +export 'message_composer/attachment/stream_message_composer_unsupported_attachment.dart' + hide DefaultStreamMessageComposerUnsupportedAttachment; export 'message_composer/message_composer.dart'; export 'message_composer/message_composer_input.dart'; export 'message_composer/message_composer_input_trailing.dart'; diff --git a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_attachment_container.dart b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_attachment_container.dart deleted file mode 100644 index a4dde0c2..00000000 --- a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_attachment_container.dart +++ /dev/null @@ -1,117 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../../theme.dart'; -import '../../controls/stream_remove_control.dart'; - -/// A styled container that wraps message composer attachment content with a -/// themed background, shape, and border. -/// -/// Built-in defaults provide a rounded shape, background color, and border -/// matching the design system tokens. -/// -/// An optional [onRemovePressed] callback adds a [StreamRemoveControl] overlay. -/// -/// {@tool snippet} -/// -/// Basic usage relying on defaults: -/// -/// ```dart -/// StreamMessageComposerAttachmentContainer( -/// onRemovePressed: () => removeAttachment(), -/// child: MyAttachmentContent(), -/// ) -/// ``` -/// {@end-tool} -/// -/// {@tool snippet} -/// -/// With custom colors: -/// -/// ```dart -/// StreamMessageComposerAttachmentContainer( -/// backgroundColor: Colors.blue.shade50, -/// borderColor: Colors.blue.shade200, -/// child: MyAttachmentContent(), -/// ) -/// ``` -/// {@end-tool} -/// -/// See also: -/// -/// * [StreamRemoveControl], the remove button shown when [onRemovePressed] -/// is provided. -/// * [StreamMessageAttachment], the analogous container for message-list -/// attachments. -class StreamMessageComposerAttachmentContainer extends StatelessWidget { - /// Creates a composer attachment container. - const StreamMessageComposerAttachmentContainer({ - super.key, - required this.child, - this.onRemovePressed, - this.backgroundColor, - this.borderColor, - }); - - /// The content widget displayed inside the container. - final Widget child; - - /// Called when the remove button is tapped. - /// - /// When non-null, a [StreamRemoveControl] overlay appears at the - /// top-right corner of the container. - final VoidCallback? onRemovePressed; - - /// The background fill color of the container. - /// - /// Falls back to [StreamColorScheme.backgroundElevation1]. - final Color? backgroundColor; - - /// The border color of the container. - /// - /// When non-null, a 1px border is drawn with this color. When null, the - /// default border from [StreamColorScheme.borderDefault] is used. - final Color? borderColor; - - @override - Widget build(BuildContext context) { - final radius = context.streamRadius; - final spacing = context.streamSpacing; - final colorScheme = context.streamColorScheme; - - final effectiveBorderColor = borderColor ?? colorScheme.borderDefault; - final effectiveBackgroundColor = backgroundColor ?? colorScheme.backgroundElevation1; - - final container = Container( - clipBehavior: .hardEdge, - margin: .all(spacing.xxs), - decoration: BoxDecoration( - borderRadius: .all(radius.lg), - color: effectiveBackgroundColor, - ), - foregroundDecoration: BoxDecoration( - borderRadius: .all(radius.lg), - border: .all( - color: effectiveBorderColor, - strokeAlign: BorderSide.strokeAlignInside, - ), - ), - child: child, - ); - - if (onRemovePressed case final onPressed?) { - return Stack( - clipBehavior: .none, - children: [ - container, - PositionedDirectional( - top: 0, - end: 0, - child: StreamRemoveControl(onPressed: onPressed), - ), - ], - ); - } - - return container; - } -} diff --git a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_file_attachment.dart b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_file_attachment.dart deleted file mode 100644 index 7138002b..00000000 --- a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_file_attachment.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'package:flutter/widgets.dart'; - -import '../../../../stream_core_flutter.dart'; - -class MessageComposerFileAttachment extends StatelessWidget { - const MessageComposerFileAttachment({ - super.key, - required this.title, - this.subtitle, - this.fileTypeIcon, - this.onRemovePressed, - }); - - final Widget title; - final Widget? subtitle; - final StreamFileTypeIcon? fileTypeIcon; - final VoidCallback? onRemovePressed; - - @override - Widget build(BuildContext context) { - final spacing = context.streamSpacing; - - final textTheme = context.streamTextTheme; - final colorScheme = context.streamColorScheme; - - final titleStyle = textTheme.metadataEmphasis.copyWith(color: colorScheme.textPrimary); - final subtitleStyle = textTheme.metadataDefault.copyWith(color: colorScheme.textSecondary); - - final effectiveTitle = DefaultTextStyle.merge(style: titleStyle, maxLines: 1, overflow: .ellipsis, child: title); - - Widget? effectiveSubtitle; - if (subtitle case final title?) { - effectiveSubtitle = DefaultTextStyle.merge(style: subtitleStyle, maxLines: 1, overflow: .ellipsis, child: title); - } - - return StreamMessageComposerAttachmentContainer( - onRemovePressed: onRemovePressed, - child: Padding( - padding: .directional( - start: spacing.md, - end: spacing.sm, - top: spacing.md, - bottom: spacing.md, - ), - child: Row( - spacing: spacing.sm, - children: [ - ?fileTypeIcon, - Expanded( - child: Column( - spacing: spacing.xxs, - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - effectiveTitle, - ?effectiveSubtitle, - ], - ), - ), - ], - ), - ), - ); - } -} diff --git a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_link_preview_attachment.dart b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_link_preview_attachment.dart deleted file mode 100644 index eb88ab71..00000000 --- a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_link_preview_attachment.dart +++ /dev/null @@ -1,99 +0,0 @@ -import 'package:flutter/widgets.dart'; - -import '../../../../stream_core_flutter.dart'; - -class MessageComposerLinkPreviewAttachment extends StatelessWidget { - const MessageComposerLinkPreviewAttachment({ - super.key, - this.title, - this.subtitle, - this.url, - this.image, - this.onRemovePressed, - }); - - final String? title; - final String? subtitle; - final String? url; - final ImageProvider? image; - final VoidCallback? onRemovePressed; - - @override - Widget build(BuildContext context) { - final colorScheme = context.streamColorScheme; - - final textColor = colorScheme.brand.shade900; - final backgroundColor = colorScheme.brand.shade100; - - final titleStyle = context.streamTextTheme.metadataEmphasis.copyWith(color: textColor); - final bodyStyle = context.streamTextTheme.metadataDefault.copyWith(color: textColor); - - final spacing = context.streamSpacing; - return StreamMessageComposerAttachmentContainer( - onRemovePressed: onRemovePressed, - backgroundColor: backgroundColor, - borderColor: StreamColors.transparent, - child: Padding( - padding: .directional( - start: spacing.xs, - end: spacing.sm, - top: spacing.xs, - bottom: spacing.xs, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (image != null) ...[ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - borderRadius: BorderRadius.all(context.streamRadius.md), - image: DecorationImage(image: image!, fit: BoxFit.cover), - ), - ), - SizedBox(width: spacing.xs), - ], - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (title case final title?) - Text( - title, - style: titleStyle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (subtitle case final subtitle?) - Text( - subtitle, - style: bodyStyle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (url case final url?) - Row( - children: [ - Icon(context.streamIcons.link, size: 12), - SizedBox(width: spacing.xxs), - Expanded( - child: Text( - url, - style: bodyStyle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ], - ), - ), - ], - ), - ), - ); - } -} diff --git a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_media_file_attachment.dart b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_media_file_attachment.dart deleted file mode 100644 index 90c29211..00000000 --- a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_media_file_attachment.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'package:flutter/widgets.dart'; - -import '../../../../stream_core_flutter.dart'; - -class MessageComposerMediaFileAttachment extends StatelessWidget { - const MessageComposerMediaFileAttachment({ - super.key, - required this.child, - this.onRemovePressed, - this.mediaBadge, - }); - - MessageComposerMediaFileAttachment.image({ - super.key, - required ImageProvider image, - required this.onRemovePressed, - ImageFrameBuilder? frameBuilder, - ImageErrorWidgetBuilder? errorBuilder, - this.mediaBadge, - }) : child = Image( - image: image, - frameBuilder: frameBuilder, - errorBuilder: errorBuilder, - fit: BoxFit.cover, - ); - - final Widget child; - final Widget? mediaBadge; - final VoidCallback? onRemovePressed; - - @override - Widget build(BuildContext context) { - final spacing = context.streamSpacing; - final colorScheme = context.streamColorScheme; - - return StreamMessageComposerAttachmentContainer( - onRemovePressed: onRemovePressed, - borderColor: colorScheme.borderOpacitySubtle, - child: SizedBox.square( - dimension: 72, - child: Stack( - children: [ - child, - if (mediaBadge case final badge?) - PositionedDirectional( - start: spacing.xxs, - bottom: spacing.xxs, - child: badge, - ), - ], - ), - ), - ); - } -} diff --git a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_reply_attachment.dart b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_reply_attachment.dart deleted file mode 100644 index bf1d60c6..00000000 --- a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/message_composer_reply_attachment.dart +++ /dev/null @@ -1,110 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; - -import '../../../../stream_core_flutter.dart'; - -const _kDefaultConstraints = BoxConstraints(minWidth: 272, minHeight: 56); - -const _kIndicatorWidth = 2.0; -const _kIndicatorHeight = 36.0; - -enum ReplyStyle { - incoming, - outgoing, -} - -class MessageComposerReplyAttachment extends StatelessWidget { - const MessageComposerReplyAttachment({ - super.key, - required this.title, - required this.subtitle, - this.trailing, - this.onRemovePressed, - this.style = .incoming, - }); - - final Widget title; - final Widget subtitle; - final Widget? trailing; - final VoidCallback? onRemovePressed; - final ReplyStyle style; - - @override - Widget build(BuildContext context) { - final radius = context.streamRadius; - final spacing = context.streamSpacing; - - final textTheme = context.streamTextTheme; - final colorScheme = context.streamColorScheme; - - final backgroundColor = switch (style) { - ReplyStyle.incoming => colorScheme.backgroundSurface, - ReplyStyle.outgoing => colorScheme.brand.shade100, - }; - - final indicatorColor = switch (style) { - ReplyStyle.incoming => colorScheme.chrome.shade400, - ReplyStyle.outgoing => colorScheme.brand.shade400, - }; - - final textColor = switch (style) { - ReplyStyle.incoming => colorScheme.textPrimary, - ReplyStyle.outgoing => colorScheme.brand.shade900, - }; - - return StreamMessageComposerAttachmentContainer( - onRemovePressed: onRemovePressed, - backgroundColor: backgroundColor, - borderColor: StreamColors.transparent, - child: ConstrainedBox( - constraints: _kDefaultConstraints, - child: Padding( - padding: .all(spacing.xs), - child: Row( - spacing: spacing.xs, - children: [ - Expanded( - child: Row( - spacing: spacing.xs, - children: [ - Container( - width: _kIndicatorWidth, - height: _kIndicatorHeight, - decoration: BoxDecoration( - color: indicatorColor, - borderRadius: .all(radius.max), - ), - ), - Expanded( - child: Column( - mainAxisSize: .min, - spacing: spacing.xxxs, - mainAxisAlignment: .center, - crossAxisAlignment: .start, - children: [ - DefaultTextStyle.merge( - maxLines: 1, - style: textTheme.metadataEmphasis.copyWith(color: textColor), - overflow: TextOverflow.ellipsis, - child: title, - ), - DefaultTextStyle.merge( - maxLines: 1, - style: textTheme.metadataDefault.copyWith(color: textColor), - overflow: TextOverflow.ellipsis, - child: subtitle, - ), - ], - ), - ), - ], - ), - ), - ?trailing, - ], - ), - ), - ), - ); - } -} diff --git a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_attachment.dart b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_attachment.dart new file mode 100644 index 00000000..e68e60c4 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_attachment.dart @@ -0,0 +1,193 @@ +import 'package:flutter/material.dart'; + +import '../../../theme.dart'; +import '../../controls/stream_remove_control.dart'; + +/// A styled container that wraps composer attachment content with a themed background, shape, +/// and border. +/// +/// [StreamMessageComposerAttachment] sizes itself to its [child], paints a rounded surface +/// behind it, and clips the [child] to that shape. An outlined border is composed onto the +/// shape. The container also adds an outer margin so adjacent attachments in a row don't +/// touch. +/// +/// When [onRemovePressed] is non-null, a circular [StreamRemoveControl] is overlaid at the +/// top-end corner, slightly outside the shape so it visually attaches to the edge. +/// +/// {@tool snippet} +/// +/// Basic usage — a rounded card with a subtle 1px border: +/// +/// ```dart +/// StreamMessageComposerAttachment( +/// onRemovePressed: () => removeAttachment(), +/// child: MyAttachmentContent(), +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// With explicit style overrides: +/// +/// ```dart +/// StreamMessageComposerAttachment( +/// style: StreamMessageComposerAttachmentThemeData( +/// backgroundColor: Colors.blue.shade50, +/// side: BorderSide(color: Colors.blue.shade200), +/// ), +/// child: MyAttachmentContent(), +/// ) +/// ``` +/// {@end-tool} +/// +/// ## Theming +/// +/// [StreamMessageComposerAttachment] uses [StreamMessageComposerAttachmentThemeData] for +/// default styling — background color, shape, border side, and outer padding. Per-instance +/// [style] takes precedence over the inherited theme. +/// +/// See also: +/// +/// * [StreamMessageComposerAttachmentTheme], for customizing attachment containers globally. +/// * [StreamRemoveControl], the remove button shown when [onRemovePressed] is provided. +/// * [StreamMessageAttachment], the analogous container for message-list attachments. +/// * [DefaultStreamMessageComposerAttachment], the default visual implementation. +class StreamMessageComposerAttachment extends StatelessWidget { + /// Creates a composer attachment container. + StreamMessageComposerAttachment({ + super.key, + required Widget child, + VoidCallback? onRemovePressed, + StreamMessageComposerAttachmentThemeData? style, + }) : props = .new( + child: child, + onRemovePressed: onRemovePressed, + style: style, + ); + + /// The properties that configure this attachment container. + final StreamMessageComposerAttachmentProps props; + + @override + Widget build(BuildContext context) { + final builder = StreamComponentFactory.of(context).messageComposerAttachment; + if (builder != null) return builder(context, props); + return DefaultStreamMessageComposerAttachment(props: props); + } +} + +/// Properties for configuring a [StreamMessageComposerAttachment]. +/// +/// This class holds all the configuration options for an attachment container, allowing them +/// to be passed through the [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamMessageComposerAttachment], which uses these properties. +/// * [DefaultStreamMessageComposerAttachment], the default implementation. +class StreamMessageComposerAttachmentProps { + /// Creates properties for an attachment container. + const StreamMessageComposerAttachmentProps({ + required this.child, + this.onRemovePressed, + this.style, + }); + + /// The content displayed inside the container. + /// + /// Clipped to the container's rounded shape. + final Widget child; + + /// Called when the remove button is tapped. + /// + /// When non-null, a [StreamRemoveControl] is overlaid at the top-end corner of the + /// container. When null, no remove control is shown. + final VoidCallback? onRemovePressed; + + /// Per-instance style overrides. + /// + /// Fields left null fall back to the inherited [StreamMessageComposerAttachmentTheme], + /// then to built-in defaults. + final StreamMessageComposerAttachmentThemeData? style; +} + +/// The default implementation of [StreamMessageComposerAttachment]. +/// +/// Renders the container with theming support from [StreamMessageComposerAttachmentTheme]. +/// It is used as the default factory implementation in [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamMessageComposerAttachment], the public API widget. +/// * [StreamMessageComposerAttachmentProps], which configures this widget. +class DefaultStreamMessageComposerAttachment extends StatelessWidget { + /// Creates a default attachment container with the given [props]. + const DefaultStreamMessageComposerAttachment({super.key, required this.props}); + + /// The properties that configure this attachment container. + final StreamMessageComposerAttachmentProps props; + + @override + Widget build(BuildContext context) { + final theme = context.streamMessageComposerAttachmentTheme.merge(props.style); + final defaults = _StreamMessageComposerAttachmentDefaults(context); + + final effectiveSide = theme.side ?? defaults.side; + final effectiveShape = (theme.shape ?? defaults.shape).copyWith(side: effectiveSide); + final effectiveBackgroundColor = theme.backgroundColor ?? defaults.backgroundColor; + final effectivePadding = theme.padding ?? defaults.padding; + + final container = Padding( + padding: effectivePadding, + child: Material( + clipBehavior: .hardEdge, + shape: effectiveShape, + color: effectiveBackgroundColor, + child: props.child, + ), + ); + + if (props.onRemovePressed case final onPressed?) { + return Stack( + clipBehavior: .none, + children: [ + container, + PositionedDirectional( + top: 0, + end: 0, + child: StreamRemoveControl(onPressed: onPressed), + ), + ], + ); + } + + return container; + } +} + +// Default theme values for [StreamMessageComposerAttachment]. +// +// Used when no explicit value is provided via [style] or +// [StreamMessageComposerAttachmentThemeData]. +class _StreamMessageComposerAttachmentDefaults extends StreamMessageComposerAttachmentThemeData { + _StreamMessageComposerAttachmentDefaults(this._context); + + final BuildContext _context; + + late final _radius = _context.streamRadius; + late final _spacing = _context.streamSpacing; + late final _colorScheme = _context.streamColorScheme; + + @override + Color get backgroundColor => _colorScheme.backgroundElevation1; + + @override + OutlinedBorder get shape => RoundedSuperellipseBorder(borderRadius: .all(_radius.lg)); + + @override + BorderSide get side => BorderSide(color: _colorScheme.borderDefault); + + @override + EdgeInsetsGeometry get padding => EdgeInsets.all(_spacing.xxs); +} diff --git a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_edit_message_attachment.dart b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_edit_message_attachment.dart new file mode 100644 index 00000000..7c10c1ab --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_edit_message_attachment.dart @@ -0,0 +1,261 @@ +import 'package:flutter/material.dart'; + +import '../../../../stream_core_flutter.dart'; + +const _kDefaultConstraints = BoxConstraints(minHeight: 56); + +const _kIndicatorWidth = 2.0; +const _kIndicatorVerticalMargin = 2.0; + +/// A composer attachment that previews the message currently being edited. +/// +/// [StreamMessageComposerEditMessageAttachment] displays a leading indicator bar, a [title] +/// (typically a localized "Edit message" label), a [subtitle] (the body of the message being +/// edited), and an optional trailing [thumbnail] of the attached media. Both labels render on +/// a single line and ellipsize on overflow. +/// +/// The preview fills the parent's width and is at least 56 tall, growing vertically to fit +/// longer content. +/// +/// {@tool snippet} +/// +/// An edit-message preview: +/// +/// ```dart +/// StreamMessageComposerEditMessageAttachment( +/// title: Text('Edit message'), +/// subtitle: Text('See you at 9!'), +/// onRemovePressed: () => cancelEdit(), +/// ) +/// ``` +/// {@end-tool} +/// +/// ## Theming +/// +/// [StreamMessageComposerEditMessageAttachment] uses +/// [StreamMessageComposerEditMessageAttachmentThemeData] for default styling. Per-instance +/// [style] takes precedence over the inherited theme. +/// +/// See also: +/// +/// * [StreamMessageComposerEditMessageAttachmentTheme], for customizing edit previews +/// globally. +/// * [StreamMessageComposerAttachment], the surrounding container. +/// * [DefaultStreamMessageComposerEditMessageAttachment], the default visual implementation. +class StreamMessageComposerEditMessageAttachment extends StatelessWidget { + /// Creates an edit-message preview attachment. + StreamMessageComposerEditMessageAttachment({ + super.key, + required Widget title, + required Widget subtitle, + Widget? thumbnail, + VoidCallback? onRemovePressed, + StreamMessageComposerEditMessageAttachmentThemeData? style, + }) : props = .new( + title: title, + subtitle: subtitle, + thumbnail: thumbnail, + onRemovePressed: onRemovePressed, + style: style, + ); + + /// The properties that configure this edit-message preview. + final StreamMessageComposerEditMessageAttachmentProps props; + + @override + Widget build(BuildContext context) { + final builder = StreamComponentFactory.of(context).messageComposerEditMessageAttachment; + if (builder != null) return builder(context, props); + return DefaultStreamMessageComposerEditMessageAttachment(props: props); + } +} + +/// Properties for configuring a [StreamMessageComposerEditMessageAttachment]. +/// +/// This class holds all the configuration options for an edit-message preview, allowing them +/// to be passed through the [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamMessageComposerEditMessageAttachment], which uses these properties. +/// * [DefaultStreamMessageComposerEditMessageAttachment], the default implementation. +class StreamMessageComposerEditMessageAttachmentProps { + /// Creates properties for an edit-message preview attachment. + const StreamMessageComposerEditMessageAttachmentProps({ + required this.title, + required this.subtitle, + this.thumbnail, + this.onRemovePressed, + this.style, + }); + + /// The primary label shown on the first line. + /// + /// Typically a [Text] showing a localized "Edit message" string. Renders on a single line + /// and ellipsizes on overflow. + final Widget title; + + /// The secondary label shown below [title]. + /// + /// Typically a [Text] previewing the body of the message being edited. Renders on a single + /// line and ellipsizes on overflow. + final Widget subtitle; + + /// An optional thumbnail of the attached media, rendered at the end of the preview row. + /// + /// Sized and shaped via the inherited theme (default 40×40 with rounded corners). The + /// caller-provided widget is responsible for filling the thumbnail bounds — typically an + /// [Image] with [BoxFit.cover]. When null, the title/subtitle column expands to the full + /// width of the row. + final Widget? thumbnail; + + /// Called when the remove button is tapped. + /// + /// When null, no remove control is shown on the surrounding container. + final VoidCallback? onRemovePressed; + + /// Per-instance style overrides. + /// + /// Fields left null fall back to the inherited + /// [StreamMessageComposerEditMessageAttachmentTheme], then to built-in defaults. + final StreamMessageComposerEditMessageAttachmentThemeData? style; +} + +/// The default implementation of [StreamMessageComposerEditMessageAttachment]. +/// +/// Renders the edit-message preview with theming support from +/// [StreamMessageComposerEditMessageAttachmentTheme]. It is used as the default factory +/// implementation in [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamMessageComposerEditMessageAttachment], the public API widget. +/// * [StreamMessageComposerEditMessageAttachmentProps], which configures this widget. +class DefaultStreamMessageComposerEditMessageAttachment extends StatelessWidget { + /// Creates a default edit-message preview with the given [props]. + const DefaultStreamMessageComposerEditMessageAttachment({super.key, required this.props}); + + /// The properties that configure this edit-message preview. + final StreamMessageComposerEditMessageAttachmentProps props; + + @override + Widget build(BuildContext context) { + final radius = context.streamRadius; + final spacing = context.streamSpacing; + + final theme = context.streamMessageComposerEditMessageAttachmentTheme.merge(props.style); + final defaults = _StreamMessageComposerEditMessageAttachmentDefaults(context); + + final effectiveBackgroundColor = theme.backgroundColor ?? defaults.backgroundColor; + final effectiveIndicatorColor = theme.indicatorColor ?? defaults.indicatorColor; + final effectiveTitleStyle = theme.titleTextStyle ?? defaults.titleTextStyle; + final effectiveSubtitleStyle = theme.subtitleTextStyle ?? defaults.subtitleTextStyle; + final effectivePadding = theme.padding ?? defaults.padding; + final effectiveThumbnailSide = theme.thumbnailSide ?? defaults.thumbnailSide; + final effectiveThumbnailShape = (theme.thumbnailShape ?? defaults.thumbnailShape).copyWith( + side: effectiveThumbnailSide, + ); + final effectiveThumbnailSize = theme.thumbnailSize ?? defaults.thumbnailSize; + + final effectiveTitle = DefaultTextStyle.merge( + style: effectiveTitleStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + child: props.title, + ); + + final effectiveSubtitle = DefaultTextStyle.merge( + style: effectiveSubtitleStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + child: props.subtitle, + ); + + Widget? effectiveThumbnail; + if (props.thumbnail case final thumbnail?) { + effectiveThumbnail = SizedBox.fromSize( + size: effectiveThumbnailSize, + child: Material( + clipBehavior: Clip.hardEdge, + shape: effectiveThumbnailShape, + child: thumbnail, + ), + ); + } + + return StreamMessageComposerAttachment( + onRemovePressed: props.onRemovePressed, + style: StreamMessageComposerAttachmentThemeData( + backgroundColor: effectiveBackgroundColor, + side: BorderSide.none, + ), + child: ConstrainedBox( + constraints: _kDefaultConstraints, + child: Padding( + padding: effectivePadding, + child: IntrinsicHeight( + child: Row( + mainAxisSize: .min, + spacing: spacing.xs, + children: [ + VerticalDivider( + width: _kIndicatorWidth, + thickness: _kIndicatorWidth, + indent: _kIndicatorVerticalMargin, + endIndent: _kIndicatorVerticalMargin, + radius: BorderRadius.all(radius.max), + color: effectiveIndicatorColor, + ), + Expanded( + child: Column( + mainAxisSize: .min, + spacing: spacing.xxxs, + mainAxisAlignment: .center, + crossAxisAlignment: .start, + children: [effectiveTitle, effectiveSubtitle], + ), + ), + ?effectiveThumbnail, + ], + ), + ), + ), + ), + ); + } +} + +// Default theme values for [StreamMessageComposerEditMessageAttachment]. +// +// Used when no explicit value is provided via +// [StreamMessageComposerEditMessageAttachmentThemeData]. +class _StreamMessageComposerEditMessageAttachmentDefaults extends StreamMessageComposerEditMessageAttachmentThemeData { + _StreamMessageComposerEditMessageAttachmentDefaults(this._context); + + final BuildContext _context; + + late final _spacing = _context.streamSpacing; + late final _textTheme = _context.streamTextTheme; + late final _colorScheme = _context.streamColorScheme; + + @override + Color get backgroundColor => _colorScheme.brand.shade100; + + @override + Color get indicatorColor => _colorScheme.brand.shade400; + + @override + TextStyle get titleTextStyle => _textTheme.metadataEmphasis.copyWith(color: _colorScheme.textPrimary); + + @override + TextStyle get subtitleTextStyle => _textTheme.metadataDefault.copyWith(color: _colorScheme.textPrimary); + + @override + EdgeInsetsGeometry get padding => EdgeInsets.all(_spacing.xs); + + @override + OutlinedBorder get thumbnailShape => RoundedSuperellipseBorder(borderRadius: .all(_context.streamRadius.md)); + + @override + Size get thumbnailSize => const Size.square(40); +} diff --git a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_file_attachment.dart b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_file_attachment.dart new file mode 100644 index 00000000..0c48d84b --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_file_attachment.dart @@ -0,0 +1,220 @@ +import 'package:flutter/widgets.dart'; + +import '../../../../stream_core_flutter.dart'; + +const _kDefaultConstraints = BoxConstraints(maxWidth: 260, maxHeight: 260, minHeight: 72); + +/// A composer attachment row that previews a file by name and metadata. +/// +/// [StreamMessageComposerFileAttachment] displays an optional leading [fileTypeIcon], a +/// [title] (the file name), and an optional [subtitle] (typically size or extension). It's +/// used for non-media files — documents, archives, etc. — that aren't shown as a thumbnail. +/// Both labels render on a single line and ellipsize on overflow. +/// +/// The row is at most 260 wide with a minimum height of 72. It shrinks to fit when a parent +/// bounds the width below 260, and the height grows to accommodate taller content (up to 260). +/// +/// {@tool snippet} +/// +/// A file attachment with name and size: +/// +/// ```dart +/// StreamMessageComposerFileAttachment( +/// title: Text('report.pdf'), +/// subtitle: Text('1.2 MB'), +/// fileTypeIcon: StreamFileTypeIcon.pdf(), +/// onRemovePressed: () => removeAttachment(), +/// ) +/// ``` +/// {@end-tool} +/// +/// ## Theming +/// +/// [StreamMessageComposerFileAttachment] uses [StreamMessageComposerFileAttachmentThemeData] +/// for default styling. Per-instance [style] takes precedence over the inherited theme. +/// +/// See also: +/// +/// * [StreamMessageComposerFileAttachmentTheme], for customizing file attachments globally. +/// * [StreamMessageComposerAttachment], the surrounding container. +/// * [StreamFileTypeIcon], the icon shown in the [fileTypeIcon] slot. +/// * [DefaultStreamMessageComposerFileAttachment], the default visual implementation. +class StreamMessageComposerFileAttachment extends StatelessWidget { + /// Creates a file attachment row. + StreamMessageComposerFileAttachment({ + super.key, + required Widget title, + Widget? subtitle, + StreamFileTypeIcon? fileTypeIcon, + VoidCallback? onRemovePressed, + StreamMessageComposerFileAttachmentThemeData? style, + }) : props = .new( + title: title, + subtitle: subtitle, + fileTypeIcon: fileTypeIcon, + onRemovePressed: onRemovePressed, + style: style, + ); + + /// The properties that configure this file attachment. + final StreamMessageComposerFileAttachmentProps props; + + @override + Widget build(BuildContext context) { + final builder = StreamComponentFactory.of(context).messageComposerFileAttachment; + if (builder != null) return builder(context, props); + return DefaultStreamMessageComposerFileAttachment(props: props); + } +} + +/// Properties for configuring a [StreamMessageComposerFileAttachment]. +/// +/// This class holds all the configuration options for a file attachment, allowing them to be +/// passed through the [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamMessageComposerFileAttachment], which uses these properties. +/// * [DefaultStreamMessageComposerFileAttachment], the default implementation. +class StreamMessageComposerFileAttachmentProps { + /// Creates properties for a file attachment. + const StreamMessageComposerFileAttachmentProps({ + required this.title, + this.subtitle, + this.fileTypeIcon, + this.onRemovePressed, + this.style, + }); + + /// The primary label shown on the first line. + /// + /// Typically a [Text] showing the file name. Renders on a single line and ellipsizes on + /// overflow. + final Widget title; + + /// An optional secondary label shown below [title]. + /// + /// Typically a [Text] showing the file size or extension. Renders on a single line and + /// ellipsizes on overflow. The row collapses to a single line when null. + final Widget? subtitle; + + /// An optional leading icon representing the file type. + /// + /// When null, the row begins with the title/subtitle column flush to the start padding. + final StreamFileTypeIcon? fileTypeIcon; + + /// Called when the remove button is tapped. + /// + /// When null, no remove control is shown on the surrounding container. + final VoidCallback? onRemovePressed; + + /// Per-instance style overrides. + /// + /// Fields left null fall back to the inherited [StreamMessageComposerFileAttachmentTheme], + /// then to built-in defaults. + final StreamMessageComposerFileAttachmentThemeData? style; +} + +/// The default implementation of [StreamMessageComposerFileAttachment]. +/// +/// Renders the file attachment with theming support from +/// [StreamMessageComposerFileAttachmentTheme]. It is used as the default factory +/// implementation in [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamMessageComposerFileAttachment], the public API widget. +/// * [StreamMessageComposerFileAttachmentProps], which configures this widget. +class DefaultStreamMessageComposerFileAttachment extends StatelessWidget { + /// Creates a default file attachment with the given [props]. + const DefaultStreamMessageComposerFileAttachment({super.key, required this.props}); + + /// The properties that configure this file attachment. + final StreamMessageComposerFileAttachmentProps props; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + final theme = context.streamMessageComposerFileAttachmentTheme.merge(props.style); + final defaults = _StreamMessageComposerFileAttachmentDefaults(context); + + final effectiveTitleStyle = theme.titleTextStyle ?? defaults.titleTextStyle; + final effectiveSubtitleStyle = theme.subtitleTextStyle ?? defaults.subtitleTextStyle; + final effectivePadding = theme.padding ?? defaults.padding; + final effectiveSpacing = theme.spacing ?? defaults.spacing; + + final effectiveTitle = DefaultTextStyle.merge( + style: effectiveTitleStyle, + maxLines: 1, + overflow: .ellipsis, + child: props.title, + ); + + Widget? effectiveSubtitle; + if (props.subtitle case final subtitle?) { + effectiveSubtitle = DefaultTextStyle.merge( + style: effectiveSubtitleStyle, + maxLines: 1, + overflow: .ellipsis, + child: subtitle, + ); + } + + return StreamMessageComposerAttachment( + onRemovePressed: props.onRemovePressed, + child: ConstrainedBox( + constraints: _kDefaultConstraints, + child: Padding( + padding: effectivePadding, + child: Row( + mainAxisSize: .min, + spacing: effectiveSpacing, + children: [ + ?props.fileTypeIcon, + Expanded( + child: Column( + spacing: spacing.xxs, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [effectiveTitle, ?effectiveSubtitle], + ), + ), + ], + ), + ), + ), + ); + } +} + +// Default theme values for [StreamMessageComposerFileAttachment]. +// +// Used when no explicit value is provided via +// [StreamMessageComposerFileAttachmentThemeData]. +class _StreamMessageComposerFileAttachmentDefaults extends StreamMessageComposerFileAttachmentThemeData { + _StreamMessageComposerFileAttachmentDefaults(this._context); + + final BuildContext _context; + + late final _spacing = _context.streamSpacing; + late final _textTheme = _context.streamTextTheme; + late final _colorScheme = _context.streamColorScheme; + + @override + TextStyle get titleTextStyle => _textTheme.metadataEmphasis.copyWith(color: _colorScheme.textPrimary); + + @override + TextStyle get subtitleTextStyle => _textTheme.metadataDefault.copyWith(color: _colorScheme.textSecondary); + + @override + EdgeInsetsGeometry get padding => EdgeInsetsDirectional.only( + start: _spacing.md, + end: _spacing.sm, + top: _spacing.md, + bottom: _spacing.md, + ); + + @override + double get spacing => _spacing.sm; +} diff --git a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_link_preview_attachment.dart b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_link_preview_attachment.dart new file mode 100644 index 00000000..ff954bda --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_link_preview_attachment.dart @@ -0,0 +1,283 @@ +import 'package:flutter/material.dart'; + +import '../../../../stream_core_flutter.dart'; + +const _kDefaultConstraints = BoxConstraints(minWidth: 290); + +/// A composer attachment that previews a link with optional thumbnail, title, subtitle, and +/// caption. +/// +/// [StreamMessageComposerLinkPreviewAttachment] displays an optional leading [thumbnail] +/// (typically a favicon or hero image), a [title] (the page title), a [subtitle] (the page +/// description), and a [caption] (typically the URL with a leading link icon). Each text +/// field renders on a single line and ellipsizes on overflow; null fields are omitted. +/// +/// The preview has a minimum width of 290 and grows to fit longer content. It shrinks to fit +/// when a parent bounds the width below 290. Height adapts to the content. +/// +/// {@tool snippet} +/// +/// A link preview with title, subtitle, and a caption with a leading link icon: +/// +/// ```dart +/// StreamMessageComposerLinkPreviewAttachment( +/// title: Text('Stream Chat'), +/// subtitle: Text('Build in-app chat in days, not months.'), +/// caption: Row( +/// spacing: 4, +/// children: [Icon(Icons.link, size: 12), Text('getstream.io')], +/// ), +/// thumbnail: Image(image: NetworkImage(faviconUrl), fit: BoxFit.cover), +/// onRemovePressed: () => removePreview(), +/// ) +/// ``` +/// {@end-tool} +/// +/// ## Theming +/// +/// [StreamMessageComposerLinkPreviewAttachment] uses +/// [StreamMessageComposerLinkPreviewAttachmentThemeData] for default styling, including the +/// thumbnail's size, shape, and border. Per-instance [style] takes precedence over the +/// inherited theme. +/// +/// See also: +/// +/// * [StreamMessageComposerLinkPreviewAttachmentTheme], for customizing link previews +/// globally. +/// * [StreamMessageComposerAttachment], the surrounding container. +/// * [DefaultStreamMessageComposerLinkPreviewAttachment], the default visual implementation. +class StreamMessageComposerLinkPreviewAttachment extends StatelessWidget { + /// Creates a link preview attachment. + StreamMessageComposerLinkPreviewAttachment({ + super.key, + Widget? title, + Widget? subtitle, + Widget? caption, + Widget? thumbnail, + VoidCallback? onRemovePressed, + StreamMessageComposerLinkPreviewAttachmentThemeData? style, + }) : props = .new( + title: title, + subtitle: subtitle, + caption: caption, + thumbnail: thumbnail, + onRemovePressed: onRemovePressed, + style: style, + ); + + /// The properties that configure this link preview. + final StreamMessageComposerLinkPreviewAttachmentProps props; + + @override + Widget build(BuildContext context) { + final builder = StreamComponentFactory.of(context).messageComposerLinkPreviewAttachment; + if (builder != null) return builder(context, props); + return DefaultStreamMessageComposerLinkPreviewAttachment(props: props); + } +} + +/// Properties for configuring a [StreamMessageComposerLinkPreviewAttachment]. +/// +/// This class holds all the configuration options for a link preview, allowing them to be +/// passed through the [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamMessageComposerLinkPreviewAttachment], which uses these properties. +/// * [DefaultStreamMessageComposerLinkPreviewAttachment], the default implementation. +class StreamMessageComposerLinkPreviewAttachmentProps { + /// Creates properties for a link preview attachment. + const StreamMessageComposerLinkPreviewAttachmentProps({ + this.title, + this.subtitle, + this.caption, + this.thumbnail, + this.onRemovePressed, + this.style, + }); + + /// The title displayed on the first line. + /// + /// Typically a [Text] showing the page title. Renders on a single line and ellipsizes on + /// overflow. Omitted when null. + final Widget? title; + + /// The subtitle displayed below [title]. + /// + /// Typically a [Text] showing the page description. Renders on a single line and + /// ellipsizes on overflow. Omitted when null. + final Widget? subtitle; + + /// The caption displayed at the bottom of the preview. + /// + /// Typically the URL with a leading link icon (callers compose the icon + text themselves, + /// e.g. via a [Row]). Renders on a single line and ellipsizes on overflow. Omitted when + /// null. + final Widget? caption; + + /// An optional leading thumbnail. + /// + /// Typically an [Image] showing a favicon or hero image. When non-null, a square thumbnail + /// is placed at the start of the row and clipped to the thumbnail border radius; when null, + /// the text column is flush with the start padding. The widget is responsible for filling + /// the thumbnail bounds (typically [Image] with [BoxFit.cover]). + final Widget? thumbnail; + + /// Called when the remove button is tapped. + /// + /// When null, no remove control is shown on the surrounding container. + final VoidCallback? onRemovePressed; + + /// Per-instance style overrides. + /// + /// Fields left null fall back to the inherited + /// [StreamMessageComposerLinkPreviewAttachmentTheme], then to built-in defaults. + final StreamMessageComposerLinkPreviewAttachmentThemeData? style; +} + +/// The default implementation of [StreamMessageComposerLinkPreviewAttachment]. +/// +/// Renders the link preview with theming support from +/// [StreamMessageComposerLinkPreviewAttachmentTheme]. It is used as the default factory +/// implementation in [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamMessageComposerLinkPreviewAttachment], the public API widget. +/// * [StreamMessageComposerLinkPreviewAttachmentProps], which configures this widget. +class DefaultStreamMessageComposerLinkPreviewAttachment extends StatelessWidget { + /// Creates a default link preview with the given [props]. + const DefaultStreamMessageComposerLinkPreviewAttachment({super.key, required this.props}); + + /// The properties that configure this link preview. + final StreamMessageComposerLinkPreviewAttachmentProps props; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + final theme = context.streamMessageComposerLinkPreviewAttachmentTheme.merge(props.style); + final defaults = _StreamMessageComposerLinkPreviewAttachmentDefaults(context); + + final effectiveBackgroundColor = theme.backgroundColor ?? defaults.backgroundColor; + final effectiveTitleStyle = theme.titleTextStyle ?? defaults.titleTextStyle; + final effectiveSubtitleStyle = theme.subtitleTextStyle ?? defaults.subtitleTextStyle; + final effectivePadding = theme.padding ?? defaults.padding; + final effectiveThumbnailSide = theme.thumbnailSide ?? defaults.thumbnailSide; + final effectiveThumbnailShape = (theme.thumbnailShape ?? defaults.thumbnailShape).copyWith( + side: effectiveThumbnailSide, + ); + final effectiveThumbnailSize = theme.thumbnailSize ?? defaults.thumbnailSize; + + Widget? effectiveThumbnail; + if (props.thumbnail case final thumbnail?) { + effectiveThumbnail = SizedBox.fromSize( + size: effectiveThumbnailSize, + child: Material( + clipBehavior: Clip.hardEdge, + shape: effectiveThumbnailShape, + child: thumbnail, + ), + ); + } + + Widget? effectiveTitle; + if (props.title case final title?) { + effectiveTitle = DefaultTextStyle.merge( + style: effectiveTitleStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + child: title, + ); + } + + Widget? effectiveSubtitle; + if (props.subtitle case final subtitle?) { + effectiveSubtitle = DefaultTextStyle.merge( + style: effectiveSubtitleStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + child: subtitle, + ); + } + + Widget? effectiveCaption; + if (props.caption case final caption?) { + effectiveCaption = DefaultTextStyle.merge( + style: effectiveSubtitleStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + child: caption, + ); + } + + return StreamMessageComposerAttachment( + onRemovePressed: props.onRemovePressed, + style: StreamMessageComposerAttachmentThemeData( + backgroundColor: effectiveBackgroundColor, + side: BorderSide.none, + ), + child: ConstrainedBox( + constraints: _kDefaultConstraints, + child: Padding( + padding: effectivePadding, + child: Row( + mainAxisSize: .min, + crossAxisAlignment: .start, + spacing: spacing.xs, + children: [ + ?effectiveThumbnail, + Expanded( + child: Column( + mainAxisSize: .min, + spacing: spacing.xxxs, + crossAxisAlignment: .start, + children: [?effectiveTitle, ?effectiveSubtitle, ?effectiveCaption], + ), + ), + ], + ), + ), + ), + ); + } +} + +// Default theme values for [StreamMessageComposerLinkPreviewAttachment]. +// +// Used when no explicit value is provided via +// [StreamMessageComposerLinkPreviewAttachmentThemeData]. +class _StreamMessageComposerLinkPreviewAttachmentDefaults extends StreamMessageComposerLinkPreviewAttachmentThemeData { + _StreamMessageComposerLinkPreviewAttachmentDefaults(this._context); + + final BuildContext _context; + + late final _radius = _context.streamRadius; + late final _spacing = _context.streamSpacing; + late final _textTheme = _context.streamTextTheme; + late final _colorScheme = _context.streamColorScheme; + late final Color _textColor = _colorScheme.brand.shade900; + + @override + Color get backgroundColor => _colorScheme.brand.shade100; + + @override + TextStyle get titleTextStyle => _textTheme.metadataEmphasis.copyWith(color: _textColor); + + @override + TextStyle get subtitleTextStyle => _textTheme.metadataDefault.copyWith(color: _textColor); + + @override + EdgeInsetsGeometry get padding => EdgeInsetsDirectional.only( + start: _spacing.xs, + end: _spacing.sm, + top: _spacing.xs, + bottom: _spacing.xs, + ); + + @override + OutlinedBorder get thumbnailShape => RoundedSuperellipseBorder(borderRadius: BorderRadius.all(_radius.md)); + + @override + Size get thumbnailSize => const Size.square(40); +} diff --git a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_media_attachment.dart b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_media_attachment.dart new file mode 100644 index 00000000..c7db6ff2 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_media_attachment.dart @@ -0,0 +1,187 @@ +import 'package:flutter/widgets.dart'; + +import '../../../../stream_core_flutter.dart'; + +/// A composer attachment that displays a fixed-size thumbnail for an image or video. +/// +/// [StreamMessageComposerMediaAttachment] displays a [child] (typically an image or video +/// thumbnail) inside a square frame (default 72×72), with an optional [mediaBadge] overlaid +/// at the bottom-start corner — used to indicate video duration or media type. The [child] +/// is given tight constraints equal to the thumbnail bounds, so an [Image] with +/// [BoxFit.cover] fills automatically. +/// +/// {@tool snippet} +/// +/// An image media attachment: +/// +/// ```dart +/// StreamMessageComposerMediaAttachment( +/// onRemovePressed: () => removeAttachment(), +/// child: Image(image: NetworkImage(url), fit: BoxFit.cover), +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// A video thumbnail with a duration badge: +/// +/// ```dart +/// StreamMessageComposerMediaAttachment( +/// mediaBadge: StreamMediaBadge( +/// type: MediaBadgeType.video, +/// duration: Duration(minutes: 1, seconds: 42), +/// ), +/// onRemovePressed: () => removeAttachment(), +/// child: Image(image: NetworkImage(thumbnailUrl), fit: BoxFit.cover), +/// ) +/// ``` +/// {@end-tool} +/// +/// ## Theming +/// +/// [StreamMessageComposerMediaAttachment] uses [StreamMessageComposerMediaAttachmentThemeData] +/// for default styling, including the border color and the thumbnail dimensions. +/// Per-instance [style] takes precedence over the inherited theme. +/// +/// See also: +/// +/// * [StreamMessageComposerMediaAttachmentTheme], for customizing media attachments globally. +/// * [StreamMessageComposerAttachment], the surrounding container. +/// * [StreamMediaBadge], the badge typically passed as [mediaBadge]. +/// * [DefaultStreamMessageComposerMediaAttachment], the default visual implementation. +class StreamMessageComposerMediaAttachment extends StatelessWidget { + /// Creates a media attachment with a custom [child]. + StreamMessageComposerMediaAttachment({ + super.key, + required Widget child, + VoidCallback? onRemovePressed, + Widget? mediaBadge, + StreamMessageComposerMediaAttachmentThemeData? style, + }) : props = .new( + child: child, + onRemovePressed: onRemovePressed, + mediaBadge: mediaBadge, + style: style, + ); + + /// The properties that configure this media attachment. + final StreamMessageComposerMediaAttachmentProps props; + + @override + Widget build(BuildContext context) { + final builder = StreamComponentFactory.of(context).messageComposerMediaAttachment; + if (builder != null) return builder(context, props); + return DefaultStreamMessageComposerMediaAttachment(props: props); + } +} + +/// Properties for configuring a [StreamMessageComposerMediaAttachment]. +/// +/// This class holds all the configuration options for a media attachment, allowing them to +/// be passed through the [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamMessageComposerMediaAttachment], which uses these properties. +/// * [DefaultStreamMessageComposerMediaAttachment], the default implementation. +class StreamMessageComposerMediaAttachmentProps { + /// Creates properties for a media attachment. + const StreamMessageComposerMediaAttachmentProps({ + required this.child, + this.onRemovePressed, + this.mediaBadge, + this.style, + }); + + /// The thumbnail content, clipped to the surrounding container's rounded shape. + /// + /// Given tight constraints equal to the thumbnail bounds (default 72×72), so an [Image] + /// with [BoxFit.cover] fills automatically without any extra wrapper. + final Widget child; + + /// An optional badge overlaid at the bottom-start corner of the thumbnail. + /// + /// Typically a [StreamMediaBadge] used to indicate video duration or media type. + final Widget? mediaBadge; + + /// Called when the remove button is tapped. + /// + /// When null, no remove control is shown on the surrounding container. + final VoidCallback? onRemovePressed; + + /// Per-instance style overrides. + /// + /// Fields left null fall back to the inherited [StreamMessageComposerMediaAttachmentTheme], + /// then to built-in defaults. + final StreamMessageComposerMediaAttachmentThemeData? style; +} + +/// The default implementation of [StreamMessageComposerMediaAttachment]. +/// +/// Renders the media attachment with theming support from +/// [StreamMessageComposerMediaAttachmentTheme]. It is used as the default factory +/// implementation in [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamMessageComposerMediaAttachment], the public API widget. +/// * [StreamMessageComposerMediaAttachmentProps], which configures this widget. +class DefaultStreamMessageComposerMediaAttachment extends StatelessWidget { + /// Creates a default media attachment with the given [props]. + const DefaultStreamMessageComposerMediaAttachment({super.key, required this.props}); + + /// The properties that configure this media attachment. + final StreamMessageComposerMediaAttachmentProps props; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + final theme = context.streamMessageComposerMediaAttachmentTheme.merge(props.style); + final defaults = _StreamMessageComposerMediaAttachmentDefaults(context); + + final effectiveBorderColor = theme.borderColor ?? defaults.borderColor; + final effectiveSize = theme.size ?? defaults.size; + + return StreamMessageComposerAttachment( + onRemovePressed: props.onRemovePressed, + style: StreamMessageComposerAttachmentThemeData( + side: BorderSide(color: effectiveBorderColor), + ), + child: SizedBox.fromSize( + size: effectiveSize, + child: Stack( + fit: .expand, + children: [ + props.child, + if (props.mediaBadge case final badge?) + PositionedDirectional( + start: spacing.xxs, + bottom: spacing.xxs, + child: badge, + ), + ], + ), + ), + ); + } +} + +// Default theme values for [StreamMessageComposerMediaAttachment]. +// +// Used when no explicit value is provided via +// [StreamMessageComposerMediaAttachmentThemeData]. +class _StreamMessageComposerMediaAttachmentDefaults extends StreamMessageComposerMediaAttachmentThemeData { + _StreamMessageComposerMediaAttachmentDefaults(this._context); + + final BuildContext _context; + + late final _colorScheme = _context.streamColorScheme; + + @override + Color get borderColor => _colorScheme.borderOpacitySubtle; + + @override + Size get size => const Size.square(72); +} diff --git a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_reply_attachment.dart b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_reply_attachment.dart new file mode 100644 index 00000000..29e5f8de --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_reply_attachment.dart @@ -0,0 +1,301 @@ +import 'package:flutter/material.dart'; + +import '../../../../stream_core_flutter.dart'; + +const _kDefaultConstraints = BoxConstraints(minHeight: 56); + +const _kIndicatorWidth = 2.0; +const _kIndicatorVerticalMargin = 2.0; + +/// The direction of the message being quoted by a reply preview. +/// +/// Drives the direction-aware default colors of [StreamMessageComposerReplyAttachment] when the +/// corresponding theme fields are not provided. +enum StreamReplyDirection { + /// The quoted message was received from another user. + incoming, + + /// The quoted message was sent by the current user. + outgoing, +} + +/// A composer attachment that previews the message being replied to. +/// +/// [StreamMessageComposerReplyAttachment] displays a leading indicator bar, a [title] +/// (typically the quoted user's name), a [subtitle] (a preview of the quoted message body), +/// and an optional trailing [thumbnail] of the quoted attachment. Both labels render on a +/// single line and ellipsize on overflow. +/// +/// [direction] selects direction-aware default colors: incoming previews use a neutral +/// surface tint with a chrome indicator, outgoing previews use a brand-tinted background with +/// a brand indicator. +/// +/// The preview fills the parent's width and is at least 56 tall, growing vertically to fit +/// longer content. +/// +/// {@tool snippet} +/// +/// A reply preview for an outgoing message: +/// +/// ```dart +/// StreamMessageComposerReplyAttachment( +/// title: Text('Reply to John Doe'), +/// subtitle: Text('We had a great time during our holiday.'), +/// direction: StreamReplyDirection.outgoing, +/// onRemovePressed: () => cancelReply(), +/// ) +/// ``` +/// {@end-tool} +/// +/// ## Theming +/// +/// [StreamMessageComposerReplyAttachment] uses +/// [StreamMessageComposerReplyAttachmentThemeData] for default styling, falling back to +/// direction-aware defaults selected by [direction]. Per-instance [style] takes precedence +/// over the inherited theme. +/// +/// See also: +/// +/// * [StreamMessageComposerReplyAttachmentTheme], for customizing reply previews globally. +/// * [StreamMessageComposerAttachment], the surrounding container. +/// * [StreamReplyDirection], which selects direction-aware defaults. +/// * [DefaultStreamMessageComposerReplyAttachment], the default visual implementation. +class StreamMessageComposerReplyAttachment extends StatelessWidget { + /// Creates a reply preview attachment. + StreamMessageComposerReplyAttachment({ + super.key, + required Widget title, + required Widget subtitle, + Widget? thumbnail, + VoidCallback? onRemovePressed, + StreamReplyDirection direction = .incoming, + StreamMessageComposerReplyAttachmentThemeData? style, + }) : props = .new( + title: title, + subtitle: subtitle, + thumbnail: thumbnail, + onRemovePressed: onRemovePressed, + direction: direction, + style: style, + ); + + /// The properties that configure this reply preview. + final StreamMessageComposerReplyAttachmentProps props; + + @override + Widget build(BuildContext context) { + final builder = StreamComponentFactory.of(context).messageComposerReplyAttachment; + if (builder != null) return builder(context, props); + return DefaultStreamMessageComposerReplyAttachment(props: props); + } +} + +/// Properties for configuring a [StreamMessageComposerReplyAttachment]. +/// +/// This class holds all the configuration options for a reply preview, allowing them to be +/// passed through the [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamMessageComposerReplyAttachment], which uses these properties. +/// * [DefaultStreamMessageComposerReplyAttachment], the default implementation. +class StreamMessageComposerReplyAttachmentProps { + /// Creates properties for a reply preview attachment. + const StreamMessageComposerReplyAttachmentProps({ + required this.title, + required this.subtitle, + this.thumbnail, + this.onRemovePressed, + this.direction = .incoming, + this.style, + }); + + /// The primary label shown on the first line. + /// + /// Typically a [Text] showing the quoted user's name. Renders on a single line and + /// ellipsizes on overflow. + final Widget title; + + /// The secondary label shown below [title]. + /// + /// Typically a [Text] previewing the quoted message body. Renders on a single line and + /// ellipsizes on overflow. + final Widget subtitle; + + /// An optional thumbnail of the quoted attachment, rendered at the end of the preview row. + /// + /// Sized and shaped via the inherited theme (default 40×40 with rounded corners). The + /// caller-provided widget is responsible for filling the thumbnail bounds — typically an + /// [Image] with [BoxFit.cover]. When null, the title/subtitle column expands to the full + /// width of the row. + final Widget? thumbnail; + + /// Called when the remove button is tapped. + /// + /// When null, no remove control is shown on the surrounding container. + final VoidCallback? onRemovePressed; + + /// Selects direction-aware default colors for the background, indicator bar, and text. + /// + /// Has no effect on fields explicitly provided through [style] or the inherited theme. + /// Defaults to [StreamReplyDirection.incoming]. + final StreamReplyDirection direction; + + /// Per-instance style overrides. + /// + /// Fields left null fall back to the inherited [StreamMessageComposerReplyAttachmentTheme], + /// then to direction-aware defaults selected by [direction]. + final StreamMessageComposerReplyAttachmentThemeData? style; +} + +/// The default implementation of [StreamMessageComposerReplyAttachment]. +/// +/// Renders the reply preview with theming support from +/// [StreamMessageComposerReplyAttachmentTheme]. It is used as the default factory +/// implementation in [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamMessageComposerReplyAttachment], the public API widget. +/// * [StreamMessageComposerReplyAttachmentProps], which configures this widget. +class DefaultStreamMessageComposerReplyAttachment extends StatelessWidget { + /// Creates a default reply preview with the given [props]. + const DefaultStreamMessageComposerReplyAttachment({super.key, required this.props}); + + /// The properties that configure this reply preview. + final StreamMessageComposerReplyAttachmentProps props; + + @override + Widget build(BuildContext context) { + final radius = context.streamRadius; + final spacing = context.streamSpacing; + + final theme = context.streamMessageComposerReplyAttachmentTheme.merge(props.style); + final defaults = _StreamMessageComposerReplyAttachmentDefaults(context, props.direction); + + final effectiveBackgroundColor = theme.backgroundColor ?? defaults.backgroundColor; + final effectiveIndicatorColor = theme.indicatorColor ?? defaults.indicatorColor; + final effectiveTitleStyle = theme.titleTextStyle ?? defaults.titleTextStyle; + final effectiveSubtitleStyle = theme.subtitleTextStyle ?? defaults.subtitleTextStyle; + final effectivePadding = theme.padding ?? defaults.padding; + final effectiveThumbnailSide = theme.thumbnailSide ?? defaults.thumbnailSide; + final effectiveThumbnailShape = (theme.thumbnailShape ?? defaults.thumbnailShape).copyWith( + side: effectiveThumbnailSide, + ); + final effectiveThumbnailSize = theme.thumbnailSize ?? defaults.thumbnailSize; + + final effectiveTitle = DefaultTextStyle.merge( + style: effectiveTitleStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + child: props.title, + ); + + final effectiveSubtitle = DefaultTextStyle.merge( + style: effectiveSubtitleStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + child: props.subtitle, + ); + + Widget? effectiveThumbnail; + if (props.thumbnail case final thumbnail?) { + effectiveThumbnail = SizedBox.fromSize( + size: effectiveThumbnailSize, + child: Material( + clipBehavior: Clip.hardEdge, + shape: effectiveThumbnailShape, + child: thumbnail, + ), + ); + } + + return StreamMessageComposerAttachment( + onRemovePressed: props.onRemovePressed, + style: StreamMessageComposerAttachmentThemeData( + backgroundColor: effectiveBackgroundColor, + side: BorderSide.none, + ), + child: ConstrainedBox( + constraints: _kDefaultConstraints, + child: Padding( + padding: effectivePadding, + child: IntrinsicHeight( + child: Row( + mainAxisSize: .min, + spacing: spacing.xs, + children: [ + VerticalDivider( + width: _kIndicatorWidth, + thickness: _kIndicatorWidth, + indent: _kIndicatorVerticalMargin, + endIndent: _kIndicatorVerticalMargin, + radius: BorderRadius.all(radius.max), + color: effectiveIndicatorColor, + ), + Expanded( + child: Column( + mainAxisSize: .min, + spacing: spacing.xxxs, + mainAxisAlignment: .center, + crossAxisAlignment: .start, + children: [effectiveTitle, effectiveSubtitle], + ), + ), + ?effectiveThumbnail, + ], + ), + ), + ), + ), + ); + } +} + +// Default theme values for [StreamMessageComposerReplyAttachment]. +// +// Used when no explicit value is provided via +// [StreamMessageComposerReplyAttachmentThemeData]. Direction-aware defaults +// are selected by [_direction]. +class _StreamMessageComposerReplyAttachmentDefaults extends StreamMessageComposerReplyAttachmentThemeData { + _StreamMessageComposerReplyAttachmentDefaults(this._context, this._direction); + + final BuildContext _context; + final StreamReplyDirection _direction; + + late final _spacing = _context.streamSpacing; + late final _textTheme = _context.streamTextTheme; + late final _colorScheme = _context.streamColorScheme; + + late final Color _textColor = switch (_direction) { + StreamReplyDirection.incoming => _colorScheme.textPrimary, + StreamReplyDirection.outgoing => _colorScheme.brand.shade900, + }; + + @override + Color get backgroundColor => switch (_direction) { + StreamReplyDirection.incoming => _colorScheme.backgroundSurface, + StreamReplyDirection.outgoing => _colorScheme.brand.shade100, + }; + + @override + Color get indicatorColor => switch (_direction) { + StreamReplyDirection.incoming => _colorScheme.chrome.shade400, + StreamReplyDirection.outgoing => _colorScheme.brand.shade400, + }; + + @override + TextStyle get titleTextStyle => _textTheme.metadataEmphasis.copyWith(color: _textColor); + + @override + TextStyle get subtitleTextStyle => _textTheme.metadataDefault.copyWith(color: _textColor); + + @override + EdgeInsetsGeometry get padding => EdgeInsets.all(_spacing.xs); + + @override + OutlinedBorder get thumbnailShape => RoundedSuperellipseBorder(borderRadius: .all(_context.streamRadius.md)); + + @override + Size get thumbnailSize => const Size.square(40); +} diff --git a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_unsupported_attachment.dart b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_unsupported_attachment.dart new file mode 100644 index 00000000..e8bfb846 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_unsupported_attachment.dart @@ -0,0 +1,176 @@ +import 'package:flutter/widgets.dart'; + +import '../../../../stream_core_flutter.dart'; + +const _kDefaultConstraints = BoxConstraints(maxWidth: 260, maxHeight: 260, minHeight: 72); + +/// A composer attachment placeholder shown for attachments the client cannot render. +/// +/// [StreamMessageComposerUnsupportedAttachment] displays a leading "unsupported" glyph and a +/// single [label] line — e.g. for custom or third-party attachment types the SDK doesn't +/// have a renderer for. The label renders on a single line and ellipsizes on overflow. +/// +/// The row is at most 260 wide with a minimum height of 72. It shrinks to fit when a parent +/// bounds the width below 260, and the height grows to accommodate taller content (up to 260). +/// +/// {@tool snippet} +/// +/// An unsupported-attachment placeholder: +/// +/// ```dart +/// StreamMessageComposerUnsupportedAttachment( +/// label: Text('Unsupported attachment'), +/// onRemovePressed: () => removeAttachment(), +/// ) +/// ``` +/// {@end-tool} +/// +/// ## Theming +/// +/// [StreamMessageComposerUnsupportedAttachment] uses +/// [StreamMessageComposerUnsupportedAttachmentThemeData] for default styling. Per-instance +/// [style] takes precedence over the inherited theme. +/// +/// See also: +/// +/// * [StreamMessageComposerUnsupportedAttachmentTheme], for customizing unsupported +/// placeholders globally. +/// * [StreamMessageComposerAttachment], the surrounding container. +/// * [DefaultStreamMessageComposerUnsupportedAttachment], the default visual implementation. +class StreamMessageComposerUnsupportedAttachment extends StatelessWidget { + /// Creates an unsupported-attachment placeholder. + StreamMessageComposerUnsupportedAttachment({ + super.key, + required Widget label, + VoidCallback? onRemovePressed, + StreamMessageComposerUnsupportedAttachmentThemeData? style, + }) : props = .new( + label: label, + onRemovePressed: onRemovePressed, + style: style, + ); + + /// The properties that configure this placeholder. + final StreamMessageComposerUnsupportedAttachmentProps props; + + @override + Widget build(BuildContext context) { + final builder = StreamComponentFactory.of(context).messageComposerUnsupportedAttachment; + if (builder != null) return builder(context, props); + return DefaultStreamMessageComposerUnsupportedAttachment(props: props); + } +} + +/// Properties for configuring a [StreamMessageComposerUnsupportedAttachment]. +/// +/// This class holds all the configuration options for an unsupported-attachment placeholder, +/// allowing them to be passed through the [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamMessageComposerUnsupportedAttachment], which uses these properties. +/// * [DefaultStreamMessageComposerUnsupportedAttachment], the default implementation. +class StreamMessageComposerUnsupportedAttachmentProps { + /// Creates properties for an unsupported-attachment placeholder. + const StreamMessageComposerUnsupportedAttachmentProps({ + required this.label, + this.onRemovePressed, + this.style, + }); + + /// The placeholder label. + /// + /// Typically a [Text] showing a localized "Unsupported attachment" string. Renders on a + /// single line and ellipsizes on overflow. + final Widget label; + + /// Called when the remove button is tapped. + /// + /// When null, no remove control is shown on the surrounding container. + final VoidCallback? onRemovePressed; + + /// Per-instance style overrides. + /// + /// Fields left null fall back to the inherited + /// [StreamMessageComposerUnsupportedAttachmentTheme], then to built-in defaults. + final StreamMessageComposerUnsupportedAttachmentThemeData? style; +} + +/// The default implementation of [StreamMessageComposerUnsupportedAttachment]. +/// +/// Renders the placeholder with theming support from +/// [StreamMessageComposerUnsupportedAttachmentTheme]. It is used as the default factory +/// implementation in [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamMessageComposerUnsupportedAttachment], the public API widget. +/// * [StreamMessageComposerUnsupportedAttachmentProps], which configures this widget. +class DefaultStreamMessageComposerUnsupportedAttachment extends StatelessWidget { + /// Creates a default unsupported-attachment placeholder with the given [props]. + const DefaultStreamMessageComposerUnsupportedAttachment({super.key, required this.props}); + + /// The properties that configure this placeholder. + final StreamMessageComposerUnsupportedAttachmentProps props; + + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + + final theme = context.streamMessageComposerUnsupportedAttachmentTheme.merge(props.style); + final defaults = _StreamMessageComposerUnsupportedAttachmentDefaults(context); + + final effectiveLabelStyle = theme.labelTextStyle ?? defaults.labelTextStyle; + final effectivePadding = theme.padding ?? defaults.padding; + final effectiveSpacing = theme.spacing ?? defaults.spacing; + + final effectiveLabel = DefaultTextStyle.merge( + style: effectiveLabelStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + child: props.label, + ); + + return StreamMessageComposerAttachment( + onRemovePressed: props.onRemovePressed, + child: ConstrainedBox( + constraints: _kDefaultConstraints, + child: Padding( + padding: effectivePadding, + child: Row( + mainAxisSize: .min, + spacing: effectiveSpacing, + children: [ + Icon(icons.unsupportedAttachment, size: 20, color: effectiveLabelStyle.color), + Expanded(child: effectiveLabel), + ], + ), + ), + ), + ); + } +} + +// Default theme values for [StreamMessageComposerUnsupportedAttachment]. +// +// Used when no explicit value is provided via +// [StreamMessageComposerUnsupportedAttachmentThemeData]. +class _StreamMessageComposerUnsupportedAttachmentDefaults extends StreamMessageComposerUnsupportedAttachmentThemeData { + _StreamMessageComposerUnsupportedAttachmentDefaults(this._context); + + final BuildContext _context; + + late final _spacing = _context.streamSpacing; + late final _textTheme = _context.streamTextTheme; + late final _colorScheme = _context.streamColorScheme; + + @override + TextStyle get labelTextStyle => _textTheme.metadataEmphasis.copyWith(color: _colorScheme.textPrimary); + + @override + EdgeInsetsGeometry get padding => + EdgeInsetsDirectional.only(start: _spacing.md, end: _spacing.sm, top: _spacing.md, bottom: _spacing.md); + + @override + double get spacing => _spacing.xs; +} 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 3252cb7d..d9e643e5 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 @@ -151,6 +151,13 @@ class StreamComponentBuilders with _$StreamComponentBuilders { StreamComponentBuilder? loadingSpinner, StreamComponentBuilder? messageAnnotation, StreamComponentBuilder? messageBubble, + StreamComponentBuilder? messageComposerAttachment, + StreamComponentBuilder? messageComposerEditMessageAttachment, + StreamComponentBuilder? messageComposerFileAttachment, + StreamComponentBuilder? messageComposerLinkPreviewAttachment, + StreamComponentBuilder? messageComposerMediaAttachment, + StreamComponentBuilder? messageComposerReplyAttachment, + StreamComponentBuilder? messageComposerUnsupportedAttachment, StreamComponentBuilder? messageContent, StreamComponentBuilder? messageMetadata, StreamComponentBuilder? messageReplies, @@ -194,6 +201,13 @@ class StreamComponentBuilders with _$StreamComponentBuilders { loadingSpinner: loadingSpinner, messageAnnotation: messageAnnotation, messageBubble: messageBubble, + messageComposerAttachment: messageComposerAttachment, + messageComposerEditMessageAttachment: messageComposerEditMessageAttachment, + messageComposerFileAttachment: messageComposerFileAttachment, + messageComposerLinkPreviewAttachment: messageComposerLinkPreviewAttachment, + messageComposerMediaAttachment: messageComposerMediaAttachment, + messageComposerReplyAttachment: messageComposerReplyAttachment, + messageComposerUnsupportedAttachment: messageComposerUnsupportedAttachment, messageContent: messageContent, messageMetadata: messageMetadata, messageReplies: messageReplies, @@ -238,6 +252,13 @@ class StreamComponentBuilders with _$StreamComponentBuilders { required this.loadingSpinner, required this.messageAnnotation, required this.messageBubble, + required this.messageComposerAttachment, + required this.messageComposerEditMessageAttachment, + required this.messageComposerFileAttachment, + required this.messageComposerLinkPreviewAttachment, + required this.messageComposerMediaAttachment, + required this.messageComposerReplyAttachment, + required this.messageComposerUnsupportedAttachment, required this.messageContent, required this.messageMetadata, required this.messageReplies, @@ -379,6 +400,48 @@ class StreamComponentBuilders with _$StreamComponentBuilders { /// When null, [StreamMessageBubble] uses [DefaultStreamMessageBubble]. final StreamComponentBuilder? messageBubble; + /// Custom builder for composer attachment container widgets. + /// + /// When null, [StreamMessageComposerAttachment] uses + /// [DefaultStreamMessageComposerAttachment]. + final StreamComponentBuilder? messageComposerAttachment; + + /// Custom builder for composer edit-message preview attachment widgets. + /// + /// When null, [StreamMessageComposerEditMessageAttachment] uses + /// [DefaultStreamMessageComposerEditMessageAttachment]. + final StreamComponentBuilder? messageComposerEditMessageAttachment; + + /// Custom builder for composer file attachment widgets. + /// + /// When null, [StreamMessageComposerFileAttachment] uses + /// [DefaultStreamMessageComposerFileAttachment]. + final StreamComponentBuilder? messageComposerFileAttachment; + + /// Custom builder for composer link preview attachment widgets. + /// + /// When null, [StreamMessageComposerLinkPreviewAttachment] uses + /// [DefaultStreamMessageComposerLinkPreviewAttachment]. + final StreamComponentBuilder? messageComposerLinkPreviewAttachment; + + /// Custom builder for composer media (image/video) attachment widgets. + /// + /// When null, [StreamMessageComposerMediaAttachment] uses + /// [DefaultStreamMessageComposerMediaAttachment]. + final StreamComponentBuilder? messageComposerMediaAttachment; + + /// Custom builder for composer reply preview attachment widgets. + /// + /// When null, [StreamMessageComposerReplyAttachment] uses + /// [DefaultStreamMessageComposerReplyAttachment]. + final StreamComponentBuilder? messageComposerReplyAttachment; + + /// Custom builder for composer unsupported-attachment placeholder widgets. + /// + /// When null, [StreamMessageComposerUnsupportedAttachment] uses + /// [DefaultStreamMessageComposerUnsupportedAttachment]. + final StreamComponentBuilder? messageComposerUnsupportedAttachment; + /// Custom builder for message content layout widgets. /// /// When null, [StreamMessageContent] uses [DefaultStreamMessageContent]. 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 12a940e8..2ad6a684 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 @@ -51,6 +51,27 @@ mixin _$StreamComponentBuilders { loadingSpinner: t < 0.5 ? a.loadingSpinner : b.loadingSpinner, messageAnnotation: t < 0.5 ? a.messageAnnotation : b.messageAnnotation, messageBubble: t < 0.5 ? a.messageBubble : b.messageBubble, + messageComposerAttachment: t < 0.5 + ? a.messageComposerAttachment + : b.messageComposerAttachment, + messageComposerEditMessageAttachment: t < 0.5 + ? a.messageComposerEditMessageAttachment + : b.messageComposerEditMessageAttachment, + messageComposerFileAttachment: t < 0.5 + ? a.messageComposerFileAttachment + : b.messageComposerFileAttachment, + messageComposerLinkPreviewAttachment: t < 0.5 + ? a.messageComposerLinkPreviewAttachment + : b.messageComposerLinkPreviewAttachment, + messageComposerMediaAttachment: t < 0.5 + ? a.messageComposerMediaAttachment + : b.messageComposerMediaAttachment, + messageComposerReplyAttachment: t < 0.5 + ? a.messageComposerReplyAttachment + : b.messageComposerReplyAttachment, + messageComposerUnsupportedAttachment: t < 0.5 + ? a.messageComposerUnsupportedAttachment + : b.messageComposerUnsupportedAttachment, messageContent: t < 0.5 ? a.messageContent : b.messageContent, messageMetadata: t < 0.5 ? a.messageMetadata : b.messageMetadata, messageReplies: t < 0.5 ? a.messageReplies : b.messageReplies, @@ -100,6 +121,29 @@ mixin _$StreamComponentBuilders { Widget Function(BuildContext, StreamMessageAnnotationProps)? messageAnnotation, Widget Function(BuildContext, StreamMessageBubbleProps)? messageBubble, + Widget Function(BuildContext, StreamMessageComposerAttachmentProps)? + messageComposerAttachment, + Widget Function( + BuildContext, + StreamMessageComposerEditMessageAttachmentProps, + )? + messageComposerEditMessageAttachment, + Widget Function(BuildContext, StreamMessageComposerFileAttachmentProps)? + messageComposerFileAttachment, + Widget Function( + BuildContext, + StreamMessageComposerLinkPreviewAttachmentProps, + )? + messageComposerLinkPreviewAttachment, + Widget Function(BuildContext, StreamMessageComposerMediaAttachmentProps)? + messageComposerMediaAttachment, + Widget Function(BuildContext, StreamMessageComposerReplyAttachmentProps)? + messageComposerReplyAttachment, + Widget Function( + BuildContext, + StreamMessageComposerUnsupportedAttachmentProps, + )? + messageComposerUnsupportedAttachment, Widget Function(BuildContext, StreamMessageContentProps)? messageContent, Widget Function(BuildContext, StreamMessageMetadataProps)? messageMetadata, Widget Function(BuildContext, StreamMessageRepliesProps)? messageReplies, @@ -146,6 +190,25 @@ mixin _$StreamComponentBuilders { loadingSpinner: loadingSpinner ?? _this.loadingSpinner, messageAnnotation: messageAnnotation ?? _this.messageAnnotation, messageBubble: messageBubble ?? _this.messageBubble, + messageComposerAttachment: + messageComposerAttachment ?? _this.messageComposerAttachment, + messageComposerEditMessageAttachment: + messageComposerEditMessageAttachment ?? + _this.messageComposerEditMessageAttachment, + messageComposerFileAttachment: + messageComposerFileAttachment ?? _this.messageComposerFileAttachment, + messageComposerLinkPreviewAttachment: + messageComposerLinkPreviewAttachment ?? + _this.messageComposerLinkPreviewAttachment, + messageComposerMediaAttachment: + messageComposerMediaAttachment ?? + _this.messageComposerMediaAttachment, + messageComposerReplyAttachment: + messageComposerReplyAttachment ?? + _this.messageComposerReplyAttachment, + messageComposerUnsupportedAttachment: + messageComposerUnsupportedAttachment ?? + _this.messageComposerUnsupportedAttachment, messageContent: messageContent ?? _this.messageContent, messageMetadata: messageMetadata ?? _this.messageMetadata, messageReplies: messageReplies ?? _this.messageReplies, @@ -200,6 +263,16 @@ mixin _$StreamComponentBuilders { loadingSpinner: other.loadingSpinner, messageAnnotation: other.messageAnnotation, messageBubble: other.messageBubble, + messageComposerAttachment: other.messageComposerAttachment, + messageComposerEditMessageAttachment: + other.messageComposerEditMessageAttachment, + messageComposerFileAttachment: other.messageComposerFileAttachment, + messageComposerLinkPreviewAttachment: + other.messageComposerLinkPreviewAttachment, + messageComposerMediaAttachment: other.messageComposerMediaAttachment, + messageComposerReplyAttachment: other.messageComposerReplyAttachment, + messageComposerUnsupportedAttachment: + other.messageComposerUnsupportedAttachment, messageContent: other.messageContent, messageMetadata: other.messageMetadata, messageReplies: other.messageReplies, @@ -255,6 +328,19 @@ mixin _$StreamComponentBuilders { _other.loadingSpinner == _this.loadingSpinner && _other.messageAnnotation == _this.messageAnnotation && _other.messageBubble == _this.messageBubble && + _other.messageComposerAttachment == _this.messageComposerAttachment && + _other.messageComposerEditMessageAttachment == + _this.messageComposerEditMessageAttachment && + _other.messageComposerFileAttachment == + _this.messageComposerFileAttachment && + _other.messageComposerLinkPreviewAttachment == + _this.messageComposerLinkPreviewAttachment && + _other.messageComposerMediaAttachment == + _this.messageComposerMediaAttachment && + _other.messageComposerReplyAttachment == + _this.messageComposerReplyAttachment && + _other.messageComposerUnsupportedAttachment == + _this.messageComposerUnsupportedAttachment && _other.messageContent == _this.messageContent && _other.messageMetadata == _this.messageMetadata && _other.messageReplies == _this.messageReplies && @@ -302,6 +388,13 @@ mixin _$StreamComponentBuilders { _this.loadingSpinner, _this.messageAnnotation, _this.messageBubble, + _this.messageComposerAttachment, + _this.messageComposerEditMessageAttachment, + _this.messageComposerFileAttachment, + _this.messageComposerLinkPreviewAttachment, + _this.messageComposerMediaAttachment, + _this.messageComposerReplyAttachment, + _this.messageComposerUnsupportedAttachment, _this.messageContent, _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 aa4ce608..525442e6 100644 --- a/packages/stream_core_flutter/lib/src/theme.dart +++ b/packages/stream_core_flutter/lib/src/theme.dart @@ -17,6 +17,13 @@ export 'theme/components/stream_list_tile_theme.dart'; export 'theme/components/stream_message_annotation_theme.dart'; export 'theme/components/stream_message_attachment_theme.dart'; export 'theme/components/stream_message_bubble_theme.dart'; +export 'theme/components/stream_message_composer_attachment_theme.dart'; +export 'theme/components/stream_message_composer_edit_message_attachment_theme.dart'; +export 'theme/components/stream_message_composer_file_attachment_theme.dart'; +export 'theme/components/stream_message_composer_link_preview_attachment_theme.dart'; +export 'theme/components/stream_message_composer_media_attachment_theme.dart'; +export 'theme/components/stream_message_composer_reply_attachment_theme.dart'; +export 'theme/components/stream_message_composer_unsupported_attachment_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'; diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_attachment_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_attachment_theme.dart new file mode 100644 index 00000000..a1d60017 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_attachment_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_composer_attachment_theme.g.theme.dart'; + +/// Applies a composer attachment theme to descendant +/// [StreamMessageComposerAttachment] widgets. +/// +/// Wrap a subtree with [StreamMessageComposerAttachmentTheme] to override +/// styling for the attachment containers shown in the message composer. +/// Access the merged theme using +/// [BuildContext.streamMessageComposerAttachmentTheme]. +/// +/// {@tool snippet} +/// +/// Override the composer attachment background for a specific section: +/// +/// ```dart +/// StreamMessageComposerAttachmentTheme( +/// data: StreamMessageComposerAttachmentThemeData( +/// backgroundColor: Colors.blue.shade50, +/// ), +/// child: StreamMessageComposer(), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageComposerAttachmentThemeData], which describes the theme +/// data. +/// * [StreamMessageComposerAttachment], the widget affected by this theme. +class StreamMessageComposerAttachmentTheme extends InheritedTheme { + /// Creates a composer attachment theme that controls descendant attachment + /// containers. + const StreamMessageComposerAttachmentTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The composer attachment theme data for descendant widgets. + final StreamMessageComposerAttachmentThemeData data; + + /// Returns the [StreamMessageComposerAttachmentThemeData] merged from local + /// and global themes. + /// + /// Local values from the nearest [StreamMessageComposerAttachmentTheme] + /// ancestor take precedence over global values from [StreamTheme.of]. + static StreamMessageComposerAttachmentThemeData of(BuildContext context) { + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamTheme.of(context).messageComposerAttachmentTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamMessageComposerAttachmentTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamMessageComposerAttachmentTheme oldWidget) => data != oldWidget.data; +} + +/// Theme data for customizing [StreamMessageComposerAttachment] widgets. +/// +/// Descendant widgets obtain their values from +/// [StreamMessageComposerAttachmentTheme.of]. All properties are null by +/// default, with fallback values applied by +/// [DefaultStreamMessageComposerAttachment]. +/// +/// {@tool snippet} +/// +/// Customize composer attachment containers globally via [StreamTheme]: +/// +/// ```dart +/// StreamTheme( +/// messageComposerAttachmentTheme: StreamMessageComposerAttachmentThemeData( +/// backgroundColor: Colors.blue.shade50, +/// side: BorderSide(color: Colors.blue.shade200), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageComposerAttachmentTheme], for overriding theme in a +/// widget subtree. +/// * [StreamMessageComposerAttachment], the widget that uses this theme data. +@themeGen +@immutable +class StreamMessageComposerAttachmentThemeData with _$StreamMessageComposerAttachmentThemeData { + /// Creates composer attachment theme data with optional overrides. + const StreamMessageComposerAttachmentThemeData({ + this.backgroundColor, + this.shape, + this.side, + this.padding, + }); + + /// Background fill color of the attachment container. + /// + /// If null, defaults to [StreamColorScheme.backgroundElevation1]. + final Color? backgroundColor; + + /// Outer shape of the attachment container. + /// + /// Composed with [side] to draw the container's border. If null, defaults + /// to a [RoundedRectangleBorder] with radius [StreamRadius.lg]. + final OutlinedBorder? shape; + + /// Border side drawn around the attachment container. + /// + /// Composed onto [shape] via [OutlinedBorder.copyWith]. If null, defaults + /// to a 1px [BorderSide] using [StreamColorScheme.borderDefault]. + final BorderSide? side; + + /// Outer padding around the attachment container, separating the container + /// from its siblings and reserving room for the optional remove control + /// overlay at the top-end corner. + /// + /// If null, defaults to [StreamSpacing.xxs] on all sides. + final EdgeInsetsGeometry? padding; + + /// Linearly interpolate between two + /// [StreamMessageComposerAttachmentThemeData] objects. + static StreamMessageComposerAttachmentThemeData? lerp( + StreamMessageComposerAttachmentThemeData? a, + StreamMessageComposerAttachmentThemeData? b, + double t, + ) => _$StreamMessageComposerAttachmentThemeData.lerp(a, b, t); +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_attachment_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_attachment_theme.g.theme.dart new file mode 100644 index 00000000..8156bfe1 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_attachment_theme.g.theme.dart @@ -0,0 +1,114 @@ +// 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_composer_attachment_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamMessageComposerAttachmentThemeData { + bool get canMerge => true; + + static StreamMessageComposerAttachmentThemeData? lerp( + StreamMessageComposerAttachmentThemeData? a, + StreamMessageComposerAttachmentThemeData? 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 StreamMessageComposerAttachmentThemeData( + backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), + 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), + ); + } + + StreamMessageComposerAttachmentThemeData copyWith({ + Color? backgroundColor, + OutlinedBorder? shape, + BorderSide? side, + EdgeInsetsGeometry? padding, + }) { + final _this = (this as StreamMessageComposerAttachmentThemeData); + + return StreamMessageComposerAttachmentThemeData( + backgroundColor: backgroundColor ?? _this.backgroundColor, + shape: shape ?? _this.shape, + side: side ?? _this.side, + padding: padding ?? _this.padding, + ); + } + + StreamMessageComposerAttachmentThemeData merge( + StreamMessageComposerAttachmentThemeData? other, + ) { + final _this = (this as StreamMessageComposerAttachmentThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + backgroundColor: other.backgroundColor, + shape: other.shape, + side: _this.side != null && other.side != null + ? BorderSide.merge(_this.side!, other.side!) + : other.side, + padding: other.padding, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamMessageComposerAttachmentThemeData); + final _other = (other as StreamMessageComposerAttachmentThemeData); + + return _other.backgroundColor == _this.backgroundColor && + _other.shape == _this.shape && + _other.side == _this.side && + _other.padding == _this.padding; + } + + @override + int get hashCode { + final _this = (this as StreamMessageComposerAttachmentThemeData); + + return Object.hash( + runtimeType, + _this.backgroundColor, + _this.shape, + _this.side, + _this.padding, + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_edit_message_attachment_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_edit_message_attachment_theme.dart new file mode 100644 index 00000000..03a70b68 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_edit_message_attachment_theme.dart @@ -0,0 +1,155 @@ +import 'package:flutter/widgets.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import '../stream_theme.dart'; + +part 'stream_message_composer_edit_message_attachment_theme.g.theme.dart'; + +/// Applies a composer edit-message attachment theme to descendant +/// [StreamMessageComposerEditMessageAttachment] widgets. +/// +/// Wrap a subtree with [StreamMessageComposerEditMessageAttachmentTheme] to override the styling +/// of the edit-message preview shown above the composer input. Access the merged theme using +/// [BuildContext.streamMessageComposerEditMessageAttachmentTheme]. +/// +/// {@tool snippet} +/// +/// Override the edit-message indicator color for a specific section: +/// +/// ```dart +/// StreamMessageComposerEditMessageAttachmentTheme( +/// data: StreamMessageComposerEditMessageAttachmentThemeData( +/// indicatorColor: Colors.green, +/// ), +/// child: StreamMessageComposer(), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageComposerEditMessageAttachmentThemeData], which describes the theme data. +/// * [StreamMessageComposerEditMessageAttachment], the widget affected by this theme. +class StreamMessageComposerEditMessageAttachmentTheme extends InheritedTheme { + /// Creates a composer edit-message attachment theme that controls descendant edit previews. + const StreamMessageComposerEditMessageAttachmentTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The composer edit-message attachment theme data for descendant widgets. + final StreamMessageComposerEditMessageAttachmentThemeData data; + + /// Returns the [StreamMessageComposerEditMessageAttachmentThemeData] merged from local and + /// global themes. + /// + /// Local values from the nearest [StreamMessageComposerEditMessageAttachmentTheme] ancestor + /// take precedence over global values from [StreamTheme.of]. + static StreamMessageComposerEditMessageAttachmentThemeData of(BuildContext context) { + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamTheme.of(context).messageComposerEditMessageAttachmentTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamMessageComposerEditMessageAttachmentTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamMessageComposerEditMessageAttachmentTheme oldWidget) => data != oldWidget.data; +} + +/// Theme data for customizing [StreamMessageComposerEditMessageAttachment] widgets. +/// +/// Descendant widgets obtain their values from +/// [StreamMessageComposerEditMessageAttachmentTheme.of]. All properties are null by default, +/// with fallback values applied by [DefaultStreamMessageComposerEditMessageAttachment] — a +/// brand-tinted background and indicator from the outgoing palette, with neutral text. +/// +/// {@tool snippet} +/// +/// Customize edit-message attachment styling globally via [StreamTheme]: +/// +/// ```dart +/// StreamTheme( +/// messageComposerEditMessageAttachmentTheme: +/// StreamMessageComposerEditMessageAttachmentThemeData( +/// indicatorColor: Colors.green, +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageComposerEditMessageAttachmentTheme], for overriding theme in a widget +/// subtree. +/// * [StreamMessageComposerEditMessageAttachment], the widget that uses this theme data. +@themeGen +@immutable +class StreamMessageComposerEditMessageAttachmentThemeData with _$StreamMessageComposerEditMessageAttachmentThemeData { + /// Creates composer edit-message attachment theme data with optional overrides. + const StreamMessageComposerEditMessageAttachmentThemeData({ + this.backgroundColor, + this.indicatorColor, + this.titleTextStyle, + this.subtitleTextStyle, + this.padding, + this.thumbnailShape, + this.thumbnailSide, + this.thumbnailSize, + }); + + /// Background fill color of the edit-message preview card. + /// + /// If null, defaults to `colorScheme.brand.shade100`. + final Color? backgroundColor; + + /// Color of the leading indicator bar. + /// + /// If null, defaults to `colorScheme.brand.shade400`. + final Color? indicatorColor; + + /// Text style for the preview title (typically the localized "Edit message" label). + /// + /// If null, defaults to [StreamTextTheme.metadataEmphasis] tinted with + /// [StreamColorScheme.textPrimary]. + final TextStyle? titleTextStyle; + + /// Text style for the preview subtitle (the message body being edited). + /// + /// If null, defaults to [StreamTextTheme.metadataDefault] tinted with + /// [StreamColorScheme.textPrimary]. + final TextStyle? subtitleTextStyle; + + /// Padding around the preview's content row. + /// + /// If null, defaults to [StreamSpacing.xs] on all sides. + final EdgeInsetsGeometry? padding; + + /// Outer shape of the trailing thumbnail. + /// + /// Composed with [thumbnailSide] to draw the thumbnail's border. If null, + /// defaults to a [RoundedSuperellipseBorder] with radius [StreamRadius.md]. + final OutlinedBorder? thumbnailShape; + + /// Border side drawn around the trailing thumbnail. + /// + /// Composed onto [thumbnailShape] via [OutlinedBorder.copyWith]. If null, + /// defaults to [BorderSide.none]. + final BorderSide? thumbnailSide; + + /// Dimensions of the trailing thumbnail. + /// + /// If null, defaults to `Size.square(40)`. + final Size? thumbnailSize; + + /// Linearly interpolate between two [StreamMessageComposerEditMessageAttachmentThemeData] + /// objects. + static StreamMessageComposerEditMessageAttachmentThemeData? lerp( + StreamMessageComposerEditMessageAttachmentThemeData? a, + StreamMessageComposerEditMessageAttachmentThemeData? b, + double t, + ) => _$StreamMessageComposerEditMessageAttachmentThemeData.lerp(a, b, t); +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_edit_message_attachment_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_edit_message_attachment_theme.g.theme.dart new file mode 100644 index 00000000..e687a716 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_edit_message_attachment_theme.g.theme.dart @@ -0,0 +1,151 @@ +// 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_composer_edit_message_attachment_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamMessageComposerEditMessageAttachmentThemeData { + bool get canMerge => true; + + static StreamMessageComposerEditMessageAttachmentThemeData? lerp( + StreamMessageComposerEditMessageAttachmentThemeData? a, + StreamMessageComposerEditMessageAttachmentThemeData? 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 StreamMessageComposerEditMessageAttachmentThemeData( + backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), + indicatorColor: Color.lerp(a.indicatorColor, b.indicatorColor, t), + titleTextStyle: TextStyle.lerp(a.titleTextStyle, b.titleTextStyle, t), + subtitleTextStyle: TextStyle.lerp( + a.subtitleTextStyle, + b.subtitleTextStyle, + t, + ), + padding: EdgeInsetsGeometry.lerp(a.padding, b.padding, t), + thumbnailShape: OutlinedBorder.lerp( + a.thumbnailShape, + b.thumbnailShape, + t, + ), + thumbnailSide: a.thumbnailSide == null + ? b.thumbnailSide + : b.thumbnailSide == null + ? a.thumbnailSide + : BorderSide.lerp(a.thumbnailSide!, b.thumbnailSide!, t), + thumbnailSize: Size.lerp(a.thumbnailSize, b.thumbnailSize, t), + ); + } + + StreamMessageComposerEditMessageAttachmentThemeData copyWith({ + Color? backgroundColor, + Color? indicatorColor, + TextStyle? titleTextStyle, + TextStyle? subtitleTextStyle, + EdgeInsetsGeometry? padding, + OutlinedBorder? thumbnailShape, + BorderSide? thumbnailSide, + Size? thumbnailSize, + }) { + final _this = (this as StreamMessageComposerEditMessageAttachmentThemeData); + + return StreamMessageComposerEditMessageAttachmentThemeData( + backgroundColor: backgroundColor ?? _this.backgroundColor, + indicatorColor: indicatorColor ?? _this.indicatorColor, + titleTextStyle: titleTextStyle ?? _this.titleTextStyle, + subtitleTextStyle: subtitleTextStyle ?? _this.subtitleTextStyle, + padding: padding ?? _this.padding, + thumbnailShape: thumbnailShape ?? _this.thumbnailShape, + thumbnailSide: thumbnailSide ?? _this.thumbnailSide, + thumbnailSize: thumbnailSize ?? _this.thumbnailSize, + ); + } + + StreamMessageComposerEditMessageAttachmentThemeData merge( + StreamMessageComposerEditMessageAttachmentThemeData? other, + ) { + final _this = (this as StreamMessageComposerEditMessageAttachmentThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + backgroundColor: other.backgroundColor, + indicatorColor: other.indicatorColor, + titleTextStyle: + _this.titleTextStyle?.merge(other.titleTextStyle) ?? + other.titleTextStyle, + subtitleTextStyle: + _this.subtitleTextStyle?.merge(other.subtitleTextStyle) ?? + other.subtitleTextStyle, + padding: other.padding, + thumbnailShape: other.thumbnailShape, + thumbnailSide: _this.thumbnailSide != null && other.thumbnailSide != null + ? BorderSide.merge(_this.thumbnailSide!, other.thumbnailSide!) + : other.thumbnailSide, + thumbnailSize: other.thumbnailSize, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamMessageComposerEditMessageAttachmentThemeData); + final _other = + (other as StreamMessageComposerEditMessageAttachmentThemeData); + + return _other.backgroundColor == _this.backgroundColor && + _other.indicatorColor == _this.indicatorColor && + _other.titleTextStyle == _this.titleTextStyle && + _other.subtitleTextStyle == _this.subtitleTextStyle && + _other.padding == _this.padding && + _other.thumbnailShape == _this.thumbnailShape && + _other.thumbnailSide == _this.thumbnailSide && + _other.thumbnailSize == _this.thumbnailSize; + } + + @override + int get hashCode { + final _this = (this as StreamMessageComposerEditMessageAttachmentThemeData); + + return Object.hash( + runtimeType, + _this.backgroundColor, + _this.indicatorColor, + _this.titleTextStyle, + _this.subtitleTextStyle, + _this.padding, + _this.thumbnailShape, + _this.thumbnailSide, + _this.thumbnailSize, + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_file_attachment_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_file_attachment_theme.dart new file mode 100644 index 00000000..1161d59b --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_file_attachment_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_composer_file_attachment_theme.g.theme.dart'; + +/// Applies a composer file attachment theme to descendant +/// [StreamMessageComposerFileAttachment] widgets. +/// +/// Wrap a subtree with [StreamMessageComposerFileAttachmentTheme] to override +/// the typography and spacing of file attachments rendered inside the +/// composer. Access the merged theme using +/// [BuildContext.streamMessageComposerFileAttachmentTheme]. +/// +/// {@tool snippet} +/// +/// Override the file attachment title style for a specific section: +/// +/// ```dart +/// StreamMessageComposerFileAttachmentTheme( +/// data: StreamMessageComposerFileAttachmentThemeData( +/// titleTextStyle: TextStyle(fontWeight: FontWeight.w700), +/// ), +/// child: StreamMessageComposer(), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageComposerFileAttachmentThemeData], which describes the +/// theme data. +/// * [StreamMessageComposerFileAttachment], the widget affected by this theme. +class StreamMessageComposerFileAttachmentTheme extends InheritedTheme { + /// Creates a composer file attachment theme that controls descendant file + /// attachments. + const StreamMessageComposerFileAttachmentTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The composer file attachment theme data for descendant widgets. + final StreamMessageComposerFileAttachmentThemeData data; + + /// Returns the [StreamMessageComposerFileAttachmentThemeData] merged from + /// local and global themes. + /// + /// Local values from the nearest [StreamMessageComposerFileAttachmentTheme] + /// ancestor take precedence over global values from [StreamTheme.of]. + static StreamMessageComposerFileAttachmentThemeData of(BuildContext context) { + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamTheme.of(context).messageComposerFileAttachmentTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamMessageComposerFileAttachmentTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamMessageComposerFileAttachmentTheme oldWidget) => data != oldWidget.data; +} + +/// Theme data for customizing [StreamMessageComposerFileAttachment] widgets. +/// +/// Descendant widgets obtain their values from +/// [StreamMessageComposerFileAttachmentTheme.of]. All properties are null by +/// default, with fallback values applied by +/// [DefaultStreamMessageComposerFileAttachment]. +/// +/// {@tool snippet} +/// +/// Customize file attachment typography globally via [StreamTheme]: +/// +/// ```dart +/// StreamTheme( +/// messageComposerFileAttachmentTheme: +/// StreamMessageComposerFileAttachmentThemeData( +/// titleTextStyle: TextStyle(fontWeight: FontWeight.w700), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageComposerFileAttachmentTheme], for overriding theme in a +/// widget subtree. +/// * [StreamMessageComposerFileAttachment], the widget that uses this theme data. +@themeGen +@immutable +class StreamMessageComposerFileAttachmentThemeData with _$StreamMessageComposerFileAttachmentThemeData { + /// Creates composer file attachment theme data with optional overrides. + const StreamMessageComposerFileAttachmentThemeData({ + this.titleTextStyle, + this.subtitleTextStyle, + this.padding, + this.spacing, + }); + + /// Text style for the file attachment title. + /// + /// If null, defaults to [StreamTextTheme.metadataEmphasis] tinted with + /// [StreamColorScheme.textPrimary]. + final TextStyle? titleTextStyle; + + /// Text style for the file attachment subtitle. + /// + /// If null, defaults to [StreamTextTheme.metadataDefault] tinted with + /// [StreamColorScheme.textSecondary]. + final TextStyle? subtitleTextStyle; + + /// Padding around the file attachment's content row. + /// + /// If null, defaults to a directional inset using [StreamSpacing.md] and + /// [StreamSpacing.sm] tokens. + final EdgeInsetsGeometry? padding; + + /// Horizontal space between the file type icon and the title/subtitle + /// column. + /// + /// If null, defaults to [StreamSpacing.sm]. + final double? spacing; + + /// Linearly interpolate between two + /// [StreamMessageComposerFileAttachmentThemeData] objects. + static StreamMessageComposerFileAttachmentThemeData? lerp( + StreamMessageComposerFileAttachmentThemeData? a, + StreamMessageComposerFileAttachmentThemeData? b, + double t, + ) => _$StreamMessageComposerFileAttachmentThemeData.lerp(a, b, t); +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_file_attachment_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_file_attachment_theme.g.theme.dart new file mode 100644 index 00000000..925e009d --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_file_attachment_theme.g.theme.dart @@ -0,0 +1,116 @@ +// 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_composer_file_attachment_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamMessageComposerFileAttachmentThemeData { + bool get canMerge => true; + + static StreamMessageComposerFileAttachmentThemeData? lerp( + StreamMessageComposerFileAttachmentThemeData? a, + StreamMessageComposerFileAttachmentThemeData? 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 StreamMessageComposerFileAttachmentThemeData( + titleTextStyle: TextStyle.lerp(a.titleTextStyle, b.titleTextStyle, t), + subtitleTextStyle: TextStyle.lerp( + a.subtitleTextStyle, + b.subtitleTextStyle, + t, + ), + padding: EdgeInsetsGeometry.lerp(a.padding, b.padding, t), + spacing: lerpDouble$(a.spacing, b.spacing, t), + ); + } + + StreamMessageComposerFileAttachmentThemeData copyWith({ + TextStyle? titleTextStyle, + TextStyle? subtitleTextStyle, + EdgeInsetsGeometry? padding, + double? spacing, + }) { + final _this = (this as StreamMessageComposerFileAttachmentThemeData); + + return StreamMessageComposerFileAttachmentThemeData( + titleTextStyle: titleTextStyle ?? _this.titleTextStyle, + subtitleTextStyle: subtitleTextStyle ?? _this.subtitleTextStyle, + padding: padding ?? _this.padding, + spacing: spacing ?? _this.spacing, + ); + } + + StreamMessageComposerFileAttachmentThemeData merge( + StreamMessageComposerFileAttachmentThemeData? other, + ) { + final _this = (this as StreamMessageComposerFileAttachmentThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + titleTextStyle: + _this.titleTextStyle?.merge(other.titleTextStyle) ?? + other.titleTextStyle, + subtitleTextStyle: + _this.subtitleTextStyle?.merge(other.subtitleTextStyle) ?? + other.subtitleTextStyle, + padding: other.padding, + spacing: other.spacing, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamMessageComposerFileAttachmentThemeData); + final _other = (other as StreamMessageComposerFileAttachmentThemeData); + + return _other.titleTextStyle == _this.titleTextStyle && + _other.subtitleTextStyle == _this.subtitleTextStyle && + _other.padding == _this.padding && + _other.spacing == _this.spacing; + } + + @override + int get hashCode { + final _this = (this as StreamMessageComposerFileAttachmentThemeData); + + return Object.hash( + runtimeType, + _this.titleTextStyle, + _this.subtitleTextStyle, + _this.padding, + _this.spacing, + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_link_preview_attachment_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_link_preview_attachment_theme.dart new file mode 100644 index 00000000..cf442814 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_link_preview_attachment_theme.dart @@ -0,0 +1,158 @@ +import 'package:flutter/widgets.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import '../stream_theme.dart'; + +part 'stream_message_composer_link_preview_attachment_theme.g.theme.dart'; + +/// Applies a composer link preview attachment theme to descendant +/// [StreamMessageComposerLinkPreviewAttachment] widgets. +/// +/// Wrap a subtree with [StreamMessageComposerLinkPreviewAttachmentTheme] to +/// override the styling of the link preview shown above the composer input. +/// Access the merged theme using +/// [BuildContext.streamMessageComposerLinkPreviewAttachmentTheme]. +/// +/// {@tool snippet} +/// +/// Override the link preview background for a specific section: +/// +/// ```dart +/// StreamMessageComposerLinkPreviewAttachmentTheme( +/// data: StreamMessageComposerLinkPreviewAttachmentThemeData( +/// backgroundColor: Colors.amber.shade50, +/// ), +/// child: StreamMessageComposer(), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageComposerLinkPreviewAttachmentThemeData], which describes +/// the theme data. +/// * [StreamMessageComposerLinkPreviewAttachment], the widget affected by this +/// theme. +class StreamMessageComposerLinkPreviewAttachmentTheme extends InheritedTheme { + /// Creates a composer link preview attachment theme that controls + /// descendant link previews. + const StreamMessageComposerLinkPreviewAttachmentTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The composer link preview attachment theme data for descendant widgets. + final StreamMessageComposerLinkPreviewAttachmentThemeData data; + + /// Returns the [StreamMessageComposerLinkPreviewAttachmentThemeData] merged + /// from local and global themes. + /// + /// Local values from the nearest + /// [StreamMessageComposerLinkPreviewAttachmentTheme] ancestor take + /// precedence over global values from [StreamTheme.of]. + static StreamMessageComposerLinkPreviewAttachmentThemeData of(BuildContext context) { + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamTheme.of(context).messageComposerLinkPreviewAttachmentTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamMessageComposerLinkPreviewAttachmentTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamMessageComposerLinkPreviewAttachmentTheme oldWidget) => data != oldWidget.data; +} + +/// Theme data for customizing [StreamMessageComposerLinkPreviewAttachment] widgets. +/// +/// Descendant widgets obtain their values from +/// [StreamMessageComposerLinkPreviewAttachmentTheme.of]. All properties are +/// null by default, with fallback values applied by +/// [DefaultStreamMessageComposerLinkPreviewAttachment]. +/// +/// {@tool snippet} +/// +/// Customize link preview styling globally via [StreamTheme]: +/// +/// ```dart +/// StreamTheme( +/// messageComposerLinkPreviewAttachmentTheme: +/// StreamMessageComposerLinkPreviewAttachmentThemeData( +/// backgroundColor: Colors.amber.shade50, +/// titleTextStyle: TextStyle(fontWeight: FontWeight.w700), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageComposerLinkPreviewAttachmentTheme], for overriding theme +/// in a widget subtree. +/// * [StreamMessageComposerLinkPreviewAttachment], the widget that uses this theme +/// data. +@themeGen +@immutable +class StreamMessageComposerLinkPreviewAttachmentThemeData with _$StreamMessageComposerLinkPreviewAttachmentThemeData { + /// Creates composer link preview attachment theme data with optional + /// overrides. + const StreamMessageComposerLinkPreviewAttachmentThemeData({ + this.backgroundColor, + this.titleTextStyle, + this.subtitleTextStyle, + this.padding, + this.thumbnailShape, + this.thumbnailSide, + this.thumbnailSize, + }); + + /// Background fill color of the link preview card. + /// + /// If null, defaults to `colorScheme.brand.shade100`. + final Color? backgroundColor; + + /// Text style for the link preview title. + /// + /// If null, defaults to [StreamTextTheme.metadataEmphasis] tinted with + /// `colorScheme.brand.shade900`. + final TextStyle? titleTextStyle; + + /// Text style for the link preview subtitle and URL. + /// + /// If null, defaults to [StreamTextTheme.metadataDefault] tinted with + /// `colorScheme.brand.shade900`. + final TextStyle? subtitleTextStyle; + + /// Padding around the link preview's content row. + /// + /// If null, defaults to a directional inset using [StreamSpacing.xs] and + /// [StreamSpacing.sm] tokens. + final EdgeInsetsGeometry? padding; + + /// Outer shape of the leading thumbnail. + /// + /// Composed with [thumbnailSide] to draw the thumbnail's border. If null, + /// defaults to a [RoundedSuperellipseBorder] with radius [StreamRadius.md]. + final OutlinedBorder? thumbnailShape; + + /// Border side drawn around the leading thumbnail. + /// + /// Composed onto [thumbnailShape] via [OutlinedBorder.copyWith]. If null, + /// defaults to [BorderSide.none]. + final BorderSide? thumbnailSide; + + /// Dimensions of the leading thumbnail. + /// + /// If null, defaults to `Size.square(40)`. + final Size? thumbnailSize; + + /// Linearly interpolate between two + /// [StreamMessageComposerLinkPreviewAttachmentThemeData] objects. + static StreamMessageComposerLinkPreviewAttachmentThemeData? lerp( + StreamMessageComposerLinkPreviewAttachmentThemeData? a, + StreamMessageComposerLinkPreviewAttachmentThemeData? b, + double t, + ) => _$StreamMessageComposerLinkPreviewAttachmentThemeData.lerp(a, b, t); +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_link_preview_attachment_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_link_preview_attachment_theme.g.theme.dart new file mode 100644 index 00000000..03f3bf25 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_link_preview_attachment_theme.g.theme.dart @@ -0,0 +1,145 @@ +// 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_composer_link_preview_attachment_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamMessageComposerLinkPreviewAttachmentThemeData { + bool get canMerge => true; + + static StreamMessageComposerLinkPreviewAttachmentThemeData? lerp( + StreamMessageComposerLinkPreviewAttachmentThemeData? a, + StreamMessageComposerLinkPreviewAttachmentThemeData? 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 StreamMessageComposerLinkPreviewAttachmentThemeData( + backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), + titleTextStyle: TextStyle.lerp(a.titleTextStyle, b.titleTextStyle, t), + subtitleTextStyle: TextStyle.lerp( + a.subtitleTextStyle, + b.subtitleTextStyle, + t, + ), + padding: EdgeInsetsGeometry.lerp(a.padding, b.padding, t), + thumbnailShape: OutlinedBorder.lerp( + a.thumbnailShape, + b.thumbnailShape, + t, + ), + thumbnailSide: a.thumbnailSide == null + ? b.thumbnailSide + : b.thumbnailSide == null + ? a.thumbnailSide + : BorderSide.lerp(a.thumbnailSide!, b.thumbnailSide!, t), + thumbnailSize: Size.lerp(a.thumbnailSize, b.thumbnailSize, t), + ); + } + + StreamMessageComposerLinkPreviewAttachmentThemeData copyWith({ + Color? backgroundColor, + TextStyle? titleTextStyle, + TextStyle? subtitleTextStyle, + EdgeInsetsGeometry? padding, + OutlinedBorder? thumbnailShape, + BorderSide? thumbnailSide, + Size? thumbnailSize, + }) { + final _this = (this as StreamMessageComposerLinkPreviewAttachmentThemeData); + + return StreamMessageComposerLinkPreviewAttachmentThemeData( + backgroundColor: backgroundColor ?? _this.backgroundColor, + titleTextStyle: titleTextStyle ?? _this.titleTextStyle, + subtitleTextStyle: subtitleTextStyle ?? _this.subtitleTextStyle, + padding: padding ?? _this.padding, + thumbnailShape: thumbnailShape ?? _this.thumbnailShape, + thumbnailSide: thumbnailSide ?? _this.thumbnailSide, + thumbnailSize: thumbnailSize ?? _this.thumbnailSize, + ); + } + + StreamMessageComposerLinkPreviewAttachmentThemeData merge( + StreamMessageComposerLinkPreviewAttachmentThemeData? other, + ) { + final _this = (this as StreamMessageComposerLinkPreviewAttachmentThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + backgroundColor: other.backgroundColor, + titleTextStyle: + _this.titleTextStyle?.merge(other.titleTextStyle) ?? + other.titleTextStyle, + subtitleTextStyle: + _this.subtitleTextStyle?.merge(other.subtitleTextStyle) ?? + other.subtitleTextStyle, + padding: other.padding, + thumbnailShape: other.thumbnailShape, + thumbnailSide: _this.thumbnailSide != null && other.thumbnailSide != null + ? BorderSide.merge(_this.thumbnailSide!, other.thumbnailSide!) + : other.thumbnailSide, + thumbnailSize: other.thumbnailSize, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamMessageComposerLinkPreviewAttachmentThemeData); + final _other = + (other as StreamMessageComposerLinkPreviewAttachmentThemeData); + + return _other.backgroundColor == _this.backgroundColor && + _other.titleTextStyle == _this.titleTextStyle && + _other.subtitleTextStyle == _this.subtitleTextStyle && + _other.padding == _this.padding && + _other.thumbnailShape == _this.thumbnailShape && + _other.thumbnailSide == _this.thumbnailSide && + _other.thumbnailSize == _this.thumbnailSize; + } + + @override + int get hashCode { + final _this = (this as StreamMessageComposerLinkPreviewAttachmentThemeData); + + return Object.hash( + runtimeType, + _this.backgroundColor, + _this.titleTextStyle, + _this.subtitleTextStyle, + _this.padding, + _this.thumbnailShape, + _this.thumbnailSide, + _this.thumbnailSize, + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_media_attachment_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_media_attachment_theme.dart new file mode 100644 index 00000000..add41471 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_media_attachment_theme.dart @@ -0,0 +1,120 @@ +import 'package:flutter/widgets.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import '../stream_theme.dart'; + +part 'stream_message_composer_media_attachment_theme.g.theme.dart'; + +/// Applies a composer media attachment theme to descendant +/// [StreamMessageComposerMediaAttachment] widgets. +/// +/// Wrap a subtree with [StreamMessageComposerMediaAttachmentTheme] to override +/// styling for image and video thumbnails rendered inside the composer. +/// Access the merged theme using +/// [BuildContext.streamMessageComposerMediaAttachmentTheme]. +/// +/// {@tool snippet} +/// +/// Override the media attachment border for a specific section: +/// +/// ```dart +/// StreamMessageComposerMediaAttachmentTheme( +/// data: StreamMessageComposerMediaAttachmentThemeData( +/// borderColor: Colors.black12, +/// ), +/// child: StreamMessageComposer(), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageComposerMediaAttachmentThemeData], which describes the +/// theme data. +/// * [StreamMessageComposerMediaAttachment], the widget affected by this theme. +class StreamMessageComposerMediaAttachmentTheme extends InheritedTheme { + /// Creates a composer media attachment theme that controls descendant media + /// attachments. + const StreamMessageComposerMediaAttachmentTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The composer media attachment theme data for descendant widgets. + final StreamMessageComposerMediaAttachmentThemeData data; + + /// Returns the [StreamMessageComposerMediaAttachmentThemeData] merged from + /// local and global themes. + /// + /// Local values from the nearest + /// [StreamMessageComposerMediaAttachmentTheme] ancestor take precedence + /// over global values from [StreamTheme.of]. + static StreamMessageComposerMediaAttachmentThemeData of(BuildContext context) { + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamTheme.of(context).messageComposerMediaAttachmentTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamMessageComposerMediaAttachmentTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamMessageComposerMediaAttachmentTheme oldWidget) => data != oldWidget.data; +} + +/// Theme data for customizing [StreamMessageComposerMediaAttachment] widgets. +/// +/// Descendant widgets obtain their values from +/// [StreamMessageComposerMediaAttachmentTheme.of]. All properties are null by +/// default, with fallback values applied by +/// [DefaultStreamMessageComposerMediaAttachment]. +/// +/// {@tool snippet} +/// +/// Customize media attachment styling globally via [StreamTheme]: +/// +/// ```dart +/// StreamTheme( +/// messageComposerMediaAttachmentTheme: +/// StreamMessageComposerMediaAttachmentThemeData( +/// borderColor: Colors.black12, +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageComposerMediaAttachmentTheme], for overriding theme in a +/// widget subtree. +/// * [StreamMessageComposerMediaAttachment], the widget that uses this theme +/// data. +@themeGen +@immutable +class StreamMessageComposerMediaAttachmentThemeData with _$StreamMessageComposerMediaAttachmentThemeData { + /// Creates composer media attachment theme data with optional overrides. + const StreamMessageComposerMediaAttachmentThemeData({ + this.borderColor, + this.size, + }); + + /// Border color drawn around the media attachment's container. + /// + /// If null, defaults to [StreamColorScheme.borderOpacitySubtle]. + final Color? borderColor; + + /// Dimensions of the media thumbnail. + /// + /// If null, defaults to `Size(72, 72)` (square). + final Size? size; + + /// Linearly interpolate between two + /// [StreamMessageComposerMediaAttachmentThemeData] objects. + static StreamMessageComposerMediaAttachmentThemeData? lerp( + StreamMessageComposerMediaAttachmentThemeData? a, + StreamMessageComposerMediaAttachmentThemeData? b, + double t, + ) => _$StreamMessageComposerMediaAttachmentThemeData.lerp(a, b, t); +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_media_attachment_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_media_attachment_theme.g.theme.dart new file mode 100644 index 00000000..623fca37 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_media_attachment_theme.g.theme.dart @@ -0,0 +1,88 @@ +// 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_composer_media_attachment_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamMessageComposerMediaAttachmentThemeData { + bool get canMerge => true; + + static StreamMessageComposerMediaAttachmentThemeData? lerp( + StreamMessageComposerMediaAttachmentThemeData? a, + StreamMessageComposerMediaAttachmentThemeData? 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 StreamMessageComposerMediaAttachmentThemeData( + borderColor: Color.lerp(a.borderColor, b.borderColor, t), + size: Size.lerp(a.size, b.size, t), + ); + } + + StreamMessageComposerMediaAttachmentThemeData copyWith({ + Color? borderColor, + Size? size, + }) { + final _this = (this as StreamMessageComposerMediaAttachmentThemeData); + + return StreamMessageComposerMediaAttachmentThemeData( + borderColor: borderColor ?? _this.borderColor, + size: size ?? _this.size, + ); + } + + StreamMessageComposerMediaAttachmentThemeData merge( + StreamMessageComposerMediaAttachmentThemeData? other, + ) { + final _this = (this as StreamMessageComposerMediaAttachmentThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith(borderColor: other.borderColor, size: other.size); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamMessageComposerMediaAttachmentThemeData); + final _other = (other as StreamMessageComposerMediaAttachmentThemeData); + + return _other.borderColor == _this.borderColor && _other.size == _this.size; + } + + @override + int get hashCode { + final _this = (this as StreamMessageComposerMediaAttachmentThemeData); + + return Object.hash(runtimeType, _this.borderColor, _this.size); + } +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_reply_attachment_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_reply_attachment_theme.dart new file mode 100644 index 00000000..4ed02c0e --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_reply_attachment_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_composer_reply_attachment_theme.g.theme.dart'; + +/// Applies a composer reply attachment theme to descendant +/// [StreamMessageComposerReplyAttachment] widgets. +/// +/// Wrap a subtree with [StreamMessageComposerReplyAttachmentTheme] to +/// override the styling of the reply preview shown above the composer input. +/// Access the merged theme using +/// [BuildContext.streamMessageComposerReplyAttachmentTheme]. +/// +/// {@tool snippet} +/// +/// Override the reply attachment indicator color for a specific section: +/// +/// ```dart +/// StreamMessageComposerReplyAttachmentTheme( +/// data: StreamMessageComposerReplyAttachmentThemeData( +/// indicatorColor: Colors.green, +/// ), +/// child: StreamMessageComposer(), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageComposerReplyAttachmentThemeData], which describes the +/// theme data. +/// * [StreamMessageComposerReplyAttachment], the widget affected by this theme. +class StreamMessageComposerReplyAttachmentTheme extends InheritedTheme { + /// Creates a composer reply attachment theme that controls descendant reply + /// previews. + const StreamMessageComposerReplyAttachmentTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The composer reply attachment theme data for descendant widgets. + final StreamMessageComposerReplyAttachmentThemeData data; + + /// Returns the [StreamMessageComposerReplyAttachmentThemeData] merged from + /// local and global themes. + /// + /// Local values from the nearest + /// [StreamMessageComposerReplyAttachmentTheme] ancestor take precedence + /// over global values from [StreamTheme.of]. + static StreamMessageComposerReplyAttachmentThemeData of(BuildContext context) { + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamTheme.of(context).messageComposerReplyAttachmentTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamMessageComposerReplyAttachmentTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamMessageComposerReplyAttachmentTheme oldWidget) => data != oldWidget.data; +} + +/// Theme data for customizing [StreamMessageComposerReplyAttachment] widgets. +/// +/// Descendant widgets obtain their values from +/// [StreamMessageComposerReplyAttachmentTheme.of]. All properties are null by +/// default, with direction-aware fallback values applied by +/// [DefaultStreamMessageComposerReplyAttachment] based on the widget's +/// [StreamReplyDirection] (incoming or outgoing). +/// +/// {@tool snippet} +/// +/// Customize reply attachment styling globally via [StreamTheme]: +/// +/// ```dart +/// StreamTheme( +/// messageComposerReplyAttachmentTheme: +/// StreamMessageComposerReplyAttachmentThemeData( +/// indicatorColor: Colors.green, +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageComposerReplyAttachmentTheme], for overriding theme in a +/// widget subtree. +/// * [StreamMessageComposerReplyAttachment], the widget that uses this theme data. +@themeGen +@immutable +class StreamMessageComposerReplyAttachmentThemeData with _$StreamMessageComposerReplyAttachmentThemeData { + /// Creates composer reply attachment theme data with optional overrides. + const StreamMessageComposerReplyAttachmentThemeData({ + this.backgroundColor, + this.indicatorColor, + this.titleTextStyle, + this.subtitleTextStyle, + this.padding, + this.thumbnailShape, + this.thumbnailSide, + this.thumbnailSize, + }); + + /// Background fill color of the reply preview card. + /// + /// If null, the consuming widget falls back to a direction-aware default: + /// `colorScheme.backgroundSurface` for incoming replies, + /// `colorScheme.brand.shade100` for outgoing. + final Color? backgroundColor; + + /// Color of the leading indicator bar. + /// + /// If null, the consuming widget falls back to a direction-aware default: + /// `colorScheme.chrome.shade400` for incoming replies, + /// `colorScheme.brand.shade400` for outgoing. + final Color? indicatorColor; + + /// Text style for the reply title (typically the quoted user's name). + /// + /// If null, defaults to [StreamTextTheme.metadataEmphasis] tinted with the + /// direction-aware text color. + final TextStyle? titleTextStyle; + + /// Text style for the reply subtitle (the quoted message preview). + /// + /// If null, defaults to [StreamTextTheme.metadataDefault] tinted with the + /// direction-aware text color. + final TextStyle? subtitleTextStyle; + + /// Padding around the reply preview's content row. + /// + /// If null, defaults to [StreamSpacing.xs] on all sides. + final EdgeInsetsGeometry? padding; + + /// Outer shape of the trailing thumbnail. + /// + /// Composed with [thumbnailSide] to draw the thumbnail's border. If null, + /// defaults to a [RoundedSuperellipseBorder] with radius [StreamRadius.md]. + final OutlinedBorder? thumbnailShape; + + /// Border side drawn around the trailing thumbnail. + /// + /// Composed onto [thumbnailShape] via [OutlinedBorder.copyWith]. If null, + /// defaults to [BorderSide.none]. + final BorderSide? thumbnailSide; + + /// Dimensions of the trailing thumbnail. + /// + /// If null, defaults to `Size.square(40)`. + final Size? thumbnailSize; + + /// Linearly interpolate between two + /// [StreamMessageComposerReplyAttachmentThemeData] objects. + static StreamMessageComposerReplyAttachmentThemeData? lerp( + StreamMessageComposerReplyAttachmentThemeData? a, + StreamMessageComposerReplyAttachmentThemeData? b, + double t, + ) => _$StreamMessageComposerReplyAttachmentThemeData.lerp(a, b, t); +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_reply_attachment_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_reply_attachment_theme.g.theme.dart new file mode 100644 index 00000000..0b048cd5 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_reply_attachment_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_composer_reply_attachment_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamMessageComposerReplyAttachmentThemeData { + bool get canMerge => true; + + static StreamMessageComposerReplyAttachmentThemeData? lerp( + StreamMessageComposerReplyAttachmentThemeData? a, + StreamMessageComposerReplyAttachmentThemeData? 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 StreamMessageComposerReplyAttachmentThemeData( + backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), + indicatorColor: Color.lerp(a.indicatorColor, b.indicatorColor, t), + titleTextStyle: TextStyle.lerp(a.titleTextStyle, b.titleTextStyle, t), + subtitleTextStyle: TextStyle.lerp( + a.subtitleTextStyle, + b.subtitleTextStyle, + t, + ), + padding: EdgeInsetsGeometry.lerp(a.padding, b.padding, t), + thumbnailShape: OutlinedBorder.lerp( + a.thumbnailShape, + b.thumbnailShape, + t, + ), + thumbnailSide: a.thumbnailSide == null + ? b.thumbnailSide + : b.thumbnailSide == null + ? a.thumbnailSide + : BorderSide.lerp(a.thumbnailSide!, b.thumbnailSide!, t), + thumbnailSize: Size.lerp(a.thumbnailSize, b.thumbnailSize, t), + ); + } + + StreamMessageComposerReplyAttachmentThemeData copyWith({ + Color? backgroundColor, + Color? indicatorColor, + TextStyle? titleTextStyle, + TextStyle? subtitleTextStyle, + EdgeInsetsGeometry? padding, + OutlinedBorder? thumbnailShape, + BorderSide? thumbnailSide, + Size? thumbnailSize, + }) { + final _this = (this as StreamMessageComposerReplyAttachmentThemeData); + + return StreamMessageComposerReplyAttachmentThemeData( + backgroundColor: backgroundColor ?? _this.backgroundColor, + indicatorColor: indicatorColor ?? _this.indicatorColor, + titleTextStyle: titleTextStyle ?? _this.titleTextStyle, + subtitleTextStyle: subtitleTextStyle ?? _this.subtitleTextStyle, + padding: padding ?? _this.padding, + thumbnailShape: thumbnailShape ?? _this.thumbnailShape, + thumbnailSide: thumbnailSide ?? _this.thumbnailSide, + thumbnailSize: thumbnailSize ?? _this.thumbnailSize, + ); + } + + StreamMessageComposerReplyAttachmentThemeData merge( + StreamMessageComposerReplyAttachmentThemeData? other, + ) { + final _this = (this as StreamMessageComposerReplyAttachmentThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + backgroundColor: other.backgroundColor, + indicatorColor: other.indicatorColor, + titleTextStyle: + _this.titleTextStyle?.merge(other.titleTextStyle) ?? + other.titleTextStyle, + subtitleTextStyle: + _this.subtitleTextStyle?.merge(other.subtitleTextStyle) ?? + other.subtitleTextStyle, + padding: other.padding, + thumbnailShape: other.thumbnailShape, + thumbnailSide: _this.thumbnailSide != null && other.thumbnailSide != null + ? BorderSide.merge(_this.thumbnailSide!, other.thumbnailSide!) + : other.thumbnailSide, + thumbnailSize: other.thumbnailSize, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamMessageComposerReplyAttachmentThemeData); + final _other = (other as StreamMessageComposerReplyAttachmentThemeData); + + return _other.backgroundColor == _this.backgroundColor && + _other.indicatorColor == _this.indicatorColor && + _other.titleTextStyle == _this.titleTextStyle && + _other.subtitleTextStyle == _this.subtitleTextStyle && + _other.padding == _this.padding && + _other.thumbnailShape == _this.thumbnailShape && + _other.thumbnailSide == _this.thumbnailSide && + _other.thumbnailSize == _this.thumbnailSize; + } + + @override + int get hashCode { + final _this = (this as StreamMessageComposerReplyAttachmentThemeData); + + return Object.hash( + runtimeType, + _this.backgroundColor, + _this.indicatorColor, + _this.titleTextStyle, + _this.subtitleTextStyle, + _this.padding, + _this.thumbnailShape, + _this.thumbnailSide, + _this.thumbnailSize, + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_unsupported_attachment_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_unsupported_attachment_theme.dart new file mode 100644 index 00000000..c9999ccb --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_unsupported_attachment_theme.dart @@ -0,0 +1,122 @@ +import 'package:flutter/widgets.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import '../stream_theme.dart'; + +part 'stream_message_composer_unsupported_attachment_theme.g.theme.dart'; + +/// Applies a composer unsupported-attachment theme to descendant +/// [StreamMessageComposerUnsupportedAttachment] widgets. +/// +/// Wrap a subtree with [StreamMessageComposerUnsupportedAttachmentTheme] to override the +/// styling of the placeholder shown for attachments the client cannot render. Access the merged +/// theme using [BuildContext.streamMessageComposerUnsupportedAttachmentTheme]. +/// +/// {@tool snippet} +/// +/// Override the unsupported-attachment label style for a specific section: +/// +/// ```dart +/// StreamMessageComposerUnsupportedAttachmentTheme( +/// data: StreamMessageComposerUnsupportedAttachmentThemeData( +/// labelTextStyle: TextStyle(fontStyle: FontStyle.italic), +/// ), +/// child: StreamMessageComposer(), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageComposerUnsupportedAttachmentThemeData], which describes the theme data. +/// * [StreamMessageComposerUnsupportedAttachment], the widget affected by this theme. +class StreamMessageComposerUnsupportedAttachmentTheme extends InheritedTheme { + /// Creates a composer unsupported-attachment theme that controls descendant placeholders. + const StreamMessageComposerUnsupportedAttachmentTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The composer unsupported-attachment theme data for descendant widgets. + final StreamMessageComposerUnsupportedAttachmentThemeData data; + + /// Returns the [StreamMessageComposerUnsupportedAttachmentThemeData] merged from local and + /// global themes. + /// + /// Local values from the nearest [StreamMessageComposerUnsupportedAttachmentTheme] ancestor + /// take precedence over global values from [StreamTheme.of]. + static StreamMessageComposerUnsupportedAttachmentThemeData of(BuildContext context) { + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamTheme.of(context).messageComposerUnsupportedAttachmentTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamMessageComposerUnsupportedAttachmentTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamMessageComposerUnsupportedAttachmentTheme oldWidget) => data != oldWidget.data; +} + +/// Theme data for customizing [StreamMessageComposerUnsupportedAttachment] widgets. +/// +/// Descendant widgets obtain their values from +/// [StreamMessageComposerUnsupportedAttachmentTheme.of]. All properties are null by default, +/// with fallback values applied by [DefaultStreamMessageComposerUnsupportedAttachment]. +/// +/// {@tool snippet} +/// +/// Customize the unsupported-attachment label globally via [StreamTheme]: +/// +/// ```dart +/// StreamTheme( +/// messageComposerUnsupportedAttachmentTheme: +/// StreamMessageComposerUnsupportedAttachmentThemeData( +/// labelTextStyle: TextStyle(fontStyle: FontStyle.italic), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageComposerUnsupportedAttachmentTheme], for overriding theme in a widget +/// subtree. +/// * [StreamMessageComposerUnsupportedAttachment], the widget that uses this theme data. +@themeGen +@immutable +class StreamMessageComposerUnsupportedAttachmentThemeData with _$StreamMessageComposerUnsupportedAttachmentThemeData { + /// Creates composer unsupported-attachment theme data with optional overrides. + const StreamMessageComposerUnsupportedAttachmentThemeData({ + this.labelTextStyle, + this.padding, + this.spacing, + }); + + /// Text style for the placeholder label. + /// + /// If null, defaults to [StreamTextTheme.metadataEmphasis] tinted with + /// [StreamColorScheme.textPrimary]. + final TextStyle? labelTextStyle; + + /// Padding around the placeholder's content row. + /// + /// If null, defaults to a directional inset using [StreamSpacing.md] and [StreamSpacing.sm] + /// tokens. + final EdgeInsetsGeometry? padding; + + /// Horizontal space between the leading icon and the label. + /// + /// If null, defaults to [StreamSpacing.xs]. + final double? spacing; + + /// Linearly interpolate between two + /// [StreamMessageComposerUnsupportedAttachmentThemeData] objects. + static StreamMessageComposerUnsupportedAttachmentThemeData? lerp( + StreamMessageComposerUnsupportedAttachmentThemeData? a, + StreamMessageComposerUnsupportedAttachmentThemeData? b, + double t, + ) => _$StreamMessageComposerUnsupportedAttachmentThemeData.lerp(a, b, t); +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_unsupported_attachment_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_unsupported_attachment_theme.g.theme.dart new file mode 100644 index 00000000..c262cac8 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_unsupported_attachment_theme.g.theme.dart @@ -0,0 +1,105 @@ +// 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_composer_unsupported_attachment_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamMessageComposerUnsupportedAttachmentThemeData { + bool get canMerge => true; + + static StreamMessageComposerUnsupportedAttachmentThemeData? lerp( + StreamMessageComposerUnsupportedAttachmentThemeData? a, + StreamMessageComposerUnsupportedAttachmentThemeData? 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 StreamMessageComposerUnsupportedAttachmentThemeData( + labelTextStyle: TextStyle.lerp(a.labelTextStyle, b.labelTextStyle, t), + padding: EdgeInsetsGeometry.lerp(a.padding, b.padding, t), + spacing: lerpDouble$(a.spacing, b.spacing, t), + ); + } + + StreamMessageComposerUnsupportedAttachmentThemeData copyWith({ + TextStyle? labelTextStyle, + EdgeInsetsGeometry? padding, + double? spacing, + }) { + final _this = (this as StreamMessageComposerUnsupportedAttachmentThemeData); + + return StreamMessageComposerUnsupportedAttachmentThemeData( + labelTextStyle: labelTextStyle ?? _this.labelTextStyle, + padding: padding ?? _this.padding, + spacing: spacing ?? _this.spacing, + ); + } + + StreamMessageComposerUnsupportedAttachmentThemeData merge( + StreamMessageComposerUnsupportedAttachmentThemeData? other, + ) { + final _this = (this as StreamMessageComposerUnsupportedAttachmentThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + labelTextStyle: + _this.labelTextStyle?.merge(other.labelTextStyle) ?? + other.labelTextStyle, + padding: other.padding, + spacing: other.spacing, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamMessageComposerUnsupportedAttachmentThemeData); + final _other = + (other as StreamMessageComposerUnsupportedAttachmentThemeData); + + return _other.labelTextStyle == _this.labelTextStyle && + _other.padding == _this.padding && + _other.spacing == _this.spacing; + } + + @override + int get hashCode { + final _this = (this as StreamMessageComposerUnsupportedAttachmentThemeData); + + return Object.hash( + runtimeType, + _this.labelTextStyle, + _this.padding, + _this.spacing, + ); + } +} 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 7aa10a5e..db934f47 100644 --- a/packages/stream_core_flutter/lib/src/theme/stream_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/stream_theme.dart @@ -18,6 +18,13 @@ import 'components/stream_emoji_button_theme.dart'; import 'components/stream_emoji_chip_theme.dart'; import 'components/stream_jump_to_unread_button_theme.dart'; import 'components/stream_list_tile_theme.dart'; +import 'components/stream_message_composer_attachment_theme.dart'; +import 'components/stream_message_composer_edit_message_attachment_theme.dart'; +import 'components/stream_message_composer_file_attachment_theme.dart'; +import 'components/stream_message_composer_link_preview_attachment_theme.dart'; +import 'components/stream_message_composer_media_attachment_theme.dart'; +import 'components/stream_message_composer_reply_attachment_theme.dart'; +import 'components/stream_message_composer_unsupported_attachment_theme.dart'; import 'components/stream_message_item_theme.dart'; import 'components/stream_online_indicator_theme.dart'; import 'components/stream_playback_speed_toggle_theme.dart'; @@ -118,6 +125,13 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { StreamEmojiChipThemeData? emojiChipTheme, StreamJumpToUnreadButtonThemeData? jumpToUnreadButtonTheme, StreamListTileThemeData? listTileTheme, + StreamMessageComposerAttachmentThemeData? messageComposerAttachmentTheme, + StreamMessageComposerEditMessageAttachmentThemeData? messageComposerEditMessageAttachmentTheme, + StreamMessageComposerFileAttachmentThemeData? messageComposerFileAttachmentTheme, + StreamMessageComposerLinkPreviewAttachmentThemeData? messageComposerLinkPreviewAttachmentTheme, + StreamMessageComposerMediaAttachmentThemeData? messageComposerMediaAttachmentTheme, + StreamMessageComposerReplyAttachmentThemeData? messageComposerReplyAttachmentTheme, + StreamMessageComposerUnsupportedAttachmentThemeData? messageComposerUnsupportedAttachmentTheme, StreamMessageItemThemeData? messageItemTheme, StreamTextInputThemeData? textInputTheme, StreamOnlineIndicatorThemeData? onlineIndicatorTheme, @@ -160,6 +174,13 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { emojiChipTheme ??= const StreamEmojiChipThemeData(); jumpToUnreadButtonTheme ??= const StreamJumpToUnreadButtonThemeData(); listTileTheme ??= const StreamListTileThemeData(); + messageComposerAttachmentTheme ??= const StreamMessageComposerAttachmentThemeData(); + messageComposerEditMessageAttachmentTheme ??= const StreamMessageComposerEditMessageAttachmentThemeData(); + messageComposerFileAttachmentTheme ??= const StreamMessageComposerFileAttachmentThemeData(); + messageComposerLinkPreviewAttachmentTheme ??= const StreamMessageComposerLinkPreviewAttachmentThemeData(); + messageComposerMediaAttachmentTheme ??= const StreamMessageComposerMediaAttachmentThemeData(); + messageComposerReplyAttachmentTheme ??= const StreamMessageComposerReplyAttachmentThemeData(); + messageComposerUnsupportedAttachmentTheme ??= const StreamMessageComposerUnsupportedAttachmentThemeData(); messageItemTheme ??= const StreamMessageItemThemeData(); textInputTheme ??= const StreamTextInputThemeData(); onlineIndicatorTheme ??= const StreamOnlineIndicatorThemeData(); @@ -196,6 +217,13 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { emojiChipTheme: emojiChipTheme, jumpToUnreadButtonTheme: jumpToUnreadButtonTheme, listTileTheme: listTileTheme, + messageComposerAttachmentTheme: messageComposerAttachmentTheme, + messageComposerEditMessageAttachmentTheme: messageComposerEditMessageAttachmentTheme, + messageComposerFileAttachmentTheme: messageComposerFileAttachmentTheme, + messageComposerLinkPreviewAttachmentTheme: messageComposerLinkPreviewAttachmentTheme, + messageComposerMediaAttachmentTheme: messageComposerMediaAttachmentTheme, + messageComposerReplyAttachmentTheme: messageComposerReplyAttachmentTheme, + messageComposerUnsupportedAttachmentTheme: messageComposerUnsupportedAttachmentTheme, messageItemTheme: messageItemTheme, textInputTheme: textInputTheme, onlineIndicatorTheme: onlineIndicatorTheme, @@ -246,6 +274,13 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { required this.emojiChipTheme, required this.jumpToUnreadButtonTheme, required this.listTileTheme, + required this.messageComposerAttachmentTheme, + required this.messageComposerEditMessageAttachmentTheme, + required this.messageComposerFileAttachmentTheme, + required this.messageComposerLinkPreviewAttachmentTheme, + required this.messageComposerMediaAttachmentTheme, + required this.messageComposerReplyAttachmentTheme, + required this.messageComposerUnsupportedAttachmentTheme, required this.messageItemTheme, required this.textInputTheme, required this.onlineIndicatorTheme, @@ -360,6 +395,27 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { /// The list tile theme for this theme. final StreamListTileThemeData listTileTheme; + /// The composer attachment container theme for this theme. + final StreamMessageComposerAttachmentThemeData messageComposerAttachmentTheme; + + /// The composer edit-message attachment theme for this theme. + final StreamMessageComposerEditMessageAttachmentThemeData messageComposerEditMessageAttachmentTheme; + + /// The composer file attachment theme for this theme. + final StreamMessageComposerFileAttachmentThemeData messageComposerFileAttachmentTheme; + + /// The composer link preview attachment theme for this theme. + final StreamMessageComposerLinkPreviewAttachmentThemeData messageComposerLinkPreviewAttachmentTheme; + + /// The composer media attachment theme for this theme. + final StreamMessageComposerMediaAttachmentThemeData messageComposerMediaAttachmentTheme; + + /// The composer reply attachment theme for this theme. + final StreamMessageComposerReplyAttachmentThemeData messageComposerReplyAttachmentTheme; + + /// The composer unsupported attachment theme for this theme. + final StreamMessageComposerUnsupportedAttachmentThemeData messageComposerUnsupportedAttachmentTheme; + /// The message item theme for this theme. /// /// Provides resolver-based styling for message sub-components. @@ -440,6 +496,13 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { emojiChipTheme: emojiChipTheme, jumpToUnreadButtonTheme: jumpToUnreadButtonTheme, listTileTheme: listTileTheme, + messageComposerAttachmentTheme: messageComposerAttachmentTheme, + messageComposerEditMessageAttachmentTheme: messageComposerEditMessageAttachmentTheme, + messageComposerFileAttachmentTheme: messageComposerFileAttachmentTheme, + messageComposerLinkPreviewAttachmentTheme: messageComposerLinkPreviewAttachmentTheme, + messageComposerMediaAttachmentTheme: messageComposerMediaAttachmentTheme, + messageComposerReplyAttachmentTheme: messageComposerReplyAttachmentTheme, + messageComposerUnsupportedAttachmentTheme: messageComposerUnsupportedAttachmentTheme, messageItemTheme: messageItemTheme, textInputTheme: textInputTheme, 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 cf9f9800..a7b8926e 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 @@ -34,6 +34,19 @@ mixin _$StreamTheme on ThemeExtension { StreamEmojiChipThemeData? emojiChipTheme, StreamJumpToUnreadButtonThemeData? jumpToUnreadButtonTheme, StreamListTileThemeData? listTileTheme, + StreamMessageComposerAttachmentThemeData? messageComposerAttachmentTheme, + StreamMessageComposerEditMessageAttachmentThemeData? + messageComposerEditMessageAttachmentTheme, + StreamMessageComposerFileAttachmentThemeData? + messageComposerFileAttachmentTheme, + StreamMessageComposerLinkPreviewAttachmentThemeData? + messageComposerLinkPreviewAttachmentTheme, + StreamMessageComposerMediaAttachmentThemeData? + messageComposerMediaAttachmentTheme, + StreamMessageComposerReplyAttachmentThemeData? + messageComposerReplyAttachmentTheme, + StreamMessageComposerUnsupportedAttachmentThemeData? + messageComposerUnsupportedAttachmentTheme, StreamMessageItemThemeData? messageItemTheme, StreamTextInputThemeData? textInputTheme, StreamOnlineIndicatorThemeData? onlineIndicatorTheme, @@ -75,6 +88,27 @@ mixin _$StreamTheme on ThemeExtension { jumpToUnreadButtonTheme: jumpToUnreadButtonTheme ?? _this.jumpToUnreadButtonTheme, listTileTheme: listTileTheme ?? _this.listTileTheme, + messageComposerAttachmentTheme: + messageComposerAttachmentTheme ?? + _this.messageComposerAttachmentTheme, + messageComposerEditMessageAttachmentTheme: + messageComposerEditMessageAttachmentTheme ?? + _this.messageComposerEditMessageAttachmentTheme, + messageComposerFileAttachmentTheme: + messageComposerFileAttachmentTheme ?? + _this.messageComposerFileAttachmentTheme, + messageComposerLinkPreviewAttachmentTheme: + messageComposerLinkPreviewAttachmentTheme ?? + _this.messageComposerLinkPreviewAttachmentTheme, + messageComposerMediaAttachmentTheme: + messageComposerMediaAttachmentTheme ?? + _this.messageComposerMediaAttachmentTheme, + messageComposerReplyAttachmentTheme: + messageComposerReplyAttachmentTheme ?? + _this.messageComposerReplyAttachmentTheme, + messageComposerUnsupportedAttachmentTheme: + messageComposerUnsupportedAttachmentTheme ?? + _this.messageComposerUnsupportedAttachmentTheme, messageItemTheme: messageItemTheme ?? _this.messageItemTheme, textInputTheme: textInputTheme ?? _this.textInputTheme, onlineIndicatorTheme: onlineIndicatorTheme ?? _this.onlineIndicatorTheme, @@ -182,6 +216,48 @@ mixin _$StreamTheme on ThemeExtension { other.listTileTheme, t, )!, + messageComposerAttachmentTheme: + StreamMessageComposerAttachmentThemeData.lerp( + _this.messageComposerAttachmentTheme, + other.messageComposerAttachmentTheme, + t, + )!, + messageComposerEditMessageAttachmentTheme: + StreamMessageComposerEditMessageAttachmentThemeData.lerp( + _this.messageComposerEditMessageAttachmentTheme, + other.messageComposerEditMessageAttachmentTheme, + t, + )!, + messageComposerFileAttachmentTheme: + StreamMessageComposerFileAttachmentThemeData.lerp( + _this.messageComposerFileAttachmentTheme, + other.messageComposerFileAttachmentTheme, + t, + )!, + messageComposerLinkPreviewAttachmentTheme: + StreamMessageComposerLinkPreviewAttachmentThemeData.lerp( + _this.messageComposerLinkPreviewAttachmentTheme, + other.messageComposerLinkPreviewAttachmentTheme, + t, + )!, + messageComposerMediaAttachmentTheme: + StreamMessageComposerMediaAttachmentThemeData.lerp( + _this.messageComposerMediaAttachmentTheme, + other.messageComposerMediaAttachmentTheme, + t, + )!, + messageComposerReplyAttachmentTheme: + StreamMessageComposerReplyAttachmentThemeData.lerp( + _this.messageComposerReplyAttachmentTheme, + other.messageComposerReplyAttachmentTheme, + t, + )!, + messageComposerUnsupportedAttachmentTheme: + StreamMessageComposerUnsupportedAttachmentThemeData.lerp( + _this.messageComposerUnsupportedAttachmentTheme, + other.messageComposerUnsupportedAttachmentTheme, + t, + )!, messageItemTheme: StreamMessageItemThemeData.lerp( _this.messageItemTheme, other.messageItemTheme, @@ -280,6 +356,20 @@ mixin _$StreamTheme on ThemeExtension { _other.emojiChipTheme == _this.emojiChipTheme && _other.jumpToUnreadButtonTheme == _this.jumpToUnreadButtonTheme && _other.listTileTheme == _this.listTileTheme && + _other.messageComposerAttachmentTheme == + _this.messageComposerAttachmentTheme && + _other.messageComposerEditMessageAttachmentTheme == + _this.messageComposerEditMessageAttachmentTheme && + _other.messageComposerFileAttachmentTheme == + _this.messageComposerFileAttachmentTheme && + _other.messageComposerLinkPreviewAttachmentTheme == + _this.messageComposerLinkPreviewAttachmentTheme && + _other.messageComposerMediaAttachmentTheme == + _this.messageComposerMediaAttachmentTheme && + _other.messageComposerReplyAttachmentTheme == + _this.messageComposerReplyAttachmentTheme && + _other.messageComposerUnsupportedAttachmentTheme == + _this.messageComposerUnsupportedAttachmentTheme && _other.messageItemTheme == _this.messageItemTheme && _other.textInputTheme == _this.textInputTheme && _other.onlineIndicatorTheme == _this.onlineIndicatorTheme && @@ -322,6 +412,13 @@ mixin _$StreamTheme on ThemeExtension { _this.emojiChipTheme, _this.jumpToUnreadButtonTheme, _this.listTileTheme, + _this.messageComposerAttachmentTheme, + _this.messageComposerEditMessageAttachmentTheme, + _this.messageComposerFileAttachmentTheme, + _this.messageComposerLinkPreviewAttachmentTheme, + _this.messageComposerMediaAttachmentTheme, + _this.messageComposerReplyAttachmentTheme, + _this.messageComposerUnsupportedAttachmentTheme, _this.messageItemTheme, _this.textInputTheme, _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 7a3778aa..58808d5a 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 @@ -14,6 +14,13 @@ import 'components/stream_emoji_button_theme.dart'; import 'components/stream_emoji_chip_theme.dart'; import 'components/stream_jump_to_unread_button_theme.dart'; import 'components/stream_list_tile_theme.dart'; +import 'components/stream_message_composer_attachment_theme.dart'; +import 'components/stream_message_composer_edit_message_attachment_theme.dart'; +import 'components/stream_message_composer_file_attachment_theme.dart'; +import 'components/stream_message_composer_link_preview_attachment_theme.dart'; +import 'components/stream_message_composer_media_attachment_theme.dart'; +import 'components/stream_message_composer_reply_attachment_theme.dart'; +import 'components/stream_message_composer_unsupported_attachment_theme.dart'; import 'components/stream_message_item_theme.dart'; import 'components/stream_online_indicator_theme.dart'; import 'components/stream_playback_speed_toggle_theme.dart'; @@ -122,6 +129,34 @@ extension StreamThemeExtension on BuildContext { /// Returns the [StreamListTileThemeData] from the nearest ancestor. StreamListTileThemeData get streamListTileTheme => StreamListTileTheme.of(this); + /// Returns the [StreamMessageComposerAttachmentThemeData] from the nearest ancestor. + StreamMessageComposerAttachmentThemeData get streamMessageComposerAttachmentTheme => + StreamMessageComposerAttachmentTheme.of(this); + + /// Returns the [StreamMessageComposerEditMessageAttachmentThemeData] from the nearest ancestor. + StreamMessageComposerEditMessageAttachmentThemeData get streamMessageComposerEditMessageAttachmentTheme => + StreamMessageComposerEditMessageAttachmentTheme.of(this); + + /// Returns the [StreamMessageComposerFileAttachmentThemeData] from the nearest ancestor. + StreamMessageComposerFileAttachmentThemeData get streamMessageComposerFileAttachmentTheme => + StreamMessageComposerFileAttachmentTheme.of(this); + + /// Returns the [StreamMessageComposerLinkPreviewAttachmentThemeData] from the nearest ancestor. + StreamMessageComposerLinkPreviewAttachmentThemeData get streamMessageComposerLinkPreviewAttachmentTheme => + StreamMessageComposerLinkPreviewAttachmentTheme.of(this); + + /// Returns the [StreamMessageComposerMediaAttachmentThemeData] from the nearest ancestor. + StreamMessageComposerMediaAttachmentThemeData get streamMessageComposerMediaAttachmentTheme => + StreamMessageComposerMediaAttachmentTheme.of(this); + + /// Returns the [StreamMessageComposerReplyAttachmentThemeData] from the nearest ancestor. + StreamMessageComposerReplyAttachmentThemeData get streamMessageComposerReplyAttachmentTheme => + StreamMessageComposerReplyAttachmentTheme.of(this); + + /// Returns the [StreamMessageComposerUnsupportedAttachmentThemeData] from the nearest ancestor. + StreamMessageComposerUnsupportedAttachmentThemeData get streamMessageComposerUnsupportedAttachmentTheme => + StreamMessageComposerUnsupportedAttachmentTheme.of(this); + /// Returns the [StreamMessageItemThemeData] from the nearest ancestor. StreamMessageItemThemeData get streamMessageItemTheme => StreamMessageItemTheme.of(this); diff --git a/packages/stream_core_flutter/test/components/message_composer/message_composer_attachment_link_preview_golden_test.dart b/packages/stream_core_flutter/test/components/message_composer/message_composer_attachment_link_preview_golden_test.dart index 96cf2f6a..ebc2b047 100644 --- a/packages/stream_core_flutter/test/components/message_composer/message_composer_attachment_link_preview_golden_test.dart +++ b/packages/stream_core_flutter/test/components/message_composer/message_composer_attachment_link_preview_golden_test.dart @@ -18,10 +18,10 @@ void main() { GoldenTestScenario( name: 'full_no_remove', child: _buildLinkPreviewInTheme( - const MessageComposerLinkPreviewAttachment( - title: 'Getting started with Stream', - subtitle: 'Build in-app messaging with our flexible SDKs.', - url: 'https://getstream.io/chat/docs/', + StreamMessageComposerLinkPreviewAttachment( + title: const Text('Getting started with Stream'), + subtitle: const Text('Build in-app messaging with our flexible SDKs.'), + caption: const _UrlCaption(url: 'https://getstream.io/chat/docs/'), onRemovePressed: null, ), ), @@ -29,10 +29,10 @@ void main() { GoldenTestScenario( name: 'full_with_remove', child: _buildLinkPreviewInTheme( - MessageComposerLinkPreviewAttachment( - title: 'Getting started with Stream', - subtitle: 'Build in-app messaging with our flexible SDKs.', - url: 'https://getstream.io/chat/docs/', + StreamMessageComposerLinkPreviewAttachment( + title: const Text('Getting started with Stream'), + subtitle: const Text('Build in-app messaging with our flexible SDKs.'), + caption: const _UrlCaption(url: 'https://getstream.io/chat/docs/'), onRemovePressed: () {}, ), ), @@ -40,11 +40,11 @@ void main() { GoldenTestScenario( name: 'full_with_image_no_remove', child: _buildLinkPreviewInTheme( - MessageComposerLinkPreviewAttachment( - title: 'Getting started with Stream', - subtitle: 'Build in-app messaging with our flexible SDKs.', - url: 'https://getstream.io/chat/docs/', - image: _placeholderImage, + StreamMessageComposerLinkPreviewAttachment( + title: const Text('Getting started with Stream'), + subtitle: const Text('Build in-app messaging with our flexible SDKs.'), + caption: const _UrlCaption(url: 'https://getstream.io/chat/docs/'), + thumbnail: Image(image: _placeholderImage, fit: BoxFit.cover), onRemovePressed: null, ), ), @@ -52,33 +52,33 @@ void main() { GoldenTestScenario( name: 'full_with_image_with_remove', child: _buildLinkPreviewInTheme( - MessageComposerLinkPreviewAttachment( - title: 'Getting started with Stream', - subtitle: 'Build in-app messaging with our flexible SDKs.', - url: 'https://getstream.io/chat/docs/', - image: _placeholderImage, + StreamMessageComposerLinkPreviewAttachment( + title: const Text('Getting started with Stream'), + subtitle: const Text('Build in-app messaging with our flexible SDKs.'), + caption: const _UrlCaption(url: 'https://getstream.io/chat/docs/'), + thumbnail: Image(image: _placeholderImage, fit: BoxFit.cover), onRemovePressed: () {}, ), ), ), GoldenTestScenario( - name: 'url_only_no_remove', + name: 'caption_only_no_remove', child: _buildLinkPreviewInTheme( - const MessageComposerLinkPreviewAttachment( + StreamMessageComposerLinkPreviewAttachment( title: null, subtitle: null, - url: 'https://getstream.io/', + caption: const _UrlCaption(url: 'https://getstream.io/'), onRemovePressed: null, ), ), ), GoldenTestScenario( - name: 'url_only_with_remove', + name: 'caption_only_with_remove', child: _buildLinkPreviewInTheme( - MessageComposerLinkPreviewAttachment( + StreamMessageComposerLinkPreviewAttachment( title: null, subtitle: null, - url: 'https://getstream.io/', + caption: const _UrlCaption(url: 'https://getstream.io/'), onRemovePressed: () {}, ), ), @@ -96,10 +96,10 @@ void main() { GoldenTestScenario( name: 'full_no_remove', child: _buildLinkPreviewInTheme( - const MessageComposerLinkPreviewAttachment( - title: 'Getting started with Stream', - subtitle: 'Build in-app messaging with our flexible SDKs.', - url: 'https://getstream.io/chat/docs/', + StreamMessageComposerLinkPreviewAttachment( + title: const Text('Getting started with Stream'), + subtitle: const Text('Build in-app messaging with our flexible SDKs.'), + caption: const _UrlCaption(url: 'https://getstream.io/chat/docs/'), onRemovePressed: null, ), brightness: Brightness.dark, @@ -108,10 +108,10 @@ void main() { GoldenTestScenario( name: 'full_with_remove', child: _buildLinkPreviewInTheme( - MessageComposerLinkPreviewAttachment( - title: 'Getting started with Stream', - subtitle: 'Build in-app messaging with our flexible SDKs.', - url: 'https://getstream.io/chat/docs/', + StreamMessageComposerLinkPreviewAttachment( + title: const Text('Getting started with Stream'), + subtitle: const Text('Build in-app messaging with our flexible SDKs.'), + caption: const _UrlCaption(url: 'https://getstream.io/chat/docs/'), onRemovePressed: () {}, ), brightness: Brightness.dark, @@ -120,11 +120,11 @@ void main() { GoldenTestScenario( name: 'full_with_image_no_remove', child: _buildLinkPreviewInTheme( - MessageComposerLinkPreviewAttachment( - title: 'Getting started with Stream', - subtitle: 'Build in-app messaging with our flexible SDKs.', - url: 'https://getstream.io/chat/docs/', - image: _placeholderImage, + StreamMessageComposerLinkPreviewAttachment( + title: const Text('Getting started with Stream'), + subtitle: const Text('Build in-app messaging with our flexible SDKs.'), + caption: const _UrlCaption(url: 'https://getstream.io/chat/docs/'), + thumbnail: Image(image: _placeholderImage, fit: BoxFit.cover), onRemovePressed: null, ), brightness: Brightness.dark, @@ -133,35 +133,35 @@ void main() { GoldenTestScenario( name: 'full_with_image_with_remove', child: _buildLinkPreviewInTheme( - MessageComposerLinkPreviewAttachment( - title: 'Getting started with Stream', - subtitle: 'Build in-app messaging with our flexible SDKs.', - url: 'https://getstream.io/chat/docs/', - image: _placeholderImage, + StreamMessageComposerLinkPreviewAttachment( + title: const Text('Getting started with Stream'), + subtitle: const Text('Build in-app messaging with our flexible SDKs.'), + caption: const _UrlCaption(url: 'https://getstream.io/chat/docs/'), + thumbnail: Image(image: _placeholderImage, fit: BoxFit.cover), onRemovePressed: () {}, ), brightness: Brightness.dark, ), ), GoldenTestScenario( - name: 'url_only_no_remove', + name: 'caption_only_no_remove', child: _buildLinkPreviewInTheme( - const MessageComposerLinkPreviewAttachment( + StreamMessageComposerLinkPreviewAttachment( title: null, subtitle: null, - url: 'https://getstream.io/', + caption: const _UrlCaption(url: 'https://getstream.io/'), onRemovePressed: null, ), brightness: Brightness.dark, ), ), GoldenTestScenario( - name: 'url_only_with_remove', + name: 'caption_only_with_remove', child: _buildLinkPreviewInTheme( - MessageComposerLinkPreviewAttachment( + StreamMessageComposerLinkPreviewAttachment( title: null, subtitle: null, - url: 'https://getstream.io/', + caption: const _UrlCaption(url: 'https://getstream.io/'), onRemovePressed: () {}, ), brightness: Brightness.dark, @@ -188,6 +188,24 @@ const _kTransparentPixelPng = [ 0x42, 0x60, 0x82, // ]; +class _UrlCaption extends StatelessWidget { + const _UrlCaption({required this.url}); + + final String url; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + spacing: 4, + children: [ + Icon(context.streamIcons.link, size: 12), + Flexible(child: Text(url)), + ], + ); + } +} + Widget _buildLinkPreviewInTheme( Widget linkPreview, { Brightness brightness = Brightness.light, diff --git a/packages/stream_core_flutter/test/components/message_composer/message_composer_attachment_reply_golden_test.dart b/packages/stream_core_flutter/test/components/message_composer/message_composer_attachment_reply_golden_test.dart index cd6a5479..f33726f0 100644 --- a/packages/stream_core_flutter/test/components/message_composer/message_composer_attachment_reply_golden_test.dart +++ b/packages/stream_core_flutter/test/components/message_composer/message_composer_attachment_reply_golden_test.dart @@ -13,27 +13,27 @@ void main() { builder: () => GoldenTestGroup( scenarioConstraints: const BoxConstraints(maxWidth: 360), children: [ - for (final style in ReplyStyle.values) + for (final style in StreamReplyDirection.values) GoldenTestScenario( name: '${style.name}_no_remove', child: _buildReplyInTheme( - MessageComposerReplyAttachment( + StreamMessageComposerReplyAttachment( title: const Text('Reply to John Doe'), subtitle: const Text('We had a great time during our holiday.'), onRemovePressed: null, - style: style, + direction: style, ), ), ), - for (final style in ReplyStyle.values) + for (final style in StreamReplyDirection.values) GoldenTestScenario( name: '${style.name}_with_remove', child: _buildReplyInTheme( - MessageComposerReplyAttachment( + StreamMessageComposerReplyAttachment( title: const Text('Reply to John Doe'), subtitle: const Text('We had a great time during our holiday.'), onRemovePressed: () {}, - style: style, + direction: style, ), ), ), @@ -47,28 +47,28 @@ void main() { builder: () => GoldenTestGroup( scenarioConstraints: const BoxConstraints(maxWidth: 360), children: [ - for (final style in ReplyStyle.values) + for (final style in StreamReplyDirection.values) GoldenTestScenario( name: '${style.name}_no_remove', child: _buildReplyInTheme( - MessageComposerReplyAttachment( + StreamMessageComposerReplyAttachment( title: const Text('Reply to John Doe'), subtitle: const Text('We had a great time during our holiday.'), onRemovePressed: null, - style: style, + direction: style, ), brightness: Brightness.dark, ), ), - for (final style in ReplyStyle.values) + for (final style in StreamReplyDirection.values) GoldenTestScenario( name: '${style.name}_with_remove', child: _buildReplyInTheme( - MessageComposerReplyAttachment( + StreamMessageComposerReplyAttachment( title: const Text('Reply to John Doe'), subtitle: const Text('We had a great time during our holiday.'), onRemovePressed: () {}, - style: style, + direction: style, ), brightness: Brightness.dark, ), From e96a55458cb7a7745dce00abe7a573c280b5aec2 Mon Sep 17 00:00:00 2001 From: xsahil03x <25670178+xsahil03x@users.noreply.github.com> Date: Tue, 5 May 2026 14:08:55 +0000 Subject: [PATCH 3/6] chore: Update Goldens --- ...er_attachment_link_preview_dark_matrix.png | Bin 5983 -> 8039 bytes ...r_attachment_link_preview_light_matrix.png | Bin 5016 -> 6388 bytes ..._composer_attachment_reply_dark_matrix.png | Bin 3881 -> 4585 bytes ...composer_attachment_reply_light_matrix.png | Bin 3479 -> 3911 bytes 4 files changed, 0 insertions(+), 0 deletions(-) diff --git a/packages/stream_core_flutter/test/components/message_composer/goldens/ci/message_composer_attachment_link_preview_dark_matrix.png b/packages/stream_core_flutter/test/components/message_composer/goldens/ci/message_composer_attachment_link_preview_dark_matrix.png index c0410944a840eb35cd125ed793065e8de035d658..1be8bda3faa002ccafca8d1138dedd86cb05dfdc 100644 GIT binary patch literal 8039 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 diff --git a/packages/stream_core_flutter/test/components/message_composer/goldens/ci/message_composer_attachment_link_preview_light_matrix.png b/packages/stream_core_flutter/test/components/message_composer/goldens/ci/message_composer_attachment_link_preview_light_matrix.png index 39b3e6a425c2bd056b51c0d574c6518aaee0e56c..d1e998851ec5149f3d0c321f2a78cc0017d37456 100644 GIT binary patch literal 6388 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 L`K9Cp>h^yD*waKO literal 5016 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 F@ju?4Z|nd7 diff --git a/packages/stream_core_flutter/test/components/message_composer/goldens/ci/message_composer_attachment_reply_dark_matrix.png b/packages/stream_core_flutter/test/components/message_composer/goldens/ci/message_composer_attachment_reply_dark_matrix.png index 88535340b42f03cb6fa71c02daffa00e5851e1fd..86e0623cf88cdddef26c2411f6686fcfd6deb05e 100644 GIT binary patch literal 4585 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 literal 3881 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 literal 3479 zcmeH~`!`$J0>^hy)lSj$i4Z24wyx_+QK2QJo;6o>n5wGhka)C0jW#M?X*((=#TZk4 zM5vlUDIrKzskEgJil}-d8k8y`ni4?}xo3L+fw{f2?z%tBUT5vI_g?#~v%h=o@AtEI z`Zw+_>Kk=70sz!q4?B7Spu~sTZ`4$w+TBofJ@itEwRb(H29-oLJQeE0Vm)2#L1m}z z6!g&{SI0xg&gabt+B0?g<2kcT69Eo~dqRse*l-c}M0`o-K5lzV$$wl0t5A8ecO)Ya zmGzxnOppodwK@LLuB{Yp{F6HGYU8i-{u$?e&YRG^4Q2GJm4&}c@3mn~t<4S>y@rMJ z`Xxu>2#Sx3iUC!i=xQoZNVw?6(B(N)Zpzx?l5iSx8M!WKLd3tR2hY~TK`Ycn6{-WU zL5&E&VY_f;V5ek{0AR~zNZL;nt}39Y{D1}ED}!1XFo6BHQ=cc#wcFM?Xu>!Tjsylc z;+nH4>{3J=Ji&w> zda2M0@6+<)ylzj9>pR&8)y0PNdOt@$Osd5h8$LF;ZfNyoIaRbf7@KZo%TqbO(Kn6c z#T=a@M@{Hhk%)U=uojksa9 zaFH%E3kVQUBPr1lgIK85(hy30Z5_vX}kj^$?P?sH{p8=JiE=TtBDQA#J#(O11tE_x|*(gcCbvRK(Pu* zW2V=7;gxN|YJB3tl7twlWMw5jY5nb;{%FNLEGk)&)}aX$CBms+&MubxZGN7?(?7Ue zdTpA^4BL4$4GGievFk!iPP4_f%k>3cbeB);JbD=#^$a{6HlauC+7Bk9O6z)Vs&oCJ zk=uJ7C<9K+)<4A!zc>h%c&$B!QR;RsU6}k$I3>F- z8d`~OpmI>)yQB&}LyV{%N`8?E?a#8-`XJ0Z978s#%O_;JNO6Vo!J{eFGj3pg$>izY z58-~!FUu_zzB}M4s{&0Nobv&(UG7&RX|vTi7Zpn>vP6c@*M?e3?oEoFl z9`p71VVUQiF^{}_w`ID@2pZIwkf#1@3zw-h82dTGRDvfz)Yl=HY zg`se-CSjNn1!sZ08bczREyZNtyOC)*Tp=Gl5bU9eQ|DB71 zW}gT1c=gGOPk~dxh#CnZ`0R+!cgmjd*x_`=nQ)~oKmYrmzR7l3U9t(j|Ko{1c&|}# zZ)Cnf#0ymouUukZv)OQ@m7DWafdzMZ+=WoR6JrJ<1A=V0ss;=}V)Rx37V`en9B)gJ zUrRxdRl!gYSvJg+2bd_fC}jGI?bGMX2i!m-FRouirHZ=OD_(1usRKryTPWNIL2K>c z7J)NgjC%(v2$R~NK6U+K4K;kUoZ0;Ekk|g7K;{#=>=tqY*Y4<%)xZ!Dq95w-!O3fn zKCO=Qhe*a^6O*#A9F65ECbD^9Woq1MbuFi$wWN6sUv&eVuMt=BzH@;<`RemP*>(`q z$0i<=8s&QFO&6yZM?k0TU^HxRDXMGit-Pl2*`!mCvvCb{gdgy=gvTbJIq)))fwwi3`u!2RVFNMK zxJ@&ukVH?ZhB1%fkl^D}cVVpdq@_a^mzNQO=4A6zm9-cSEB=^$1-Lbvg9)oLtT{~d zYguJTQ&_b;$@&1%arM0>U7zA$&O{$q7N3e!jOOGc-6T+`lJ9HC$)wq{lklLhI%3PX z)CT#6u8wP~kV=iKp`d-!l{(ogq3vo;NF=!O3O9HWYSY|+vM$SoxtCS81y@(7gq?I=MSm+6P_y7b{cm*8l(j From 70ca5bbbe4eda9a7acadb0efbda34614b68cfe93 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 6 May 2026 17:10:50 +0200 Subject: [PATCH 4/6] feat(ui): add StreamIntrinsicBoundedCrossAxis for opt-in bounded measurement Introduces a per-child marker that opts a single child of StreamIntrinsicFlex into measurement under the parent's cross-axis ceiling instead of the default unbounded constraints. This unblocks descendants that require a bounded cross-axis (shrink-wrapping viewports, Wrap, etc.) without forcing the whole column into bounded measurement, so unmarked siblings keep shrink-wrapping to their natural extent. Used in DefaultStreamReactions to wrap the bubble subtree, fixing layout failures when a message bubble contains a shrink-wrapping ListView (e.g. the voice-recording attachment playlist). When a layout failure does surface, the rendering library augments the FlutterError via informationCollector with a hint pointing at StreamIntrinsicBoundedCrossAxis, the affected StreamIntrinsicFlex identified via describeForError, a fallback diagnostic for upstream sources (UnconstrainedBox / OverflowBox), and the canonical flutter.dev/unbounded-constraints link. Cascade errors from the failed subtree are dropped so error reporters and the debug console see only the actionable failure. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../common/stream_intrinsic_flex.dart | 211 ++++++++++++++++-- .../common/stream_intrinsic_flex.dart | 193 +++++++++++++++- .../components/reaction/stream_reactions.dart | 10 +- .../common/stream_intrinsic_flex_test.dart | 107 +++++++++ 4 files changed, 499 insertions(+), 22 deletions(-) create mode 100644 packages/stream_core_flutter/test/components/common/stream_intrinsic_flex_test.dart diff --git a/apps/design_system_gallery/lib/components/common/stream_intrinsic_flex.dart b/apps/design_system_gallery/lib/components/common/stream_intrinsic_flex.dart index 9abf82a6..1c07bef9 100644 --- a/apps/design_system_gallery/lib/components/common/stream_intrinsic_flex.dart +++ b/apps/design_system_gallery/lib/components/common/stream_intrinsic_flex.dart @@ -94,13 +94,13 @@ Widget buildStreamIntrinsicFlexPlayground(BuildContext context) { ); // null = hidden - final childConfigs = <({_ChildKind kind, bool candidate, _ChildAlignment alignment})?>[]; + final childConfigs = <({_ChildKind kind, bool candidate, bool bounded, _ChildAlignment alignment})?>[]; const defaults = [ - (_ChildKind.fixedMedium, true, _ChildAlignment.start), - (_ChildKind.expanded, false, _ChildAlignment.start), - (_ChildKind.fixedLarge, false, _ChildAlignment.start), - (_ChildKind.aligned, false, _ChildAlignment.end), - (_ChildKind.fixedSmall, true, _ChildAlignment.start), + (_ChildKind.fixedMedium, true, false, _ChildAlignment.start), + (_ChildKind.expanded, false, false, _ChildAlignment.start), + (_ChildKind.fixedLarge, false, false, _ChildAlignment.start), + (_ChildKind.aligned, false, false, _ChildAlignment.end), + (_ChildKind.fixedSmall, true, false, _ChildAlignment.start), ]; for (var i = 0; i < 5; i++) { @@ -127,20 +127,27 @@ Widget buildStreamIntrinsicFlexPlayground(BuildContext context) { initialValue: defaults[i].$2, description: 'Mark child ${i + 1} as a StreamIntrinsicSizeCandidate.', ); + final bounded = context.knobs.boolean( + label: 'Child ${i + 1} bounded', + initialValue: defaults[i].$3, + description: + 'Wrap child ${i + 1} in StreamIntrinsicBoundedCrossAxis ' + "so it is measured under the parent's cross-axis ceiling.", + ); final isVertical = direction == Axis.vertical; - var alignment = defaults[i].$3; + var alignment = defaults[i].$4; if (kind == _ChildKind.aligned) { alignment = context.knobs.object.dropdown( label: 'Child ${i + 1} alignment', options: _ChildAlignment.values, labelBuilder: (value) => value.label(isVertical: isVertical), - initialOption: defaults[i].$3, + initialOption: defaults[i].$4, description: 'Alignment for child ${i + 1}.', ); } - childConfigs.add((kind: kind, candidate: candidate, alignment: alignment)); + childConfigs.add((kind: kind, candidate: candidate, bounded: bounded, alignment: alignment)); } return _PlaygroundBody( @@ -168,11 +175,11 @@ class _PlaygroundBody extends StatelessWidget { final MainAxisSize mainAxisSize; final double spacing; final CrossAxisAlignment crossAxisAlignment; - final List<({_ChildKind kind, bool candidate, _ChildAlignment alignment})?> childConfigs; + final List<({_ChildKind kind, bool candidate, bool bounded, _ChildAlignment alignment})?> childConfigs; List _buildChildren( BuildContext context, { - bool wrapCandidates = true, + bool wrapMarkers = true, }) { final textTheme = context.streamTextTheme; final radius = context.streamRadius; @@ -235,9 +242,12 @@ class _PlaygroundBody extends StatelessWidget { ), }; - if (wrapCandidates && config.candidate) { + if (wrapMarkers && config.candidate) { child = StreamIntrinsicSizeCandidate(child: child); } + if (wrapMarkers && config.bounded) { + child = StreamIntrinsicBoundedCrossAxis(child: child); + } return child; }(), ]; @@ -301,7 +311,7 @@ class _PlaygroundBody extends StatelessWidget { mainAxisSize: mainAxisSize, crossAxisAlignment: crossAxisAlignment, spacing: spacing, - children: _buildChildren(context, wrapCandidates: false), + children: _buildChildren(context, wrapMarkers: false), ), ), ), @@ -379,6 +389,7 @@ Widget buildStreamIntrinsicFlexShowcase(BuildContext context) { children: const [ _ShrinkWrapSection(), _SizeCandidatesSection(), + _BoundedChildrenSection(), _CrossAxisAlignmentSection(), _BaselineAlignmentSection(), _NegativeSpacingSection(), @@ -824,6 +835,180 @@ Widget _wrapCandidate({required bool isCandidate, required Widget child}) { return child; } +// ============================================================================= +// Bounded Children Section +// ============================================================================= + +class _BoundedChildrenSection extends StatelessWidget { + const _BoundedChildrenSection(); + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.md, + children: const [ + _SectionLabel(label: 'BOUNDED CHILDREN'), + _ExampleCard( + title: 'Marker changes how a child is measured', + description: + 'StreamIntrinsicColumn measures children with an unbounded ' + 'cross-axis by default, so Align shrink-wraps. Wrap a child ' + "in StreamIntrinsicBoundedCrossAxis to give it the parent's " + 'cross-axis ceiling — Align then fills, and the column resolves ' + 'to that wider extent.', + child: _BoundedAlignDemo(), + ), + _ExampleCard( + title: 'Hosting a ListView(shrinkWrap: true)', + description: + 'A vertical viewport asserts when given an unbounded width. ' + 'Wrapping the child in StreamIntrinsicBoundedCrossAxis hands ' + 'it a bounded width during pass-1, so a shrink-wrapping ' + 'ListView (or Wrap) inside it can lay out without asserting.', + child: _BoundedListViewDemo(), + ), + ], + ); + } +} + +class _BoundedAlignDemo extends StatelessWidget { + const _BoundedAlignDemo(); + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final radius = context.streamRadius; + final spacing = context.streamSpacing; + + Widget alignedChild() => Align( + alignment: Alignment.centerRight, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: colorScheme.accentPrimary.withValues(alpha: 0.16), + borderRadius: BorderRadius.all(radius.sm), + ), + child: Text( + 'aligned right', + style: textTheme.metadataEmphasis.copyWith(color: colorScheme.accentPrimary), + ), + ), + ); + + Widget card({ + required String label, + required Widget alignChild, + required bool accent, + }) { + final accentColor = accent ? colorScheme.accentPrimary : colorScheme.textTertiary; + return _VariantDemo( + label: label, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 240), + child: DecoratedBox( + decoration: BoxDecoration( + color: accentColor.withValues(alpha: 0.08), + borderRadius: BorderRadius.all(radius.md), + border: Border.all(color: accentColor.withValues(alpha: 0.3)), + ), + child: StreamIntrinsicColumn( + spacing: spacing.xs, + children: [ + _ColoredBar(width: 80, label: 'Fixed 80', palette: _childPalette[0]), + alignChild, + ], + ), + ), + ), + ); + } + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.lg, + children: [ + Expanded( + child: card( + label: 'unmarked', + alignChild: alignedChild(), + accent: false, + ), + ), + Expanded( + child: card( + label: 'StreamIntrinsicBoundedCrossAxis', + alignChild: StreamIntrinsicBoundedCrossAxis(child: alignedChild()), + accent: true, + ), + ), + ], + ); + } +} + +class _BoundedListViewDemo extends StatelessWidget { + const _BoundedListViewDemo(); + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final radius = context.streamRadius; + final spacing = context.streamSpacing; + + final list = ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: 4, + separatorBuilder: (_, _) => Divider(height: 1, color: colorScheme.borderSubtle), + itemBuilder: (_, i) => Padding( + padding: EdgeInsets.symmetric(horizontal: spacing.sm, vertical: spacing.xs), + child: Text( + 'Item ${i + 1}', + style: textTheme.metadataEmphasis.copyWith(color: colorScheme.textPrimary), + ), + ), + ); + + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 280), + child: DecoratedBox( + decoration: BoxDecoration( + color: colorScheme.accentPrimary.withValues(alpha: 0.08), + borderRadius: BorderRadius.all(radius.md), + border: Border.all(color: colorScheme.accentPrimary.withValues(alpha: 0.3)), + ), + child: StreamIntrinsicColumn( + spacing: spacing.xs, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: spacing.sm, vertical: spacing.xs), + child: Text( + 'Header', + style: textTheme.captionEmphasis.copyWith(color: colorScheme.accentPrimary), + ), + ), + StreamIntrinsicBoundedCrossAxis(child: list), + Padding( + padding: EdgeInsets.symmetric(horizontal: spacing.sm, vertical: spacing.xs), + child: Text( + 'Footer', + style: textTheme.captionEmphasis.copyWith(color: colorScheme.accentPrimary), + ), + ), + ], + ), + ), + ); + } +} + // ============================================================================= // Cross-Axis Alignment Section // ============================================================================= diff --git a/packages/stream_core_flutter/lib/src/components/common/stream_intrinsic_flex.dart b/packages/stream_core_flutter/lib/src/components/common/stream_intrinsic_flex.dart index ee6b06f0..95f30402 100644 --- a/packages/stream_core_flutter/lib/src/components/common/stream_intrinsic_flex.dart +++ b/packages/stream_core_flutter/lib/src/components/common/stream_intrinsic_flex.dart @@ -1,5 +1,6 @@ import 'dart:math' as math; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; @@ -30,6 +31,19 @@ import 'package:flutter/rendering.dart'; /// are laid out within that extent. If no candidates exist, every child /// participates. /// +/// ## Bounded children +/// +/// By default children are measured with an unbounded cross-axis so they +/// can report their natural extent. Descendants that require a bounded +/// cross-axis to lay out (e.g. [ListView] with `shrinkWrap: true`, +/// [Wrap]) assert under that contract. +/// +/// If one or more children are wrapped in [StreamIntrinsicBoundedCrossAxis], +/// those children are measured under the incoming cross-axis ceiling +/// instead. The marker is independent of [StreamIntrinsicSizeCandidate] — +/// the marked child still participates in cross-axis resolution like any +/// other child. +/// /// {@tool snippet} /// /// A column that shrink-wraps to the widest child: @@ -63,12 +77,30 @@ import 'package:flutter/rendering.dart'; /// ``` /// {@end-tool} /// +/// {@tool snippet} +/// +/// If a child contains a descendant that requires a bounded cross-axis +/// (a shrink-wrapping viewport, a [Wrap]), wrap it in +/// [StreamIntrinsicBoundedCrossAxis]: +/// +/// ```dart +/// StreamIntrinsicColumn( +/// spacing: 8.0, +/// children: [ +/// StreamIntrinsicBoundedCrossAxis(child: MyContent()), // bounded +/// MyFooter(), // unbounded (default) +/// ], +/// ) +/// ``` +/// {@end-tool} +/// /// ## Layout algorithm /// /// 1. Lay out non-flex children with unbounded cross-axis to measure their -/// natural sizes. +/// natural sizes. Children wrapped in [StreamIntrinsicBoundedCrossAxis] +/// are measured under the parent's cross-axis ceiling instead. /// 2. Distribute remaining main-axis space to flexible children and lay -/// them out with unbounded cross-axis. +/// them out, with the same cross-axis treatment as step 1. /// 3. Resolve the cross-axis extent: /// - With candidates: `min(max(candidate extents), constraint)`. /// - Without candidates: `min(max(all extents), constraint)`. @@ -89,6 +121,8 @@ import 'package:flutter/rendering.dart'; /// * [StreamIntrinsicRow], for a horizontal variant. /// * [StreamIntrinsicSizeCandidate], to mark specific children as /// cross-axis candidates. +/// * [StreamIntrinsicBoundedCrossAxis], to give specific children +/// bounded cross-axis measurement. class StreamIntrinsicFlex extends MultiChildRenderObjectWidget { /// Creates an intrinsic flex layout. /// @@ -261,6 +295,8 @@ class StreamIntrinsicFlex extends MultiChildRenderObjectWidget { /// * [StreamIntrinsicFlex], the direction-agnostic variant. /// * [StreamIntrinsicSizeCandidate], to mark specific children as /// width candidates. +/// * [StreamIntrinsicBoundedCrossAxis], to give specific children a +/// bounded width. class StreamIntrinsicColumn extends StreamIntrinsicFlex { /// Creates a vertical intrinsic flex layout that shrink-wraps its width. const StreamIntrinsicColumn({ @@ -306,6 +342,8 @@ class StreamIntrinsicColumn extends StreamIntrinsicFlex { /// * [StreamIntrinsicFlex], the direction-agnostic variant. /// * [StreamIntrinsicSizeCandidate], to mark specific children as /// height candidates. +/// * [StreamIntrinsicBoundedCrossAxis], to give specific children a +/// bounded height. class StreamIntrinsicRow extends StreamIntrinsicFlex { /// Creates a horizontal intrinsic flex layout that shrink-wraps its height. const StreamIntrinsicRow({ @@ -380,9 +418,73 @@ class StreamIntrinsicSizeCandidate extends ParentDataWidget<_IntrinsicFlexParent Type get debugTypicalAncestorWidgetClass => StreamIntrinsicFlex; } +/// Marks a child of [StreamIntrinsicFlex] to be measured under the +/// parent's cross-axis ceiling instead of unbounded constraints. +/// +/// By default children are measured unbounded so [Align] and similar +/// widgets shrink-wrap. Descendants that require a bounded cross-axis +/// (e.g. [ListView] with `shrinkWrap: true`, [Wrap]) assert under that +/// contract — wrap them in this widget to opt into the bounded path. +/// +/// Has no effect when the parent's cross-axis is itself unbounded. +/// +/// This can be combined with [StreamIntrinsicSizeCandidate] on the same +/// child: +/// +/// ```dart +/// StreamIntrinsicColumn( +/// children: [ +/// StreamIntrinsicSizeCandidate( +/// child: StreamIntrinsicBoundedCrossAxis(child: MyContent()), +/// ), +/// ], +/// ) +/// ``` +/// +/// {@tool snippet} +/// +/// A column whose first child needs a bounded width to host a [ListView] +/// with `shrinkWrap: true`: +/// +/// ```dart +/// StreamIntrinsicColumn( +/// children: [ +/// StreamIntrinsicBoundedCrossAxis(child: BubbleWithListView()), +/// ReactionStrip(), +/// ], +/// ) +/// ``` +/// {@end-tool} +class StreamIntrinsicBoundedCrossAxis extends ParentDataWidget<_IntrinsicFlexParentData> { + /// Creates a widget that marks its child for bounded cross-axis + /// measurement. + const StreamIntrinsicBoundedCrossAxis({ + super.key, + required super.child, + }); + + @override + void applyParentData(RenderObject renderObject) { + final parentData = renderObject.parentData; + if (parentData is _IntrinsicFlexParentData && !parentData.boundedCrossAxis) { + parentData.boundedCrossAxis = true; + final renderParent = renderObject.parent; + if (renderParent is RenderObject) { + renderParent.markNeedsLayout(); + } + } + } + + @override + Type get debugTypicalAncestorWidgetClass => StreamIntrinsicFlex; +} + class _IntrinsicFlexParentData extends FlexParentData { // ignore: type_annotate_public_apis var isSizeCandidate = false; + + // ignore: type_annotate_public_apis + var boundedCrossAxis = false; } // ─── Render object ─── @@ -668,6 +770,79 @@ class _RenderStreamIntrinsicFlex extends RenderBox // ── Shared sizing (used by both performLayout and computeDryLayout) ── + // Lays out [child] with [constraints], augmenting the first + // FlutterError raised in the descendant subtree with a + // StreamIntrinsicBoundedCrossAxis hint and silencing the cascade. + // No-op when [constraints] are bounded on the cross-axis. + Size _layoutChildOrAddBoundedHint( + RenderBox child, + BoxConstraints constraints, + ChildLayouter layoutChild, + ) { + final crossBounded = _isVertical ? constraints.hasBoundedWidth : constraints.hasBoundedHeight; + if (crossBounded) return layoutChild(child, constraints); + + final originalOnError = FlutterError.onError; + var hintAttached = false; + FlutterError.onError = (details) { + if (!hintAttached) { + hintAttached = true; + originalOnError?.call(_appendBoundedHint(details)); + return; + } + // Cascade — every ancestor proxy in the failed subtree rethrows + // when accessing the missing size. Carries no new information; + // dropped entirely so it doesn't reach error reporters or the + // debug console. + }; + try { + return layoutChild(child, constraints); + } catch (_) { + // The descendant's failure escaped layoutChild (typically the + // AssertionError from accessing the failed child's `size`). The + // actionable error is already reported above; swallow this trailing + // throw and return a zero-size fallback so pass 2 can re-lay out + // every child under the resolved cross extent. Without this, parent + // data stays dirty and flushSemantics asserts later in the frame. + return Size.zero; + } finally { + FlutterError.onError = originalOnError; + } + } + + // Returns [details] with a StreamIntrinsicBoundedCrossAxis hint + // appended via FlutterErrorDetails.informationCollector. The hint is + // conditional — the unbounded constraint may originate here or from + // an intermediate widget below. + FlutterErrorDetails _appendBoundedHint(FlutterErrorDetails details) { + return FlutterErrorDetails( + exception: details.exception, + stack: details.stack, + library: details.library, + context: details.context, + silent: details.silent, + informationCollector: () => [ + ErrorHint( + 'A StreamIntrinsicFlex ancestor is measuring its children with ' + 'an unbounded cross-axis. Wrap the affected child of the ' + 'StreamIntrinsicFlex in StreamIntrinsicBoundedCrossAxis.', + ), + ErrorSpacer(), + describeForError('The StreamIntrinsicFlex'), + ErrorSpacer(), + ErrorDescription( + 'If wrapping does not resolve the error, an UnconstrainedBox ' + 'or OverflowBox below is forcing unbounded constraints; remove ' + 'or replace it instead.', + ), + ErrorSpacer(), + ErrorHint('See also: https://flutter.dev/unbounded-constraints'), + ErrorSpacer(), + if (details.informationCollector != null) ...details.informationCollector!(), + ], + ); + } + _LayoutSizes _computeSizes({ required BoxConstraints constraints, required ChildLayouter layoutChild, @@ -687,7 +862,8 @@ class _RenderStreamIntrinsicFlex extends RenderBox ))) : null; - // Pass 1a: lay out non-flex children with unbounded cross-axis. + // Pass 1a: lay out non-flex children with unbounded cross-axis, + // overridden per-child by StreamIntrinsicBoundedCrossAxis. var totalFlex = 0; var candidateExtent = 0.0; var allExtent = 0.0; @@ -706,8 +882,8 @@ class _RenderStreamIntrinsicFlex extends RenderBox totalFlex += flex; firstFlexChild ??= child; } else { - const childConstraints = BoxConstraints(); - final s = layoutChild(child, childConstraints); + final childConstraints = data.boundedCrossAxis ? _withCross(maxCross) : const BoxConstraints(); + final s = _layoutChildOrAddBoundedHint(child, childConstraints, layoutChild); final cross = _crossSize(s); inflexibleMainTotal += _mainSize(s); allExtent = math.max(allExtent, cross); @@ -732,7 +908,7 @@ class _RenderStreamIntrinsicFlex extends RenderBox final freeSpace = math.max(0, maxMainSize - inflexibleMainTotal - totalSpacing); final spacePerFlex = totalFlex > 0 ? freeSpace / totalFlex : 0.0; - // Pass 1c: lay out flex children with their allocation + unbounded cross. + // Pass 1c: lay out flex children with their allocation + cross-axis ceiling matching pass 1a. for (var fc = firstFlexChild; fc != null; fc = childAfter(fc)) { final data = fc.parentData! as _IntrinsicFlexParentData; final flex = _getFlex(fc); @@ -742,8 +918,9 @@ class _RenderStreamIntrinsicFlex extends RenderBox FlexFit.tight => maxChildExtent, FlexFit.loose => 0.0, }; - final childConstraints = _flexConstraints(minChildExtent, maxChildExtent, double.infinity); - final s = layoutChild(fc, childConstraints); + final crossCeiling = data.boundedCrossAxis ? maxCross : double.infinity; + final childConstraints = _flexConstraints(minChildExtent, maxChildExtent, crossCeiling); + final s = _layoutChildOrAddBoundedHint(fc, childConstraints, layoutChild); final cross = _crossSize(s); allExtent = math.max(allExtent, cross); if (data.isSizeCandidate) { 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 2371018e..85976bc5 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 @@ -365,6 +365,11 @@ class DefaultStreamReactions extends StatelessWidget { // when overlapping (later children have higher z-order). For // top-positioned reactions we flip verticalDirection so the column still // lays out bottom-to-top while keeping reactions last in the paint order. + // + // The bubble is wrapped in StreamIntrinsicBoundedCrossAxis so descendants + // like ListView(shrinkWrap: true) inside it receive a bounded width. + // Resolution still treats it as a regular child — column width remains + // max(bubble, strip). return StreamIntrinsicColumn( spacing: columnSpacing, crossAxisAlignment: effectiveCrossAxisAlignment, @@ -373,7 +378,10 @@ class DefaultStreamReactions extends StatelessWidget { .header => VerticalDirection.up, .footer => VerticalDirection.down, }, - children: [props.child!, alignedStrip], + children: [ + StreamIntrinsicBoundedCrossAxis(child: props.child!), + alignedStrip, + ], ); } diff --git a/packages/stream_core_flutter/test/components/common/stream_intrinsic_flex_test.dart b/packages/stream_core_flutter/test/components/common/stream_intrinsic_flex_test.dart new file mode 100644 index 00000000..976978cd --- /dev/null +++ b/packages/stream_core_flutter/test/components/common/stream_intrinsic_flex_test.dart @@ -0,0 +1,107 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; + +void main() { + Widget wrap(Widget child) => Directionality( + textDirection: TextDirection.ltr, + child: child, + ); + + Widget buildShrinkWrappingList() => ListView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + children: const [ + SizedBox(height: 20, child: Text('item 1')), + SizedBox(height: 20, child: Text('item 2')), + ], + ); + + // Captures every FlutterErrorDetails reported through onError into a + // local list and intentionally does *not* forward to the framework's + // handler. That keeps the cascade of layout errors from being dumped to + // the test console while still letting us inspect what fired. + List captureErrors() { + final caught = []; + final originalOnError = FlutterError.onError; + FlutterError.onError = caught.add; + addTearDown(() => FlutterError.onError = originalOnError); + return caught; + } + + group('StreamIntrinsicFlex bounded-cross-axis hint', () { + testWidgets( + 'augments the layout failure with a StreamIntrinsicBoundedCrossAxis hint', + (tester) async { + final caught = captureErrors(); + + await tester.pumpWidget( + wrap( + StreamIntrinsicColumn( + children: [buildShrinkWrappingList()], + ), + ), + ); + + expect(caught, isNotEmpty); + expect(caught.first.exception.toString(), contains('unbounded')); + + final info = caught.first.informationCollector?.call() ?? const []; + final infoText = info.map((node) => node.toString()).join('\n'); + expect(infoText, contains('StreamIntrinsicBoundedCrossAxis')); + expect(infoText, contains('flutter.dev/unbounded-constraints')); + }, + ); + + testWidgets( + 'lays out cleanly when the offending child is wrapped in ' + 'StreamIntrinsicBoundedCrossAxis', + (tester) async { + await tester.pumpWidget( + wrap( + StreamIntrinsicColumn( + children: [ + StreamIntrinsicBoundedCrossAxis(child: buildShrinkWrappingList()), + ], + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(find.text('item 1'), findsOneWidget); + expect(find.text('item 2'), findsOneWidget); + }, + ); + + testWidgets( + 'hint is conditional and covers upstream culprits when the unbounded ' + 'constraint originates above this widget', + (tester) async { + // Wrapping the column in an UnconstrainedBox makes the column + // itself receive unbounded cross-axis constraints. The wrapper + // can't tell whether the unbounded came from us or from above, + // so the hint is phrased to cover both branches. + final caught = captureErrors(); + + await tester.pumpWidget( + wrap( + UnconstrainedBox( + child: StreamIntrinsicColumn( + children: [buildShrinkWrappingList()], + ), + ), + ), + ); + + expect(caught, isNotEmpty); + expect(caught.first.exception.toString(), contains('unbounded')); + + final info = caught.first.informationCollector?.call() ?? const []; + final infoText = info.map((node) => node.toString()).join('\n'); + // Local fix and upstream possibility are both surfaced. + expect(infoText, contains('StreamIntrinsicBoundedCrossAxis')); + expect(infoText, contains('UnconstrainedBox')); + }, + ); + }); +} From e953fa350e0f53a9dd03cb78457d878e92e3b72d Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 6 May 2026 17:11:30 +0200 Subject: [PATCH 5/6] fix(ui): use transparent Material for composer attachment thumbnails The Material wrapping the thumbnail in each composer attachment was defaulting to MaterialType.canvas, which paints an opaque background behind the thumbnail. Set type to MaterialType.transparency so the Material only contributes clipping and the thumbnail itself shows through cleanly. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../stream_message_composer_edit_message_attachment.dart | 1 + .../stream_message_composer_link_preview_attachment.dart | 1 + .../attachment/stream_message_composer_reply_attachment.dart | 1 + 3 files changed, 3 insertions(+) diff --git a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_edit_message_attachment.dart b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_edit_message_attachment.dart index 7c10c1ab..671cac20 100644 --- a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_edit_message_attachment.dart +++ b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_edit_message_attachment.dart @@ -176,6 +176,7 @@ class DefaultStreamMessageComposerEditMessageAttachment extends StatelessWidget effectiveThumbnail = SizedBox.fromSize( size: effectiveThumbnailSize, child: Material( + type: MaterialType.transparency, clipBehavior: Clip.hardEdge, shape: effectiveThumbnailShape, child: thumbnail, diff --git a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_link_preview_attachment.dart b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_link_preview_attachment.dart index ff954bda..62630d45 100644 --- a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_link_preview_attachment.dart +++ b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_link_preview_attachment.dart @@ -174,6 +174,7 @@ class DefaultStreamMessageComposerLinkPreviewAttachment extends StatelessWidget effectiveThumbnail = SizedBox.fromSize( size: effectiveThumbnailSize, child: Material( + type: MaterialType.transparency, clipBehavior: Clip.hardEdge, shape: effectiveThumbnailShape, child: thumbnail, diff --git a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_reply_attachment.dart b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_reply_attachment.dart index 29e5f8de..fa40bfa3 100644 --- a/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_reply_attachment.dart +++ b/packages/stream_core_flutter/lib/src/components/message_composer/attachment/stream_message_composer_reply_attachment.dart @@ -203,6 +203,7 @@ class DefaultStreamMessageComposerReplyAttachment extends StatelessWidget { effectiveThumbnail = SizedBox.fromSize( size: effectiveThumbnailSize, child: Material( + type: MaterialType.transparency, clipBehavior: Clip.hardEdge, shape: effectiveThumbnailShape, child: thumbnail, From 4e243bc45d635842ee4b58919d52c7672731e01e Mon Sep 17 00:00:00 2001 From: xsahil03x <25670178+xsahil03x@users.noreply.github.com> Date: Thu, 7 May 2026 11:22:10 +0000 Subject: [PATCH 6/6] chore: Update Goldens --- ...er_attachment_link_preview_dark_matrix.png | Bin 8039 -> 7187 bytes ...r_attachment_link_preview_light_matrix.png | Bin 6388 -> 5736 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/packages/stream_core_flutter/test/components/message_composer/goldens/ci/message_composer_attachment_link_preview_dark_matrix.png b/packages/stream_core_flutter/test/components/message_composer/goldens/ci/message_composer_attachment_link_preview_dark_matrix.png index 1be8bda3faa002ccafca8d1138dedd86cb05dfdc..05179d73d69267e5cc239d76231fcff1bddf07b7 100644 GIT binary patch literal 7187 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 E1!o=Cvj6}9 literal 8039 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 BfKC7a literal 6388 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 L`K9Cp>h^yD*waKO