diff --git a/.gitignore b/.gitignore index 06a0c25816..ae47dab8f5 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ coverage_helper_test.dart **/doc/api/ pubspec.lock pubspec_overrides.yaml +devtools_options.yaml flutter_export_environment.sh generated_plugin_registrant.* GeneratedPluginRegistrant.* diff --git a/docs/docs_screenshots/pubspec.yaml b/docs/docs_screenshots/pubspec.yaml index 741c1f8a3e..e3fc5f94e8 100644 --- a/docs/docs_screenshots/pubspec.yaml +++ b/docs/docs_screenshots/pubspec.yaml @@ -22,7 +22,7 @@ dependencies: stream_core_flutter: git: url: https://github.com/GetStream/stream-core-flutter.git - ref: 639f99401891f171e9cc2264eea822ef3ede3f99 + ref: da615a2b232948bf89e46ea3d4c2e99084420544 path: packages/stream_core_flutter dev_dependencies: diff --git a/docs/docs_screenshots/test/message_input/goldens/ci/message_input_quoted_message.png b/docs/docs_screenshots/test/message_input/goldens/ci/message_input_quoted_message.png index 7dcda465cf..0d41d9dfd2 100644 Binary files a/docs/docs_screenshots/test/message_input/goldens/ci/message_input_quoted_message.png and b/docs/docs_screenshots/test/message_input/goldens/ci/message_input_quoted_message.png differ diff --git a/docs/docs_screenshots/test/message_list/goldens/ci/message_reaction_theming.png b/docs/docs_screenshots/test/message_list/goldens/ci/message_reaction_theming.png index 9d48020eda..2eab723295 100644 Binary files a/docs/docs_screenshots/test/message_list/goldens/ci/message_reaction_theming.png and b/docs/docs_screenshots/test/message_list/goldens/ci/message_reaction_theming.png differ diff --git a/docs/docs_screenshots/test/voice_recording/goldens/ci/voice_recording_finished.png b/docs/docs_screenshots/test/voice_recording/goldens/ci/voice_recording_finished.png index c59f7d6719..3b1e56c832 100644 Binary files a/docs/docs_screenshots/test/voice_recording/goldens/ci/voice_recording_finished.png and b/docs/docs_screenshots/test/voice_recording/goldens/ci/voice_recording_finished.png differ diff --git a/melos.yaml b/melos.yaml index cdc24fb372..953579af6e 100644 --- a/melos.yaml +++ b/melos.yaml @@ -101,7 +101,7 @@ command: stream_core_flutter: git: url: https://github.com/GetStream/stream-core-flutter.git - ref: 639f99401891f171e9cc2264eea822ef3ede3f99 + ref: da615a2b232948bf89e46ea3d4c2e99084420544 path: packages/stream_core_flutter synchronized: ^3.1.0+1 thumblr: ^0.0.4 diff --git a/packages/stream_chat/lib/src/core/platform_detector/platform_detector.dart b/packages/stream_chat/lib/src/core/platform_detector/platform_detector.dart index 593a967f90..7e1da8c558 100644 --- a/packages/stream_chat/lib/src/core/platform_detector/platform_detector.dart +++ b/packages/stream_chat/lib/src/core/platform_detector/platform_detector.dart @@ -1,3 +1,4 @@ +import 'package:meta/meta.dart' show visibleForTesting; import 'package:stream_chat/src/core/platform_detector/platform_detector_stub.dart' if (dart.library.html) 'platform_detector_web.dart' if (dart.library.io) 'platform_detector_io.dart'; @@ -67,6 +68,30 @@ class CurrentPlatform { }; } + /// Override the value reported by [type] in tests. + /// + /// Setting this affects all reads of [type], [name], and the per-platform + /// flags ([isAndroid], [isWeb], …). Reset to `null` after each test (e.g. + /// in `tearDown`) to avoid leaking state. + /// + /// The override is honored only when asserts are enabled (debug, profile, + /// and tests); release builds tree-shake it away. Mirrors Flutter's + /// `debugDefaultTargetPlatformOverride`. + @visibleForTesting + static PlatformType? debugCurrentPlatformOverride; + /// Get current platform type - static PlatformType get type => currentPlatform; + static PlatformType get type { + var result = currentPlatform; + assert( + () { + if (debugCurrentPlatformOverride case final override?) { + result = override; + } + return true; + }(), + 'debugCurrentPlatformOverride applied', + ); + return result; + } } diff --git a/packages/stream_chat/test/src/core/platform_detector/platform_detector_test.dart b/packages/stream_chat/test/src/core/platform_detector/platform_detector_test.dart index 110fb64eba..0ac16d03e4 100644 --- a/packages/stream_chat/test/src/core/platform_detector/platform_detector_test.dart +++ b/packages/stream_chat/test/src/core/platform_detector/platform_detector_test.dart @@ -22,4 +22,25 @@ void main() { expect(CurrentPlatform.isWindows, isFalse); expect(CurrentPlatform.isFuchsia, isFalse); }); + + group('debugCurrentPlatformOverride', () { + tearDown(() => CurrentPlatform.debugCurrentPlatformOverride = null); + + test('changes type, name, and flags', () { + CurrentPlatform.debugCurrentPlatformOverride = PlatformType.web; + + expect(CurrentPlatform.type, PlatformType.web); + expect(CurrentPlatform.name, 'web'); + expect(CurrentPlatform.isWeb, isTrue); + expect(CurrentPlatform.isLinux, isFalse); + }); + + test('clearing the override restores the real platform', () { + CurrentPlatform.debugCurrentPlatformOverride = PlatformType.windows; + CurrentPlatform.debugCurrentPlatformOverride = null; + + expect(CurrentPlatform.type, PlatformType.linux); + expect(CurrentPlatform.isWindows, isFalse); + }); + }); } diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 6702419904..dc629487f4 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -25,6 +25,7 @@ - Updated several `Translations` default strings and added new abstract members — see [`migrations/redesign/localizations.md`](../../migrations/redesign/localizations.md). - Renamed `MuteIconPosition` → `AttributePosition` (values `title` → `inlineTitle`, `subtitle` → `trailingBottom`) and `StreamChannelListItemThemeData.muteIconPosition` → `attributePosition`. Now controls both mute and pin icons in `StreamChannelListTile`. - Removed `AttachmentModalSheet`, `ErrorAlertSheet` and `StreamChannelInfoBottomSheet`. +- Removed `StreamMarkdownMessage`; use `StreamMessageText` (re-exported from `stream_core_flutter`) instead. ✅ Added @@ -32,6 +33,7 @@ - Redesigned `StreamSystemMessage` / `StreamModeratedMessage` with a pill-shaped style and visual customisation props. - Added visual customisation props to `ThreadSeparator` and `UnreadMessagesSeparator`. - Added `StreamUnsupportedAttachment` and `UnsupportedAttachmentBuilder` for unrecognised attachment types. +- Added `StreamQuotedMessage` and `StreamQuotedMessageThemeData` for the quoted message preview. - `MessagePreviewFormatter` now renders `AttachmentType.urlPreview` messages with a link icon and caption / OG title / `linkAttachmentText` fallback. - Added `StreamPollCardStyle`, `StreamPollQuestionStyle` and `StreamPollOptionVotesStyle` shared style classes for the poll sheets. - Added a total vote count footer and per-option "View all" action to `StreamPollResultsSheet`. @@ -43,6 +45,7 @@ - Added `Translations.totalVoteCountLabel({int? count})`, `viewAllLabel`, `pollVotesLabel`, `endVoteConfirmationMessage` and `questionLabel({bool isPlural = false})`. - Added `Translations.reactionsCountText(int count)` for the reaction-detail sheet header. - Added `StreamChannelListTile.isPinned` — renders a pin icon alongside the existing mute icon for pinned channels. +- Added `StreamChatConfigurationData.reactionOverlap` and `StreamMessageReactions.overlap` to control whether reactions overlap the message bubble edge. When unset, falls back to the platform-based default (overlap on mobile, no overlap on desktop and web). 🔄 Changed @@ -52,6 +55,8 @@ 🐞 Fixed +- Fixed `StreamCommandAutocompleteOptions` and `StreamMentionAutocompleteOptions` expanding to half the screen height — both now cap at a fixed max height (208px / 176px) and scroll internally so the list can't dominate the screen or overlap the header. +- Fixed the "Add an option" button in the poll creator looking like a tappable empty option row while disabled. The button is now hidden when adding a new option isn't allowed (an existing option is empty, or the maximum has been reached) instead of rendering as a disabled lookalike. - Fixed voice recording duration label jumping by ~1 second when playback starts. The recording timer tracks duration in whole seconds, so the stored value can be up to 1 second longer than the actual audio file. The player now resolves this by keeping the larger of the stored and player-reported durations, matching the strategy used by the iOS SDK. - Fixed voice message time label displaying elapsed time instead of remaining time. - Fixed RTL layout for the scroll-to-bottom button, swipe-to-reply icon, and voice recording lock button. @@ -73,6 +78,8 @@ - Fixed `PollAddCommentDialog` and `PollSuggestOptionDialog` accepting whitespace-only or unchanged submissions; the confirm action now disables when the trimmed text is empty or matches the initial value. - Fixed `StreamPhotoGalleryTile` using a hand-rolled icon as its loading placeholder and silently rendering nothing on decode failure. Now uses the shared `StreamImageLoadingPlaceholder` while loading and `StreamImageErrorPlaceholder` if the thumbnail fails to load. - Fixed poll, attachment-action, and message-action dialog buttons rendering their labels in uppercase (e.g. `CANCEL`, `SEND`, `FLAG`, `DELETE`); they now use the localized labels as-is so they match the rest of the system. +- Fixed tapping a quoted parent message inside a thread doing nothing (or kicking back to the channel). The thread message list now resolves the parent slot directly and scrolls/highlights it instead of falling through to `loadChannelAtMessage`. +- Fixed the jump-to-message highlight starting before the scroll settled, which made the fade barely visible (or invisible if the target hadn't been mounted yet). The message list now awaits the scroll, then plays a 1s hold + 1s ease-out fade — closer to the highlight feel in Slack's permalink jump. ## 10.0.0-beta.13 diff --git a/packages/stream_chat_flutter/lib/src/ai_assistant/streaming_message_view.dart b/packages/stream_chat_flutter/lib/src/ai_assistant/streaming_message_view.dart index 0a81884920..5b8c205e96 100644 --- a/packages/stream_chat_flutter/lib/src/ai_assistant/streaming_message_view.dart +++ b/packages/stream_chat_flutter/lib/src/ai_assistant/streaming_message_view.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:stream_chat_flutter/src/ai_assistant/stream_typewriter_builder.dart'; -import 'package:stream_chat_flutter/src/misc/markdown_message.dart'; import 'package:stream_chat_flutter/src/utils/device_segmentation.dart'; import 'package:stream_chat_flutter/src/utils/helpers.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart' as core; /// {@template streamingMessageView} /// A widget that displays a message in a streaming fashion. The message is @@ -76,8 +76,8 @@ class _StreamingMessageViewState extends State { @override Widget build(BuildContext context) { - return StreamMarkdownMessage( - data: _displayText, + return core.StreamMessageText( + _displayText, selectable: isDesktopDeviceOrWeb, onTapLink: switch (widget.onTapLink) { final onTapLink? => onTapLink, diff --git a/packages/stream_chat_flutter/lib/src/autocomplete/stream_command_autocomplete_options.dart b/packages/stream_chat_flutter/lib/src/autocomplete/stream_command_autocomplete_options.dart index dd92cdb9bf..625e66cba2 100644 --- a/packages/stream_chat_flutter/lib/src/autocomplete/stream_command_autocomplete_options.dart +++ b/packages/stream_chat_flutter/lib/src/autocomplete/stream_command_autocomplete_options.dart @@ -3,6 +3,10 @@ import 'package:stream_chat_flutter/src/message_input/attachment_picker/options/ import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +// Caps the card height so a long command list scrolls internally +// instead of pushing the composer / header off the screen. +const _kMaxHeight = 208.0; + /// {@template commands_overlay} /// Overlay for displaying commands that can be used /// to interact with the channel. @@ -48,6 +52,7 @@ class StreamCommandAutocompleteOptions extends StatelessWidget { return StreamAutocompleteOptions( options: commands, + maxHeight: _kMaxHeight, elevation: elevation, margin: margin, shape: shape, diff --git a/packages/stream_chat_flutter/lib/src/autocomplete/stream_mention_autocomplete_options.dart b/packages/stream_chat_flutter/lib/src/autocomplete/stream_mention_autocomplete_options.dart index 5fa238458a..ceac0ba902 100644 --- a/packages/stream_chat_flutter/lib/src/autocomplete/stream_mention_autocomplete_options.dart +++ b/packages/stream_chat_flutter/lib/src/autocomplete/stream_mention_autocomplete_options.dart @@ -2,6 +2,10 @@ import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +// Caps the card height so a long mention list scrolls internally +// instead of pushing the composer / header off the screen. +const _kMaxHeight = 176.0; + /// {@template user_mentions_overlay} /// Overlay for displaying users that can be mentioned. /// {@endtemplate} @@ -93,6 +97,7 @@ class _StreamMentionAutocompleteOptionsState extends State( options: users, + maxHeight: _kMaxHeight, elevation: elevation, margin: margin, shape: shape, diff --git a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_header.dart b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_header.dart index 43b5802324..214321a84b 100644 --- a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_header.dart +++ b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_header.dart @@ -1,8 +1,7 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'package:stream_core_flutter/stream_core_flutter.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart' as core; /// A widget that shows the input header of the message composer. /// Uses the factory to show custom components or used the default implementation. @@ -104,7 +103,7 @@ class _DefaultStreamMessageComposerInputHeader extends StatelessWidget { final attachment = voiceRecordings.elementAtOrNull(index); if (attachment == null) return child; - return StreamMessageComposerAttachmentContainer( + return core.StreamMessageComposerAttachment( onRemovePressed: () => _onAttachmentRemovePressed(attachment), child: child, ); @@ -119,11 +118,14 @@ class _DefaultStreamMessageComposerInputHeader extends StatelessWidget { if (ogAttachment != null) Padding( padding: contentPadding, - child: MessageComposerLinkPreviewAttachment( - title: ogAttachment.title, - subtitle: ogAttachment.text, - image: ogAttachment.imageUrl != null ? CachedNetworkImageProvider(ogAttachment.imageUrl!) : null, - url: ogAttachment.titleLink, + child: core.StreamMessageComposerLinkPreviewAttachment( + title: ogAttachment.title != null ? Text(ogAttachment.title!) : null, + subtitle: ogAttachment.text != null ? Text(ogAttachment.text!) : null, + caption: ogAttachment.titleLink != null ? Text(ogAttachment.titleLink!) : null, + thumbnail: switch (ogAttachment.imageUrl) { + final imageUrl? when imageUrl.isNotEmpty => core.StreamNetworkImage(imageUrl, fit: .cover), + _ => null, + }, onRemovePressed: () { controller.clearOGAttachment(); props.focusNode?.unfocus(); @@ -162,11 +164,10 @@ class _EditMessageInHeader extends StatelessWidget { @override Widget build(BuildContext context) { - return MessageComposerReplyAttachment( + return core.StreamMessageComposerEditMessageAttachment( title: Text(context.translations.editMessageLabel), subtitle: StreamMessagePreviewText(message: message), onRemovePressed: onRemovePressed, - style: ReplyStyle.outgoing, ); } } @@ -182,64 +183,44 @@ class _QuotedMessageInHeader extends StatelessWidget { final VoidCallback onRemovePressed; final String? currentUserId; - ImageProvider? _imageProvider(Message message) { + Widget? _buildThumbnail(BuildContext context, Message message) { final attachments = message.attachments; if (attachments.isEmpty || attachments.length > 1) return null; final attachment = attachments.first; - if (attachment.type == AttachmentType.file) return null; - final imageUrl = attachment.imageUrl ?? attachment.thumbUrl ?? attachment.assetUrl; - - if (imageUrl == null) return null; - return CachedNetworkImageProvider(imageUrl); - } + final type = attachment.type; - String? _mimeTypeAttachment(Message message) { - final attachments = message.attachments; - if (attachments.isEmpty) return null; - final attachment = attachments.first; + if (type == .image || type == .video || type == .giphy) { + return StreamMediaAttachmentThumbnail(media: attachment, fit: .cover); + } - if (attachment.type != AttachmentType.file) return null; - if (attachments.any((it) => it.mimeType != attachment.mimeType)) return null; + if (type == .file) { + // Only show a single file-type icon when every file shares a mime type. + final mimeType = attachment.mimeType; + if (mimeType == null) return null; + if (attachments.any((it) => it.mimeType != mimeType)) return null; + return StreamFileTypeIcon.fromMimeType(mimeType: mimeType, size: .lg); + } - return attachment.mimeType; + return null; } @override Widget build(BuildContext context) { final isIncoming = currentUserId != quotedMessage.user?.id; - final image = _imageProvider(quotedMessage); - final mimeType = _mimeTypeAttachment(quotedMessage); - - Widget? trailing; - if (image != null) { - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - borderRadius: BorderRadius.all(context.streamRadius.md), - image: DecorationImage(image: image, fit: BoxFit.cover), - ), - ); - } else if (mimeType != null) { - trailing = StreamFileTypeIcon.fromMimeType(mimeType: mimeType); - } else { - trailing = null; - } - final translations = context.translations; final title = switch (isIncoming) { true => translations.replyToUserLabel(quotedMessage.user?.name ?? ''), false => translations.youText, }; - return MessageComposerReplyAttachment( + return core.StreamMessageComposerReplyAttachment( title: Text(title), subtitle: StreamMessagePreviewText(message: quotedMessage), onRemovePressed: onRemovePressed, - trailing: trailing, - style: isIncoming ? .incoming : .outgoing, + thumbnail: _buildThumbnail(context, quotedMessage), + direction: isIncoming ? .incoming : .outgoing, ); } } diff --git a/packages/stream_chat_flutter/lib/src/components/stream_chat_component_builders.dart b/packages/stream_chat_flutter/lib/src/components/stream_chat_component_builders.dart index 625de1b547..7431c0a21e 100644 --- a/packages/stream_chat_flutter/lib/src/components/stream_chat_component_builders.dart +++ b/packages/stream_chat_flutter/lib/src/components/stream_chat_component_builders.dart @@ -23,6 +23,7 @@ Iterable> streamChatComponentBuilders({ StreamComponentBuilder? linkPreviewAttachment, StreamComponentBuilder? voiceRecordingAttachment, StreamComponentBuilder? pollAttachment, + StreamComponentBuilder? quotedMessage, StreamComponentBuilder? unsupportedAttachment, }) { final builders = [ @@ -46,6 +47,7 @@ Iterable> streamChatComponentBuilders({ if (linkPreviewAttachment != null) StreamComponentBuilderExtension(builder: linkPreviewAttachment), if (voiceRecordingAttachment != null) StreamComponentBuilderExtension(builder: voiceRecordingAttachment), if (pollAttachment != null) StreamComponentBuilderExtension(builder: pollAttachment), + if (quotedMessage != null) StreamComponentBuilderExtension(builder: quotedMessage), if (unsupportedAttachment != null) StreamComponentBuilderExtension(builder: unsupportedAttachment), ]; diff --git a/packages/stream_chat_flutter/lib/src/dialogs/channel_info_dialog.dart b/packages/stream_chat_flutter/lib/src/dialogs/channel_info_dialog.dart deleted file mode 100644 index 7fb93904b5..0000000000 --- a/packages/stream_chat_flutter/lib/src/dialogs/channel_info_dialog.dart +++ /dev/null @@ -1,77 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template channelInfoDialog} -/// A dialog for showing information about a channel on desktop & web platforms. -/// {@endtemplate} -class ChannelInfoDialog extends StatelessWidget { - /// {@macro channelInfoDialog} - const ChannelInfoDialog({ - super.key, - required this.channel, - }); - - /// The channel to display information about. - final Channel channel; - - @override - Widget build(BuildContext context) { - final streamTheme = StreamChatTheme.of(context); - final members = channel.state?.members ?? []; - - final userAsMember = members.firstWhere( - (e) => e.user?.id == StreamChat.of(context).currentUser?.id, - ); - return StreamChannel( - channel: channel, - child: SimpleDialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - backgroundColor: streamTheme.colorTheme.appBg, - title: Text( - channel.name ?? channel.id!, - style: StreamChatTheme.of(context).textTheme.headlineBold, - ), - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - StreamChannelInfo( - channel: channel, - textStyle: context.streamTextTheme.captionDefault, - ), - ], - ), - const SizedBox(height: 16), - if (channel.isDistinct && channel.memberCount == 2) - Column( - children: [ - StreamUserAvatar( - size: .xl, - user: members - .firstWhere( - (e) => e.user?.id != userAsMember.user?.id, - ) - .user!, - ), - const SizedBox(height: 6), - Text( - members - .firstWhere( - (e) => e.user?.id != userAsMember.user?.id, - ) - .user - ?.name ?? - '', - style: StreamChatTheme.of(context).textTheme.footnoteBold, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ], - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/dialogs/confirmation_dialog.dart b/packages/stream_chat_flutter/lib/src/dialogs/confirmation_dialog.dart deleted file mode 100644 index 04fd31b841..0000000000 --- a/packages/stream_chat_flutter/lib/src/dialogs/confirmation_dialog.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template confirmationDialog} -/// A dialog that prompts the user to take an action or cancel. -/// {@endtemplate} -class ConfirmationDialog extends StatelessWidget { - /// {@macro confirmationDialog} - const ConfirmationDialog({ - super.key, - required this.titleText, - required this.promptText, - required this.affirmativeText, - required this.onConfirmation, - }); - - /// The text to use for the dialog title. - final String titleText; - - /// The text to use for the dialog prompt. - final String promptText; - - /// The text to use for the confirmation button. - final String affirmativeText; - - /// The action to perform when the user confirms their choice. - final VoidCallback onConfirmation; - - @override - Widget build(BuildContext context) { - final streamTheme = StreamChatTheme.of(context); - return AlertDialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - backgroundColor: streamTheme.colorTheme.appBg, - title: Text(titleText), - content: Text(promptText), - actions: [ - TextButton( - style: TextButton.styleFrom( - foregroundColor: streamTheme.colorTheme.accentPrimary, - ), - onPressed: () => Navigator.of(context).pop(false), - child: Text(context.translations.cancelLabel), - ), - TextButton( - style: TextButton.styleFrom( - foregroundColor: streamTheme.colorTheme.accentPrimary, - ), - onPressed: () { - onConfirmation.call(); - Navigator.of(context).pop(true); - }, - child: Text(affirmativeText), - ), - ], - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/dialogs/delete_message_dialog.dart b/packages/stream_chat_flutter/lib/src/dialogs/delete_message_dialog.dart deleted file mode 100644 index 3532b0930e..0000000000 --- a/packages/stream_chat_flutter/lib/src/dialogs/delete_message_dialog.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template deleteMessageDialog} -/// A dialog that asks the user to confirm that they want to -/// delete the selected message. -/// {@endtemplate} -class DeleteMessageDialog extends StatelessWidget { - /// {@macro deleteMessageDialog} - const DeleteMessageDialog({ - super.key, - }); - - @override - Widget build(BuildContext context) { - final streamTheme = StreamChatTheme.of(context); - return AlertDialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - backgroundColor: streamTheme.colorTheme.appBg, - title: Text(context.translations.deleteMessageLabel), - content: Text(context.translations.deleteMessageQuestion), - actions: [ - TextButton( - style: TextButton.styleFrom( - foregroundColor: streamTheme.colorTheme.accentPrimary, - ), - onPressed: () => Navigator.of(context).pop(false), - child: Text(context.translations.cancelLabel), - ), - TextButton( - style: TextButton.styleFrom( - foregroundColor: streamTheme.colorTheme.accentPrimary, - ), - onPressed: () => Navigator.of(context).pop(true), - child: Text(context.translations.deleteLabel), - ), - ], - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/dialogs/dialogs.dart b/packages/stream_chat_flutter/lib/src/dialogs/dialogs.dart deleted file mode 100644 index 2daa1f6fb0..0000000000 --- a/packages/stream_chat_flutter/lib/src/dialogs/dialogs.dart +++ /dev/null @@ -1,4 +0,0 @@ -export 'channel_info_dialog.dart'; -export 'confirmation_dialog.dart'; -export 'delete_message_dialog.dart'; -export 'message_dialog.dart'; diff --git a/packages/stream_chat_flutter/lib/src/dialogs/message_dialog.dart b/packages/stream_chat_flutter/lib/src/dialogs/message_dialog.dart deleted file mode 100644 index 24daa59d48..0000000000 --- a/packages/stream_chat_flutter/lib/src/dialogs/message_dialog.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template messageDialog} -/// A dialog that displays a message to a user. Falls back to a -/// generic error message if no [titleText] and [messageText] are specified. -/// -/// If using this dialog to display the default generic error, be sure NOT to -/// specify a [titleText] and [messageText] so the fallback strings can be used. -/// {@endtemplate} -class MessageDialog extends StatelessWidget { - /// {@macro messageDialog} - const MessageDialog({ - super.key, - this.titleText, - this.messageText, - }); - - /// The optional error message title to use. - final String? titleText; - - /// The optional error message to use. - final String? messageText; - - @override - Widget build(BuildContext context) { - final streamTheme = StreamChatTheme.of(context); - return AlertDialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - backgroundColor: streamTheme.colorTheme.appBg, - title: Text(titleText ?? context.translations.somethingWentWrongError), - content: messageText != null - ? Text( - messageText ?? context.translations.operationCouldNotBeCompletedText, - ) - : null, - actions: [ - TextButton( - style: TextButton.styleFrom( - foregroundColor: streamTheme.colorTheme.accentPrimary, - ), - child: Text(context.translations.okLabel), - onPressed: () => Navigator.of(context).pop(), - ), - ], - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/indicators/unread_indicator.dart b/packages/stream_chat_flutter/lib/src/indicators/unread_indicator.dart index 6705494e97..3caf8974e7 100644 --- a/packages/stream_chat_flutter/lib/src/indicators/unread_indicator.dart +++ b/packages/stream_chat_flutter/lib/src/indicators/unread_indicator.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'package:stream_core_flutter/stream_core_flutter.dart'; /// {@template streamUnreadIndicator} /// Shows different unread counts of the user. @@ -112,7 +111,6 @@ class StreamUnreadIndicator extends StatelessWidget { final effectiveOffset = offset ?? const Offset(8, -6).directional(textDirection); return StreamBadgeNotification( - size: StreamBadgeNotificationSize.xs, label: switch (unreadCount) { > 99 => '99+', _ => '$unreadCount', diff --git a/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer_attachment.dart b/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer_attachment.dart index 59b9f2eda8..3364c50307 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer_attachment.dart @@ -68,49 +68,64 @@ class DefaultMessageComposerAttachment extends StatelessWidget { /// Controller used for audio/voice-recording attachment playback. StreamAudioPlaylistController? get audioPlaylistController => props.audioPlaylistController; + // Adapts the [ValueSetter] callback shape used in this package + // to the [VoidCallback] shape expected by core composer attachments. + VoidCallback? get _onRemoveAttachment { + final callback = onRemovePressed; + if (callback == null) return null; + return () => callback(attachment); + } + @override Widget build(BuildContext context) { - if (attachment.type == AttachmentType.file) { - return SizedBox( - width: 268, - child: MessageComposerFileAttachment( - title: Text(attachment.title ?? context.translations.fileText), - subtitle: Text(fileSize(attachment.file?.size ?? attachment.extraData['file_size'])), - fileTypeIcon: .fromMimeType(mimeType: attachment.file?.mediaType?.mimeType), - onRemovePressed: onRemovePressed != null ? () => onRemovePressed!(attachment) : null, - ), - ); - } + return switch (attachment.type) { + .file => _buildFileAttachment(context), + .audio || .voiceRecording => _buildVoiceRecordingAttachment(context), + .image || .video || .giphy => _buildMediaAttachment(context), + _ => _buildUnsupportedAttachment(context), + }; + } - if (attachment.type == AttachmentType.audio || attachment.type == AttachmentType.voiceRecording) { - if (audioPlaylistController == null) { - return const SizedBox.shrink(); - } + Widget _buildFileAttachment(BuildContext context) { + final fileSizeBytes = attachment.file?.size ?? attachment.extraData['file_size']; + final mimeType = attachment.file?.mediaType?.mimeType; - final hasTrack = audioPlaylistController!.value.tracks.any((it) => it.key == attachment); + return StreamMessageComposerFileAttachment( + title: Text(attachment.title ?? context.translations.fileText), + subtitle: Text(fileSize(fileSizeBytes)), + fileTypeIcon: .fromMimeType(mimeType: mimeType), + onRemovePressed: _onRemoveAttachment, + ); + } - if (!hasTrack) { - return const SizedBox.shrink(); - } + Widget _buildVoiceRecordingAttachment(BuildContext context) { + final controller = audioPlaylistController; + if (controller == null) return const SizedBox.shrink(); - final trackIndex = audioPlaylistController!.value.tracks.indexWhere((it) => it.key == attachment); + final trackIndex = controller.value.tracks.indexWhere((it) => it.key == attachment); + if (trackIndex < 0) return const SizedBox.shrink(); - return SizedBox( - width: 268, - child: MessageInputVoiceRecordingAttachment( - attachment: attachment, - index: trackIndex, - controller: audioPlaylistController!, - onRemovePressed: onRemovePressed, - ), - ); - } + return MessageInputVoiceRecordingAttachment( + attachment: attachment, + index: trackIndex, + controller: controller, + onRemovePressed: onRemovePressed, + ); + } + Widget _buildMediaAttachment(BuildContext context) { return StreamMediaAttachmentBuilder( attachment: attachment, onRemovePressed: onRemovePressed, ); } + + Widget _buildUnsupportedAttachment(BuildContext context) { + return StreamMessageComposerUnsupportedAttachment( + label: Text(context.translations.unsupportedAttachmentLabel), + onRemovePressed: _onRemoveAttachment, + ); + } } /// Widget used to display the list of voice recording type attachments added to @@ -145,7 +160,7 @@ class MessageInputVoiceRecordingAttachment extends StatelessWidget { final track = state.tracks.firstWhereOrNull((it) => it.key == attachment); if (track == null) return const SizedBox.shrink(); - return StreamMessageComposerAttachmentContainer( + return core.StreamMessageComposerAttachment( onRemovePressed: switch (onRemovePressed) { final callback? => () => callback(attachment), _ => null, @@ -205,21 +220,17 @@ class StreamMediaAttachmentBuilder extends StatelessWidget { final durationSecs = attachment.extraData['duration'] as num?; final videoDuration = durationSecs != null ? Duration(seconds: durationSecs.round()) : null; - final mediaBadge = attachment.type == AttachmentType.video - ? StreamMediaBadge(type: MediaBadgeType.video, duration: videoDuration) - : null; + Widget? effectiveMediaBadge; + if (attachment.type == .video) { + effectiveMediaBadge = StreamMediaBadge(type: .video, duration: videoDuration); + } return Container( key: Key(attachment.id), - child: MessageComposerMediaFileAttachment( - mediaBadge: mediaBadge, + child: StreamMessageComposerMediaAttachment( + mediaBadge: effectiveMediaBadge, onRemovePressed: onRemovePressed != null ? () => onRemovePressed!(attachment) : null, - child: StreamMediaAttachmentThumbnail( - media: attachment, - width: double.infinity, - height: double.infinity, - fit: BoxFit.cover, - ), + child: StreamMediaAttachmentThumbnail(media: attachment, fit: BoxFit.cover), ), ); } diff --git a/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer_attachment_list.dart b/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer_attachment_list.dart index 166ba13fd5..581f4e425b 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer_attachment_list.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer_attachment_list.dart @@ -3,6 +3,10 @@ import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/src/audio/audio_playlist_controller.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_core_flutter/stream_core_flutter.dart'; +// The local [StreamMessageComposerAttachment] (chat-domain wrapper) shadows +// the same-named container from `stream_core_flutter`; this prefixed import +// lets us reach the container by its core name without renaming either side. +import 'package:stream_core_flutter/stream_core_flutter.dart' as core show StreamMessageComposerAttachment; part 'stream_message_composer_attachment.dart'; diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart index 0ebcfdca90..a098dce4df 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart @@ -592,6 +592,17 @@ class _StreamMessageListViewState extends State { super.dispose(); } + // Duration of the programmatic scroll triggered by [_moveToAndHighlight]. + static const _kScrollToDuration = Duration(seconds: 1); + + // The highlight pulses on the target message after a jump: it stays at full + // color for [_kHighlightHoldDuration], then fades to transparent over + // [_kHighlightFadeDuration]. Tuned to feel like Slack's permalink jump — + // a clearly visible hold so the user can confirm "this is the message", + // followed by a graceful fade. + static const _kHighlightHoldDuration = Duration(seconds: 1); + static const _kHighlightFadeDuration = Duration(seconds: 1); + void _highlightMessage(String messageId) { setState(() { _highlightedMessageId = messageId; @@ -606,13 +617,21 @@ class _StreamMessageListViewState extends State { bool scrollTo = true, }) async { if (messageId != null) { - final index = messages.indexWhere((m) => m.id == messageId); + // In a thread the parent message lives outside the `messages` list and + // is rendered as the very last item, so search for it explicitly when a + // thread reply quotes it. + final isThreadParent = _isThreadConversation && messageId == widget.parentMessage?.id; + final index = isThreadParent ? messages.length + 2 : messages.indexWhere((m) => m.id == messageId); if (index >= 0) { + // Wait for the scroll to settle before flagging the message as + // highlighted; otherwise the highlight tween fires while the list is + // still animating (or before the target item is even mounted) and the + // user only sees the tail end of the fade. if (scrollTo) { - _scrollController?.scrollTo( + await _scrollController?.scrollTo( index: index + 2, // +2 to account for loader and footer - duration: const Duration(seconds: 1), + duration: _kScrollToDuration, curve: Curves.easeInOut, alignment: 0.1, ); @@ -640,11 +659,40 @@ class _StreamMessageListViewState extends State { ); } - if (messageId != null) { + if (messageId != null && mounted) { _highlightMessage(messageId); } } + // Wraps [child] in the highlight pulse if [message] is the currently + // highlighted message. Holds at full color for [_kHighlightHoldDuration], + // then fades to transparent over [_kHighlightFadeDuration]. + Widget _maybeWrapWithHighlight({required Message message, required Widget child}) { + if (_highlightedMessageId != message.id) return child; + + final colorScheme = context.streamColorScheme; + final highlightColor = widget.messageHighlightColor ?? colorScheme.backgroundHighlight; + + // Drive the whole sequence (hold + fade) with a single tween whose curve is + // clamped to the trailing fade window — this gives us the hold for free. + final totalMs = _kHighlightHoldDuration.inMilliseconds + _kHighlightFadeDuration.inMilliseconds; + final fadeStart = _kHighlightHoldDuration.inMilliseconds / totalMs; + + return TweenAnimationBuilder( + key: ValueKey('highlight-$_highlightGeneration'), + tween: ColorTween(begin: highlightColor, end: highlightColor.withValues(alpha: 0)), + duration: Duration(milliseconds: totalMs), + curve: Interval(fadeStart, 1, curve: Curves.easeOut), + onEnd: () { + if (_highlightedMessageId == message.id) { + setState(() => _highlightedMessageId = null); + } + }, + builder: (_, color, child) => ColoredBox(color: color!, child: child), + child: child, + ); + } + @override Widget build(BuildContext context) { // TODO: Revisit this nested Portal setup during desktop reactions refactor @@ -1174,7 +1222,7 @@ class _StreamMessageListViewState extends State { final contentKind = resolveContentKind(message); final isInThread = widget.parentMessage != null; - return StreamMessageLayout( + final layout = StreamMessageLayout( data: StreamMessageLayoutData( stackPosition: .single, alignment: isMyMessage ? .end : .start, @@ -1188,6 +1236,8 @@ class _StreamMessageListViewState extends State { }, ), ); + + return _maybeWrapWithHighlight(message: message, child: layout); } Widget _buildScrollToBottom() { @@ -1227,7 +1277,6 @@ class _StreamMessageListViewState extends State { if (showUnreadCount && widget.showUnreadCountOnScrollToBottom) { button = StreamBadgeNotification( label: '${unreadCount > 99 ? '99+' : unreadCount}', - size: StreamBadgeNotificationSize.sm, child: button, ); } @@ -1328,7 +1377,7 @@ class _StreamMessageListViewState extends State { final isInThread = widget.parentMessage != null; final stackPosition = computeStackPosition(message: message, previous: prevMessage, next: nextMessage); - Widget child = StreamMessageLayout( + final layout = StreamMessageLayout( data: StreamMessageLayoutData( stackPosition: stackPosition, alignment: isMyMessage ? .end : .start, @@ -1343,24 +1392,7 @@ class _StreamMessageListViewState extends State { ), ); - if (_highlightedMessageId == message.id) { - final colorScheme = context.streamColorScheme; - final highlightColor = widget.messageHighlightColor ?? colorScheme.backgroundHighlight; - child = TweenAnimationBuilder( - key: ValueKey('highlight-$_highlightGeneration'), - tween: ColorTween(begin: highlightColor, end: highlightColor.withValues(alpha: 0)), - duration: const Duration(seconds: 3), - onEnd: () { - if (_highlightedMessageId == message.id) { - setState(() => _highlightedMessageId = null); - } - }, - builder: (_, color, child) => ColoredBox(color: color!, child: child), - child: child, - ); - } - - return child; + return _maybeWrapWithHighlight(message: message, child: layout); } void _handleItemPositionsChanged() { diff --git a/packages/stream_chat_flutter/lib/src/message_modal/message_action_confirmation_modal.dart b/packages/stream_chat_flutter/lib/src/message_modal/message_action_confirmation_modal.dart index 10f8b06609..04b71057aa 100644 --- a/packages/stream_chat_flutter/lib/src/message_modal/message_action_confirmation_modal.dart +++ b/packages/stream_chat_flutter/lib/src/message_modal/message_action_confirmation_modal.dart @@ -94,8 +94,8 @@ class StreamMessageActionConfirmationModal extends StatelessWidget { child: cancelActionTitle ?? Text(translations.cancelLabel), ), StreamButton( - type: .ghost, - style: .destructive, + type: .solid, + style: isDestructiveAction ? .destructive : .primary, size: .small, onPressed: () => Navigator.of(context).maybePop(true), child: confirmActionTitle ?? Text(translations.confirmLabel), diff --git a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_content.dart b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_content.dart index 6288c4b6f6..4e38ed840f 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_content.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_content.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:stream_chat_flutter/src/attachment/builder/attachment_widget_builder.dart'; -import 'package:stream_chat_flutter/src/channel/stream_message_preview_text.dart'; import 'package:stream_chat_flutter/src/message_widget/components/stream_message_deleted.dart'; import 'package:stream_chat_flutter/src/message_widget/components/stream_message_reactions.dart'; import 'package:stream_chat_flutter/src/message_widget/components/stream_message_text.dart'; import 'package:stream_chat_flutter/src/message_widget/parse_attachments.dart'; +import 'package:stream_chat_flutter/src/message_widget/stream_quoted_message.dart'; import 'package:stream_chat_flutter/src/utils/typedefs.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'package:stream_core_flutter/stream_core_flutter.dart' as core; @@ -165,40 +165,17 @@ class _StreamMessageContentState extends State { final bubbleContent = ConstrainedBox( constraints: const BoxConstraints().copyWith(maxWidth: widthLimit), child: core.StreamColumn( - spacing: spacing.xxs, mainAxisSize: .min, + spacing: spacing.xs, crossAxisAlignment: .start, children: [ if (widget.message.quotedMessage case final quotedMessage?) - // TODO: Refactor this with attachments - ConstrainedBox( - constraints: const .tightFor(width: 272), - child: GestureDetector( - onTap: !quotedMessage.isDeleted && widget.onQuotedMessageTap != null - ? () => widget.onQuotedMessageTap!(quotedMessage) - : null, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: core.StreamMessageTheme( - data: core.StreamMessageThemeData( - incoming: core.StreamMessageStyle( - backgroundColor: context.streamColorScheme.backgroundSurfaceStrong, - ), - outgoing: core.StreamMessageStyle( - backgroundColor: context.streamColorScheme.brand.shade150, - ), - ), - child: core.MessageComposerReplyAttachment( - title: Text(quotedMessage.user?.name ?? ''), - subtitle: StreamMessagePreviewText(message: quotedMessage), - style: switch (core.StreamMessageLayout.messageAlignmentOf(context)) { - core.StreamMessageAlignment.start => .incoming, - core.StreamMessageAlignment.end => .outgoing, - }, - ), - ), - ), - ), + StreamQuotedMessage( + quotedMessage: quotedMessage, + onTap: switch (widget.onQuotedMessageTap) { + final onTap? => () => onTap(quotedMessage), + _ => null, + }, ), ParseAttachments( key: attachmentsKey, diff --git a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_reactions.dart b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_reactions.dart index ee8a8c78e6..c3f155e7f9 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_reactions.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_reactions.dart @@ -24,6 +24,7 @@ class StreamMessageReactions extends StatelessWidget { required this.message, this.type, this.position, + this.overlap, this.sorting, this.onPressed, this.child, @@ -43,6 +44,11 @@ class StreamMessageReactions extends StatelessWidget { /// and [core.StreamReactionsPosition.header] on mobile. final core.StreamReactionsPosition? position; + /// Whether reactions overlap the message bubble edge. + /// + /// When null, defaults to `true` on mobile and `false` on desktop and web. + final bool? overlap; + /// Controls how reaction groups are sorted when displayed. /// /// Defaults to [ReactionSorting.byFirstReactionAt] when null. @@ -64,6 +70,7 @@ class StreamMessageReactions extends StatelessWidget { final effectiveType = type ?? config.reactionType ?? core.StreamReactionsType.segmented; final effectivePosition = position ?? config.reactionPosition ?? core.StreamReactionsPosition.header; + final effectiveOverlap = overlap ?? config.reactionOverlap ?? !isDesktopDeviceOrWeb; final reactionGroups = message.reactionGroups?.entries; final effectiveReactionSorting = sorting ?? ReactionSorting.byFirstReactionAt; @@ -79,7 +86,7 @@ class StreamMessageReactions extends StatelessWidget { return core.StreamReactions( type: effectiveType, position: effectivePosition, - overlap: !isDesktopDeviceOrWeb, + overlap: effectiveOverlap, onPressed: onPressed, items: [...?items], child: child, diff --git a/packages/stream_chat_flutter/lib/src/message_widget/stream_quoted_message.dart b/packages/stream_chat_flutter/lib/src/message_widget/stream_quoted_message.dart new file mode 100644 index 0000000000..3acfb7b488 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_widget/stream_quoted_message.dart @@ -0,0 +1,296 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/attachment/thumbnail/media_attachment_thumbnail.dart'; +import 'package:stream_chat_flutter/src/channel/stream_message_preview_text.dart'; +import 'package:stream_chat_flutter/src/components/stream_chat_component_builders.dart'; +import 'package:stream_chat_flutter/src/theme/quoted_message_theme.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; + +/// A preview of a quoted message rendered above a reply. +/// +/// [StreamQuotedMessage] shows the quoted message's author and a short text +/// preview, with an optional trailing thumbnail when the quoted message has +/// a media or file attachment. It is rendered above the body of a message +/// that has a non-null [Message.quotedMessage]. +/// +/// The card chrome (background, shape, outer padding) and the inner content +/// padding are all controlled via [StreamQuotedMessageThemeData]. +/// +/// {@tool snippet} +/// +/// Basic usage: +/// +/// ```dart +/// StreamQuotedMessage( +/// quotedMessage: message.quotedMessage!, +/// onTap: () => navigateToMessage(message.quotedMessage!), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamQuotedMessageProps], which configures this widget. +/// * [DefaultStreamQuotedMessage], the default implementation. +/// * [StreamQuotedMessageTheme], for theming. +class StreamQuotedMessage extends StatelessWidget { + /// Creates a [StreamQuotedMessage]. + StreamQuotedMessage({ + super.key, + required Message quotedMessage, + BoxConstraints? constraints, + VoidCallback? onTap, + }) : props = .new( + quotedMessage: quotedMessage, + constraints: constraints, + onTap: onTap, + ); + + /// The properties that configure this widget. + final StreamQuotedMessageProps props; + + @override + Widget build(BuildContext context) { + final builder = context.chatComponentBuilder(); + if (builder != null) return builder(context, props); + return DefaultStreamQuotedMessage(props: props); + } +} + +/// Properties for configuring a [StreamQuotedMessage]. +/// +/// Holds all the configuration options for the quoted-message preview, +/// allowing them to be passed through the [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamQuotedMessage], which uses these properties. +/// * [DefaultStreamQuotedMessage], the default implementation. +class StreamQuotedMessageProps { + /// Creates properties for a quoted-message preview. + const StreamQuotedMessageProps({ + required this.quotedMessage, + this.constraints, + this.onTap, + }); + + /// The message being quoted. + final Message quotedMessage; + + /// The constraints to use when displaying the preview. + final BoxConstraints? constraints; + + /// Called when the user taps the preview. + /// + /// Typically used to scroll to the quoted message in the message list. + final VoidCallback? onTap; +} + +const _kDefaultConstraints = BoxConstraints.tightFor(width: 272); + +const _kIndicatorWidth = 2.0; +const _kIndicatorVerticalMargin = 2.0; + +/// The default implementation of [StreamQuotedMessage]. +/// +/// Renders the quoted-message preview with a vertical color indicator on +/// the leading edge, the author name as the title, a short text preview as +/// the subtitle, and an optional 40×40 trailing thumbnail or file-type icon. +/// +/// Colors are picked directly off [StreamColorScheme] using the alignment +/// provided by the surrounding [StreamMessageLayout]. +/// +/// See also: +/// +/// * [StreamQuotedMessage], the public API widget. +/// * [StreamQuotedMessageProps], which configures this widget. +class DefaultStreamQuotedMessage extends StatelessWidget { + /// Creates a default Stream quoted-message preview. + const DefaultStreamQuotedMessage({super.key, required this.props}); + + /// The properties that configure this widget. + final StreamQuotedMessageProps props; + + @override + Widget build(BuildContext context) { + final quotedMessage = props.quotedMessage; + final spacing = context.streamSpacing; + final radius = context.streamRadius; + + final theme = StreamQuotedMessageTheme.of(context); + final defaults = _StreamQuotedMessageDefaults(context); + + final effectiveTitleTextStyle = theme.titleTextStyle ?? defaults.titleTextStyle; + final effectiveSubtitleTextStyle = theme.subtitleTextStyle ?? defaults.subtitleTextStyle; + final effectiveIndicatorColor = theme.indicatorColor ?? defaults.indicatorColor; + final effectiveBackgroundColor = theme.backgroundColor ?? defaults.backgroundColor; + + final effectiveSide = theme.side ?? defaults.side; + final effectiveShape = (theme.shape ?? defaults.shape).copyWith(side: effectiveSide); + final effectiveMargin = theme.margin ?? defaults.margin; + 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 canTap = !quotedMessage.isDeleted && props.onTap != null; + final constraints = props.constraints ?? _kDefaultConstraints; + + final effectiveTitle = DefaultTextStyle.merge( + style: effectiveTitleTextStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + child: Text(quotedMessage.user?.name ?? ''), + ); + + final effectiveSubtitle = DefaultTextStyle.merge( + style: effectiveSubtitleTextStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + child: StreamMessagePreviewText(message: quotedMessage), + ); + + Widget? effectiveThumbnail; + if (_buildThumbnail(context, quotedMessage) case final thumbnail?) { + effectiveThumbnail = SizedBox.fromSize( + size: effectiveThumbnailSize, + child: Material( + type: MaterialType.transparency, + clipBehavior: Clip.hardEdge, + shape: effectiveThumbnailShape, + child: thumbnail, + ), + ); + } + + return Padding( + padding: effectiveMargin, + child: Material( + clipBehavior: Clip.hardEdge, + shape: effectiveShape, + color: effectiveBackgroundColor, + child: ConstrainedBox( + constraints: constraints, + child: InkWell( + onTap: canTap ? props.onTap : null, + 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, + ], + ), + ), + ), + ), + ), + ), + ); + } + + Widget? _buildThumbnail(BuildContext context, Message message) { + final attachments = message.attachments; + if (attachments.isEmpty || attachments.length > 1) return null; + + final attachment = attachments.first; + final type = attachment.type; + + if (type == .image || type == .video || type == .giphy) { + return StreamMediaAttachmentThumbnail(media: attachment, fit: .cover); + } + + if (type == .file) { + // Only show a single file-type icon when every file shares a mime type. + final mimeType = attachment.mimeType; + if (mimeType == null) return null; + if (attachments.any((it) => it.mimeType != mimeType)) return null; + return StreamFileTypeIcon.fromMimeType(mimeType: mimeType, size: .lg); + } + + return null; + } +} + +// Default values for [StreamQuotedMessageThemeData] backed by stream design +// tokens. The incoming/outgoing palette is picked directly off +// [StreamColorScheme] using the alignment provided by [StreamMessageLayout]. +class _StreamQuotedMessageDefaults extends StreamQuotedMessageThemeData { + _StreamQuotedMessageDefaults(this._context); + + final BuildContext _context; + + late final _alignment = StreamMessageLayout.messageAlignmentOf(_context); + + late final StreamSpacing _spacing = _context.streamSpacing; + late final StreamRadius _radius = _context.streamRadius; + late final StreamColorScheme _colorScheme = _context.streamColorScheme; + late final StreamTextTheme _textTheme = _context.streamTextTheme; + + Color get _textColor => switch (_alignment) { + .start => _colorScheme.textPrimary, + .end => _colorScheme.brand.shade900, + }; + + @override + TextStyle get titleTextStyle => _textTheme.metadataEmphasis.copyWith(color: _textColor); + + @override + TextStyle get subtitleTextStyle => _textTheme.metadataDefault.copyWith(color: _textColor); + + @override + Color get indicatorColor => switch (_alignment) { + .start => _colorScheme.chrome.shade400, + .end => _colorScheme.brand.shade400, + }; + + @override + Color get backgroundColor => switch (_alignment) { + .start => _colorScheme.backgroundSurfaceStrong, + .end => _colorScheme.brand.shade150, + }; + + @override + OutlinedBorder get shape => RoundedSuperellipseBorder(borderRadius: .all(_radius.lg)); + + @override + BorderSide get side => BorderSide.none; + + @override + EdgeInsetsGeometry get margin => EdgeInsets.symmetric(horizontal: _spacing.xs); + + @override + EdgeInsetsGeometry get padding => EdgeInsetsDirectional.only( + start: _spacing.sm, + end: _spacing.xs, + top: _spacing.xs, + bottom: _spacing.xs, + ); + + @override + OutlinedBorder get thumbnailShape => RoundedSuperellipseBorder(borderRadius: .all(_radius.md)); + + @override + Size get thumbnailSize => const Size.square(40); +} diff --git a/packages/stream_chat_flutter/lib/src/misc/back_button.dart b/packages/stream_chat_flutter/lib/src/misc/back_button.dart index 70ca5453bd..7b440227e2 100644 --- a/packages/stream_chat_flutter/lib/src/misc/back_button.dart +++ b/packages/stream_chat_flutter/lib/src/misc/back_button.dart @@ -29,19 +29,11 @@ class StreamBackButton extends StatelessWidget { _ => context.streamIcons.arrowLeft, }; - Widget icon = Icon(iconData); - if (showUnreadCount) { - icon = switch (channelId) { - final cid? => StreamUnreadIndicator.channels(cid: cid, child: icon), - _ => StreamUnreadIndicator(child: icon), - }; - } - - return StreamButton.icon( + Widget button = StreamButton.icon( type: .ghost, size: .medium, style: .secondary, - icon: icon, + icon: Icon(iconData), onPressed: () { if (onPressed case final onPressed?) { return onPressed(); @@ -50,5 +42,14 @@ class StreamBackButton extends StatelessWidget { Navigator.maybePop(context); }, ); + + if (showUnreadCount) { + button = switch (channelId) { + final cid? => StreamUnreadIndicator.channels(offset: .zero, cid: cid, child: button), + _ => StreamUnreadIndicator(offset: .zero, child: button), + }; + } + + return button; } } diff --git a/packages/stream_chat_flutter/lib/src/misc/markdown_message.dart b/packages/stream_chat_flutter/lib/src/misc/markdown_message.dart deleted file mode 100644 index 5b908cd866..0000000000 --- a/packages/stream_chat_flutter/lib/src/misc/markdown_message.dart +++ /dev/null @@ -1,105 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; -import 'package:stream_chat_flutter/src/ai_assistant/streaming_message_view.dart'; - -import 'package:stream_chat_flutter/src/utils/device_segmentation.dart'; -import 'package:stream_core_flutter/stream_core_flutter.dart' as core; - -/// {@template streamMarkdownMessage} -/// A widget that displays a markdown message. This widget uses the markdown -/// package to parse the markdown data and display it. -/// -/// This widget is used by [StreamMessageText] and [StreamingMessageView] to -/// display the message text. -/// {@endtemplate} -class StreamMarkdownMessage extends StatelessWidget { - /// {@macro streamMarkdownMessage} - const StreamMarkdownMessage({ - super.key, - required this.data, - this.selectable, - this.onTapLink, - this.messageTheme, - this.styleSheet, - this.syntaxHighlighter, - this.builders = const {}, - this.paddingBuilders = const {}, - }); - - /// The markdown data to display. - final String data; - - /// Whether the text is selectable. - final bool? selectable; - - /// Called when the user taps a link. - final MarkdownTapLinkCallback? onTapLink; - - /// The theme to apply to the message text. - final core.StreamMessageStyle? messageTheme; - - /// Optional style sheet to customize the markdown output. - /// - /// When provided, it will be merged with the default one. - final MarkdownStyleSheet? styleSheet; - - /// The syntax highlighter used to color text in `pre` elements. - /// - /// If null, the [MarkdownStyleSheet.code] style is used for `pre` elements. - final SyntaxHighlighter? syntaxHighlighter; - - /// Render certain tags, usually used with [extensionSet] - /// - /// For example, we will add support for `sub` tag: - /// - /// ```dart - /// builders: { - /// 'sub': SubscriptBuilder(), - /// } - /// ``` - /// - /// The `SubscriptBuilder` is a subclass of [MarkdownElementBuilder]. - final Map builders; - - /// Add padding for different tags (use only for block elements and img) - /// - /// For example, we will add padding for `img` tag: - /// - /// ```dart - /// paddingBuilders: { - /// 'img': ImgPaddingBuilder(), - /// } - /// ``` - /// - /// The `ImgPaddingBuilder` is a subclass of [MarkdownPaddingBuilder]. - final Map paddingBuilders; - - @override - Widget build(BuildContext context) { - final themeData = Theme.of(context); - - return MarkdownBody( - data: data, - selectable: selectable ?? isDesktopDeviceOrWeb, - onTapText: () {}, - onSelectionChanged: (val, selection, cause) {}, - onTapLink: onTapLink, - syntaxHighlighter: syntaxHighlighter, - builders: builders, - paddingBuilders: paddingBuilders, - styleSheet: - MarkdownStyleSheet.fromTheme( - themeData.copyWith( - textTheme: themeData.textTheme.apply( - bodyColor: messageTheme?.textColor, - ), - ), - ) - .copyWith( - a: TextStyle(color: messageTheme?.textLinkColor), - p: TextStyle(color: messageTheme?.textColor), - ) - .merge(styleSheet), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/poll/creator/poll_option_reorderable_list_view.dart b/packages/stream_chat_flutter/lib/src/poll/creator/poll_option_reorderable_list_view.dart index e3105c7a17..4469fe44fa 100644 --- a/packages/stream_chat_flutter/lib/src/poll/creator/poll_option_reorderable_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/poll/creator/poll_option_reorderable_list_view.dart @@ -463,13 +463,11 @@ class _PollOptionReorderableListViewState extends State Navigator.of(context).maybePop(true), diff --git a/packages/stream_chat_flutter/lib/src/poll/interactor/poll_add_comment_dialog.dart b/packages/stream_chat_flutter/lib/src/poll/interactor/poll_add_comment_dialog.dart index 4dd15fe5e5..fea092c86f 100644 --- a/packages/stream_chat_flutter/lib/src/poll/interactor/poll_add_comment_dialog.dart +++ b/packages/stream_chat_flutter/lib/src/poll/interactor/poll_add_comment_dialog.dart @@ -65,7 +65,7 @@ class _PollAddCommentDialogState extends State { child: Text(context.translations.cancelLabel), ), StreamButton( - type: .ghost, + type: .solid, style: .primary, size: .small, onPressed: switch (_comment.trim()) { diff --git a/packages/stream_chat_flutter/lib/src/poll/interactor/poll_end_vote_dialog.dart b/packages/stream_chat_flutter/lib/src/poll/interactor/poll_end_vote_dialog.dart index 7e294dac5e..9294c7f27d 100644 --- a/packages/stream_chat_flutter/lib/src/poll/interactor/poll_end_vote_dialog.dart +++ b/packages/stream_chat_flutter/lib/src/poll/interactor/poll_end_vote_dialog.dart @@ -43,7 +43,7 @@ class PollEndVoteDialog extends StatelessWidget { child: Text(context.translations.cancelLabel), ), StreamButton( - type: .ghost, + type: .solid, style: .destructive, size: .small, onPressed: () => Navigator.of(context).maybePop(true), diff --git a/packages/stream_chat_flutter/lib/src/poll/interactor/poll_suggest_option_dialog.dart b/packages/stream_chat_flutter/lib/src/poll/interactor/poll_suggest_option_dialog.dart index eaa35da8e1..f71c32e09e 100644 --- a/packages/stream_chat_flutter/lib/src/poll/interactor/poll_suggest_option_dialog.dart +++ b/packages/stream_chat_flutter/lib/src/poll/interactor/poll_suggest_option_dialog.dart @@ -67,7 +67,7 @@ class _PollSuggestOptionDialogState extends State { child: Text(context.translations.cancelLabel), ), StreamButton( - type: .ghost, + type: .solid, style: .primary, size: .small, onPressed: switch (_option.trim()) { diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_item.dart b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_item.dart index 12c5dab9d3..424beb272e 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_item.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_item.dart @@ -297,7 +297,7 @@ class StreamChannelListTile extends StatelessWidget { child: Column( mainAxisSize: .min, spacing: spacing.xxs, - crossAxisAlignment: .center, + crossAxisAlignment: .start, children: [ _TitleRow( title: title, @@ -467,9 +467,6 @@ class _ChannelListDeliveryStatus extends StatelessWidget { @override Widget build(BuildContext context) { - final colorTheme = context.streamMessageTheme.mergeWithDefaults(context); - final colorScheme = context.streamColorScheme; - return BetterStreamBuilder>( stream: channel.state?.readStream, initialData: channel.state?.read, @@ -477,38 +474,11 @@ class _ChannelListDeliveryStatus extends StatelessWidget { final isRead = data.readsOf(message: message).isNotEmpty; final isDelivered = data.deliveriesOf(message: message).isNotEmpty; - final Widget icon; - if (isRead) { - icon = Icon( - context.streamIcons.checks, - size: 16, - color: colorTheme.outgoing?.textReadColor ?? colorScheme.accentPrimary, - ); - } else if (isDelivered) { - icon = Icon( - context.streamIcons.checks, - size: 16, - color: colorTheme.outgoing?.textTimestampColor ?? colorScheme.textTertiary, - ); - } else if (message.state.isCompleted) { - icon = Icon( - context.streamIcons.checkmark, - size: 16, - color: colorTheme.outgoing?.textTimestampColor ?? colorScheme.textTertiary, - ); - } else if (message.state.isOutgoing) { - icon = Icon( - context.streamIcons.clock, - size: 16, - color: colorTheme.outgoing?.textTimestampColor ?? colorScheme.textTertiary, - ); - } else { - return const Empty(); - } - - return Padding( - padding: const EdgeInsetsDirectional.only(end: 4), - child: icon, + return StreamSendingIndicator( + size: 16, + message: message, + isMessageRead: isRead, + isMessageDelivered: isDelivered, ); }, ); @@ -640,6 +610,8 @@ class _ChannelLastMessageWithStatusState extends State<_ChannelLastMessageWithSt ), initialData: (channelState.draft, channelState.messages), builder: (context, data) { + final spacing = context.streamSpacing; + final (draft, messages) = data; final config = StreamChatConfiguration.maybeOf(context); @@ -669,21 +641,22 @@ class _ChannelLastMessageWithStatusState extends State<_ChannelLastMessageWithSt final isOwnMessage = currentUser != null && latestLastMessage.user?.id == currentUser.id; // Show delivery status prefix only for own messages. - final Widget deliveryPrefix; + Widget? deliveryPrefix; if (isOwnMessage) { - deliveryPrefix = - widget.sendingIndicatorBuilder?.call(context, latestLastMessage) ?? - _ChannelListDeliveryStatus( - channel: widget.channel, - message: latestLastMessage, - ); - } else { - deliveryPrefix = const Empty(); + if (widget.sendingIndicatorBuilder case final builder?) { + deliveryPrefix = builder(context, latestLastMessage); + } else { + deliveryPrefix = _ChannelListDeliveryStatus( + channel: widget.channel, + message: latestLastMessage, + ); + } } return Row( + spacing: spacing.xxs, children: [ - if (!latestLastMessage.isDeleted) deliveryPrefix, + if (!latestLastMessage.isDeleted) ?deliveryPrefix, Flexible( child: StreamMessagePreviewText( message: latestLastMessage, diff --git a/packages/stream_chat_flutter/lib/src/stream_chat_configuration.dart b/packages/stream_chat_flutter/lib/src/stream_chat_configuration.dart index 0eb6f675bc..89cb62e1ea 100644 --- a/packages/stream_chat_flutter/lib/src/stream_chat_configuration.dart +++ b/packages/stream_chat_flutter/lib/src/stream_chat_configuration.dart @@ -169,6 +169,7 @@ class StreamChatConfigurationData { List? attachmentBuilders, StreamReactionsType? reactionType, StreamReactionsPosition? reactionPosition, + bool? reactionOverlap, }) { return StreamChatConfigurationData._( loadingIndicator: loadingIndicator, @@ -182,6 +183,7 @@ class StreamChatConfigurationData { attachmentBuilders: attachmentBuilders, reactionType: reactionType, reactionPosition: reactionPosition, + reactionOverlap: reactionOverlap, ); } @@ -197,6 +199,7 @@ class StreamChatConfigurationData { required this.attachmentBuilders, this.reactionType, this.reactionPosition, + this.reactionOverlap, }); /// Copies the configuration options from one [StreamChatConfigurationData] to @@ -213,6 +216,7 @@ class StreamChatConfigurationData { List? attachmentBuilders, StreamReactionsType? reactionType, StreamReactionsPosition? reactionPosition, + bool? reactionOverlap, }) { return StreamChatConfigurationData( reactionIconResolver: reactionIconResolver ?? this.reactionIconResolver, @@ -226,6 +230,7 @@ class StreamChatConfigurationData { attachmentBuilders: attachmentBuilders ?? this.attachmentBuilders, reactionType: reactionType ?? this.reactionType, reactionPosition: reactionPosition ?? this.reactionPosition, + reactionOverlap: reactionOverlap ?? this.reactionOverlap, ); } @@ -284,6 +289,13 @@ class StreamChatConfigurationData { /// ([StreamReactionsPosition.header]). final StreamReactionsPosition? reactionPosition; + /// Whether reactions overlap the message bubble edge across all message + /// widgets. + /// + /// When null, the widget resolves its own default (overlap on mobile, + /// no overlap on desktop and web). + final bool? reactionOverlap; + static Widget _defaultUserImage( BuildContext context, User user, diff --git a/packages/stream_chat_flutter/lib/src/theme/quoted_message_theme.dart b/packages/stream_chat_flutter/lib/src/theme/quoted_message_theme.dart new file mode 100644 index 0000000000..38c37b9aa0 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/theme/quoted_message_theme.dart @@ -0,0 +1,181 @@ +import 'package:flutter/widgets.dart'; +import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +part 'quoted_message_theme.g.theme.dart'; + +/// Applies a quoted-message theme to descendant [StreamQuotedMessage] +/// widgets. +/// +/// Wrap a subtree with [StreamQuotedMessageTheme] to override the styling of +/// the quoted-message preview rendered inside replies. Access the merged +/// theme using [StreamQuotedMessageTheme.of]. +/// +/// {@tool snippet} +/// +/// Override quoted-message styling for a specific section: +/// +/// ```dart +/// StreamQuotedMessageTheme( +/// data: StreamQuotedMessageThemeData( +/// titleTextStyle: TextStyle(fontWeight: FontWeight.w700), +/// indicatorColor: Colors.green, +/// ), +/// child: StreamMessageListView(), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamQuotedMessageThemeData], which describes the theme data. +/// * [StreamQuotedMessage], the widget affected by this theme. +class StreamQuotedMessageTheme extends InheritedTheme { + /// Creates a quoted-message theme that controls descendant widgets. + const StreamQuotedMessageTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The quoted-message theme data for descendant widgets. + final StreamQuotedMessageThemeData data; + + /// Returns the [StreamQuotedMessageThemeData] merged from local and global + /// themes. + /// + /// Local values from the nearest [StreamQuotedMessageTheme] ancestor take + /// precedence over global values from [StreamChatTheme.of]. + /// + /// This allows partial overrides — for example, overriding only + /// [StreamQuotedMessageThemeData.titleTextStyle] while inheriting other + /// properties from the global theme. + static StreamQuotedMessageThemeData of(BuildContext context) { + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamChatTheme.of(context).quotedMessageTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) => StreamQuotedMessageTheme(data: data, child: child); + + @override + bool updateShouldNotify(StreamQuotedMessageTheme oldWidget) => data != oldWidget.data; +} + +/// Theme data for customizing [StreamQuotedMessage] widgets. +/// +/// All fields are nullable. When a field is null, the consuming widget falls +/// back to a default derived from the alignment-aware [StreamColorScheme] +/// and [StreamTextTheme] tokens. +/// +/// {@tool snippet} +/// +/// Customize quoted-message appearance globally: +/// +/// ```dart +/// StreamChatThemeData( +/// quotedMessageTheme: StreamQuotedMessageThemeData( +/// titleTextStyle: TextStyle(fontWeight: FontWeight.w700), +/// padding: EdgeInsetsDirectional.all(12), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamQuotedMessage], the widget that uses this theme data. +/// * [StreamQuotedMessageTheme], for overriding theme in a widget subtree. +@themeGen +@immutable +class StreamQuotedMessageThemeData with _$StreamQuotedMessageThemeData { + /// Creates quoted-message theme data with optional style overrides. + const StreamQuotedMessageThemeData({ + this.titleTextStyle, + this.subtitleTextStyle, + this.indicatorColor, + this.backgroundColor, + this.shape, + this.side, + this.margin, + this.padding, + this.thumbnailShape, + this.thumbnailSide, + this.thumbnailSize, + }); + + /// The text style for the quoted user's name. + /// + /// If null, defaults to [StreamTextTheme.metadataEmphasis] tinted with the + /// alignment-aware text color. + final TextStyle? titleTextStyle; + + /// The text style for the quoted message preview. + /// + /// If null, defaults to [StreamTextTheme.metadataDefault] tinted with the + /// alignment-aware text color. + final TextStyle? subtitleTextStyle; + + /// Color of the leading indicator bar. + /// + /// If null, the consuming widget falls back to a direction-aware default: + /// `colorScheme.chrome.shade400` for incoming, `colorScheme.brand.shade400` + /// for outgoing. + final Color? indicatorColor; + + /// Background fill color of the quoted-message card. + /// + /// If null, the consuming widget falls back to a direction-aware default: + /// `colorScheme.backgroundSurfaceStrong` for incoming, + /// `colorScheme.brand.shade150` for outgoing. + final Color? backgroundColor; + + /// Shape of the quoted-message card. + /// + /// Composed with [side] to draw the card's border. If null, defaults to a + /// [RoundedSuperellipseBorder] with radius [StreamRadius.lg]. + final OutlinedBorder? shape; + + /// Border side drawn around the quoted-message card. + /// + /// Composed onto [shape] via [OutlinedBorder.copyWith]. If null, defaults to + /// [BorderSide.none]. + final BorderSide? side; + + /// Outer margin applied around the quoted-message card. + /// + /// If null, defaults to `EdgeInsets.symmetric(horizontal: spacing.xs)`. + final EdgeInsetsGeometry? margin; + + /// Inner padding around the indicator, text content, and optional trailing + /// thumbnail. This is the spacing between the card edge and its contents. + /// + /// If null, defaults to `EdgeInsetsDirectional.only(start: spacing.sm, + /// end: spacing.xs, top: spacing.xs, bottom: spacing.xs)`. + 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 [StreamQuotedMessageThemeData] objects. + static StreamQuotedMessageThemeData? lerp( + StreamQuotedMessageThemeData? a, + StreamQuotedMessageThemeData? b, + double t, + ) => _$StreamQuotedMessageThemeData.lerp(a, b, t); +} diff --git a/packages/stream_chat_flutter/lib/src/theme/quoted_message_theme.g.theme.dart b/packages/stream_chat_flutter/lib/src/theme/quoted_message_theme.g.theme.dart new file mode 100644 index 0000000000..d7c5e96ecd --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/theme/quoted_message_theme.g.theme.dart @@ -0,0 +1,172 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'quoted_message_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamQuotedMessageThemeData { + bool get canMerge => true; + + static StreamQuotedMessageThemeData? lerp( + StreamQuotedMessageThemeData? a, + StreamQuotedMessageThemeData? 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 StreamQuotedMessageThemeData( + titleTextStyle: TextStyle.lerp(a.titleTextStyle, b.titleTextStyle, t), + subtitleTextStyle: TextStyle.lerp( + a.subtitleTextStyle, + b.subtitleTextStyle, + t, + ), + indicatorColor: Color.lerp(a.indicatorColor, b.indicatorColor, t), + 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), + margin: EdgeInsetsGeometry.lerp(a.margin, b.margin, 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), + ); + } + + StreamQuotedMessageThemeData copyWith({ + TextStyle? titleTextStyle, + TextStyle? subtitleTextStyle, + Color? indicatorColor, + Color? backgroundColor, + OutlinedBorder? shape, + BorderSide? side, + EdgeInsetsGeometry? margin, + EdgeInsetsGeometry? padding, + OutlinedBorder? thumbnailShape, + BorderSide? thumbnailSide, + Size? thumbnailSize, + }) { + final _this = (this as StreamQuotedMessageThemeData); + + return StreamQuotedMessageThemeData( + titleTextStyle: titleTextStyle ?? _this.titleTextStyle, + subtitleTextStyle: subtitleTextStyle ?? _this.subtitleTextStyle, + indicatorColor: indicatorColor ?? _this.indicatorColor, + backgroundColor: backgroundColor ?? _this.backgroundColor, + shape: shape ?? _this.shape, + side: side ?? _this.side, + margin: margin ?? _this.margin, + padding: padding ?? _this.padding, + thumbnailShape: thumbnailShape ?? _this.thumbnailShape, + thumbnailSide: thumbnailSide ?? _this.thumbnailSide, + thumbnailSize: thumbnailSize ?? _this.thumbnailSize, + ); + } + + StreamQuotedMessageThemeData merge(StreamQuotedMessageThemeData? other) { + final _this = (this as StreamQuotedMessageThemeData); + + 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, + indicatorColor: other.indicatorColor, + backgroundColor: other.backgroundColor, + shape: other.shape, + side: _this.side != null && other.side != null + ? BorderSide.merge(_this.side!, other.side!) + : other.side, + margin: other.margin, + 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 StreamQuotedMessageThemeData); + final _other = (other as StreamQuotedMessageThemeData); + + return _other.titleTextStyle == _this.titleTextStyle && + _other.subtitleTextStyle == _this.subtitleTextStyle && + _other.indicatorColor == _this.indicatorColor && + _other.backgroundColor == _this.backgroundColor && + _other.shape == _this.shape && + _other.side == _this.side && + _other.margin == _this.margin && + _other.padding == _this.padding && + _other.thumbnailShape == _this.thumbnailShape && + _other.thumbnailSide == _this.thumbnailSide && + _other.thumbnailSize == _this.thumbnailSize; + } + + @override + int get hashCode { + final _this = (this as StreamQuotedMessageThemeData); + + return Object.hash( + runtimeType, + _this.titleTextStyle, + _this.subtitleTextStyle, + _this.indicatorColor, + _this.backgroundColor, + _this.shape, + _this.side, + _this.margin, + _this.padding, + _this.thumbnailShape, + _this.thumbnailSide, + _this.thumbnailSize, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart b/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart index 62e4a5605a..95f964d669 100644 --- a/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart +++ b/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart @@ -60,6 +60,7 @@ class StreamChatThemeData { StreamThreadListTileThemeData? threadListTileTheme, StreamDraftListTileThemeData? draftListTileTheme, StreamVoiceRecordingAttachmentThemeData? voiceRecordingAttachmentTheme, + StreamQuotedMessageThemeData? quotedMessageTheme, StreamChannelListItemThemeData? channelListItemTheme, }) { brightness ??= colorTheme?.brightness ?? Brightness.light; @@ -90,6 +91,7 @@ class StreamChatThemeData { threadListTileTheme: threadListTileTheme, draftListTileTheme: draftListTileTheme, voiceRecordingAttachmentTheme: voiceRecordingAttachmentTheme, + quotedMessageTheme: quotedMessageTheme, channelListItemTheme: channelListItemTheme, ); @@ -122,6 +124,7 @@ class StreamChatThemeData { required this.threadListTileTheme, required this.draftListTileTheme, required this.voiceRecordingAttachmentTheme, + required this.quotedMessageTheme, required this.channelListItemTheme, }); @@ -190,6 +193,7 @@ class StreamChatThemeData { ), ), voiceRecordingAttachmentTheme: const StreamVoiceRecordingAttachmentThemeData(), + quotedMessageTheme: const StreamQuotedMessageThemeData(), channelListItemTheme: const StreamChannelListItemThemeData(), ); } @@ -246,6 +250,9 @@ class StreamChatThemeData { /// Theme configuration for the [StreamVoiceRecordingAttachment] widget. final StreamVoiceRecordingAttachmentThemeData voiceRecordingAttachmentTheme; + /// Theme configuration for the [StreamQuotedMessage] widget. + final StreamQuotedMessageThemeData quotedMessageTheme; + /// Theme configuration for the [StreamChannelListItem] widget. final StreamChannelListItemThemeData channelListItemTheme; @@ -275,6 +282,7 @@ class StreamChatThemeData { StreamThreadListTileThemeData? threadListTileTheme, StreamDraftListTileThemeData? draftListTileTheme, StreamVoiceRecordingAttachmentThemeData? voiceRecordingAttachmentTheme, + StreamQuotedMessageThemeData? quotedMessageTheme, StreamChannelListItemThemeData? channelListItemTheme, }) => StreamChatThemeData.raw( textTheme: this.textTheme.merge(textTheme), @@ -295,6 +303,7 @@ class StreamChatThemeData { threadListTileTheme: threadListTileTheme ?? this.threadListTileTheme, draftListTileTheme: draftListTileTheme ?? this.draftListTileTheme, voiceRecordingAttachmentTheme: voiceRecordingAttachmentTheme ?? this.voiceRecordingAttachmentTheme, + quotedMessageTheme: quotedMessageTheme ?? this.quotedMessageTheme, channelListItemTheme: channelListItemTheme ?? this.channelListItemTheme, ); @@ -320,6 +329,7 @@ class StreamChatThemeData { threadListTileTheme: threadListTileTheme.merge(other.threadListTileTheme), draftListTileTheme: draftListTileTheme.merge(other.draftListTileTheme), voiceRecordingAttachmentTheme: voiceRecordingAttachmentTheme.merge(other.voiceRecordingAttachmentTheme), + quotedMessageTheme: quotedMessageTheme.merge(other.quotedMessageTheme), channelListItemTheme: channelListItemTheme.merge(other.channelListItemTheme), ); } diff --git a/packages/stream_chat_flutter/lib/src/theme/themes.dart b/packages/stream_chat_flutter/lib/src/theme/themes.dart index 8d809f1103..7212c2e13e 100644 --- a/packages/stream_chat_flutter/lib/src/theme/themes.dart +++ b/packages/stream_chat_flutter/lib/src/theme/themes.dart @@ -13,6 +13,7 @@ export 'poll_option_votes_style.dart'; export 'poll_options_sheet_theme.dart'; export 'poll_question_style.dart'; export 'poll_results_sheet_theme.dart'; +export 'quoted_message_theme.dart'; export 'stream_channel_list_item_theme.dart'; export 'text_theme.dart'; export 'thread_list_tile_theme.dart'; diff --git a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart index 677a11dc7a..1cc13a2c5c 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -15,6 +15,12 @@ export 'package:stream_core_flutter/stream_core_flutter.dart' StreamAvatarStackSize, StreamButton, StreamButtonThemeStyle, + StreamBadgeCount, + StreamBadgeCountTheme, + StreamBadgeCountThemeData, + StreamBadgeNotification, + StreamBadgeNotificationTheme, + StreamBadgeNotificationThemeData, StreamCheckbox, StreamCheckboxSize, StreamCheckboxStyle, @@ -181,6 +187,7 @@ export 'src/message_modal/message_modal.dart'; export 'src/message_modal/moderated_message_actions_modal.dart'; export 'src/message_widget/message_widget.dart'; export 'src/message_widget/moderated_message.dart'; +export 'src/message_widget/stream_quoted_message.dart'; export 'src/message_widget/system_message.dart'; export 'src/misc/adaptive_dialog_action.dart'; export 'src/misc/animated_circle_border_painter.dart'; @@ -188,7 +195,6 @@ export 'src/misc/back_button.dart'; export 'src/misc/connection_status_builder.dart'; export 'src/misc/date_divider.dart'; export 'src/misc/info_tile.dart'; -export 'src/misc/markdown_message.dart'; export 'src/misc/option_list_tile.dart'; export 'src/misc/reaction_icon_resolver.dart'; diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index 2cf647bdf3..e63d1d61eb 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -61,7 +61,7 @@ dependencies: stream_core_flutter: git: url: https://github.com/GetStream/stream-core-flutter.git - ref: 639f99401891f171e9cc2264eea822ef3ede3f99 + ref: da615a2b232948bf89e46ea3d4c2e99084420544 path: packages/stream_core_flutter svg_icon_widget: ^0.0.1 synchronized: ^3.1.0+1 diff --git a/packages/stream_chat_flutter/test/src/dialogs/channel_info_dialog_test.dart b/packages/stream_chat_flutter/test/src/dialogs/channel_info_dialog_test.dart deleted file mode 100644 index 6d411750b2..0000000000 --- a/packages/stream_chat_flutter/test/src/dialogs/channel_info_dialog_test.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:stream_chat_flutter/src/dialogs/channel_info_dialog.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import '../mocks.dart'; - -void main() { - late MockClient client; - late MockClientState clientState; - late MockOwnUser user; - late MockChannel channel; - late MockChannelState channelState; - - setUpAll(() { - client = MockClient(); - clientState = MockClientState(); - user = MockOwnUser(); - channel = MockChannel(); - channelState = MockChannelState(); - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(user); - when(() => user.id).thenReturn('1'); - when(() => channel.state).thenReturn(channelState); - when(() => channelState.members).thenReturn([ - Member( - user: User( - id: '1', - ), - ), - Member( - user: User( - id: '2', - ), - ), - ]); - when(() => channel.name).thenReturn('test-channel'); - when(() => channel.isDistinct).thenReturn(true); - when(() => channel.memberCount).thenReturn(2); - when(() => channelState.membersStream).thenAnswer( - (_) => Stream.value([ - Member( - user: User( - id: '1', - ), - ), - Member( - user: User( - id: '2', - ), - ), - ]), - ); - }); - - testWidgets('ChannelInfoDialog shows info and members', (tester) async { - await tester.pumpWidget( - MaterialApp( - home: StreamChat( - client: client, - streamChatThemeData: StreamChatThemeData.light(), - child: Scaffold( - body: Center( - child: ChannelInfoDialog( - channel: channel, - ), - ), - ), - ), - ), - ); - - // wait for the initial state to be rendered. - await tester.pump(Duration.zero); - - expect(find.byType(SimpleDialog), findsOneWidget); - expect(find.byType(StreamChannelInfo), findsOneWidget); - expect(find.byType(StreamUserAvatar), findsOneWidget); - }); -} diff --git a/packages/stream_chat_flutter/test/src/dialogs/confirmation_dialog_test.dart b/packages/stream_chat_flutter/test/src/dialogs/confirmation_dialog_test.dart deleted file mode 100644 index 671837c4d7..0000000000 --- a/packages/stream_chat_flutter/test/src/dialogs/confirmation_dialog_test.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'package:alchemist/alchemist.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_chat_flutter/src/dialogs/confirmation_dialog.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import '../material_app_wrapper.dart'; - -void main() { - group('ConfirmationDialog tests', () { - testWidgets('renders with title, prompt, and action', (tester) async { - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: Builder( - builder: (context) { - return Center( - child: StreamChatTheme( - data: StreamChatThemeData.light(), - child: ConfirmationDialog( - titleText: context.translations.toggleMuteUnmuteUserText(isMuted: false), - promptText: context.translations.toggleMuteUnmuteUserQuestion(isMuted: false), - affirmativeText: context.translations.toggleMuteUnmuteAction(isMuted: false), - onConfirmation: () {}, - ), - ), - ); - }, - ), - ), - ), - ); - - expect(find.byType(AlertDialog), findsOneWidget); - expect(find.text('Mute User'), findsOneWidget); - expect(find.text('Are you sure you want to mute this user?'), findsOneWidget); - expect(find.text('MUTE'), findsOneWidget); - }); - - goldenTest( - 'golden test for ConfirmationDialog', - fileName: 'confirmation_dialog_0', - constraints: const BoxConstraints.tightFor(width: 400, height: 300), - builder: () => MaterialAppWrapper( - home: Scaffold( - body: Builder( - builder: (context) { - return Center( - child: StreamChatTheme( - data: StreamChatThemeData.light(), - child: ConfirmationDialog( - titleText: context.translations.toggleMuteUnmuteUserText(isMuted: false), - promptText: context.translations.toggleMuteUnmuteUserQuestion(isMuted: false), - affirmativeText: context.translations.toggleMuteUnmuteAction(isMuted: false), - onConfirmation: () {}, - ), - ), - ); - }, - ), - ), - ), - ); - }); -} diff --git a/packages/stream_chat_flutter/test/src/dialogs/delete_message_dialog_test.dart b/packages/stream_chat_flutter/test/src/dialogs/delete_message_dialog_test.dart deleted file mode 100644 index ff045abf8f..0000000000 --- a/packages/stream_chat_flutter/test/src/dialogs/delete_message_dialog_test.dart +++ /dev/null @@ -1,54 +0,0 @@ -import 'package:alchemist/alchemist.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_chat_flutter/src/dialogs/delete_message_dialog.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import '../material_app_wrapper.dart'; - -void main() { - group('DeleteMessageDialog tests', () { - testWidgets('renders with correct title and actions', (tester) async { - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: Builder( - builder: (context) { - return Center( - child: StreamChatTheme( - data: StreamChatThemeData.light(), - child: const DeleteMessageDialog(), - ), - ); - }, - ), - ), - ), - ); - - expect(find.byType(AlertDialog), findsOneWidget); - expect(find.text('Delete Message'), findsOneWidget); - expect(find.text('Delete'), findsOneWidget); - }); - - goldenTest( - 'golden test for DeleteMessageDialog', - fileName: 'delete_message_dialog_0', - constraints: const BoxConstraints.tightFor(width: 400, height: 300), - builder: () => MaterialAppWrapper( - home: Scaffold( - body: Builder( - builder: (context) { - return Center( - child: StreamChatTheme( - data: StreamChatThemeData.light(), - child: const DeleteMessageDialog(), - ), - ); - }, - ), - ), - ), - ); - }); -} diff --git a/packages/stream_chat_flutter/test/src/dialogs/goldens/ci/confirmation_dialog_0.png b/packages/stream_chat_flutter/test/src/dialogs/goldens/ci/confirmation_dialog_0.png deleted file mode 100644 index 4e95b434c5..0000000000 Binary files a/packages/stream_chat_flutter/test/src/dialogs/goldens/ci/confirmation_dialog_0.png and /dev/null differ diff --git a/packages/stream_chat_flutter/test/src/dialogs/goldens/ci/delete_message_dialog_0.png b/packages/stream_chat_flutter/test/src/dialogs/goldens/ci/delete_message_dialog_0.png deleted file mode 100644 index 874e419fef..0000000000 Binary files a/packages/stream_chat_flutter/test/src/dialogs/goldens/ci/delete_message_dialog_0.png and /dev/null differ diff --git a/packages/stream_chat_flutter/test/src/dialogs/goldens/ci/message_dialog_0.png b/packages/stream_chat_flutter/test/src/dialogs/goldens/ci/message_dialog_0.png deleted file mode 100644 index ef6313474e..0000000000 Binary files a/packages/stream_chat_flutter/test/src/dialogs/goldens/ci/message_dialog_0.png and /dev/null differ diff --git a/packages/stream_chat_flutter/test/src/dialogs/goldens/ci/message_dialog_1.png b/packages/stream_chat_flutter/test/src/dialogs/goldens/ci/message_dialog_1.png deleted file mode 100644 index 2dd9ed245d..0000000000 Binary files a/packages/stream_chat_flutter/test/src/dialogs/goldens/ci/message_dialog_1.png and /dev/null differ diff --git a/packages/stream_chat_flutter/test/src/dialogs/goldens/ci/message_dialog_2.png b/packages/stream_chat_flutter/test/src/dialogs/goldens/ci/message_dialog_2.png deleted file mode 100644 index ef6313474e..0000000000 Binary files a/packages/stream_chat_flutter/test/src/dialogs/goldens/ci/message_dialog_2.png and /dev/null differ diff --git a/packages/stream_chat_flutter/test/src/dialogs/message_dialog_test.dart b/packages/stream_chat_flutter/test/src/dialogs/message_dialog_test.dart deleted file mode 100644 index 5a44836080..0000000000 --- a/packages/stream_chat_flutter/test/src/dialogs/message_dialog_test.dart +++ /dev/null @@ -1,126 +0,0 @@ -import 'package:alchemist/alchemist.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_chat_flutter/src/dialogs/message_dialog.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import '../material_app_wrapper.dart'; - -void main() { - group('MessageDialog tests', () { - testWidgets('shows default info', (tester) async { - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: Builder( - builder: (context) { - return Center( - child: StreamChatTheme( - data: StreamChatThemeData.light(), - child: const MessageDialog(), - ), - ); - }, - ), - ), - ), - ); - - expect(find.byType(AlertDialog), findsOneWidget); - expect(find.text('Something went wrong'), findsOneWidget); - expect(find.text('OK'), findsOneWidget); - }); - - testWidgets('shows custom info', (tester) async { - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: Builder( - builder: (context) { - return Center( - child: StreamChatTheme( - data: StreamChatThemeData.light(), - child: const MessageDialog( - titleText: 'Message', - messageText: 'Message body', - ), - ), - ); - }, - ), - ), - ), - ); - - expect(find.byType(AlertDialog), findsOneWidget); - expect(find.text('Message'), findsOneWidget); - expect(find.text('Message body'), findsOneWidget); - expect(find.text('OK'), findsOneWidget); - }); - - goldenTest( - 'golden test for default MessageDialog', - fileName: 'message_dialog_0', - constraints: const BoxConstraints.tightFor(width: 400, height: 300), - builder: () => MaterialAppWrapper( - home: Scaffold( - body: Builder( - builder: (context) { - return Center( - child: StreamChatTheme( - data: StreamChatThemeData.light(), - child: const MessageDialog(), - ), - ); - }, - ), - ), - ), - ); - - goldenTest( - 'golden test for custom MessageDialog', - fileName: 'message_dialog_1', - constraints: const BoxConstraints.tightFor(width: 400, height: 300), - builder: () => MaterialAppWrapper( - home: Scaffold( - body: Builder( - builder: (context) { - return Center( - child: StreamChatTheme( - data: StreamChatThemeData.light(), - child: const MessageDialog( - titleText: 'Message', - messageText: 'Message body', - ), - ), - ); - }, - ), - ), - ), - ); - - goldenTest( - 'golden test for custom MessageDialog with no body', - fileName: 'message_dialog_2', - constraints: const BoxConstraints.tightFor(width: 400, height: 300), - builder: () => MaterialAppWrapper( - home: Scaffold( - body: Builder( - builder: (context) { - return Center( - child: StreamChatTheme( - data: StreamChatThemeData.light(), - child: const MessageDialog( - titleText: 'Message', - ), - ), - ); - }, - ), - ), - ), - ); - }); -} diff --git a/packages/stream_chat_flutter/test/src/message_input/message_input_attachment_list_test.dart b/packages/stream_chat_flutter/test/src/message_input/message_input_attachment_list_test.dart index 0d0c2c3c02..6f2c045cfc 100644 --- a/packages/stream_chat_flutter/test/src/message_input/message_input_attachment_list_test.dart +++ b/packages/stream_chat_flutter/test/src/message_input/message_input_attachment_list_test.dart @@ -37,7 +37,7 @@ void main() { ); // Expect 2 file attachments and 1 media attachment - expect(find.byType(MessageComposerFileAttachment), findsNWidgets(2)); + expect(find.byType(StreamMessageComposerFileAttachment), findsNWidgets(2)); expect(find.byType(MessageInputMediaAttachments), findsOneWidget); expect(find.byType(StreamMediaAttachmentThumbnail), findsOneWidget); }, @@ -113,7 +113,7 @@ void main() { ); // Expect 2 file attachments - expect(find.byType(MessageComposerFileAttachment), findsNWidgets(2)); + expect(find.byType(StreamMessageComposerFileAttachment), findsNWidgets(2)); }, ); @@ -154,8 +154,8 @@ void main() { 'MessageInputMediaAttachments should render media attachments', (WidgetTester tester) async { final attachments = [ - Attachment(type: 'media', id: 'media1'), - Attachment(type: 'media', id: 'media2'), + Attachment(type: 'image', id: 'image1'), + Attachment(type: 'video', id: 'video1'), ]; await tester.pumpWidget( @@ -188,6 +188,27 @@ void main() { expect(find.byType(SizedBox), findsOneWidget); }, ); + + testWidgets( + 'MessageInputMediaAttachments should render unsupported attachment for unknown types', + (WidgetTester tester) async { + final attachments = [ + Attachment(type: 'unknown', id: 'unknown1'), + Attachment(type: 'something_else', id: 'unknown2'), + ]; + + await tester.pumpWidget( + wrapWithStreamChat( + MessageInputMediaAttachments( + attachments: attachments, + ), + ), + ); + + // Expect a fallback unsupported attachment widget for each unknown type. + expect(find.byType(StreamMessageComposerUnsupportedAttachment), findsNWidgets(2)); + }, + ); }); group('StreamMediaAttachmentBuilder tests', () { diff --git a/packages/stream_chat_flutter/test/src/message_modal/message_actions_modal_test.dart b/packages/stream_chat_flutter/test/src/message_modal/message_actions_modal_test.dart index 30352e5505..d0fb2ccb0a 100644 --- a/packages/stream_chat_flutter/test/src/message_modal/message_actions_modal_test.dart +++ b/packages/stream_chat_flutter/test/src/message_modal/message_actions_modal_test.dart @@ -149,8 +149,9 @@ void main() { Widget buildMessageWidget({bool reverse = false}) { return Builder( builder: (context) { - final messageTheme = context.streamMessageTheme.mergeWithDefaults(context); - final messageStyle = reverse ? messageTheme.outgoing! : messageTheme.incoming!; + final colorScheme = context.streamColorScheme; + final backgroundColor = reverse ? colorScheme.brand.shade100 : colorScheme.backgroundSurface; + final textColor = reverse ? colorScheme.brand.shade900 : colorScheme.textPrimary; return Container( padding: const EdgeInsets.symmetric( @@ -159,11 +160,11 @@ void main() { ), decoration: BoxDecoration( borderRadius: BorderRadius.circular(14), - color: messageStyle.backgroundColor, + color: backgroundColor, ), child: Text( message.text ?? '', - style: TextStyle(color: messageStyle.textColor), + style: TextStyle(color: textColor), ), ); }, diff --git a/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_delete_option_dialog_dark.png b/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_delete_option_dialog_dark.png index ebcd2f81b9..eb2eadbb24 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_delete_option_dialog_dark.png and b/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_delete_option_dialog_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_delete_option_dialog_light.png b/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_delete_option_dialog_light.png index ebcd2f81b9..eb2eadbb24 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_delete_option_dialog_light.png and b/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_delete_option_dialog_light.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/stream_poll_creator_sheet_dark.png b/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/stream_poll_creator_sheet_dark.png index dab802ee70..5cbe0e663e 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/stream_poll_creator_sheet_dark.png and b/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/stream_poll_creator_sheet_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/stream_poll_creator_sheet_light.png b/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/stream_poll_creator_sheet_light.png index a7eac4aa5a..9ab8682257 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/stream_poll_creator_sheet_light.png and b/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/stream_poll_creator_sheet_light.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/creator/poll_option_reorderable_list_view_test.dart b/packages/stream_chat_flutter/test/src/poll/creator/poll_option_reorderable_list_view_test.dart index 2a6d481f16..f98acf1af7 100644 --- a/packages/stream_chat_flutter/test/src/poll/creator/poll_option_reorderable_list_view_test.dart +++ b/packages/stream_chat_flutter/test/src/poll/creator/poll_option_reorderable_list_view_test.dart @@ -87,13 +87,8 @@ void main() { ), ); - // Find the add button - final addButton = find.addOptionButton(); - expect(addButton, findsOneWidget); - - // The button should be disabled since we're at max options - final button = tester.widget(addButton); - expect(button.props.onPressed, isNull); + // The add button should be hidden since we're at max options. + expect(find.addOptionButton(), findsNothing); }); testWidgets('should respect both min and max options', (tester) async { @@ -129,10 +124,8 @@ void main() { // Should now have 4 options (max reached) expect(find.byType(TextField), findsNWidgets(4)); - // Add button should now be disabled since we reached max - final addButton = find.addOptionButton(); - final button = tester.widget(addButton); - expect(button.props.onPressed, isNull); + // Add button should now be hidden since we reached max. + expect(find.addOptionButton(), findsNothing); }); testWidgets( @@ -188,7 +181,7 @@ void main() { group('Empty Options Prevention', () { testWidgets( - 'should disable add button when empty option exists', + 'should hide add button when empty option exists', (tester) async { await tester.pumpWidget( _wrapWithMaterialApp( @@ -203,18 +196,13 @@ void main() { ), ); - // Find the add button - final addButton = find.addOptionButton(); - expect(addButton, findsOneWidget); - - // The button should be disabled since there's already an empty option - final button = tester.widget(addButton); - expect(button.props.onPressed, isNull); + // The button should be hidden since there's already an empty option. + expect(find.addOptionButton(), findsNothing); }, ); testWidgets( - 'should enable add button when no empty options exist', + 'should show add button when no empty options exist', (tester) async { await tester.pumpWidget( _wrapWithMaterialApp( @@ -228,18 +216,16 @@ void main() { ), ); - // Find the add button + // The button should be visible since no empty options exist. final addButton = find.addOptionButton(); expect(addButton, findsOneWidget); - - // The button should be enabled since no empty options exist final button = tester.widget(addButton); expect(button.props.onPressed, isNotNull); }, ); testWidgets( - 'should re-enable add button after filling empty option', + 'should reveal add button after filling empty option', (tester) async { await tester.pumpWidget( _wrapWithMaterialApp( @@ -253,19 +239,18 @@ void main() { ), ); - // Initially, add button should be disabled - var addButton = find.addOptionButton(); - var button = tester.widget(addButton); - expect(button.props.onPressed, isNull); + // Initially, the add button should be hidden. + expect(find.addOptionButton(), findsNothing); - // Fill the empty option + // Fill the empty option. final textFields = find.byType(TextField); await tester.enterText(textFields.last, 'Option 2'); await tester.pumpAndSettle(); - // Now add button should be enabled - addButton = find.addOptionButton(); - button = tester.widget(addButton); + // The add button should now be visible and enabled. + final addButton = find.addOptionButton(); + expect(addButton, findsOneWidget); + final button = tester.widget(addButton); expect(button.props.onPressed, isNotNull); }, ); diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_add_comment_dialog_dark.png b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_add_comment_dialog_dark.png index a0c7c88178..8f0e562191 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_add_comment_dialog_dark.png and b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_add_comment_dialog_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_add_comment_dialog_light.png b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_add_comment_dialog_light.png index a0c7c88178..8f0e562191 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_add_comment_dialog_light.png and b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_add_comment_dialog_light.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_add_comment_dialog_with_initial_value_dark.png b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_add_comment_dialog_with_initial_value_dark.png index 80300a0fbb..ae654b983d 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_add_comment_dialog_with_initial_value_dark.png and b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_add_comment_dialog_with_initial_value_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_add_comment_dialog_with_initial_value_light.png b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_add_comment_dialog_with_initial_value_light.png index 80300a0fbb..ae654b983d 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_add_comment_dialog_with_initial_value_light.png and b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_add_comment_dialog_with_initial_value_light.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_end_vote_dialog_dark.png b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_end_vote_dialog_dark.png index 20bb11e3eb..522fa8bee0 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_end_vote_dialog_dark.png and b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_end_vote_dialog_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_end_vote_dialog_light.png b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_end_vote_dialog_light.png index 20bb11e3eb..522fa8bee0 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_end_vote_dialog_light.png and b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_end_vote_dialog_light.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_suggest_option_dialog_dark.png b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_suggest_option_dialog_dark.png index 2e44c33ede..bdbf32dadb 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_suggest_option_dialog_dark.png and b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_suggest_option_dialog_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_suggest_option_dialog_light.png b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_suggest_option_dialog_light.png index 5608443e6e..c1621b48f1 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_suggest_option_dialog_light.png and b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_suggest_option_dialog_light.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_suggest_option_dialog_with_initial_option_dark.png b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_suggest_option_dialog_with_initial_option_dark.png index 7c8b599030..3efb8c2da6 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_suggest_option_dialog_with_initial_option_dark.png and b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_suggest_option_dialog_with_initial_option_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_suggest_option_dialog_with_initial_option_light.png b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_suggest_option_dialog_with_initial_option_light.png index e7c969dcc8..b8a4a03607 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_suggest_option_dialog_with_initial_option_light.png and b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_suggest_option_dialog_with_initial_option_light.png differ diff --git a/sample_app/lib/app.dart b/sample_app/lib/app.dart index f3fbea8f26..3916d9a41c 100644 --- a/sample_app/lib/app.dart +++ b/sample_app/lib/app.dart @@ -240,6 +240,7 @@ extension on SampleAppConfigData { enforceUniqueReactions: enforceUniqueReactions, reactionType: reactionType, reactionPosition: reactionPosition, + reactionOverlap: reactionOverlap?.value, attachmentBuilders: [ if (enableLocationSharing) LocationAttachmentBuilder( diff --git a/sample_app/lib/config/sample_app_config.dart b/sample_app/lib/config/sample_app_config.dart index 9d92d83853..2524ccaf1c 100644 --- a/sample_app/lib/config/sample_app_config.dart +++ b/sample_app/lib/config/sample_app_config.dart @@ -17,6 +17,7 @@ const _kDraftMessagesEnabled = 'config.draftMessagesEnabled'; const _kEnforceUniqueReactions = 'config.enforceUniqueReactions'; const _kReactionType = 'config.reactionType'; const _kReactionPosition = 'config.reactionPosition'; +const _kReactionOverlap = 'config.reactionOverlap'; const _kLocale = 'config.locale'; const _sentinel = Object(); @@ -25,6 +26,26 @@ const _sentinel = Object(); /// [kStreamChatSupportedLanguages]. final supportedLocales = kStreamChatSupportedLanguages.map(Locale.new).toList(); +/// Reaction overlap preference for the sample app. +/// +/// Mapped to `bool` via [value] when passed to +/// [StreamChatConfigurationData.reactionOverlap]. Use `null` for the SDK's +/// platform-based default (overlap on mobile, no overlap on desktop/web). +enum SampleReactionOverlap { + /// Always overlap the message bubble edge. + overlap(true), + + /// Never overlap the message bubble edge. + noOverlap(false) + ; + + // ignore: avoid_positional_boolean_parameters + const SampleReactionOverlap(this.value); + + /// The boolean value passed to the SDK. + final bool value; +} + // --------------------------------------------------------------------------- // SampleAppConfigData // --------------------------------------------------------------------------- @@ -47,6 +68,7 @@ class SampleAppConfigData { bool enforceUniqueReactions = false, StreamReactionsType? reactionType, StreamReactionsPosition? reactionPosition, + SampleReactionOverlap? reactionOverlap, }) { return SampleAppConfigData.raw( themeMode: themeMode, @@ -60,6 +82,7 @@ class SampleAppConfigData { enforceUniqueReactions: enforceUniqueReactions, reactionType: reactionType, reactionPosition: reactionPosition, + reactionOverlap: reactionOverlap, ); } @@ -76,6 +99,7 @@ class SampleAppConfigData { required this.enforceUniqueReactions, required this.reactionType, required this.reactionPosition, + required this.reactionOverlap, }); /// Loads config from [StreamingSharedPreferences], falling back to defaults. @@ -93,6 +117,7 @@ class SampleAppConfigData { enforceUniqueReactions: prefs.getBool(_kEnforceUniqueReactions, defaultValue: false).getValue(), reactionType: _intToReactionType(prefs.getInt(_kReactionType, defaultValue: -1).getValue()), reactionPosition: _intToReactionPosition(prefs.getInt(_kReactionPosition, defaultValue: -1).getValue()), + reactionOverlap: _intToReactionOverlap(prefs.getInt(_kReactionOverlap, defaultValue: -1).getValue()), ); } @@ -133,12 +158,18 @@ class SampleAppConfigData { /// Where reactions appear relative to the message bubble. final StreamReactionsPosition? reactionPosition; + /// Whether reactions overlap the message bubble edge. + /// + /// When null, the SDK's platform-based default is used (overlap on + /// mobile, no overlap on desktop and web). + final SampleReactionOverlap? reactionOverlap; + // -- copyWith ------------------------------------------------------------- /// Creates a copy with the given fields replaced. /// - /// For nullable fields ([locale], [reactionType], [reactionPosition]), - /// pass explicitly as `null` to reset to default/system. + /// For nullable fields ([locale], [reactionType], [reactionPosition], + /// [reactionOverlap]), pass explicitly as `null` to reset to default/system. SampleAppConfigData copyWith({ ThemeMode? themeMode, Object? locale = _sentinel, @@ -151,6 +182,7 @@ class SampleAppConfigData { bool? enforceUniqueReactions, Object? reactionType = _sentinel, Object? reactionPosition = _sentinel, + Object? reactionOverlap = _sentinel, }) { return SampleAppConfigData.raw( themeMode: themeMode ?? this.themeMode, @@ -166,6 +198,7 @@ class SampleAppConfigData { reactionPosition: reactionPosition == _sentinel ? this.reactionPosition : reactionPosition as StreamReactionsPosition?, + reactionOverlap: reactionOverlap == _sentinel ? this.reactionOverlap : reactionOverlap as SampleReactionOverlap?, ); } @@ -184,6 +217,7 @@ class SampleAppConfigData { prefs.setBool(_kEnforceUniqueReactions, enforceUniqueReactions); prefs.setInt(_kReactionType, _reactionTypeToInt(reactionType)); prefs.setInt(_kReactionPosition, _reactionPositionToInt(reactionPosition)); + prefs.setInt(_kReactionOverlap, _reactionOverlapToInt(reactionOverlap)); } static StreamReactionsType? _intToReactionType(int value) { @@ -199,6 +233,13 @@ class SampleAppConfigData { static int _reactionTypeToInt(StreamReactionsType? type) => type?.index ?? -1; static int _reactionPositionToInt(StreamReactionsPosition? position) => position?.index ?? -1; + + static SampleReactionOverlap? _intToReactionOverlap(int value) { + if (value < 0 || value >= SampleReactionOverlap.values.length) return null; + return SampleReactionOverlap.values[value]; + } + + static int _reactionOverlapToInt(SampleReactionOverlap? overlap) => overlap?.index ?? -1; } // --------------------------------------------------------------------------- diff --git a/sample_app/lib/config/sample_app_config_screen.dart b/sample_app/lib/config/sample_app_config_screen.dart index f6bfffa0e8..9c6f8b6f44 100644 --- a/sample_app/lib/config/sample_app_config_screen.dart +++ b/sample_app/lib/config/sample_app_config_screen.dart @@ -143,6 +143,16 @@ class SampleAppConfigScreen extends StatelessWidget { }, onChanged: (v) => SampleAppConfig.update(context, config.copyWith(reactionPosition: v)), ), + _SegmentedRow( + title: 'Reaction Overlap', + value: config.reactionOverlap, + segments: const { + null: 'Default', + SampleReactionOverlap.overlap: 'Overlap', + SampleReactionOverlap.noOverlap: 'No Overlap', + }, + onChanged: (v) => SampleAppConfig.update(context, config.copyWith(reactionOverlap: v)), + ), ], ), diff --git a/sample_app/lib/pages/channel_list_page.dart b/sample_app/lib/pages/channel_list_page.dart index 9e126e0a1b..4b7cd032a3 100644 --- a/sample_app/lib/pages/channel_list_page.dart +++ b/sample_app/lib/pages/channel_list_page.dart @@ -85,24 +85,27 @@ class _ChannelListPageState extends State { color: colorScheme.backgroundElevation1, border: Border(top: BorderSide(color: colorScheme.borderSubtle)), ), - child: BottomNavigationBar( - elevation: 0, - iconSize: 20, - currentIndex: _currentIndex, - type: BottomNavigationBarType.fixed, - selectedItemColor: colorScheme.textPrimary, - unselectedItemColor: colorScheme.textTertiary, - backgroundColor: Colors.transparent, - selectedLabelStyle: textTheme.metadataEmphasis, - unselectedLabelStyle: textTheme.metadataEmphasis, - onTap: (index) => setState(() => _currentIndex = index), - items: enabledTabs.map((tab) { - return BottomNavigationBarItem( - icon: tab.icon, - activeIcon: tab.selectedIcon, - label: tab.label, - ); - }).toList(), + child: StreamBadgeNotificationTheme( + data: const .new(size: .xs), + child: BottomNavigationBar( + elevation: 0, + iconSize: 20, + currentIndex: _currentIndex, + type: BottomNavigationBarType.fixed, + selectedItemColor: colorScheme.textPrimary, + unselectedItemColor: colorScheme.textTertiary, + backgroundColor: Colors.transparent, + selectedLabelStyle: textTheme.metadataEmphasis, + unselectedLabelStyle: textTheme.metadataEmphasis, + onTap: (index) => setState(() => _currentIndex = index), + items: enabledTabs.map((tab) { + return BottomNavigationBarItem( + icon: tab.icon, + activeIcon: tab.selectedIcon, + label: tab.label, + ); + }).toList(), + ), ), ), body: IndexedStack( diff --git a/sample_app/lib/pages/chat_info_screen.dart b/sample_app/lib/pages/chat_info_screen.dart index 912a788111..268b485021 100644 --- a/sample_app/lib/pages/chat_info_screen.dart +++ b/sample_app/lib/pages/chat_info_screen.dart @@ -131,18 +131,21 @@ class _MediaSection extends StatelessWidget { return _Section( children: [ _Tile( - icon: icons.pin, - label: 'Pinned Messages', + icon: Icon(icons.pin), + label: const Text('Pinned Messages'), + trailing: Icon(icons.chevronRight), onTap: () => _push(context, const PinnedMessagesScreen()), ), _Tile( - icon: icons.image, - label: 'Photos & Videos', + icon: Icon(icons.image), + label: const Text('Photos & Videos'), + trailing: Icon(icons.chevronRight), onTap: () => _push(context, const ChannelMediaDisplayScreen()), ), _Tile( - icon: icons.folder, - label: 'Files', + icon: Icon(icons.folder), + label: const Text('Files'), + trailing: Icon(icons.chevronRight), onTap: () => _push(context, const ChannelFileDisplayScreen()), ), ], @@ -174,8 +177,8 @@ class _ActionsSection extends StatelessWidget { stream: channel.isMutedStream, initialData: channel.isMuted, builder: (context, isMuted) => _Tile( - icon: isMuted ? icons.audio : icons.mute, - label: isMuted ? 'Unmute User' : 'Mute User', + icon: Icon(isMuted ? icons.audio : icons.mute), + label: Text(isMuted ? 'Unmute User' : 'Mute User'), trailing: StreamSwitch( value: isMuted, onChanged: (_) { @@ -189,14 +192,14 @@ class _ActionsSection extends StatelessWidget { ), ), _Tile( - icon: icons.noSign, - label: 'Block User', + icon: Icon(icons.noSign), + label: const Text('Block User'), onTap: () => _showNotImplementedSnack(context), ), if (channel.canDeleteChannel) _Tile( - icon: icons.delete, - label: 'Delete Conversation', + icon: Icon(icons.delete), + label: const Text('Delete Conversation'), destructive: true, onTap: () => _confirmDelete(context), ), @@ -257,11 +260,11 @@ class _Section extends StatelessWidget { } } -/// A single row inside a [_Section] — leading icon, label, trailing widget. -/// -/// Defaults the trailing to a chevron when [onTap] is provided and no -/// explicit [trailing] was passed. Setting [destructive] paints both the -/// icon and the label with [StreamColorScheme.accentError] via a local +/// A single row inside a [_Section] — leading icon, label, and an optional +/// [trailing] widget. Navigation rows should pass an explicit chevron; +/// action rows that confirm or toggle in place pass [trailing] only when +/// they need a control (e.g. a switch). Setting [destructive] paints both +/// the icon and the label with [StreamColorScheme.accentError] via a local /// [StreamListTileTheme] override. class _Tile extends StatelessWidget { const _Tile({ @@ -272,24 +275,17 @@ class _Tile extends StatelessWidget { this.destructive = false, }); - final IconData icon; - final String label; + final Widget icon; + final Widget label; final VoidCallback? onTap; final Widget? trailing; final bool destructive; @override Widget build(BuildContext context) { - final icons = context.streamIcons; final spacing = context.streamSpacing; - final colorScheme = context.streamColorScheme; - var trailing = this.trailing; - if (trailing == null && onTap != null) { - trailing = Icon(icons.chevronRight, color: colorScheme.textSecondary); - } - return StreamListTileTheme( data: StreamListTileThemeData( iconColor: destructive ? .all(colorScheme.accentError) : null, @@ -298,9 +294,9 @@ class _Tile extends StatelessWidget { contentPadding: .symmetric(horizontal: spacing.sm), ), child: StreamListTile( - leading: Icon(icon), + leading: icon, trailing: trailing, - title: Text(label), + title: label, onTap: onTap, ), ); @@ -312,8 +308,8 @@ class _Tile extends StatelessWidget { // Mirrors the dialog pattern used by the poll interactor (e.g. // `showPollEndVoteDialog` / `showPollDeleteOptionDialog`) and the // SDK-internal `StreamMessageActionConfirmationModal`: a Material -// [AlertDialog] with two ghost [StreamButton]s, secondary for cancel and -// destructive for confirm. +// [AlertDialog] with a ghost secondary cancel and a solid destructive +// confirm. // // Resolves to `true` on confirm, `false` on cancel, `null` on dismiss. Future _showConfirmationDialog({ @@ -360,7 +356,7 @@ class _ConfirmationDialog extends StatelessWidget { child: Text(context.translations.cancelLabel), ), StreamButton( - type: .ghost, + type: .solid, style: .destructive, size: .small, onPressed: () => Navigator.of(context).maybePop(true), diff --git a/sample_app/lib/pages/group_info_screen.dart b/sample_app/lib/pages/group_info_screen.dart index 8531086ff2..a4b5b28636 100644 --- a/sample_app/lib/pages/group_info_screen.dart +++ b/sample_app/lib/pages/group_info_screen.dart @@ -129,18 +129,21 @@ class _MediaSection extends StatelessWidget { return _Section( children: [ _Tile( - icon: icons.pin, - label: 'Pinned Messages', + icon: Icon(icons.pin), + label: const Text('Pinned Messages'), + trailing: Icon(icons.chevronRight), onTap: () => _push(context, const PinnedMessagesScreen()), ), _Tile( - icon: icons.image, - label: 'Photos & Videos', + icon: Icon(icons.image), + label: const Text('Photos & Videos'), + trailing: Icon(icons.chevronRight), onTap: () => _push(context, const ChannelMediaDisplayScreen()), ), _Tile( - icon: icons.folder, - label: 'Files', + icon: Icon(icons.folder), + label: const Text('Files'), + trailing: Icon(icons.chevronRight), onTap: () => _push(context, const ChannelFileDisplayScreen()), ), ], @@ -270,9 +273,9 @@ class _MembersHeader extends StatelessWidget { } } -/// Card grouping the conversation-level actions — mute and leave. Group -/// channels intentionally don't expose a destructive delete here; that -/// action lives on the channel-list long-press sheet. +/// Card grouping the conversation-level actions — mute, leave, and (for +/// admins) delete. Mirrors the destructive actions exposed on the +/// channel-list long-press sheet so both surfaces stay in sync. class _ActionsSection extends StatelessWidget { const _ActionsSection(); @@ -287,8 +290,8 @@ class _ActionsSection extends StatelessWidget { stream: channel.isMutedStream, initialData: channel.isMuted, builder: (context, isMuted) => _Tile( - icon: isMuted ? icons.audio : icons.mute, - label: isMuted ? 'Unmute Group' : 'Mute Group', + icon: Icon(isMuted ? icons.audio : icons.mute), + label: Text(isMuted ? 'Unmute Group' : 'Mute Group'), trailing: StreamSwitch( value: isMuted, onChanged: (_) { @@ -303,11 +306,18 @@ class _ActionsSection extends StatelessWidget { ), if (channel.canLeaveChannel) _Tile( - icon: icons.leave, - label: 'Leave Group', + icon: Icon(icons.leave), + label: const Text('Leave Group'), destructive: true, onTap: () => _confirmLeave(context), ), + if (channel.canDeleteChannel) + _Tile( + icon: Icon(icons.delete), + label: const Text('Delete Group'), + destructive: true, + onTap: () => _confirmDelete(context), + ), ], ); } @@ -331,6 +341,25 @@ class _ActionsSection extends StatelessWidget { // the channel page would crash since we're no longer a member. navigator.popUntil((route) => route.isFirst); } + + Future _confirmDelete(BuildContext context) async { + final navigator = Navigator.of(context); + final channel = StreamChannel.of(context).channel; + + final confirmed = await _showConfirmationDialog( + context: context, + title: 'Delete group', + content: 'Are you sure you want to delete this group?', + confirmLabel: 'Delete', + ); + if (confirmed != true) return; + + await channel.delete(); + // Pop every screen until we land on the channel list — going back to + // the channel page would crash trying to read state from the now + // deleted channel. + navigator.popUntil((route) => route.isFirst); + } } /// A rounded section card that visually groups its [children] with a single @@ -360,11 +389,12 @@ class _Section extends StatelessWidget { } } -/// A single row inside a [_Section] — leading icon, label, optional -/// trailing widget. Defaults the trailing to a chevron when [onTap] is -/// provided and no explicit [trailing] is passed. [destructive] paints -/// both the icon and the label with [StreamColorScheme.accentError] via a -/// local [StreamListTileTheme] override. +/// A single row inside a [_Section] — leading icon, label, and an optional +/// [trailing] widget. Navigation rows should pass an explicit chevron; +/// action rows that confirm or toggle in place pass [trailing] only when +/// they need a control (e.g. a switch). Setting [destructive] paints both +/// the icon and the label with [StreamColorScheme.accentError] via a local +/// [StreamListTileTheme] override. class _Tile extends StatelessWidget { const _Tile({ required this.icon, @@ -374,22 +404,17 @@ class _Tile extends StatelessWidget { this.destructive = false, }); - final IconData icon; - final String label; + final Widget icon; + final Widget label; final VoidCallback? onTap; final Widget? trailing; final bool destructive; @override Widget build(BuildContext context) { - final icons = context.streamIcons; final spacing = context.streamSpacing; - final colorScheme = context.streamColorScheme; - final effectiveTrailing = - trailing ?? (onTap != null ? Icon(icons.chevronRight, color: colorScheme.textSecondary) : null); - return StreamListTileTheme( data: StreamListTileThemeData( iconColor: destructive ? .all(colorScheme.accentError) : null, @@ -398,9 +423,9 @@ class _Tile extends StatelessWidget { contentPadding: .symmetric(horizontal: spacing.sm), ), child: StreamListTile( - leading: Icon(icon), - trailing: effectiveTrailing, - title: Text(label), + leading: icon, + trailing: trailing, + title: label, onTap: onTap, ), ); @@ -411,9 +436,9 @@ class _Tile extends StatelessWidget { // // Mirrors the dialog pattern used by the poll interactor and the // SDK-internal `StreamMessageActionConfirmationModal` — a Material -// [AlertDialog] with two ghost [StreamButton]s, secondary for cancel and -// destructive for confirm. Resolves to `true` on confirm, `false` on -// cancel, `null` on dismiss. +// [AlertDialog] with a ghost secondary cancel and a solid destructive +// confirm. Resolves to `true` on confirm, `false` on cancel, `null` on +// dismiss. Future _showConfirmationDialog({ required BuildContext context, required String title, @@ -458,7 +483,7 @@ class _ConfirmationDialog extends StatelessWidget { child: Text(context.translations.cancelLabel), ), StreamButton( - type: .ghost, + type: .solid, style: .destructive, size: .small, onPressed: () => Navigator.of(context).maybePop(true), diff --git a/sample_app/lib/widgets/all_members_sheet.dart b/sample_app/lib/widgets/all_members_sheet.dart index ce25be938d..4982d8fe4a 100644 --- a/sample_app/lib/widgets/all_members_sheet.dart +++ b/sample_app/lib/widgets/all_members_sheet.dart @@ -213,8 +213,8 @@ class ContactDetailSheet extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ _ActionTile( - icon: icons.messageBubble, - label: 'Send Direct Message', + icon: Icon(icons.messageBubble), + label: const Text('Send Direct Message'), onTap: () => emit(SendDirectMessage(user: user)), ), // Reactively flip Mute / Unmute as the global mute list @@ -223,14 +223,14 @@ class ContactDetailSheet extends StatelessWidget { stream: client.userMutedStream(user.id), initialData: client.isUserMuted(user.id), builder: (context, isMuted) => _ActionTile( - icon: isMuted ? icons.audio : icons.mute, - label: isMuted ? 'Unmute User' : 'Mute User', + icon: Icon(isMuted ? icons.audio : icons.mute), + label: Text(isMuted ? 'Unmute User' : 'Mute User'), onTap: () => emit(isMuted ? UnmuteUser(user: user) : MuteUser(user: user)), ), ), _ActionTile( - icon: icons.noSign, - label: 'Block User', + icon: Icon(icons.noSign), + label: const Text('Block User'), onTap: () => emit(BlockUser(user: user)), ), ], @@ -288,8 +288,8 @@ class _ContactDetailHeader extends StatelessWidget { class _ActionTile extends StatelessWidget { const _ActionTile({required this.icon, required this.label, this.onTap}); - final IconData icon; - final String label; + final Widget icon; + final Widget label; final VoidCallback? onTap; @override @@ -302,8 +302,8 @@ class _ActionTile extends StatelessWidget { contentPadding: .symmetric(horizontal: spacing.sm), ), child: StreamListTile( - leading: Icon(icon), - title: Text(label), + leading: icon, + title: label, onTap: onTap, ), ); diff --git a/sample_app/lib/widgets/channel_detail_sheet.dart b/sample_app/lib/widgets/channel_detail_sheet.dart index 99cb3dee76..db01f34510 100644 --- a/sample_app/lib/widgets/channel_detail_sheet.dart +++ b/sample_app/lib/widgets/channel_detail_sheet.dart @@ -175,16 +175,16 @@ class ChannelDetailSheet extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ _ChannelDetailAction( - icon: icons.info, - label: 'View Info', + icon: Icon(icons.info), + label: const Text('View Info'), onTap: () => emit(ViewChannelInfo(user: otherUser)), ), BetterStreamBuilder( stream: channel.isPinnedStream, initialData: channel.isPinned, builder: (context, isPinned) => _ChannelDetailAction( - icon: isPinned ? icons.unpin : icons.pin, - label: isPinned ? 'Unpin Chat' : 'Pin Chat', + icon: Icon(isPinned ? icons.unpin : icons.pin), + label: Text(isPinned ? 'Unpin Chat' : 'Pin Chat'), onTap: () => emit(isPinned ? const UnpinChannel() : const PinChannel()), ), ), @@ -193,30 +193,30 @@ class ChannelDetailSheet extends StatelessWidget { stream: client.userMutedStream(otherUser.id), initialData: client.isUserMuted(otherUser.id), builder: (context, isMuted) => _ChannelDetailAction( - icon: isMuted ? icons.audio : icons.mute, - label: isMuted ? 'Unmute User' : 'Mute User', + icon: Icon(isMuted ? icons.audio : icons.mute), + label: Text(isMuted ? 'Unmute User' : 'Mute User'), onTap: () => emit( isMuted ? UnmuteChannelMember(user: otherUser) : MuteChannelMember(user: otherUser), ), ), ), _ChannelDetailAction( - icon: icons.noSign, - label: 'Block User', + icon: Icon(icons.noSign), + label: const Text('Block User'), onTap: () => emit(BlockChannelMember(user: otherUser)), ), ], if (canLeave) _ChannelDetailAction( - icon: icons.leave, - label: 'Leave Group', + icon: Icon(icons.leave), + label: const Text('Leave Group'), destructive: true, onTap: () => emit(const LeaveChannel()), ), if (canDelete) _ChannelDetailAction( - icon: icons.delete, - label: isOneToOne ? 'Delete Chat' : 'Delete Group', + icon: Icon(icons.delete), + label: Text(isOneToOne ? 'Delete Chat' : 'Delete Group'), destructive: true, onTap: () => emit(const DeleteChannel()), ), @@ -337,8 +337,8 @@ class _ChannelDetailAction extends StatelessWidget { this.destructive = false, }); - final IconData icon; - final String label; + final Widget icon; + final Widget label; final VoidCallback? onTap; final bool destructive; @@ -355,8 +355,8 @@ class _ChannelDetailAction extends StatelessWidget { contentPadding: .symmetric(horizontal: spacing.sm), ), child: StreamListTile( - leading: Icon(icon), - title: Text(label), + leading: icon, + title: label, onTap: onTap, ), ); diff --git a/sample_app/lib/widgets/channel_list.dart b/sample_app/lib/widgets/channel_list.dart index 01602ae161..c32abfd2e5 100644 --- a/sample_app/lib/widgets/channel_list.dart +++ b/sample_app/lib/widgets/channel_list.dart @@ -379,7 +379,7 @@ class _ConfirmationDialog extends StatelessWidget { child: Text(context.translations.cancelLabel), ), StreamButton( - type: .ghost, + type: .solid, style: .destructive, size: .small, onPressed: () => Navigator.of(context).maybePop(true), diff --git a/sample_app/lib/widgets/edit_group_sheet.dart b/sample_app/lib/widgets/edit_group_sheet.dart index 2952d02e95..051bb8a192 100644 --- a/sample_app/lib/widgets/edit_group_sheet.dart +++ b/sample_app/lib/widgets/edit_group_sheet.dart @@ -124,42 +124,37 @@ class _EditGroupSheetState extends State { final viewInsets = MediaQuery.viewInsetsOf(context); return SafeArea( - top: false, child: Column( - // Shrink-wrap to content height — the sheet sits as high as it - // needs to be (header + avatar + input + keyboard inset) and no - // higher. With Stack(StackFit.loose) upstream the StreamSheet - // honours the min size and rests just above the keyboard. - mainAxisSize: MainAxisSize.min, children: [ StreamSheetHeader( title: const Text('Edit'), - // Default `.medium` size — matches the auto-implied close - // button on the leading side so the header stays balanced. trailing: StreamButton.icon( icon: Icon(context.streamIcons.checkmark), type: .solid, onPressed: _canSave ? _save : null, ), ), - Padding( - padding: EdgeInsets.all(spacing.md) + viewInsets, - child: Column( - children: [ - _AvatarPreview( - pickedPath: _pickedPath, - imageOverride: _imageOverride, - imageRemoved: _imageRemoved, - uploadProgress: _uploadProgress, - onTap: _openAvatarPicker, - ), - SizedBox(height: spacing.xxl), - StreamTextInput( - controller: _nameController, - autofocus: true, - hintText: 'Group name', - ), - ], + Expanded( + child: Padding( + padding: EdgeInsets.all(spacing.md) + viewInsets, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _AvatarPreview( + pickedPath: _pickedPath, + imageOverride: _imageOverride, + imageRemoved: _imageRemoved, + uploadProgress: _uploadProgress, + onTap: _openAvatarPicker, + ), + SizedBox(height: spacing.xxl), + StreamTextInput( + controller: _nameController, + autofocus: true, + hintText: 'Group name', + ), + ], + ), ), ), ], @@ -474,18 +469,18 @@ class _AvatarPickerSheet extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ _PickerTile( - icon: icons.camera, - label: 'Take Photo', + icon: Icon(icons.camera), + label: const Text('Take Photo'), onTap: () => emit(_AvatarPickerAction.takePhoto), ), _PickerTile( - icon: icons.image, - label: 'Choose Image', + icon: Icon(icons.image), + label: const Text('Choose Image'), onTap: () => emit(_AvatarPickerAction.chooseImage), ), _PickerTile( - icon: icons.delete, - label: 'Reset Picture', + icon: Icon(icons.delete), + label: const Text('Reset Picture'), destructive: true, onTap: () => emit(_AvatarPickerAction.resetPicture), ), @@ -512,8 +507,8 @@ class _PickerTile extends StatelessWidget { this.destructive = false, }); - final IconData icon; - final String label; + final Widget icon; + final Widget label; final VoidCallback onTap; final bool destructive; @@ -530,8 +525,8 @@ class _PickerTile extends StatelessWidget { contentPadding: .symmetric(horizontal: spacing.sm), ), child: StreamListTile( - leading: Icon(icon), - title: Text(label), + leading: icon, + title: label, onTap: onTap, ), );