diff --git a/docs/docs_screenshots/pubspec.yaml b/docs/docs_screenshots/pubspec.yaml index 8b383e39a4..66d18ded4d 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: 784a2a444f3a16875eba1c04b9b4c49062584c53 + ref: 3d3f22057a89679454e819b4ca8ff55fe800abd9 path: packages/stream_core_flutter dev_dependencies: diff --git a/docs/docs_screenshots/test/src/mocks.dart b/docs/docs_screenshots/test/src/mocks.dart index 6fc2e4d967..dccdd43971 100644 --- a/docs/docs_screenshots/test/src/mocks.dart +++ b/docs/docs_screenshots/test/src/mocks.dart @@ -136,7 +136,7 @@ void setupMockChannel({ ).thenAnswer((_) => Stream.value(DateTime.parse('2020-06-22 12:00:00'))); when(() => channel.state).thenReturn(channelState); when(() => channel.client).thenReturn(client); - when(() => channel.config).thenReturn(ChannelConfig(mutes: true)); + when(() => channel.config).thenReturn(ChannelConfig(mutes: true, replies: true)); when(channel.getRemainingCooldown).thenReturn(0); when(() => channel.getRemainingCooldown(lastMessageAt: any(named: 'lastMessageAt'))).thenReturn(0); when(() => channel.isDistinct).thenReturn(false); diff --git a/docs/docs_screenshots/test/theming/goldens/macos/theming_red.png b/docs/docs_screenshots/test/theming/goldens/macos/theming_red.png index 23a3952377..30d11ee982 100644 Binary files a/docs/docs_screenshots/test/theming/goldens/macos/theming_red.png and b/docs/docs_screenshots/test/theming/goldens/macos/theming_red.png differ diff --git a/melos.yaml b/melos.yaml index bfff78e1f3..cd8f42c760 100644 --- a/melos.yaml +++ b/melos.yaml @@ -107,7 +107,7 @@ command: stream_core_flutter: git: url: https://github.com/GetStream/stream-core-flutter.git - ref: 784a2a444f3a16875eba1c04b9b4c49062584c53 + ref: 3d3f22057a89679454e819b4ca8ff55fe800abd9 path: packages/stream_core_flutter stream_thumbnail: ^0.1.0 synchronized: ^3.4.0 diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 16ed165e77..bc49143f7d 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -1,7 +1,20 @@ ## Upcoming +⚠️ Changed + +- `StreamPhotoGallery` now defaults its `padding` to the bottom safe-area inset instead of no padding, so the last row clears the home indicator. Pass an explicit `padding` to opt out. + ✅ Added +- Added `StreamChannelPage` — a ready-to-use channel page widget that wires up `StreamChannelHeader`, `StreamMessageListView`, and `StreamMessageComposer` with floating or docked layout driven by the active app style. +- Added `StreamThreadPage` — a ready-to-use thread page widget with the same floating/docked layout support. +- Added `ComposerLocation` and `MessageComposerProps.location` — explicitly controls whether `StreamMessageComposer` renders `floating` or `docked`. When null it falls back to `StreamMessageComposerThemeData.location`, and then to the ambient `StreamAppStyle`. +- Added `StreamMessageComposerTheme` and `StreamMessageComposerThemeData` — a component theme for the composer, also available globally as `StreamChatThemeData.messageComposerTheme`. +- Added `StreamMessageListView.config` — accepts an explicit `StreamMessageListViewConfiguration` per widget; falls back to `StreamChatConfigurationData.messageListViewConfiguration` from the nearest ancestor when omitted. +- Added `StreamMessageListView.topPadding` and `bottomPadding` — padding applied to the scroll view's top and bottom edges, used by floating app bar / composer layouts to keep messages visible without an extra `setState`. +- Added `appBarBehavior` parameter to `StreamChannelHeader`, `StreamChannelListHeader`, and `StreamBackButton`, controlling floating vs pinned app bar appearance (avatar shadow, back-button style). Falls back to `StreamAppBarTheme`, and then to the ambient `StreamAppStyle`, when null. +- Added `messageListViewConfiguration` field to `StreamChatConfigurationData`, allowing a global `StreamMessageListViewConfiguration` default for all `StreamMessageListView` widgets. Pass it via `StreamChat.configData` to configure behaviors like `swipeToReply` and `highlightInitialMessage` app-wide without wiring them per-page. +- Re-exported `StreamScaffold`, `StreamScaffoldInsets`, `StreamBottomNavBar`, `StreamBottomNavBarItem`, `StreamAppStyle`, `StreamAppBarBehavior`, `StreamBottomAppBarBehavior`, and `streamFloatingFadeLinearGradient` from `stream_core_flutter` via `package:stream_chat_flutter/stream_chat_flutter.dart`. - Added `StreamMessageListViewConfiguration.autoScrollPolicy` to control whether and how `StreamMessageListView` scrolls to the newest message when a new message arrives. Use `StreamAutoScrollPolicy.disabled` to fully control scrolling yourself. - Added `onReactionSelected` to `StreamMessageReactionPicker`, a context-aware callback that provides the `BuildContext` for navigation. - Added an `errorSubtitle` to `StreamScrollViewErrorWidget`, which now falls back to the design's generic error copy (title, description, and a "Try Again" retry label) when values aren't provided. @@ -17,6 +30,7 @@ - Fixed the default `StreamChannel` loading and error states not being themed or localized; `StreamChat` now installs themed, connection-aware defaults, overridable per `StreamChannel` or via `DefaultStreamChannelBuilders`. - Fixed the default list/scroll-view error states (channel, message, member, user, thread, poll-vote, reaction, search, and photo) showing raw or fixed errors; they are now connection-aware (no internet / slow connection), falling back to each view's specific error text. - Fixed `StreamTypingIndicator` briefly showing typing users from a different context (main channel vs. thread) on its first frame. +- Fixed the "Message deleted" bubble overflowing its maximum width when the localized label is long; the label now wraps instead. ## 10.2.0 diff --git a/packages/stream_chat_flutter/lib/src/channel/channel_header.dart b/packages/stream_chat_flutter/lib/src/channel/channel_header.dart index d74684965a..e9ab17159d 100644 --- a/packages/stream_chat_flutter/lib/src/channel/channel_header.dart +++ b/packages/stream_chat_flutter/lib/src/channel/channel_header.dart @@ -148,8 +148,21 @@ class StreamChannelHeader extends StatelessWidget implements PreferredSizeWidget var subtitle = this.subtitle; subtitle ??= StreamChannelInfo(channel: channel); + final effectiveAppBarBehavior = + style?.behavior ?? + StreamAppBarTheme.of(context).style?.behavior ?? + (StreamTheme.of(context).appStyle.isFloating ? .floating : .regular); + final showAvatarShadow = switch (effectiveAppBarBehavior) { + .floating => true, + .regular => false, + }; + var trailing = this.trailing; - trailing ??= _DefaultChannelAvatar(channel: channel, onPressed: onChannelAvatarPressed); + trailing ??= _DefaultChannelAvatar( + channel: channel, + onPressed: onChannelAvatarPressed, + isFloating: showAvatarShadow, + ); return Portal( child: StreamConnectionStatusBuilder( @@ -198,10 +211,11 @@ class StreamChannelHeader extends StatelessWidget implements PreferredSizeWidget } class _DefaultChannelAvatar extends StatelessWidget { - const _DefaultChannelAvatar({required this.channel, this.onPressed}); + const _DefaultChannelAvatar({required this.channel, this.onPressed, this.isFloating = false}); final Channel channel; final void Function(Channel channel)? onPressed; + final bool isFloating; @override Widget build(BuildContext context) { @@ -222,6 +236,7 @@ class _DefaultChannelAvatar extends StatelessWidget { child: StreamChannelAvatar( size: .lg, channel: channel, + isFloating: isFloating, ), ), ), diff --git a/packages/stream_chat_flutter/lib/src/channel/channel_list_header.dart b/packages/stream_chat_flutter/lib/src/channel/channel_list_header.dart index e4ed899c7b..e4729011ae 100644 --- a/packages/stream_chat_flutter/lib/src/channel/channel_list_header.dart +++ b/packages/stream_chat_flutter/lib/src/channel/channel_list_header.dart @@ -125,7 +125,16 @@ class StreamChannelListHeader extends StatelessWidget implements PreferredSizeWi final _client = client ?? StreamChat.of(context).client; final headerTheme = StreamChatTheme.of(context).channelListHeaderTheme; - final leading = _DefaultUserAvatar(client: _client, onPressed: onUserAvatarPressed); + final effectiveAppBarBehavior = + style?.behavior ?? + StreamAppBarTheme.of(context).style?.behavior ?? + (StreamTheme.of(context).appStyle.isFloating ? .floating : .regular); + final hasAvatarShadow = switch (effectiveAppBarBehavior) { + .floating => true, + .regular => false, + }; + + final leading = _DefaultUserAvatar(client: _client, onPressed: onUserAvatarPressed, isFloating: hasAvatarShadow); return Portal( child: StreamConnectionStatusBuilder( @@ -179,10 +188,11 @@ class StreamChannelListHeader extends StatelessWidget implements PreferredSizeWi } class _DefaultUserAvatar extends StatelessWidget { - const _DefaultUserAvatar({required this.client, this.onPressed}); + const _DefaultUserAvatar({required this.client, this.onPressed, this.isFloating = false}); final StreamChatClient client; final void Function(User user)? onPressed; + final bool isFloating; @override Widget build(BuildContext context) { @@ -211,6 +221,7 @@ class _DefaultUserAvatar extends StatelessWidget { size: .lg, user: user, showOnlineIndicator: false, + isFloating: isFloating, ), ), ), diff --git a/packages/stream_chat_flutter/lib/src/channel/channel_page.dart b/packages/stream_chat_flutter/lib/src/channel/channel_page.dart new file mode 100644 index 0000000000..e0b3fcb52e --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/channel/channel_page.dart @@ -0,0 +1,157 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// A channel page with optional floating composer support. +class StreamChannelPage extends StatefulWidget { + /// Creates a [StreamChannelPage]. + const StreamChannelPage({ + super.key, + this.initialScrollIndex, + this.initialAlignment, + this.onBackPressed, + this.onChannelAvatarPressed, + }); + + /// Initial scroll index for the message list. + final int? initialScrollIndex; + + /// Initial scroll alignment for the message list. + final double? initialAlignment; + + /// Called when the header's back button is pressed. + /// + /// Replaces the default action, which pops the current route. When null the + /// default is kept. + final VoidCallback? onBackPressed; + + /// Called when the default channel-avatar trailing is pressed. + final void Function(BuildContext context, Channel channel)? onChannelAvatarPressed; + + @override + State createState() => _StreamChannelPageState(); +} + +class _StreamChannelPageState extends State { + late final FocusNode _focusNode; + late final StreamMessageComposerController _messageComposerController; + + @override + void initState() { + super.initState(); + _focusNode = FocusNode(); + _messageComposerController = StreamMessageComposerController(); + } + + @override + void dispose() { + _focusNode.dispose(); + _messageComposerController.dispose(); + super.dispose(); + } + + void _reply(Message message) { + _messageComposerController.quotedMessage = message; + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + _focusNode.requestFocus(); + }); + } + + void _editMessage(Message message) { + _messageComposerController.editMessage(message); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + _focusNode.requestFocus(); + }); + } + + @override + Widget build(BuildContext context) { + final appBar = StreamChannelHeader( + // Leaving this null keeps the header's own default back button, which + // pops the route. + leading: switch (widget.onBackPressed) { + final onBackPressed? => StreamBackButton(onPressed: onBackPressed, showUnreadCount: true), + _ => null, + }, + onChannelAvatarPressed: (channel) => widget.onChannelAvatarPressed?.call(context, channel), + ); + + final composer = StreamMessageComposer( + focusNode: _focusNode, + messageComposerController: _messageComposerController, + onQuotedMessageCleared: _messageComposerController.clearQuotedMessage, + enableVoiceRecording: true, + ); + + final typingIndicator = StreamTypingIndicator( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + style: context.streamTextTheme.captionDefault.copyWith( + color: context.streamColorScheme.textSecondary, + ), + ); + + return StreamScaffold( + backgroundColor: context.streamColorScheme.backgroundApp, + appBar: appBar, + bottom: composer, + body: _ChannelPageBody( + initialScrollIndex: widget.initialScrollIndex, + initialAlignment: widget.initialAlignment, + onReply: _reply, + onEditMessage: _editMessage, + typingIndicator: typingIndicator, + ), + ); + } +} + +/// The body of [StreamChannelPage]. +/// +/// Reads [StreamScaffoldInsets] to provide correct [topPadding] and +/// [bottomPadding] to [StreamMessageListView], and positions the typing +/// indicator just above the composer (floating or docked) using the same +/// inset values. +class _ChannelPageBody extends StatelessWidget { + const _ChannelPageBody({ + required this.typingIndicator, + required this.onReply, + required this.onEditMessage, + this.initialScrollIndex, + this.initialAlignment, + }); + + final Widget typingIndicator; + final void Function(Message) onReply; + final void Function(Message) onEditMessage; + final int? initialScrollIndex; + final double? initialAlignment; + + @override + Widget build(BuildContext context) { + final insets = StreamScaffoldInsets.of(context); + + return Stack( + children: [ + StreamMessageListView( + initialScrollIndex: initialScrollIndex, + initialAlignment: initialAlignment, + onEditMessageTap: onEditMessage, + onReplyTap: onReply, + threadBuilder: (_, parentMessage) { + return StreamThreadPage(parent: parentMessage!); + }, + topPadding: insets.topPadding, + bottomPadding: insets.bottomPadding, + ), + Positioned( + bottom: insets.bottomPadding, + left: 0, + right: 0, + child: typingIndicator, + ), + ], + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/channel/thread_page.dart b/packages/stream_chat_flutter/lib/src/channel/thread_page.dart new file mode 100644 index 0000000000..00050d4d42 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/channel/thread_page.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// A page that displays a thread of messages for a given parent message. +class StreamThreadPage extends StatefulWidget { + /// Creates a [StreamThreadPage]. + const StreamThreadPage({ + super.key, + required this.parent, + this.initialScrollIndex, + this.initialAlignment, + this.onViewInChannelTap, + }); + + /// The parent message of the thread. + final Message parent; + + /// Initial scroll index for the thread message list. + final int? initialScrollIndex; + + /// Initial scroll alignment for the thread message list. + final double? initialAlignment; + + /// Called when the user taps "View in channel". + final void Function(Message message)? onViewInChannelTap; + + @override + State createState() => _StreamThreadPageState(); +} + +class _StreamThreadPageState extends State { + final FocusNode _focusNode = FocusNode(); + late StreamMessageComposerController _messageComposerController; + + @override + void initState() { + super.initState(); + _messageComposerController = StreamMessageComposerController( + message: Message(parentId: widget.parent.id), + ); + } + + @override + void dispose() { + _focusNode.dispose(); + _messageComposerController.dispose(); + super.dispose(); + } + + void _reply(Message message) { + _messageComposerController.quotedMessage = message; + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + _focusNode.requestFocus(); + }); + } + + void _editMessage(Message message) { + _messageComposerController.editMessage(message); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + _focusNode.requestFocus(); + }); + } + + @override + Widget build(BuildContext context) { + final appBar = StreamThreadHeader(parent: widget.parent); + + final composer = widget.parent.type != 'deleted' + ? StreamMessageComposer( + focusNode: _focusNode, + messageComposerController: _messageComposerController, + enableVoiceRecording: true, + ) + : null; + + return StreamScaffold( + appBar: appBar, + bottom: composer, + body: _ThreadBody( + parent: widget.parent, + initialScrollIndex: widget.initialScrollIndex, + initialAlignment: widget.initialAlignment, + onReply: _reply, + onEditMessageTap: _editMessage, + onViewInChannelTap: widget.onViewInChannelTap, + ), + ); + } +} + +class _ThreadBody extends StatelessWidget { + const _ThreadBody({ + required this.parent, + required this.onReply, + required this.onEditMessageTap, + this.initialScrollIndex, + this.initialAlignment, + this.onViewInChannelTap, + }); + + final Message parent; + final void Function(Message) onReply; + final int? initialScrollIndex; + final double? initialAlignment; + final void Function(Message message)? onViewInChannelTap; + final void Function(Message message)? onEditMessageTap; + + @override + Widget build(BuildContext context) { + final insets = StreamScaffoldInsets.of(context); + + return StreamMessageListView( + parentMessage: parent, + initialScrollIndex: initialScrollIndex, + initialAlignment: initialAlignment, + onReplyTap: onReply, + onEditMessageTap: onEditMessageTap, + onViewInChannelTap: onViewInChannelTap, + topPadding: insets.topPadding, + bottomPadding: insets.bottomPadding, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/components/avatar/stream_channel_avatar.dart b/packages/stream_chat_flutter/lib/src/components/avatar/stream_channel_avatar.dart index 4a004c46fd..655aed8401 100644 --- a/packages/stream_chat_flutter/lib/src/components/avatar/stream_channel_avatar.dart +++ b/packages/stream_chat_flutter/lib/src/components/avatar/stream_channel_avatar.dart @@ -55,6 +55,7 @@ class StreamChannelAvatar extends StatelessWidget { super.key, this.size, required this.channel, + this.isFloating, this.semanticsLabel, }); @@ -66,6 +67,13 @@ class StreamChannelAvatar extends StatelessWidget { /// If null, defaults to [StreamAvatarGroupSize.lg]. final StreamAvatarGroupSize? size; + /// Whether to show a drop shadow around the avatar. + /// + /// Defaults to false. The shadow style is determined by + /// [StreamAvatarThemeData.boxShadow], falling back to + /// [StreamBoxShadow.elevation3]. + final bool? isFloating; + /// Screen-reader label for the avatar. /// /// When null (the default), a `"Group"` label is emitted for group @@ -88,6 +96,7 @@ class StreamChannelAvatar extends StatelessWidget { imageUrl: channelImage, semanticsLabel: effectiveLabel, size: _avatarSizeForAvatarGroupSize(effectiveSize), + isFloating: isFloating, placeholder: (_) => const _StreamChannelAvatarPlaceholder(), ), noDataBuilder: (context) => BetterStreamBuilder( @@ -108,6 +117,7 @@ class StreamChannelAvatar extends StatelessWidget { size: _avatarSizeForAvatarGroupSize(effectiveSize), // TODO: make this configurable when the online state is shown. showOnlineIndicator: otherUser.online, + isFloating: isFloating, ); } @@ -115,6 +125,7 @@ class StreamChannelAvatar extends StatelessWidget { size: effectiveSize, semanticsLabel: effectiveLabel, users: users.sortedBy((it) => it.id == currentUserId ? 1 : 0), + isFloating: isFloating, ); }, ), diff --git a/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar.dart b/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar.dart index dbb74b1345..1f164dc843 100644 --- a/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar.dart +++ b/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar.dart @@ -68,6 +68,7 @@ class StreamUserAvatar extends StatelessWidget { required this.user, this.showBorder = true, this.showOnlineIndicator = true, + this.isFloating, this.semanticsLabel, }); @@ -85,6 +86,13 @@ class StreamUserAvatar extends StatelessWidget { /// Defaults to true. final bool showOnlineIndicator; + /// Whether to show a drop shadow around the avatar. + /// + /// Defaults to false. The shadow style is determined by + /// [StreamAvatarThemeData.boxShadow], falling back to + /// [StreamBoxShadow.elevation3]. + final bool? isFloating; + /// The size of the avatar. /// /// If null, uses [StreamAvatarThemeData.size], or falls back to @@ -114,6 +122,7 @@ class StreamUserAvatar extends StatelessWidget { size: effectiveSize, imageUrl: user.image, showBorder: showBorder, + isFloating: isFloating, backgroundColor: effectiveBackgroundColor, foregroundColor: effectiveForegroundColor, semanticsLabel: semanticsLabel, diff --git a/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar_group.dart b/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar_group.dart index 9f0890e00a..dd2b3c92cd 100644 --- a/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar_group.dart +++ b/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar_group.dart @@ -55,6 +55,7 @@ class StreamUserAvatarGroup extends StatelessWidget { super.key, required this.users, this.size, + this.isFloating, this.semanticsLabel, }); @@ -66,6 +67,13 @@ class StreamUserAvatarGroup extends StatelessWidget { /// If null, defaults to [StreamAvatarGroupSize.lg]. final StreamAvatarGroupSize? size; + /// Whether to show a drop shadow around the avatar group. + /// + /// Defaults to false. The shadow style is determined by + /// [StreamAvatarThemeData.boxShadow], falling back to + /// [StreamBoxShadow.elevation3]. + final bool? isFloating; + /// Screen-reader label for the avatar group. /// /// When null (the default), each child avatar carries its own @@ -82,6 +90,7 @@ class StreamUserAvatarGroup extends StatelessWidget { (user) => StreamUserAvatar( user: user, showOnlineIndicator: false, + isFloating: isFloating, ), ), ); diff --git a/packages/stream_chat_flutter/lib/src/message_action/message_actions_builder.dart b/packages/stream_chat_flutter/lib/src/message_action/message_actions_builder.dart index ddbf28adf5..d8e82115d4 100644 --- a/packages/stream_chat_flutter/lib/src/message_action/message_actions_builder.dart +++ b/packages/stream_chat_flutter/lib/src/message_action/message_actions_builder.dart @@ -115,6 +115,7 @@ class StreamMessageActionsBuilder { final isParentMessage = (message.replyCount ?? 0) > 0; final canShowInChannel = message.showInChannel ?? true; final isPrivateMessage = message.hasRestrictedVisibility; + final repliesEnabled = channel.config?.replies ?? true; final canSendReply = channel.canSendReply; final canPinMessage = channel.canPinMessage; final canQuoteMessage = channel.canQuoteMessage; @@ -143,7 +144,7 @@ class StreamMessageActionsBuilder { // Thread reply action is only available for parent messages that are not in a // thread view, as replying in a thread that is already being viewed doesn't make sense. // Additionally, the channel needs to support sending replies. - if (canSendReply && !isThreadMessage && !isInThreadView) { + if (canSendReply && repliesEnabled && !isThreadMessage && !isInThreadView) { messageActions.add( StreamContextMenuAction( value: ThreadReply(message: message), diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_gallery_picker.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_gallery_picker.dart index 9526853147..a8c953de0f 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_gallery_picker.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_gallery_picker.dart @@ -6,7 +6,6 @@ import 'package:path_provider/path_provider.dart'; import 'package:photo_manager/photo_manager.dart'; import 'package:stream_chat_flutter/src/message_input/attachment_picker/stream_attachment_picker.dart'; import 'package:stream_chat_flutter/src/message_input/attachment_picker/stream_attachment_picker_controller.dart'; -import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; import 'package:stream_chat_flutter/src/scroll_view/photo_gallery/stream_photo_gallery.dart'; import 'package:stream_chat_flutter/src/scroll_view/photo_gallery/stream_photo_gallery_controller.dart'; import 'package:stream_chat_flutter/src/utils/utils.dart'; @@ -75,7 +74,7 @@ class _StreamGalleryPickerState extends State { return FutureBuilder( future: requestPermission, builder: (context, snapshot) { - if (!snapshot.hasData) return const Empty(); + if (!snapshot.hasData) return const SizedBox.expand(); final spacing = context.streamSpacing; final textTheme = context.streamTextTheme; diff --git a/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart b/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart index 2b58cd8ba2..23110fb800 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart @@ -3,7 +3,9 @@ import 'dart:math' as math; import 'package:desktop_drop/desktop_drop.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/semantics.dart'; +// Needed for RenderProxyBox; it also re-exports the semantics library that +// Assertiveness comes from, which is why flutter/semantics.dart isn't imported. +import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:stream_chat_flutter/src/message_input/audio_recorder/audio_recorder_announcer.dart'; import 'package:stream_chat_flutter/src/message_input/composer_attachment_announcer.dart'; @@ -99,6 +101,7 @@ class StreamMessageComposer extends StatelessWidget { TextCapitalization textCapitalization = TextCapitalization.sentences, bool autofocus = false, bool autoCorrect = true, + ComposerLocation? location, }) : props = .new( onMessageSent: onMessageSent, preMessageSending: preMessageSending, @@ -136,6 +139,7 @@ class StreamMessageComposer extends StatelessWidget { textCapitalization: textCapitalization, autofocus: autofocus, autoCorrect: autoCorrect, + location: location, ); /// Creates a [StreamMessageComposer] from a pre-built [MessageComposerProps]. @@ -197,6 +201,7 @@ class MessageComposerProps { this.textCapitalization = TextCapitalization.sentences, this.autofocus = false, this.autoCorrect = true, + this.location, }); /// Function called after sending the message. @@ -406,6 +411,9 @@ class MessageComposerProps { /// Defaults to true. final bool autoCorrect; + /// The location of the message composer. + final ComposerLocation? location; + /// Returns a copy of this [MessageComposerProps] with the given fields /// replaced with new values. MessageComposerProps copyWith({ @@ -444,6 +452,7 @@ class MessageComposerProps { TextCapitalization? textCapitalization, bool? autofocus, bool? autoCorrect, + ComposerLocation? location, }) { return MessageComposerProps( onMessageSent: onMessageSent ?? this.onMessageSent, @@ -481,6 +490,7 @@ class MessageComposerProps { textCapitalization: textCapitalization ?? this.textCapitalization, autofocus: autofocus ?? this.autofocus, autoCorrect: autoCorrect ?? this.autoCorrect, + location: location ?? this.location, ); } @@ -818,27 +828,61 @@ class DefaultStreamMessageComposerState extends State composerBody, + .docked => DecoratedBox( + decoration: BoxDecoration(color: context.streamColorScheme.backgroundElevation1), + child: composerBody, + ), + }, ); return ComposerAttachmentAnnouncer( @@ -936,66 +980,94 @@ class DefaultStreamMessageComposerState extends State PopScope( - canPop: !_isPickerVisible, - onPopInvokedWithResult: (didPop, _) { - if (!didPop) _hidePicker(); - }, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - DropTarget( - onDragDone: (details) async { - final attachments = []; - for (final file in details.files) { - attachments.add(await file.toAttachment(type: AttachmentType.file)); - } - if (attachments.isNotEmpty) _addAttachments(attachments); + builder: (context, value, _) { + // Extracted so the floating gradient can wrap just the pill, keeping + // gradient height stable when the picker (a sibling) opens. + final pill = DropTarget( + onDragDone: (details) async { + final attachments = []; + for (final file in details.files) { + attachments.add(await file.toAttachment(type: AttachmentType.file)); + } + if (attachments.isNotEmpty) _addAttachments(attachments); + }, + onDragEntered: (_) {}, + onDragExited: (_) {}, + child: Focus( + skipTraversal: true, + onKeyEvent: _handleKeyPressed, + child: StreamChatMessageInput( + controller: controller, + currentUserId: currentUserId, + onAttachmentButtonPressed: widget.props.disableAttachments ? null : _onAttachmentButtonPressed, + isPickerOpen: _isPickerVisible, + placeholder: _buildPlaceholder(context), + focusNode: focusNode, + onSendPressed: sendMessage, + canAlsoSendToChannel: _shouldShowSendToChannelCheckbox(), + audioRecorderController: widget.props.enableVoiceRecording ? _audioRecorderController : null, + sendVoiceRecordingAutomatically: widget.props.sendVoiceRecordingAutomatically, + feedback: widget.props.voiceRecordingFeedback, + onQuotedMessageCleared: () { + _effectiveController.clearQuotedMessage(); + widget.props.onQuotedMessageCleared?.call(); }, - onDragEntered: (_) {}, - onDragExited: (_) {}, - child: Focus( - skipTraversal: true, - onKeyEvent: _handleKeyPressed, - child: StreamChatMessageInput( - controller: controller, - currentUserId: currentUserId, - onAttachmentButtonPressed: widget.props.disableAttachments ? null : _onAttachmentButtonPressed, - isPickerOpen: _isPickerVisible, - placeholder: _buildPlaceholder(context), - focusNode: focusNode, - onSendPressed: sendMessage, - canAlsoSendToChannel: _shouldShowSendToChannelCheckbox(), - audioRecorderController: widget.props.enableVoiceRecording ? _audioRecorderController : null, - sendVoiceRecordingAutomatically: widget.props.sendVoiceRecordingAutomatically, - feedback: widget.props.voiceRecordingFeedback, - onQuotedMessageCleared: () { - _effectiveController.clearQuotedMessage(); - widget.props.onQuotedMessageCleared?.call(); - }, - textInputAction: widget.props.textInputAction, - keyboardType: widget.props.keyboardType, - textCapitalization: widget.props.textCapitalization, - autofocus: widget.props.autofocus, - autocorrect: widget.props.autoCorrect, - ), - ), + textInputAction: widget.props.textInputAction, + keyboardType: widget.props.keyboardType, + textCapitalization: widget.props.textCapitalization, + autofocus: widget.props.autofocus, + autocorrect: widget.props.autoCorrect, + isFloating: isFloating, ), - SizeTransition( - sizeFactor: _pickerAnimation, - // ignore: deprecated_member_use - axisAlignment: -1, - child: _buildInlineAttachmentPicker(context), - ), - ], - ), - ), + ), + ); + + return PopScope( + canPop: !_isPickerVisible, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) _hidePicker(); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + // Reversed paint order so the pill (and its shadow) paints on top + // of the picker panel. VerticalDirection.up keeps the pill visually + // above the picker while making it the last-painted child. + verticalDirection: VerticalDirection.up, + children: [ + SizeTransition( + sizeFactor: _pickerAnimation, + // ignore: deprecated_member_use, alternative is only available since Flutter 3.44 + axisAlignment: -1, + child: _buildInlineAttachmentPicker(context), + ), + // Both the pill and the picker sit on one backdrop painted behind + // the whole composer. Reporting the pill's height keeps that + // backdrop's fade confined to the pill, so the fade grows with the + // pill but never stretches when the picker opens or closes. + if (isFloating) + _ReportHeight(onHeightChanged: (height) => _pillHeight.value = height, child: pill) + else + pill, + ], + ), + ); + }, ); } + // Height of the floating pill, reported from layout and read back at paint + // time by _FloatingComposerBackdropPainter. Layout always runs before paint, + // so the backdrop's fade is never a frame behind the pill. + final _pillHeight = ValueNotifier(0); + Widget _buildInlineAttachmentPicker(BuildContext context) { if (!_isPickerVisible) return const SizedBox.shrink(); @@ -1007,29 +1079,31 @@ class DefaultStreamMessageComposerState extends State fadeExtent; + + @override + void paint(Canvas canvas, Size size) { + if (size.isEmpty) return; + + // The gradient runs bottom-to-top, so the solid run is measured from the + // bottom and the fade occupies the top `extent` pixels. + final extent = math.min(fadeExtent.value, size.height); + final solidFraction = (1 - extent / size.height).clamp(0.0, 1.0); + + final rect = Offset.zero & size; + final gradient = streamFloatingFadeLinearGradient( + color: color, + solidFraction: solidFraction, + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + ); + + canvas.drawRect(rect, Paint()..shader = gradient.createShader(rect)); + } + + @override + bool shouldRepaint(_FloatingComposerBackdropPainter oldDelegate) { + return oldDelegate.color != color || oldDelegate.fadeExtent != fadeExtent; + } +} + +/// Reports [child]'s laid-out height via [onHeightChanged] during layout. +/// +/// Used to feed the pill's height to [_FloatingComposerBackdropPainter]. The +/// callback fires inside `performLayout`, which is safe for the listener to turn +/// into a repaint: the framework flushes all layout before any painting, so the +/// backdrop sees the current height in the same frame. +class _ReportHeight extends SingleChildRenderObjectWidget { + const _ReportHeight({required this.onHeightChanged, required super.child}); + + final ValueChanged onHeightChanged; + + @override + _RenderReportHeight createRenderObject(BuildContext context) { + return _RenderReportHeight(onHeightChanged: onHeightChanged); + } + + @override + void updateRenderObject(BuildContext context, _RenderReportHeight renderObject) { + renderObject.onHeightChanged = onHeightChanged; + } +} + +class _RenderReportHeight extends RenderProxyBox { + _RenderReportHeight({required this.onHeightChanged}); + + ValueChanged onHeightChanged; + + @override + void performLayout() { + super.performLayout(); + onHeightChanged(size.height); + } +} 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 b7b0bdc78c..04d12d6d59 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 @@ -124,8 +124,10 @@ class StreamMessageListView extends StatefulWidget { this.onEphemeralMessageTap, this.onModeratedMessageTap, this.onMessageLongPress, - this.config = const StreamMessageListViewConfiguration(), + this.config, this.builders = const StreamMessageListViewBuilders(), + this.topPadding = 0, + this.bottomPadding = 0, }); /// Predicate used to filter messages. @@ -264,8 +266,10 @@ class StreamMessageListView extends StatefulWidget { /// [StreamMessageListViewConfiguration.markReadWhenAtTheBottom], scroll /// physics, and other non-builder, non-theme settings. /// - /// Defaults to [StreamMessageListViewConfiguration] with all defaults. - final StreamMessageListViewConfiguration config; + /// When null, falls back to + /// [StreamChatConfigurationData.messageListViewConfiguration] from the + /// nearest [StreamChatConfiguration] ancestor. + final StreamMessageListViewConfiguration? config; /// Custom slot builders for this message list view. /// @@ -275,6 +279,18 @@ class StreamMessageListView extends StatefulWidget { /// Defaults to [StreamMessageListViewBuilders] with no overrides. final StreamMessageListViewBuilders builders; + /// Top padding added to the message list scroll view. + /// + /// Used by the floating app bar layout to keep the first message visible + /// below the app bar without requiring a separate [setState] call. + final double topPadding; + + /// Bottom padding added to the message list scroll view. + /// + /// Used by the floating composer layout to keep the last message visible + /// above the composer without requiring a separate [setState] call. + final double bottomPadding; + @override _StreamMessageListViewState createState() => _StreamMessageListViewState(); } @@ -327,6 +343,14 @@ class _StreamMessageListViewState extends State { MessageListController get _messageListController => widget.messageListController ?? _defaultController; + /// Returns the effective [StreamMessageListViewConfiguration] for this list. + /// + /// Uses [widget.config] when explicitly provided, otherwise falls back to + /// [StreamChatConfigurationData.messageListViewConfiguration] from the + /// nearest [StreamChatConfiguration] ancestor. + StreamMessageListViewConfiguration get _config => + widget.config ?? StreamChatConfiguration.of(context).messageListViewConfiguration; + StreamSubscription? _messageNewListener; StreamSubscription? _userReadListener; @@ -354,7 +378,7 @@ class _StreamMessageListViewState extends State { _unreadState.value = _readUnreadSnapshot(); - final highlightInitialMessage = widget.config.highlightInitialMessage; + final highlightInitialMessage = _config.highlightInitialMessage; final highlightMessageId = switch ((highlightInitialMessage, _isThreadConversation)) { (true, true) => _ThreadHighlightScope.of(context), (true, false) => streamChannel?.initialMessageId, @@ -389,7 +413,7 @@ class _StreamMessageListViewState extends State { isAtBottom: isAtBottom, ); - final behavior = widget.config.autoScrollPolicy.resolve(details); + final behavior = _config.autoScrollPolicy.resolve(details); // Synchronous (not post-frame) so the scroll clears SPL's anchor key // before the rebuild's `didUpdateWidget`; otherwise anchor @@ -563,9 +587,9 @@ class _StreamMessageListViewState extends State { child: Portal( child: ScaffoldMessenger( child: MessageListCore( - paginationLimit: widget.config.paginationLimit, - maximumMessageLimit: widget.config.maximumMessageLimit, - retentionTrimBuffer: widget.config.retentionTrimBuffer, + paginationLimit: _config.paginationLimit, + maximumMessageLimit: _config.maximumMessageLimit, + retentionTrimBuffer: _config.retentionTrimBuffer, messageFilter: widget.messageFilter, loadingBuilder: defaultLoadingBuilder, emptyBuilder: defaultEmptyBuilder, @@ -609,7 +633,7 @@ class _StreamMessageListViewState extends State { } return StreamInfoTile( - showMessage: widget.config.showConnectionStateTile && showStatus, + showMessage: _config.showConnectionStateTile && showStatus, tileAnchor: Alignment.topCenter, childAnchor: Alignment.topCenter, message: statusString, @@ -623,15 +647,18 @@ class _StreamMessageListViewState extends State { }, child: ScrollablePositionedList.separated( key: Key('mlv-${streamChannel?.channel.cid}-${widget.parentMessage?.id}'), - padding: .symmetric(vertical: context.streamSpacing.sm), - keyboardDismissBehavior: widget.config.keyboardDismissBehavior, + padding: .only( + top: max(widget.topPadding, context.streamSpacing.sm), + bottom: max(widget.bottomPadding, context.streamSpacing.sm), + ), + keyboardDismissBehavior: _config.keyboardDismissBehavior, itemPositionsListener: _itemPositionListener, initialScrollIndex: initialIndex, initialAlignment: initialAlignment, - physics: widget.config.scrollPhysics, + physics: _config.scrollPhysics, itemScrollController: _scrollController, - reverse: widget.config.reverse, - shrinkWrap: widget.config.shrinkWrap, + reverse: _config.reverse, + shrinkWrap: _config.shrinkWrap, itemCount: itemCount, itemKeyBuilder: (index) { // Layout (see comment block below): indices 0/1 and the @@ -675,7 +702,7 @@ class _StreamMessageListViewState extends State { return ThreadSeparator(parentMessage: widget.parentMessage!); } if (i == itemCount - 3) { - if (widget.config.reverse ? widget.builders.header == null : widget.builders.footer == null) { + if (_config.reverse ? widget.builders.header == null : widget.builders.footer == null) { if (messages.isNotEmpty) { final message = messages.last; return _maybeBuildWithUnreadMessagesSeparator( @@ -689,7 +716,7 @@ class _StreamMessageListViewState extends State { return const SizedBox(height: 8); } if (i == 0) { - if (widget.config.reverse ? widget.builders.footer == null : widget.builders.header == null) { + if (_config.reverse ? widget.builders.footer == null : widget.builders.header == null) { return const Empty(); } return const SizedBox(height: 8); @@ -698,7 +725,7 @@ class _StreamMessageListViewState extends State { if (i == 1 || i == itemCount - 4) return const Empty(); late final Message message, nextMessage; - if (widget.config.reverse) { + if (_config.reverse) { message = messages[i - 1]; nextMessage = messages[i - 2]; } else { @@ -735,7 +762,7 @@ class _StreamMessageListViewState extends State { } if (i == itemCount - 2) { - if (widget.config.reverse) { + if (_config.reverse) { return widget.builders.header?.call(context) ?? const Empty(); } else { return widget.builders.footer?.call(context) ?? const Empty(); @@ -757,7 +784,7 @@ class _StreamMessageListViewState extends State { } if (i == 0) { - if (widget.config.reverse) { + if (_config.reverse) { return widget.builders.footer?.call(context) ?? const Empty(); } else { return widget.builders.header?.call(context) ?? const Empty(); @@ -776,13 +803,13 @@ class _StreamMessageListViewState extends State { ); }, ), - if (widget.config.showFloatingDateDivider) + if (_config.showFloatingDateDivider) Positioned( - top: context.streamSpacing.sm, + top: max(widget.topPadding, context.streamSpacing.sm), child: FloatingDateDivider( itemCount: itemCount, - reverse: widget.config.reverse, - fadeNearInlineDivider: widget.config.fadeFloatingDateDividerNearInline, + reverse: _config.reverse, + fadeNearInlineDivider: _config.fadeFloatingDateDividerNearInline, itemPositionListener: _itemPositionListener.itemPositions, messages: messages, dateDividerBuilder: switch (widget.builders.floatingDateDivider) { @@ -791,22 +818,32 @@ class _StreamMessageListViewState extends State { }, ), ), - if (widget.config.showScrollToBottom) - BetterStreamBuilder( - stream: streamChannel!.channel.state!.isUpToDateStream, - initialData: streamChannel!.channel.state!.isUpToDate, - builder: (context, snapshot) => ValueListenableBuilder( + if (_config.showScrollToBottom) + if (_isThreadConversation) + ValueListenableBuilder( valueListenable: _showScrollToBottom, child: _buildScrollToBottom(), builder: (context, value, child) { - if (!snapshot || value) return child!; + if (value) return child!; return const Empty(); }, + ) + else + BetterStreamBuilder( + stream: streamChannel!.channel.state!.isUpToDateStream, + initialData: streamChannel!.channel.state!.isUpToDate, + builder: (context, snapshot) => ValueListenableBuilder( + valueListenable: _showScrollToBottom, + child: _buildScrollToBottom(), + builder: (context, value, child) { + if (!snapshot || value) return child!; + return const Empty(); + }, + ), ), - ), - if (widget.config.showUnreadIndicator && !_isThreadConversation) + if (_config.showUnreadIndicator && !_isThreadConversation) Positioned( - top: context.streamSpacing.sm, + top: max(widget.topPadding, context.streamSpacing.sm), child: UnreadIndicatorButton( onJumpTap: scrollToUnreadDefaultTapAction, onDismissTap: _markMessagesAsRead, @@ -1013,7 +1050,7 @@ class _StreamMessageListViewState extends State { Widget buildParentMessage(Message message) { final parentMessageProps = StreamMessageItemProps( message: message, - swipeToReply: widget.config.swipeToReply, + swipeToReply: _config.swipeToReply, onThreadTap: _onThreadTap, onMessageTap: widget.onMessageTap, onMessageLongPress: widget.onMessageLongPress, @@ -1067,14 +1104,14 @@ class _StreamMessageListViewState extends State { type: .outline, size: .medium, isFloating: true, - icon: switch (widget.config.reverse) { + icon: switch (_config.reverse) { true => Icon(context.streamIcons.arrowDown), false => Icon(context.streamIcons.arrowUp), }, onPressed: () => scrollToBottomDefaultTapAction(unreadCount), ); - if (showUnreadCount && widget.config.showUnreadCountOnScrollToBottom) { + if (showUnreadCount && _config.showUnreadCountOnScrollToBottom) { button = StreamBadgeNotification( label: '${unreadCount > 99 ? '99+' : unreadCount}', child: button, @@ -1082,7 +1119,7 @@ class _StreamMessageListViewState extends State { } return PositionedDirectional( - bottom: 16, + bottom: max(16, widget.bottomPadding), end: 16, child: button, ); @@ -1135,7 +1172,7 @@ class _StreamMessageListViewState extends State { final messageItemProps = StreamMessageItemProps( message: message, - swipeToReply: widget.config.swipeToReply, + swipeToReply: _config.swipeToReply, onThreadTap: _onThreadTap, onViewInChannelTap: _isThreadConversation ? widget.onViewInChannelTap ?? (message) => Navigator.of(context).pop(message.id) @@ -1221,7 +1258,7 @@ class _StreamMessageListViewState extends State { _lastFullyVisibleMessage = newLastFullyVisibleMessage; // Mark messages as read if needed. - if (widget.config.markReadWhenAtTheBottom) { + if (_config.markReadWhenAtTheBottom) { _maybeMarkMessagesAsRead().ignore(); } } diff --git a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_deleted.dart b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_deleted.dart index 3e6a7bdeb0..ae0a3316f3 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_deleted.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_deleted.dart @@ -29,7 +29,11 @@ class StreamMessageDeleted extends StatelessWidget { mainAxisSize: .min, children: [ Icon(icons.noSign, size: 16), - core.StreamMessageText(padding: .zero, context.translations.messageDeletedLabel), + // Flexible so a long translation wraps inside the bubble instead of + // overflowing it — the bubble has a fixed maximum width. + Flexible( + child: core.StreamMessageText(padding: .zero, context.translations.messageDeletedLabel), + ), ], ), ); 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 0ffb015666..5c39b3848b 100644 --- a/packages/stream_chat_flutter/lib/src/misc/back_button.dart +++ b/packages/stream_chat_flutter/lib/src/misc/back_button.dart @@ -11,6 +11,7 @@ class StreamBackButton extends StatelessWidget { this.onPressed, this.showUnreadCount = false, this.channelId, + this.appBarBehavior, }); /// Callback for when button is pressed @@ -22,6 +23,12 @@ class StreamBackButton extends StatelessWidget { /// Channel ID used to retrieve unread count final String? channelId; + /// Controls the back button's visual/layout behavior (floating vs regular). + /// + /// When null, falls back to [StreamAppBarStyle.appBarBehavior] from the + /// ambient [StreamAppBarTheme], then to the ambient [StreamAppStyle]. + final StreamAppBarBehavior? appBarBehavior; + @override Widget build(BuildContext context) { final localizations = MaterialLocalizations.of(context); @@ -31,9 +38,18 @@ class StreamBackButton extends StatelessWidget { .iOS || .macOS => context.streamIcons.chevronLeft, _ => context.streamIcons.arrowLeft, }; + final effectiveAppBarBehavior = + appBarBehavior ?? + StreamAppBarTheme.of(context).style?.behavior ?? + (StreamTheme.of(context).appStyle.isFloating ? .floating : .regular); + final isFloating = switch (effectiveAppBarBehavior) { + .floating => true, + .regular => false, + }; Widget button = StreamButton.icon( - type: .ghost, + type: isFloating ? .outline : .ghost, + isFloating: isFloating, size: .medium, style: .secondary, tooltip: backTooltip, diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_skeleton_loading.dart b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_skeleton_loading.dart index 9fe1634204..61878a4567 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_skeleton_loading.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_skeleton_loading.dart @@ -19,6 +19,7 @@ class StreamChannelListSkeletonLoading extends StatelessWidget { Widget build(BuildContext context) { return StreamSkeletonLoading( child: ListView.separated( + padding: EdgeInsets.zero, physics: const NeverScrollableScrollPhysics(), itemCount: itemCount, separatorBuilder: (context, index) => const SizedBox(height: 1), diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/photo_gallery/stream_photo_gallery.dart b/packages/stream_chat_flutter/lib/src/scroll_view/photo_gallery/stream_photo_gallery.dart index f19cdaa817..ff3e6cd7b4 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/photo_gallery/stream_photo_gallery.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/photo_gallery/stream_photo_gallery.dart @@ -322,7 +322,7 @@ class StreamPhotoGallery extends StatelessWidget { primary: primary, physics: physics, shrinkWrap: shrinkWrap, - padding: padding, + padding: padding ?? EdgeInsets.only(bottom: MediaQuery.paddingOf(context).bottom), scrollController: scrollController, addAutomaticKeepAlives: addAutomaticKeepAlives, addRepaintBoundaries: addRepaintBoundaries, diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_skeleton_loading.dart b/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_skeleton_loading.dart index d470c7eb46..2ab714d71f 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_skeleton_loading.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_skeleton_loading.dart @@ -19,6 +19,7 @@ class StreamThreadListSkeletonLoading extends StatelessWidget { Widget build(BuildContext context) { return StreamSkeletonLoading( child: ListView.separated( + padding: EdgeInsets.zero, physics: const NeverScrollableScrollPhysics(), itemCount: itemCount, separatorBuilder: (context, index) => const SizedBox(height: 1), 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 f8958c6ffa..62c46a3ce1 100644 --- a/packages/stream_chat_flutter/lib/src/stream_chat_configuration.dart +++ b/packages/stream_chat_flutter/lib/src/stream_chat_configuration.dart @@ -165,6 +165,7 @@ class StreamChatConfigurationData { List? attachmentBuilders, StreamReactionsType? reactionType, StreamReactionsPosition? reactionPosition, + StreamMessageListViewConfiguration messageListViewConfiguration = const StreamMessageListViewConfiguration(), }) { return StreamChatConfigurationData._( reactionIconResolver: reactionIconResolver ?? const DefaultReactionIconResolver(), @@ -175,6 +176,7 @@ class StreamChatConfigurationData { attachmentBuilders: attachmentBuilders, reactionType: reactionType, reactionPosition: reactionPosition, + messageListViewConfiguration: messageListViewConfiguration, ); } @@ -185,6 +187,7 @@ class StreamChatConfigurationData { required this.messagePreviewFormatter, required this.imageCDN, required this.attachmentBuilders, + required this.messageListViewConfiguration, this.reactionType, this.reactionPosition, }); @@ -200,6 +203,7 @@ class StreamChatConfigurationData { List? attachmentBuilders, StreamReactionsType? reactionType, StreamReactionsPosition? reactionPosition, + StreamMessageListViewConfiguration? messageListViewConfiguration, }) { return StreamChatConfigurationData( reactionIconResolver: reactionIconResolver ?? this.reactionIconResolver, @@ -210,6 +214,7 @@ class StreamChatConfigurationData { attachmentBuilders: attachmentBuilders ?? this.attachmentBuilders, reactionType: reactionType ?? this.reactionType, reactionPosition: reactionPosition ?? this.reactionPosition, + messageListViewConfiguration: messageListViewConfiguration ?? this.messageListViewConfiguration, ); } @@ -258,4 +263,10 @@ class StreamChatConfigurationData { /// When null, the widget resolves its own default /// ([StreamReactionsPosition.header]). final StreamReactionsPosition? reactionPosition; + + /// The default [StreamMessageListViewConfiguration] applied to every + /// [StreamMessageListView] that does not provide its own explicit [config]. + /// + /// Defaults to [StreamMessageListViewConfiguration] with all defaults. + final StreamMessageListViewConfiguration messageListViewConfiguration; } diff --git a/packages/stream_chat_flutter/lib/src/theme/message_composer_theme.dart b/packages/stream_chat_flutter/lib/src/theme/message_composer_theme.dart new file mode 100644 index 0000000000..3cc8c79412 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/theme/message_composer_theme.dart @@ -0,0 +1,130 @@ +import 'package:flutter/widgets.dart'; +import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +part 'message_composer_theme.g.theme.dart'; + +/// The placement of the message composer — floating above the keyboard or +/// docked at the bottom edge of the screen. +/// +/// When null on [StreamMessageComposerThemeData], the ambient [StreamAppStyle] +/// is used as a fallback — [StreamAppStyle.floating] maps to [floating] and +/// [StreamAppStyle.regular] maps to [docked]. +/// +/// See also: +/// +/// * [StreamMessageComposerThemeData.location], which carries this +/// value. +/// * [StreamAppStyle], the global app-wide style that acts as fallback. +enum ComposerLocation { + /// The composer floats above the on-screen keyboard with appropriate safe + /// area padding. + floating, + + /// The composer is docked at the bottom edge of the screen. + docked, +} + +/// Applies a message composer theme to descendant composer widgets. +/// +/// Wrap a subtree with [StreamMessageComposerTheme] to override the composer +/// location. Access the merged theme using [StreamMessageComposerTheme.of]. +/// +/// {@tool snippet} +/// +/// Override composer placement for a specific screen: +/// +/// ```dart +/// StreamMessageComposerTheme( +/// data: StreamMessageComposerThemeData( +/// location: ComposerLocation.floating, +/// ), +/// child: StreamChannel( +/// channel: channel, +/// child: ChannelPage(), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageComposerThemeData], which describes the theme data. +/// * [StreamMessageComposerThemeData.location], the setting it holds. +class StreamMessageComposerTheme extends InheritedTheme { + /// Creates a message composer theme that controls descendant composers. + const StreamMessageComposerTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The message composer theme data for descendant widgets. + final StreamMessageComposerThemeData data; + + /// Returns the [StreamMessageComposerThemeData] merged from local and global + /// themes. + /// + /// Local values from the nearest [StreamMessageComposerTheme] ancestor take + /// precedence over global values from [StreamChatTheme.of]. + /// + /// This allows partial overrides — for example, overriding only + /// [StreamMessageComposerThemeData.location] in a subtree while + /// inheriting other properties from the global theme. + static StreamMessageComposerThemeData of(BuildContext context) { + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamChatTheme.of(context).messageComposerTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) => StreamMessageComposerTheme(data: data, child: child); + + @override + bool updateShouldNotify(StreamMessageComposerTheme oldWidget) => data != oldWidget.data; +} + +/// Theme data for customizing the message composer placement. +/// +/// All fields are nullable. When a field is null, the consuming widget falls +/// back to the ambient [StreamAppStyle]. +/// +/// {@tool snippet} +/// +/// Override composer placement globally: +/// +/// ```dart +/// StreamChatThemeData( +/// messageComposerTheme: StreamMessageComposerThemeData( +/// location: ComposerLocation.floating, +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [ComposerLocation], the enum that describes the placement options. +/// * [StreamMessageComposerTheme], for overriding the theme in a subtree. +@themeGen +@immutable +class StreamMessageComposerThemeData with _$StreamMessageComposerThemeData { + /// Creates message composer theme data with optional overrides. + const StreamMessageComposerThemeData({this.location}); + + /// The placement of the message composer. + /// + /// When null the value falls back to the ambient [StreamAppStyle]: + /// [StreamAppStyle.floating] → [ComposerLocation.floating], + /// [StreamAppStyle.regular] → [ComposerLocation.docked]. + /// + /// Set this to override the global style for the composer only, without + /// affecting other components. + final ComposerLocation? location; + + /// Linearly interpolate between two [StreamMessageComposerThemeData] objects. + static StreamMessageComposerThemeData? lerp( + StreamMessageComposerThemeData? a, + StreamMessageComposerThemeData? b, + double t, + ) => _$StreamMessageComposerThemeData.lerp(a, b, t); +} diff --git a/packages/stream_chat_flutter/lib/src/theme/message_composer_theme.g.theme.dart b/packages/stream_chat_flutter/lib/src/theme/message_composer_theme.g.theme.dart new file mode 100644 index 0000000000..ddd6ea3c2e --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/theme/message_composer_theme.g.theme.dart @@ -0,0 +1,83 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'message_composer_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamMessageComposerThemeData { + bool get canMerge => true; + + static StreamMessageComposerThemeData? lerp( + StreamMessageComposerThemeData? a, + StreamMessageComposerThemeData? 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 StreamMessageComposerThemeData( + location: t < 0.5 ? a.location : b.location, + ); + } + + StreamMessageComposerThemeData copyWith({ + ComposerLocation? location, + }) { + final _this = (this as StreamMessageComposerThemeData); + + return StreamMessageComposerThemeData( + location: location ?? _this.location, + ); + } + + StreamMessageComposerThemeData merge(StreamMessageComposerThemeData? other) { + final _this = (this as StreamMessageComposerThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith(location: other.location); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamMessageComposerThemeData); + final _other = (other as StreamMessageComposerThemeData); + + return _other.location == _this.location; + } + + @override + int get hashCode { + final _this = (this as StreamMessageComposerThemeData); + + return Object.hash(runtimeType, _this.location); + } +} 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 e487996ce0..e883a3f8d8 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 @@ -132,6 +132,7 @@ class StreamChatThemeData extends ThemeExtension with _$Str StreamAppBarThemeData? channelHeaderTheme, StreamAppBarThemeData? channelListHeaderTheme, StreamAppBarThemeData? threadHeaderTheme, + StreamMessageComposerThemeData? messageComposerTheme, StreamMessageListViewThemeData? messageListViewTheme, StreamPollCreatorThemeData? pollCreatorTheme, StreamPollInteractorThemeData? pollInteractorTheme, @@ -149,6 +150,9 @@ class StreamChatThemeData extends ThemeExtension with _$Str channelListHeaderTheme ??= const StreamAppBarThemeData(); threadHeaderTheme ??= const StreamAppBarThemeData(); + // Message composer + messageComposerTheme ??= const StreamMessageComposerThemeData(); + // Message list messageListViewTheme ??= const StreamMessageListViewThemeData(); @@ -170,6 +174,7 @@ class StreamChatThemeData extends ThemeExtension with _$Str channelHeaderTheme: channelHeaderTheme, channelListHeaderTheme: channelListHeaderTheme, threadHeaderTheme: threadHeaderTheme, + messageComposerTheme: messageComposerTheme, messageListViewTheme: messageListViewTheme, pollCreatorTheme: pollCreatorTheme, pollInteractorTheme: pollInteractorTheme, @@ -189,6 +194,7 @@ class StreamChatThemeData extends ThemeExtension with _$Str required this.channelHeaderTheme, required this.channelListHeaderTheme, required this.threadHeaderTheme, + required this.messageComposerTheme, required this.messageListViewTheme, required this.pollCreatorTheme, required this.pollInteractorTheme, @@ -211,6 +217,9 @@ class StreamChatThemeData extends ThemeExtension with _$Str /// The thread header app bar theme for this theme. final StreamAppBarThemeData threadHeaderTheme; + /// The message composer theme for this theme. + final StreamMessageComposerThemeData messageComposerTheme; + /// The message list view theme for this theme. final StreamMessageListViewThemeData messageListViewTheme; diff --git a/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.g.theme.dart b/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.g.theme.dart index ee0012763b..105993cf0d 100644 --- a/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.g.theme.dart +++ b/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.g.theme.dart @@ -15,6 +15,7 @@ mixin _$StreamChatThemeData on ThemeExtension { StreamAppBarThemeData? channelHeaderTheme, StreamAppBarThemeData? channelListHeaderTheme, StreamAppBarThemeData? threadHeaderTheme, + StreamMessageComposerThemeData? messageComposerTheme, StreamMessageListViewThemeData? messageListViewTheme, StreamPollCreatorThemeData? pollCreatorTheme, StreamPollInteractorThemeData? pollInteractorTheme, @@ -34,6 +35,7 @@ mixin _$StreamChatThemeData on ThemeExtension { channelListHeaderTheme: channelListHeaderTheme ?? _this.channelListHeaderTheme, threadHeaderTheme: threadHeaderTheme ?? _this.threadHeaderTheme, + messageComposerTheme: messageComposerTheme ?? _this.messageComposerTheme, messageListViewTheme: messageListViewTheme ?? _this.messageListViewTheme, pollCreatorTheme: pollCreatorTheme ?? _this.pollCreatorTheme, pollInteractorTheme: pollInteractorTheme ?? _this.pollInteractorTheme, @@ -80,6 +82,11 @@ mixin _$StreamChatThemeData on ThemeExtension { other.threadHeaderTheme, t, )!, + messageComposerTheme: StreamMessageComposerThemeData.lerp( + _this.messageComposerTheme, + other.messageComposerTheme, + t, + )!, messageListViewTheme: StreamMessageListViewThemeData.lerp( _this.messageListViewTheme, other.messageListViewTheme, @@ -155,6 +162,7 @@ mixin _$StreamChatThemeData on ThemeExtension { return _other.channelHeaderTheme == _this.channelHeaderTheme && _other.channelListHeaderTheme == _this.channelListHeaderTheme && _other.threadHeaderTheme == _this.threadHeaderTheme && + _other.messageComposerTheme == _this.messageComposerTheme && _other.messageListViewTheme == _this.messageListViewTheme && _other.pollCreatorTheme == _this.pollCreatorTheme && _other.pollInteractorTheme == _this.pollInteractorTheme && @@ -178,6 +186,7 @@ mixin _$StreamChatThemeData on ThemeExtension { _this.channelHeaderTheme, _this.channelListHeaderTheme, _this.threadHeaderTheme, + _this.messageComposerTheme, _this.messageListViewTheme, _this.pollCreatorTheme, _this.pollInteractorTheme, diff --git a/packages/stream_chat_flutter/lib/src/theme/themes.dart b/packages/stream_chat_flutter/lib/src/theme/themes.dart index ab4d9de545..d247f4d95c 100644 --- a/packages/stream_chat_flutter/lib/src/theme/themes.dart +++ b/packages/stream_chat_flutter/lib/src/theme/themes.dart @@ -1,3 +1,4 @@ +export 'message_composer_theme.dart'; export 'message_list_view_theme.dart'; export 'poll_card_style.dart'; export 'poll_comments_sheet_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 6a1c377bcd..9d5e38808e 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -39,9 +39,11 @@ export 'src/channel/channel_header.dart'; export 'src/channel/channel_info.dart'; export 'src/channel/channel_list_header.dart'; export 'src/channel/channel_name.dart'; +export 'src/channel/channel_page.dart'; export 'src/channel/stream_channel_name.dart'; export 'src/channel/stream_draft_message_preview_text.dart'; export 'src/channel/stream_message_preview_text.dart'; +export 'src/channel/thread_page.dart'; // region SDK Design Refresh Components export 'src/components/avatar/stream_channel_avatar.dart'; export 'src/components/avatar/stream_user_avatar.dart'; diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index 5675718f2e..f2b582d727 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -66,7 +66,7 @@ dependencies: # ignore: invalid_dependency git: url: https://github.com/GetStream/stream-core-flutter.git - ref: 784a2a444f3a16875eba1c04b9b4c49062584c53 + ref: 3d3f22057a89679454e819b4ca8ff55fe800abd9 path: packages/stream_core_flutter stream_thumbnail: ^0.1.0 svg_icon_widget: ^0.0.1 diff --git a/packages/stream_chat_flutter/test/src/channel/channel_page_test.dart b/packages/stream_chat_flutter/test/src/channel/channel_page_test.dart new file mode 100644 index 0000000000..e317fb5d78 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/channel/channel_page_test.dart @@ -0,0 +1,256 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:record/record.dart'; +import 'package:stream_chat_flutter/src/message_input/stream_chat_message_input.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../fakes.dart'; +import '../mocks.dart'; + +void main() { + testWidgets('renders a header, a message list and a composer', (tester) async { + await _pumpChannelPage(tester); + + expect(find.byType(StreamChannelHeader), findsOneWidget); + expect(find.byType(StreamMessageListView), findsOneWidget); + expect(find.byType(StreamMessageComposer), findsOneWidget); + }); + + testWidgets('renders a typing indicator above the composer', (tester) async { + await _pumpChannelPage(tester, appStyle: StreamAppStyle.floating); + + final indicator = tester.getRect(_bodyTypingIndicator()); + final composer = tester.getRect(find.byType(StreamMessageComposer)); + + expect(indicator.bottom, lessThanOrEqualTo(composer.top)); + }); + + testWidgets('tapping the channel avatar invokes onChannelAvatarPressed', (tester) async { + Channel? pressedChannel; + await _pumpChannelPage(tester, onChannelAvatarPressed: (_, channel) => pressedChannel = channel); + + await tester.tap(_channelAvatarTapTarget()); + await tester.pumpAndSettle(); + + expect(pressedChannel, isNotNull); + }); + + testWidgets('tapping back invokes onBackPressed', (tester) async { + var backPressed = 0; + await _pumpChannelPage(tester, onBackPressed: () => backPressed++); + + await tester.tap(find.byType(StreamBackButton)); + await tester.pumpAndSettle(); + + expect(backPressed, 1); + }); + + testWidgets('onBackPressed replaces the default pop', (tester) async { + await _pumpChannelPage(tester, onBackPressed: () {}, pushOntoARoute: true); + + await tester.tap(find.byType(StreamBackButton)); + await tester.pumpAndSettle(); + + expect(find.byType(StreamChannelPage), findsOneWidget); + }); + + testWidgets('pops the route when onBackPressed is not set', (tester) async { + await _pumpChannelPage(tester, pushOntoARoute: true); + + await tester.tap(find.byType(StreamBackButton)); + await tester.pumpAndSettle(); + + expect(find.byType(StreamChannelPage), findsNothing); + }); + + testWidgets('replying to a message quotes it in the composer', (tester) async { + final message = Message(id: 'message-id', text: 'Hello world!'); + + await _pumpChannelPage(tester); + _messageListView(tester).onReplyTap!(message); + await tester.pumpAndSettle(); + + expect(_composerController(tester).message.quotedMessage, message); + }); + + testWidgets('replying to a message focuses the composer', (tester) async { + final message = Message(id: 'message-id', text: 'Hello world!'); + + await _pumpChannelPage(tester); + _messageListView(tester).onReplyTap!(message); + await tester.pumpAndSettle(); + + expect(_composerFocusNode(tester).hasFocus, isTrue); + }); + + testWidgets('editing a message loads it into the composer', (tester) async { + final message = Message(id: 'message-id', text: 'Hello world!'); + + await _pumpChannelPage(tester); + _messageListView(tester).onEditMessageTap!(message); + await tester.pumpAndSettle(); + + expect(_composerController(tester).messageBeingEdited, message); + }); + + testWidgets('editing a message focuses the composer', (tester) async { + final message = Message(id: 'message-id', text: 'Hello world!'); + + await _pumpChannelPage(tester); + _messageListView(tester).onEditMessageTap!(message); + await tester.pumpAndSettle(); + + expect(_composerFocusNode(tester).hasFocus, isTrue); + }); + + testWidgets('opens a thread page for the tapped parent message', (tester) async { + final parentMessage = Message(id: 'parent-id', text: 'Hello world!'); + + await _pumpChannelPage(tester); + final context = tester.element(find.byType(StreamMessageListView)); + final thread = _messageListView(tester).threadBuilder!(context, parentMessage); + + expect(thread, isA().having((it) => it.parent, 'parent', parentMessage)); + }); + + testWidgets('insets the message list behind a floating app bar and composer', (tester) async { + await _pumpChannelPage(tester, appStyle: StreamAppStyle.floating); + + final messageListView = _messageListView(tester); + + expect(messageListView.topPadding, greaterThan(0)); + expect(messageListView.bottomPadding, greaterThan(0)); + }); + + testWidgets('does not inset the message list when the app style is regular', (tester) async { + await _pumpChannelPage(tester); + + final messageListView = _messageListView(tester); + + expect(messageListView.topPadding, 0); + expect(messageListView.bottomPadding, 0); + }); + + testWidgets('disposes its composer controller when removed from the tree', (tester) async { + await _pumpChannelPage(tester); + final controller = _composerController(tester); + + // A bare widget, so the whole app subtree unmounts. + await tester.pumpWidget(const SizedBox.shrink()); + + // A disposed ChangeNotifier throws when listened to again. + expect(() => controller.addListener(() {}), throwsFlutterError); + }); +} + +StreamMessageListView _messageListView(WidgetTester tester) { + return tester.widget(find.byType(StreamMessageListView)); +} + +/// The typing indicator the page renders in its body. +/// +/// The header renders one of its own as part of the channel subtitle, so the +/// plain type finder is ambiguous. +Finder _bodyTypingIndicator() { + final inHeader = find + .descendant(of: find.byType(StreamChannelHeader), matching: find.byType(StreamTypingIndicator)) + .evaluate() + .toSet(); + + return find.byElementPredicate((element) { + return element.widget is StreamTypingIndicator && !inHeader.contains(element); + }); +} + +/// The gesture detector wrapping the header's channel avatar. +Finder _channelAvatarTapTarget() { + return find.ancestor( + of: find.byType(StreamChannelAvatar), + matching: find.byType(GestureDetector), + ); +} + +/// The controller the page created and handed to its composer. +StreamMessageComposerController _composerController(WidgetTester tester) { + return tester.widget(find.byType(StreamChatMessageInput)).controller!; +} + +/// The focus node the page created and handed to its composer. +FocusNode _composerFocusNode(WidgetTester tester) { + return tester.widget(find.byType(StreamChatMessageInput)).focusNode!; +} + +Future _pumpChannelPage( + WidgetTester tester, { + StreamAppStyle appStyle = StreamAppStyle.regular, + void Function(BuildContext context, Channel channel)? onChannelAvatarPressed, + VoidCallback? onBackPressed, + bool pushOntoARoute = false, +}) async { + final originalRecordPlatform = RecordPlatform.instance; + RecordPlatform.instance = FakeRecordPlatform(); + addTearDown(() => RecordPlatform.instance = originalRecordPlatform); + + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + final currentUser = OwnUser(id: 'user-id'); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(currentUser); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(currentUser)); + when(() => clientState.totalUnreadCount).thenReturn(0); + when(() => clientState.totalUnreadCountStream).thenAnswer((_) => Stream.value(0)); + + when(() => channel.client).thenReturn(client); + when(() => channel.state).thenReturn(channelState); + when(channel.getRemainingCooldown).thenReturn(0); + when(() => channel.lastMessageAt).thenReturn(null); + when(() => channel.name).thenReturn('test'); + when(() => channel.nameStream).thenAnswer((_) => Stream.value('test')); + when(() => channel.image).thenReturn(null); + when(() => channel.imageStream).thenAnswer((_) => Stream.value(null)); + when(() => channel.isMuted).thenReturn(false); + when(() => channel.isMutedStream).thenAnswer((_) => Stream.value(false)); + when(() => channel.extraData).thenReturn({'name': 'test'}); + when(() => channel.extraDataStream).thenAnswer((_) => Stream.value({'name': 'test'})); + + when(() => channelState.members).thenReturn([]); + when(() => channelState.membersStream).thenAnswer((_) => Stream.value([])); + when(() => channelState.messages).thenReturn([]); + when(() => channelState.messagesStream).thenAnswer((_) => Stream.value([])); + when(() => channelState.threadsStream).thenAnswer((_) => const Stream.empty()); + when(() => channelState.draft).thenReturn(null); + when(() => channelState.isUpToDateStream).thenAnswer((_) => Stream.value(true)); + when(() => channelState.unreadCountStream).thenAnswer((_) => Stream.value(0)); + when(() => channelState.readStream).thenAnswer((_) => Stream.value([])); + when(() => channelState.currentUserRead).thenReturn(null); + when(() => channelState.currentUserReadStream).thenAnswer((_) => const Stream.empty()); + + final page = StreamChannelPage( + onChannelAvatarPressed: onChannelAvatarPressed, + onBackPressed: onBackPressed, + ); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(extensions: [StreamTheme(appStyle: appStyle)]), + // Chat context lives above the navigator so it survives a pop. + builder: (context, child) => StreamChat( + client: client, + child: StreamChannel(channel: channel, child: child!), + ), + // '/channel' seeds the stack with '/' underneath it, giving the back + // button something to pop to. + initialRoute: pushOntoARoute ? '/channel' : '/', + routes: { + '/': (_) => pushOntoARoute ? const Scaffold(body: SizedBox.shrink()) : page, + '/channel': (_) => page, + }, + ), + ); + + await tester.pumpAndSettle(); +} diff --git a/packages/stream_chat_flutter/test/src/channel/thread_page_test.dart b/packages/stream_chat_flutter/test/src/channel/thread_page_test.dart new file mode 100644 index 0000000000..381e737919 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/channel/thread_page_test.dart @@ -0,0 +1,226 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:record/record.dart'; +import 'package:stream_chat_flutter/src/message_input/stream_chat_message_input.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../fakes.dart'; +import '../mocks.dart'; + +void main() { + testWidgets('renders a header, a message list and a composer', (tester) async { + await _pumpThreadPage(tester); + + expect(find.byType(StreamThreadHeader), findsOneWidget); + expect(find.byType(StreamMessageListView), findsOneWidget); + expect(find.byType(StreamMessageComposer), findsOneWidget); + }); + + testWidgets('shows the thread of the parent message', (tester) async { + final parent = Message(id: 'parent-id', text: 'Hello world!'); + + await _pumpThreadPage(tester, parent: parent); + + expect(_messageListView(tester).parentMessage, parent); + }); + + testWidgets('addresses new messages to the parent thread', (tester) async { + final parent = Message(id: 'parent-id', text: 'Hello world!'); + + await _pumpThreadPage(tester, parent: parent); + + expect(_composerController(tester).message.parentId, 'parent-id'); + }); + + testWidgets('hides the composer when the parent message is deleted', (tester) async { + final parent = Message(id: 'parent-id', text: 'Hello world!', type: 'deleted'); + + await _pumpThreadPage(tester, parent: parent); + + expect(find.byType(StreamMessageComposer), findsNothing); + }); + + testWidgets('still shows the thread when the parent message is deleted', (tester) async { + final parent = Message(id: 'parent-id', text: 'Hello world!', type: 'deleted'); + + await _pumpThreadPage(tester, parent: parent); + + expect(find.byType(StreamMessageListView), findsOneWidget); + }); + + testWidgets('replying to a message quotes it in the composer', (tester) async { + final message = Message(id: 'message-id', text: 'Hello world!'); + + await _pumpThreadPage(tester); + _messageListView(tester).onReplyTap!(message); + await tester.pumpAndSettle(); + + expect(_composerController(tester).message.quotedMessage, message); + }); + + testWidgets('replying to a message focuses the composer', (tester) async { + final message = Message(id: 'message-id', text: 'Hello world!'); + + await _pumpThreadPage(tester); + _messageListView(tester).onReplyTap!(message); + await tester.pumpAndSettle(); + + expect(_composerFocusNode(tester).hasFocus, isTrue); + }); + + testWidgets('editing a message loads it into the composer', (tester) async { + final message = Message(id: 'message-id', text: 'Hello world!'); + + await _pumpThreadPage(tester); + _messageListView(tester).onEditMessageTap!(message); + await tester.pumpAndSettle(); + + expect(_composerController(tester).messageBeingEdited, message); + }); + + testWidgets('editing a message focuses the composer', (tester) async { + final message = Message(id: 'message-id', text: 'Hello world!'); + + await _pumpThreadPage(tester); + _messageListView(tester).onEditMessageTap!(message); + await tester.pumpAndSettle(); + + expect(_composerFocusNode(tester).hasFocus, isTrue); + }); + + testWidgets('forwards onViewInChannelTap to the message list', (tester) async { + final message = Message(id: 'message-id', text: 'Hello world!'); + Message? viewedInChannel; + + await _pumpThreadPage(tester, onViewInChannelTap: (message) => viewedInChannel = message); + _messageListView(tester).onViewInChannelTap!(message); + await tester.pumpAndSettle(); + + expect(viewedInChannel, message); + }); + + testWidgets('insets the message list behind a floating app bar and composer', (tester) async { + await _pumpThreadPage(tester, appStyle: StreamAppStyle.floating); + + final messageListView = _messageListView(tester); + + expect(messageListView.topPadding, greaterThan(0)); + expect(messageListView.bottomPadding, greaterThan(0)); + }); + + testWidgets('does not inset the message list when the app style is regular', (tester) async { + await _pumpThreadPage(tester); + + final messageListView = _messageListView(tester); + + expect(messageListView.topPadding, 0); + expect(messageListView.bottomPadding, 0); + }); + + testWidgets('disposes its composer controller when removed from the tree', (tester) async { + await _pumpThreadPage(tester); + final controller = _composerController(tester); + + // A bare widget, so the whole app subtree unmounts. + await tester.pumpWidget(const SizedBox.shrink()); + + // A disposed ChangeNotifier throws when listened to again. + expect(() => controller.addListener(() {}), throwsFlutterError); + }); +} + +StreamMessageListView _messageListView(WidgetTester tester) { + return tester.widget(find.byType(StreamMessageListView)); +} + +/// The controller the page created and handed to its composer. +StreamMessageComposerController _composerController(WidgetTester tester) { + return tester.widget(find.byType(StreamChatMessageInput)).controller!; +} + +/// The focus node the page created and handed to its composer. +FocusNode _composerFocusNode(WidgetTester tester) { + return tester.widget(find.byType(StreamChatMessageInput)).focusNode!; +} + +Future _pumpThreadPage( + WidgetTester tester, { + Message? parent, + StreamAppStyle appStyle = StreamAppStyle.regular, + void Function(Message message)? onViewInChannelTap, +}) async { + final originalRecordPlatform = RecordPlatform.instance; + RecordPlatform.instance = FakeRecordPlatform(); + addTearDown(() => RecordPlatform.instance = originalRecordPlatform); + + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + final currentUser = OwnUser(id: 'user-id'); + final parentMessage = parent ?? Message(id: 'parent-id', text: 'Hello world!'); + + // The thread is loaded — an empty reply list, not a missing one, is what + // takes the message list out of its skeleton-loading state. + final threads = {parentMessage.id: []}; + + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(currentUser); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(currentUser)); + when(() => clientState.totalUnreadCount).thenReturn(0); + when(() => clientState.totalUnreadCountStream).thenAnswer((_) => Stream.value(0)); + when(() => clientState.channels).thenReturn({}); + when(() => clientState.channelsStream).thenAnswer((_) => Stream.value({})); + + when(() => channel.client).thenReturn(client); + when(() => channel.state).thenReturn(channelState); + when(channel.getRemainingCooldown).thenReturn(0); + when(() => channel.lastMessageAt).thenReturn(null); + when(() => channel.name).thenReturn('test'); + when(() => channel.nameStream).thenAnswer((_) => Stream.value('test')); + when(() => channel.image).thenReturn(null); + when(() => channel.imageStream).thenAnswer((_) => Stream.value(null)); + when(() => channel.isMuted).thenReturn(false); + when(() => channel.isMutedStream).thenAnswer((_) => Stream.value(false)); + when(() => channel.extraData).thenReturn({'name': 'test'}); + when(() => channel.extraDataStream).thenAnswer((_) => Stream.value({'name': 'test'})); + when( + () => channel.getReplies( + any(), + options: any(named: 'options'), + preferOffline: any(named: 'preferOffline'), + ), + ).thenAnswer((_) async => QueryRepliesResponse()..messages = []); + + when(() => channelState.members).thenReturn([]); + when(() => channelState.membersStream).thenAnswer((_) => Stream.value([])); + when(() => channelState.messages).thenReturn([]); + when(() => channelState.messagesStream).thenAnswer((_) => Stream.value([])); + when(() => channelState.threads).thenReturn(threads); + when(() => channelState.threadsStream).thenAnswer((_) => Stream.value(threads)); + when(() => channelState.draft).thenReturn(null); + when(() => channelState.isUpToDateStream).thenAnswer((_) => Stream.value(true)); + when(() => channelState.unreadCountStream).thenAnswer((_) => Stream.value(0)); + when(() => channelState.readStream).thenAnswer((_) => Stream.value([])); + when(() => channelState.currentUserRead).thenReturn(null); + when(() => channelState.currentUserReadStream).thenAnswer((_) => const Stream.empty()); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(extensions: [StreamTheme(appStyle: appStyle)]), + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: StreamThreadPage( + parent: parentMessage, + onViewInChannelTap: onViewInChannelTap, + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); +} diff --git a/packages/stream_chat_flutter/test/src/message_action/message_actions_builder_test.dart b/packages/stream_chat_flutter/test/src/message_action/message_actions_builder_test.dart index 61655298f4..1858a6937a 100644 --- a/packages/stream_chat_flutter/test/src/message_action/message_actions_builder_test.dart +++ b/packages/stream_chat_flutter/test/src/message_action/message_actions_builder_test.dart @@ -73,7 +73,7 @@ void main() { bool enableMutes = true, }) { final customChannel = MockChannel(ownCapabilities: capabilities); - final channelConfig = ChannelConfig(mutes: enableMutes); + final channelConfig = ChannelConfig(mutes: enableMutes, replies: true); when(() => customChannel.config).thenReturn(channelConfig); return customChannel; } diff --git a/packages/stream_chat_flutter/test/src/message_input/message_composer_location_test.dart b/packages/stream_chat_flutter/test/src/message_input/message_composer_location_test.dart new file mode 100644 index 0000000000..729142abac --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_input/message_composer_location_test.dart @@ -0,0 +1,197 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:record/record.dart'; +import 'package:stream_chat_flutter/src/message_input/stream_chat_message_input.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../fakes.dart'; +import '../mocks.dart'; + +void main() { + testWidgets('composer is docked when the app style is regular', (tester) async { + await _pumpComposer(tester, appStyle: StreamAppStyle.regular); + + expect(_resolvedLocation(tester), ComposerLocation.docked); + }); + + testWidgets('composer floats when the app style is floating', (tester) async { + await _pumpComposer(tester, appStyle: StreamAppStyle.floating); + + expect(_resolvedLocation(tester), ComposerLocation.floating); + }); + + testWidgets('the global composer theme overrides the app style', (tester) async { + await _pumpComposer( + tester, + appStyle: StreamAppStyle.regular, + globalTheme: const StreamMessageComposerThemeData(location: ComposerLocation.floating), + ); + + expect(_resolvedLocation(tester), ComposerLocation.floating); + }); + + testWidgets('a local composer theme overrides the global theme', (tester) async { + await _pumpComposer( + tester, + appStyle: StreamAppStyle.floating, + globalTheme: const StreamMessageComposerThemeData(location: ComposerLocation.floating), + localTheme: const StreamMessageComposerThemeData(location: ComposerLocation.docked), + ); + + expect(_resolvedLocation(tester), ComposerLocation.docked); + }); + + testWidgets('the location property overrides both the theme and the app style', (tester) async { + await _pumpComposer( + tester, + appStyle: StreamAppStyle.floating, + globalTheme: const StreamMessageComposerThemeData(location: ComposerLocation.floating), + location: ComposerLocation.docked, + ); + + expect(_resolvedLocation(tester), ComposerLocation.docked); + }); + + testWidgets('the docked composer fills its background with the elevation-1 color', (tester) async { + await _pumpComposer(tester, appStyle: StreamAppStyle.regular); + + expect(_backgroundFillFinder(tester), findsOneWidget); + }); + + testWidgets('the floating composer does not fill its background', (tester) async { + // Floating paints a fading backdrop instead of an opaque fill, so the + // message list stays visible behind the composer. + await _pumpComposer(tester, appStyle: StreamAppStyle.floating); + + expect(_backgroundFillFinder(tester), findsNothing); + }); + + testWidgets('the floating composer lifts the input above the bottom safe area', (tester) async { + const bottomInset = 34.0; + await _pumpComposer(tester, appStyle: StreamAppStyle.floating, bottomPadding: bottomInset); + + final gap = _gapBelowInput(tester); + + expect(gap, moreOrLessEquals(bottomInset)); + }); + + testWidgets('the floating composer keeps a minimum gap when there is no safe area', (tester) async { + await _pumpComposer(tester, appStyle: StreamAppStyle.floating); + + final gap = _gapBelowInput(tester); + + // Falls back to `spacing.md` so the pill never sits flush with the edge. + final context = tester.element(find.byType(StreamChatMessageInput)); + expect(gap, moreOrLessEquals(context.streamSpacing.md)); + }); + + testWidgets('the floating composer leaves no gap when the safe area is disabled', (tester) async { + await _pumpComposer( + tester, + appStyle: StreamAppStyle.floating, + bottomPadding: 34, + enableSafeArea: false, + ); + + final gap = _gapBelowInput(tester); + + expect(gap, moreOrLessEquals(0)); + }); +} + +/// The location the composer resolved, read back from the input it built. +ComposerLocation _resolvedLocation(WidgetTester tester) { + final input = tester.widget(find.byType(StreamChatMessageInput)); + return input.isFloating ? ComposerLocation.floating : ComposerLocation.docked; +} + +/// Finds the opaque background fill the docked composer paints behind itself. +Finder _backgroundFillFinder(WidgetTester tester) { + final context = tester.element(find.byType(StreamChatMessageInput)); + final fill = BoxDecoration(color: context.streamColorScheme.backgroundElevation1); + + return find.descendant( + of: find.byType(StreamMessageComposer), + matching: find.byWidgetPredicate((widget) => widget is DecoratedBox && widget.decoration == fill), + ); +} + +/// The vertical distance between the bottom of the input pill and the bottom +/// of the composer. +double _gapBelowInput(WidgetTester tester) { + final composer = tester.getRect(find.byType(StreamMessageComposer)); + final input = tester.getRect(find.byType(StreamChatMessageInput)); + + return composer.bottom - input.bottom; +} + +/// Pumps a [StreamMessageComposer] with the given placement inputs. +/// +/// The composer is placed at the bottom of the screen so its rect can be +/// compared against the input pill's. +Future _pumpComposer( + WidgetTester tester, { + required StreamAppStyle appStyle, + StreamMessageComposerThemeData? globalTheme, + StreamMessageComposerThemeData? localTheme, + ComposerLocation? location, + bool? enableSafeArea, + double bottomPadding = 0, +}) async { + final originalRecordPlatform = RecordPlatform.instance; + RecordPlatform.instance = FakeRecordPlatform(); + addTearDown(() => RecordPlatform.instance = originalRecordPlatform); + + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(OwnUser(id: 'user-id'))); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(channel.getRemainingCooldown).thenReturn(0); + when(() => channel.lastMessageAt).thenReturn(null); + when(() => channel.extraData).thenReturn({'name': 'test'}); + when(() => channel.extraDataStream).thenAnswer((_) => Stream.value({'name': 'test'})); + when(() => channelState.members).thenReturn([]); + when(() => channelState.membersStream).thenAnswer((_) => Stream.value([])); + when(() => channelState.messages).thenReturn([]); + when(() => channelState.messagesStream).thenAnswer((_) => Stream.value([])); + when(() => channelState.draft).thenReturn(null); + + final composer = StreamMessageComposer( + location: location, + enableSafeArea: enableSafeArea, + ); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(extensions: [StreamTheme(appStyle: appStyle)]), + home: MediaQuery( + data: MediaQueryData(padding: EdgeInsets.only(bottom: bottomPadding)), + child: StreamChat( + client: client, + themeData: StreamChatThemeData(messageComposerTheme: globalTheme), + child: StreamChannel( + channel: channel, + child: Scaffold( + body: Align( + alignment: Alignment.bottomCenter, + child: switch (localTheme) { + final localTheme? => StreamMessageComposerTheme(data: localTheme, child: composer), + _ => composer, + }, + ), + ), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); +} diff --git a/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/stream_message_deleted_dark.png b/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/stream_message_deleted_dark.png new file mode 100644 index 0000000000..e59943949c Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/stream_message_deleted_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/stream_message_deleted_light.png b/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/stream_message_deleted_light.png new file mode 100644 index 0000000000..5827a114aa Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/stream_message_deleted_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_widget/stream_message_deleted_test.dart b/packages/stream_chat_flutter/test/src/message_widget/stream_message_deleted_test.dart new file mode 100644 index 0000000000..8fa37a5b75 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_widget/stream_message_deleted_test.dart @@ -0,0 +1,163 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/src/message_widget/components/stream_message_deleted.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +void main() { + // The bubble is capped at the message item's max width, so the label has to + // wrap rather than run past it. Guards the `Flexible` around the label. + testWidgets('lays out a long label without overflowing', (tester) async { + await tester.pumpWidget(_wrapWithApp(_deletedBubble(_longLabel))); + + expect(tester.takeException(), isNull); + }); + + testWidgets('lays out the default label without overflowing', (tester) async { + await tester.pumpWidget(_wrapWithApp(_deletedBubble(_shortLabel))); + + expect(tester.takeException(), isNull); + }); + + testWidgets('wraps a long label onto more than one line', (tester) async { + await tester.pumpWidget(_wrapWithApp(_deletedBubble(_shortLabel))); + final singleLine = tester.getSize(find.byType(StreamMessageDeleted)).height; + + await tester.pumpWidget(_wrapWithApp(_deletedBubble(_longLabel))); + final wrapped = tester.getSize(find.byType(StreamMessageDeleted)).height; + + expect(wrapped, greaterThan(singleLine)); + }); + + testWidgets('keeps a long label within the bubble width', (tester) async { + await tester.pumpWidget(_wrapWithApp(_deletedBubble(_longLabel))); + + final width = tester.getSize(find.byType(StreamMessageDeleted)).width; + + expect(width, lessThanOrEqualTo(_bubbleMaxWidth)); + }); + + for (final brightness in Brightness.values) { + goldenTest( + '[${brightness.name}] -> StreamMessageDeleted looks fine', + fileName: 'stream_message_deleted_${brightness.name}', + constraints: const BoxConstraints.tightFor(width: 460, height: 420), + // The app is themed once around the whole group; the scenarios below + // vary only the label. + builder: () => _wrapWithApp( + brightness: brightness, + GoldenTestGroup( + columns: 1, + children: [ + GoldenTestScenario( + name: 'very short label — bubble hugs it', + child: _deletedBubble(_tinyLabel, maxWidth: _goldenBubbleMaxWidth), + ), + GoldenTestScenario( + name: 'short label', + child: _deletedBubble(_shortLabel, maxWidth: _goldenBubbleMaxWidth), + ), + GoldenTestScenario( + name: 'longest shipped translation', + child: _deletedBubble(_longestShippedLabel, maxWidth: _goldenBubbleMaxWidth), + ), + GoldenTestScenario( + name: 'long label wraps inside the bubble', + child: _deletedBubble(_longLabel, maxWidth: _goldenBubbleMaxWidth), + ), + ], + ), + ), + ); + } +} + +/// Mirrors the default `StreamMessageItemProps.maxWidth`, which is what caps +/// the bubble in a real message list. Used by the layout tests, so they pin +/// the real geometry. +const _bubbleMaxWidth = 272.0; + +/// The cap used by the goldens instead of [_bubbleMaxWidth]. +/// +/// Every glyph in the test font is a square of the font size, so text measures +/// far wider here than in a real app — at [_bubbleMaxWidth] even the English +/// label would wrap, which is not what users see. Widening the cap absorbs that +/// difference so the goldens show the line breaks a real font produces. +const _goldenBubbleMaxWidth = 350.0; + +/// Short enough that the bubble hugs it, showing it is not forced to full width. +const _tinyLabel = 'Del'; + +/// The shipped English label. +const _shortLabel = 'Message deleted'; + +/// The longest `messageDeletedLabel` in `stream_chat_localizations` that uses +/// Latin script, so the golden renders it without needing extra fonts. +const _longestShippedLabel = 'Messaggio eliminato'; + +/// Longer than any shipped translation, to show the wrapping behaviour. +const _longLabel = 'This message was deleted by a moderator'; + +/// A [StreamMessageDeleted] reading [label], capped at [maxWidth]. +Widget _deletedBubble(String label, {double maxWidth = _bubbleMaxWidth}) { + return Builder( + builder: (context) => Localizations.override( + context: context, + delegates: [_FixedLabelDelegate(label)], + child: Align( + alignment: Alignment.centerLeft, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth), + child: const StreamMessageDeleted(), + ), + ), + ), + ); +} + +Widget _wrapWithApp(Widget child, {Brightness brightness = Brightness.light}) { + return MaterialApp( + debugShowCheckedModeBanner: false, + theme: ThemeData( + brightness: brightness, + extensions: [StreamTheme(brightness: brightness)], + ), + home: Scaffold( + backgroundColor: brightness == Brightness.dark ? const Color(0xFF101418) : const Color(0xFFF7F7F8), + body: Center(child: child), + ), + ); +} + +/// Serves a [StreamChatLocalizations] whose `messageDeletedLabel` is fixed. +class _FixedLabelDelegate extends LocalizationsDelegate { + const _FixedLabelDelegate(this.label); + + final String label; + + @override + bool isSupported(Locale locale) => true; + + @override + SynchronousFuture load(Locale locale) { + return SynchronousFuture(_FixedLabelTranslations(label)); + } + + @override + bool shouldReload(_FixedLabelDelegate old) => old.label != label; +} + +/// A [StreamChatLocalizations] that only answers `messageDeletedLabel`. +/// +/// [StreamMessageDeleted] reads nothing else, so anything else reaching this +/// stub is a mistake worth failing on rather than quietly defaulting. +class _FixedLabelTranslations implements StreamChatLocalizations { + const _FixedLabelTranslations(this.messageDeletedLabel); + + @override + final String messageDeletedLabel; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/packages/stream_chat_flutter/test/src/theme/message_composer_theme_test.dart b/packages/stream_chat_flutter/test/src/theme/message_composer_theme_test.dart new file mode 100644 index 0000000000..b5b0a08f9d --- /dev/null +++ b/packages/stream_chat_flutter/test/src/theme/message_composer_theme_test.dart @@ -0,0 +1,179 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../mocks.dart'; + +void main() { + test('copyWith with no arguments returns an equal MessageComposerThemeData', () { + const themeData = StreamMessageComposerThemeData(location: ComposerLocation.floating); + + expect(themeData.copyWith(), themeData); + }); + + test('copyWith with no arguments preserves the hashCode', () { + const themeData = StreamMessageComposerThemeData(location: ComposerLocation.floating); + + expect(themeData.copyWith().hashCode, themeData.hashCode); + }); + + test('copyWith overrides the location', () { + const docked = StreamMessageComposerThemeData(location: ComposerLocation.docked); + + expect(docked.copyWith(location: ComposerLocation.floating).location, ComposerLocation.floating); + }); + + test('MessageComposerThemeData instances with different locations are not equal', () { + expect( + const StreamMessageComposerThemeData(location: ComposerLocation.docked), + isNot(const StreamMessageComposerThemeData(location: ComposerLocation.floating)), + ); + }); + + test('lerp at t = 0 resolves to the start location', () { + expect( + StreamMessageComposerThemeData.lerp(_dockedTheme, _floatingTheme, 0)?.location, + ComposerLocation.docked, + ); + }); + + test('lerp below the halfway point resolves to the start location', () { + expect( + StreamMessageComposerThemeData.lerp(_dockedTheme, _floatingTheme, 0.49)?.location, + ComposerLocation.docked, + ); + }); + + test('lerp at or past the halfway point resolves to the end location', () { + expect( + StreamMessageComposerThemeData.lerp(_dockedTheme, _floatingTheme, 0.5)?.location, + ComposerLocation.floating, + ); + }); + + test('lerp at t = 1 resolves to the end location', () { + expect(StreamMessageComposerThemeData.lerp(_dockedTheme, _floatingTheme, 1), _floatingTheme); + }); + + test('merge with null keeps the original location', () { + expect(_dockedTheme.merge(null), _dockedTheme); + }); + + test('merge overrides the location with the other theme', () { + expect(_dockedTheme.merge(_floatingTheme), _floatingTheme); + }); + + test('merge with an empty theme keeps the original location', () { + expect(_floatingTheme.merge(const StreamMessageComposerThemeData()), _floatingTheme); + }); + + testWidgets('of returns a null location when no theme is configured', (tester) async { + final context = await _pumpAndCaptureContext(tester); + + expect(StreamMessageComposerTheme.of(context).location, isNull); + }); + + testWidgets('of returns the global theme location when no local theme is present', (tester) async { + final context = await _pumpAndCaptureContext(tester, globalTheme: _floatingTheme); + + expect(StreamMessageComposerTheme.of(context).location, ComposerLocation.floating); + }); + + testWidgets('of merges the local theme over the global theme', (tester) async { + final context = await _pumpAndCaptureContext( + tester, + globalTheme: _dockedTheme, + localTheme: _floatingTheme, + ); + + expect(StreamMessageComposerTheme.of(context).location, ComposerLocation.floating); + }); + + testWidgets('of falls back to the global theme when the local theme sets no location', (tester) async { + final context = await _pumpAndCaptureContext( + tester, + globalTheme: _floatingTheme, + localTheme: const StreamMessageComposerThemeData(), + ); + + expect(StreamMessageComposerTheme.of(context).location, ComposerLocation.floating); + }); + + testWidgets('wrap re-establishes the theme in a detached subtree', (tester) async { + late BuildContext capturedContext; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + // `wrap` is what InheritedTheme.captureAll calls to carry the theme + // across a route boundary; assert it round-trips the same data. + final theme = StreamMessageComposerTheme( + data: _floatingTheme, + child: Builder( + builder: (context) { + capturedContext = context; + return const SizedBox.shrink(); + }, + ), + ); + + return theme.wrap(context, theme.child); + }, + ), + ), + ); + + expect(StreamMessageComposerTheme.of(capturedContext).location, ComposerLocation.floating); + }); + + testWidgets('updateShouldNotify is true when the data changes', (tester) async { + const oldWidget = StreamMessageComposerTheme(data: _dockedTheme, child: SizedBox.shrink()); + const newWidget = StreamMessageComposerTheme(data: _floatingTheme, child: SizedBox.shrink()); + + expect(newWidget.updateShouldNotify(oldWidget), isTrue); + }); + + testWidgets('updateShouldNotify is false when the data is unchanged', (tester) async { + const oldWidget = StreamMessageComposerTheme(data: _dockedTheme, child: SizedBox.shrink()); + const newWidget = StreamMessageComposerTheme(data: _dockedTheme, child: SizedBox.shrink()); + + expect(newWidget.updateShouldNotify(oldWidget), isFalse); + }); +} + +const _dockedTheme = StreamMessageComposerThemeData(location: ComposerLocation.docked); +const _floatingTheme = StreamMessageComposerThemeData(location: ComposerLocation.floating); + +/// Pumps a [StreamChat] configured with [globalTheme], optionally wrapped in a +/// local [StreamMessageComposerTheme] carrying [localTheme], and returns a +/// context below both. +Future _pumpAndCaptureContext( + WidgetTester tester, { + StreamMessageComposerThemeData? globalTheme, + StreamMessageComposerThemeData? localTheme, +}) async { + late BuildContext capturedContext; + + final leaf = Builder( + builder: (context) { + capturedContext = context; + return const SizedBox.shrink(); + }, + ); + + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => StreamChat( + client: MockClient(), + themeData: StreamChatThemeData(messageComposerTheme: globalTheme), + child: child, + ), + home: switch (localTheme) { + final localTheme? => StreamMessageComposerTheme(data: localTheme, child: leaf), + _ => leaf, + }, + ), + ); + + return capturedContext; +} diff --git a/sample_app/ios/Runner.xcodeproj/project.pbxproj b/sample_app/ios/Runner.xcodeproj/project.pbxproj index 6fa58ae5c8..e1cace4f6a 100644 --- a/sample_app/ios/Runner.xcodeproj/project.pbxproj +++ b/sample_app/ios/Runner.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; 8210C58B8A5DAB805ACF46A1 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4899F8CDC11D7148C6179369 /* Pods_Runner.framework */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; @@ -51,6 +52,7 @@ 4899F8CDC11D7148C6179369 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 90B43020AC538F68084BBAFD /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; @@ -69,6 +71,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, 8210C58B8A5DAB805ACF46A1 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -87,6 +90,7 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, @@ -166,13 +170,15 @@ 3B06AD1E1E4923F5004D2608 /* Thin Binary */, 0BC14C55242B5A7A0028DE94 /* Embed App Extensions */, 7BD3737C65F59B233B7F7FC6 /* [CP] Embed Pods Frameworks */, - 78D972C66518C545847BBB5A /* [CP] Copy Pods Resources */, ); buildRules = ( ); dependencies = ( ); name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; @@ -202,6 +208,9 @@ Base, ); mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + ); productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; projectRoot = ""; @@ -243,24 +252,6 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; - 78D972C66518C545847BBB5A /* [CP] Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh", - "${PODS_CONFIGURATION_BUILD_DIR}/firebase_messaging/firebase_messaging_Privacy.bundle", - ); - name = "[CP] Copy Pods Resources"; - outputPaths = ( - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/firebase_messaging_Privacy.bundle", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; - showEnvVarsInLog = 0; - }; 7BD3737C65F59B233B7F7FC6 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -268,97 +259,11 @@ ); inputPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh", - "${BUILT_PRODUCTS_DIR}/DKImagePickerController/DKImagePickerController.framework", - "${BUILT_PRODUCTS_DIR}/DKPhotoGallery/DKPhotoGallery.framework", - "${BUILT_PRODUCTS_DIR}/FirebaseCore/FirebaseCore.framework", - "${BUILT_PRODUCTS_DIR}/FirebaseCoreExtension/FirebaseCoreExtension.framework", - "${BUILT_PRODUCTS_DIR}/FirebaseCoreInternal/FirebaseCoreInternal.framework", - "${BUILT_PRODUCTS_DIR}/FirebaseCrashlytics/FirebaseCrashlytics.framework", - "${BUILT_PRODUCTS_DIR}/FirebaseInstallations/FirebaseInstallations.framework", - "${BUILT_PRODUCTS_DIR}/FirebaseMessaging/FirebaseMessaging.framework", - "${BUILT_PRODUCTS_DIR}/FirebaseRemoteConfigInterop/FirebaseRemoteConfigInterop.framework", - "${BUILT_PRODUCTS_DIR}/FirebaseSessions/FirebaseSessions.framework", - "${BUILT_PRODUCTS_DIR}/GoogleDataTransport/GoogleDataTransport.framework", - "${BUILT_PRODUCTS_DIR}/GoogleUtilities/GoogleUtilities.framework", - "${BUILT_PRODUCTS_DIR}/PromisesObjC/FBLPromises.framework", - "${BUILT_PRODUCTS_DIR}/PromisesSwift/Promises.framework", - "${BUILT_PRODUCTS_DIR}/SDWebImage/SDWebImage.framework", - "${BUILT_PRODUCTS_DIR}/SwiftyGif/SwiftyGif.framework", - "${BUILT_PRODUCTS_DIR}/audio_session/audio_session.framework", - "${BUILT_PRODUCTS_DIR}/connectivity_plus/connectivity_plus.framework", - "${BUILT_PRODUCTS_DIR}/device_info_plus/device_info_plus.framework", - "${BUILT_PRODUCTS_DIR}/file_picker/file_picker.framework", - "${BUILT_PRODUCTS_DIR}/file_selector_ios/file_selector_ios.framework", - "${BUILT_PRODUCTS_DIR}/flutter_app_badger/flutter_app_badger.framework", - "${BUILT_PRODUCTS_DIR}/flutter_local_notifications/flutter_local_notifications.framework", - "${BUILT_PRODUCTS_DIR}/flutter_secure_storage_darwin/flutter_secure_storage_darwin.framework", - "${BUILT_PRODUCTS_DIR}/gal/gal.framework", - "${BUILT_PRODUCTS_DIR}/geolocator_apple/geolocator_apple.framework", - "${BUILT_PRODUCTS_DIR}/get_thumbnail_video/get_thumbnail_video.framework", - "${BUILT_PRODUCTS_DIR}/image_picker_ios/image_picker_ios.framework", - "${BUILT_PRODUCTS_DIR}/integration_test/integration_test.framework", - "${BUILT_PRODUCTS_DIR}/just_audio/just_audio.framework", - "${BUILT_PRODUCTS_DIR}/libwebp/libwebp.framework", "${BUILT_PRODUCTS_DIR}/media_kit_video/media_kit_video.framework", - "${BUILT_PRODUCTS_DIR}/nanopb/nanopb.framework", - "${BUILT_PRODUCTS_DIR}/package_info_plus/package_info_plus.framework", - "${BUILT_PRODUCTS_DIR}/photo_manager/photo_manager.framework", - "${BUILT_PRODUCTS_DIR}/record_ios/record_ios.framework", - "${BUILT_PRODUCTS_DIR}/share_plus/share_plus.framework", - "${BUILT_PRODUCTS_DIR}/shared_preferences_foundation/shared_preferences_foundation.framework", - "${BUILT_PRODUCTS_DIR}/sqflite_darwin/sqflite_darwin.framework", - "${BUILT_PRODUCTS_DIR}/sqlite3/sqlite3.framework", - "${BUILT_PRODUCTS_DIR}/sqlite3_flutter_libs/sqlite3_flutter_libs.framework", - "${BUILT_PRODUCTS_DIR}/url_launcher_ios/url_launcher_ios.framework", - "${BUILT_PRODUCTS_DIR}/video_player_avfoundation/video_player_avfoundation.framework", - "${BUILT_PRODUCTS_DIR}/wakelock_plus/wakelock_plus.framework", ); name = "[CP] Embed Pods Frameworks"; outputPaths = ( - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/DKImagePickerController.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/DKPhotoGallery.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseCore.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseCoreExtension.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseCoreInternal.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseCrashlytics.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseInstallations.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseMessaging.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseRemoteConfigInterop.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseSessions.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/GoogleDataTransport.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/GoogleUtilities.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FBLPromises.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Promises.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SDWebImage.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SwiftyGif.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/audio_session.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/connectivity_plus.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/device_info_plus.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/file_picker.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/file_selector_ios.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/flutter_app_badger.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/flutter_local_notifications.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/flutter_secure_storage_darwin.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/gal.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/geolocator_apple.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/get_thumbnail_video.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/image_picker_ios.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/integration_test.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/just_audio.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libwebp.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/media_kit_video.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/nanopb.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/package_info_plus.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/photo_manager.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/record_ios.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/share_plus.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/shared_preferences_foundation.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/sqflite_darwin.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/sqlite3.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/sqlite3_flutter_libs.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/url_launcher_ios.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/video_player_avfoundation.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/wakelock_plus.framework", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; @@ -718,6 +623,20 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } diff --git a/sample_app/lib/app.dart b/sample_app/lib/app.dart index 0ccefffdfd..4484f351af 100644 --- a/sample_app/lib/app.dart +++ b/sample_app/lib/app.dart @@ -178,16 +178,24 @@ class _StreamChatSampleAppState extends State child: Builder( builder: (context) { final config = context.sampleAppConfig; + + final appStyle = switch (config.appStyle) { + SampleAppStyle.regular => StreamAppStyle.regular, + SampleAppStyle.floating => StreamAppStyle.floating, + }; + return DynamicColorBuilder( builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) { return MaterialApp.router( theme: createTheme( dynamicColor: config.enableDynamicColor ? lightDynamic : null, brightness: Brightness.light, + appStyle: appStyle, ), darkTheme: createTheme( dynamicColor: config.enableDynamicColor ? darkDynamic : null, brightness: Brightness.dark, + appStyle: appStyle, ), themeMode: config.themeMode, locale: config.locale, @@ -250,6 +258,7 @@ class _StreamChatSampleAppState extends State ThemeData createTheme({ required ColorScheme? dynamicColor, required Brightness brightness, + required StreamAppStyle appStyle, }) { return ThemeData( brightness: brightness, @@ -262,6 +271,7 @@ class _StreamChatSampleAppState extends State brightness: brightness, ), brightness: brightness, + appStyle: appStyle, ), ], ); @@ -284,6 +294,10 @@ extension on SampleAppConfigData { }, ), ], + messageListViewConfiguration: const StreamMessageListViewConfiguration( + highlightInitialMessage: true, + swipeToReply: true, + ), ); } } diff --git a/sample_app/lib/config/sample_app_config.dart b/sample_app/lib/config/sample_app_config.dart index 05fd984366..08b4edf00e 100644 --- a/sample_app/lib/config/sample_app_config.dart +++ b/sample_app/lib/config/sample_app_config.dart @@ -3,11 +3,25 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_localizations/stream_chat_localizations.dart'; import 'package:streaming_shared_preferences/streaming_shared_preferences.dart'; +// --------------------------------------------------------------------------- +// AppStyle enum +// --------------------------------------------------------------------------- + +/// The visual style for the app's UI chrome (app bar, composer, bottom bar). +enum SampleAppStyle { + /// Standard docked composer with solid app bar and bottom bar. + regular, + + /// Floating composer with translucent overlapping chrome. + floating, +} + // --------------------------------------------------------------------------- // Preference keys // --------------------------------------------------------------------------- const _kThemeMode = 'config.themeMode'; +const _kAppStyle = 'config.appStyle'; const _kForceRtl = 'config.forceRtl'; const _kEnableDynamicColor = 'config.enableDynamicColor'; const _kEnableReminderActions = 'config.enableReminderActions'; @@ -39,6 +53,7 @@ class SampleAppConfigData { factory SampleAppConfigData({ Locale? locale, ThemeMode themeMode = .system, + SampleAppStyle appStyle = .regular, bool forceRtl = false, bool enableDynamicColor = false, bool enableReminderActions = false, @@ -52,6 +67,7 @@ class SampleAppConfigData { }) { return SampleAppConfigData.raw( themeMode: themeMode, + appStyle: appStyle, locale: locale, forceRtl: forceRtl, enableDynamicColor: enableDynamicColor, @@ -69,6 +85,7 @@ class SampleAppConfigData { /// Raw constructor used internally and by persistence. const SampleAppConfigData.raw({ required this.themeMode, + required this.appStyle, required this.locale, required this.forceRtl, required this.enableDynamicColor, @@ -85,8 +102,10 @@ class SampleAppConfigData { /// Loads config from [StreamingSharedPreferences], falling back to defaults. factory SampleAppConfigData.fromPreferences(StreamingSharedPreferences prefs) { final localeStr = prefs.getString(_kLocale, defaultValue: '').getValue(); + final appStyleIndex = prefs.getInt(_kAppStyle, defaultValue: SampleAppStyle.regular.index).getValue(); return SampleAppConfigData.raw( themeMode: ThemeMode.values[prefs.getInt(_kThemeMode, defaultValue: ThemeMode.system.index).getValue()], + appStyle: SampleAppStyle.values[appStyleIndex.clamp(0, SampleAppStyle.values.length - 1)], locale: localeStr.isEmpty ? null : Locale(localeStr), forceRtl: prefs.getBool(_kForceRtl, defaultValue: false).getValue(), enableDynamicColor: prefs.getBool(_kEnableDynamicColor, defaultValue: false).getValue(), @@ -106,6 +125,9 @@ class SampleAppConfigData { /// The theme mode for the app (system, light, dark). final ThemeMode themeMode; + /// The visual style for the app chrome (app bar, composer, bottom bar). + final SampleAppStyle appStyle; + /// The locale override for the app. When null, the system locale is used. final Locale? locale; @@ -150,6 +172,7 @@ class SampleAppConfigData { /// pass explicitly as `null` to reset to default/system. SampleAppConfigData copyWith({ ThemeMode? themeMode, + SampleAppStyle? appStyle, Object? locale = _sentinel, bool? forceRtl, bool? enableDynamicColor, @@ -164,6 +187,7 @@ class SampleAppConfigData { }) { return SampleAppConfigData.raw( themeMode: themeMode ?? this.themeMode, + appStyle: appStyle ?? this.appStyle, locale: locale == _sentinel ? this.locale : locale as Locale?, forceRtl: forceRtl ?? this.forceRtl, enableDynamicColor: enableDynamicColor ?? this.enableDynamicColor, @@ -185,6 +209,7 @@ class SampleAppConfigData { /// Persists all fields to [StreamingSharedPreferences]. void saveToPreferences(StreamingSharedPreferences prefs) { prefs.setInt(_kThemeMode, themeMode.index); + prefs.setInt(_kAppStyle, appStyle.index); prefs.setString(_kLocale, locale?.languageCode ?? ''); prefs.setBool(_kForceRtl, forceRtl); prefs.setBool(_kEnableDynamicColor, enableDynamicColor); diff --git a/sample_app/lib/config/sample_app_config_screen.dart b/sample_app/lib/config/sample_app_config_screen.dart index 9a9f3d7b51..3e9e7499c4 100644 --- a/sample_app/lib/config/sample_app_config_screen.dart +++ b/sample_app/lib/config/sample_app_config_screen.dart @@ -22,153 +22,171 @@ class SampleAppConfigScreen extends StatelessWidget { final spacing = context.streamSpacing; final icons = context.streamIcons; - return Scaffold( + return StreamScaffold( backgroundColor: colorScheme.backgroundApp, appBar: StreamAppBar(title: const Text('Configuration')), - body: SingleChildScrollView( - padding: EdgeInsets.symmetric(horizontal: spacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox(height: spacing.xs), - - // ── Appearance ── - const _SectionHeader(title: 'Appearance'), - SizedBox(height: spacing.xs), - _SettingsCard( + body: Builder( + builder: (context) { + final topInset = StreamScaffoldInsets.maybeOf(context)?.topPadding ?? 0.0; + return SingleChildScrollView( + padding: EdgeInsets.fromLTRB(spacing.md, topInset, spacing.md, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - _SegmentedRow( - title: 'Theme', - value: config.themeMode, - segments: const { - ThemeMode.system: 'System', - ThemeMode.light: 'Light', - ThemeMode.dark: 'Dark', - }, - segmentIcons: const { - ThemeMode.system: Icons.brightness_auto_outlined, - ThemeMode.light: Icons.light_mode_outlined, - ThemeMode.dark: Icons.dark_mode_outlined, - }, - onChanged: (v) => SampleAppConfig.update(context, config.copyWith(themeMode: v)), - ), - _SwitchRow( - icon: Icons.palette_outlined, - title: 'Dynamic Color', - subtitle: _dynamicColorSupported - ? 'Theme colors derived from the device wallpaper/accent' - : 'Only supported on Android, macOS, Windows, and Linux', - value: _dynamicColorSupported && config.enableDynamicColor, - onChanged: _dynamicColorSupported - ? (v) => SampleAppConfig.update(context, config.copyWith(enableDynamicColor: v)) - : null, - ), - _LocaleRow(config: config), - _SwitchRow( - icon: icons.reorder, - title: 'Force RTL', - subtitle: 'Right-to-left layout direction', - value: config.forceRtl, - onChanged: (v) => SampleAppConfig.update(context, config.copyWith(forceRtl: v)), + SizedBox(height: spacing.xs), + + // ── Appearance ── + const _SectionHeader(title: 'Appearance'), + SizedBox(height: spacing.xs), + _SettingsCard( + children: [ + _SegmentedRow( + title: 'Theme', + value: config.themeMode, + segments: const { + ThemeMode.system: 'System', + ThemeMode.light: 'Light', + ThemeMode.dark: 'Dark', + }, + segmentIcons: const { + ThemeMode.system: Icons.brightness_auto_outlined, + ThemeMode.light: Icons.light_mode_outlined, + ThemeMode.dark: Icons.dark_mode_outlined, + }, + onChanged: (v) => SampleAppConfig.update(context, config.copyWith(themeMode: v)), + ), + _SegmentedRow( + title: 'App Style', + value: config.appStyle, + segments: const { + SampleAppStyle.regular: 'Regular', + SampleAppStyle.floating: 'Floating', + }, + segmentIcons: const { + SampleAppStyle.regular: Icons.web_asset_outlined, + SampleAppStyle.floating: Icons.filter_none_outlined, + }, + onChanged: (v) => SampleAppConfig.update(context, config.copyWith(appStyle: v)), + ), + _SwitchRow( + icon: Icons.palette_outlined, + title: 'Dynamic Color', + subtitle: _dynamicColorSupported + ? 'Theme colors derived from the device wallpaper/accent' + : 'Only supported on Android, macOS, Windows, and Linux', + value: _dynamicColorSupported && config.enableDynamicColor, + onChanged: _dynamicColorSupported + ? (v) => SampleAppConfig.update(context, config.copyWith(enableDynamicColor: v)) + : null, + ), + _LocaleRow(config: config), + _SwitchRow( + icon: icons.reorder, + title: 'Force RTL', + subtitle: 'Right-to-left layout direction', + value: config.forceRtl, + onChanged: (v) => SampleAppConfig.update(context, config.copyWith(forceRtl: v)), + ), + ], ), - ], - ), - - SizedBox(height: spacing.xl), - // ── Features ── - const _SectionHeader(title: 'Features'), - SizedBox(height: spacing.xs), - _SettingsCard( - children: [ - _SwitchRow( - icon: icons.bell, - title: 'Reminders', - subtitle: 'Remind me, Save for later, Edit', - value: config.enableReminderActions, - onChanged: (v) => SampleAppConfig.update(context, config.copyWith(enableReminderActions: v)), - ), - _SwitchRow( - icon: icons.delete, - title: 'Delete for Me', - subtitle: 'Delete message for current user', - value: config.enableDeleteForMe, - onChanged: (v) => SampleAppConfig.update(context, config.copyWith(enableDeleteForMe: v)), - ), - _SwitchRow( - icon: icons.info, - title: 'Message Info', - subtitle: 'Show delivery info sheet', - value: config.enableMessageInfo, - onChanged: (v) => SampleAppConfig.update(context, config.copyWith(enableMessageInfo: v)), - ), - _SwitchRow( - icon: icons.location, - title: 'Location Sharing', - subtitle: 'Attachment builder and picker', - value: config.enableLocationSharing, - onChanged: (v) => SampleAppConfig.update(context, config.copyWith(enableLocationSharing: v)), + SizedBox(height: spacing.xl), + + // ── Features ── + const _SectionHeader(title: 'Features'), + SizedBox(height: spacing.xs), + _SettingsCard( + children: [ + _SwitchRow( + icon: icons.bell, + title: 'Reminders', + subtitle: 'Remind me, Save for later, Edit', + value: config.enableReminderActions, + onChanged: (v) => SampleAppConfig.update(context, config.copyWith(enableReminderActions: v)), + ), + _SwitchRow( + icon: icons.delete, + title: 'Delete for Me', + subtitle: 'Delete message for current user', + value: config.enableDeleteForMe, + onChanged: (v) => SampleAppConfig.update(context, config.copyWith(enableDeleteForMe: v)), + ), + _SwitchRow( + icon: icons.info, + title: 'Message Info', + subtitle: 'Show delivery info sheet', + value: config.enableMessageInfo, + onChanged: (v) => SampleAppConfig.update(context, config.copyWith(enableMessageInfo: v)), + ), + _SwitchRow( + icon: icons.location, + title: 'Location Sharing', + subtitle: 'Attachment builder and picker', + value: config.enableLocationSharing, + onChanged: (v) => SampleAppConfig.update(context, config.copyWith(enableLocationSharing: v)), + ), + ], ), - ], - ), - SizedBox(height: spacing.xl), - - // ── Chat ── - const _SectionHeader(title: 'Chat'), - SizedBox(height: spacing.xs), - _SettingsCard( - children: [ - _SwitchRow( - icon: icons.edit, - title: 'Draft Messages', - subtitle: 'Enable draft message saving', - value: config.draftMessagesEnabled, - onChanged: (v) => SampleAppConfig.update(context, config.copyWith(draftMessagesEnabled: v)), - ), - _SwitchRow( - icon: icons.emoji, - title: 'Unique Reactions', - subtitle: 'New reaction replaces existing', - value: config.enforceUniqueReactions, - onChanged: (v) => SampleAppConfig.update(context, config.copyWith(enforceUniqueReactions: v)), + SizedBox(height: spacing.xl), + + // ── Chat ── + const _SectionHeader(title: 'Chat'), + SizedBox(height: spacing.xs), + _SettingsCard( + children: [ + _SwitchRow( + icon: icons.edit, + title: 'Draft Messages', + subtitle: 'Enable draft message saving', + value: config.draftMessagesEnabled, + onChanged: (v) => SampleAppConfig.update(context, config.copyWith(draftMessagesEnabled: v)), + ), + _SwitchRow( + icon: icons.emoji, + title: 'Unique Reactions', + subtitle: 'New reaction replaces existing', + value: config.enforceUniqueReactions, + onChanged: (v) => SampleAppConfig.update(context, config.copyWith(enforceUniqueReactions: v)), + ), + ], ), - ], - ), - SizedBox(height: spacing.xl), - - // ── Reactions ── - const _SectionHeader(title: 'Reactions'), - SizedBox(height: spacing.xs), - _SettingsCard( - children: [ - _SegmentedRow( - title: 'Reaction Type', - value: config.reactionType, - segments: const { - null: 'Default', - StreamReactionsType.segmented: 'Segmented', - StreamReactionsType.clustered: 'Clustered', - }, - onChanged: (v) => SampleAppConfig.update(context, config.copyWith(reactionType: v)), - ), - _SegmentedRow( - title: 'Reaction Position', - value: config.reactionPosition, - segments: const { - null: 'Default', - StreamReactionsPosition.header: 'Header', - StreamReactionsPosition.footer: 'Footer', - }, - onChanged: (v) => SampleAppConfig.update(context, config.copyWith(reactionPosition: v)), + SizedBox(height: spacing.xl), + + // ── Reactions ── + const _SectionHeader(title: 'Reactions'), + SizedBox(height: spacing.xs), + _SettingsCard( + children: [ + _SegmentedRow( + title: 'Reaction Type', + value: config.reactionType, + segments: const { + null: 'Default', + StreamReactionsType.segmented: 'Segmented', + StreamReactionsType.clustered: 'Clustered', + }, + onChanged: (v) => SampleAppConfig.update(context, config.copyWith(reactionType: v)), + ), + _SegmentedRow( + title: 'Reaction Position', + value: config.reactionPosition, + segments: const { + null: 'Default', + StreamReactionsPosition.header: 'Header', + StreamReactionsPosition.footer: 'Footer', + }, + onChanged: (v) => SampleAppConfig.update(context, config.copyWith(reactionPosition: v)), + ), + ], ), + + SizedBox(height: spacing.xxl), ], ), - - SizedBox(height: spacing.xxl), - ], - ), + ); + }, ), ); } diff --git a/sample_app/lib/pages/advanced_options_page.dart b/sample_app/lib/pages/advanced_options_page.dart index 525e4cf2da..6533a122f9 100644 --- a/sample_app/lib/pages/advanced_options_page.dart +++ b/sample_app/lib/pages/advanced_options_page.dart @@ -100,13 +100,14 @@ class _AdvancedOptionsPageState extends State { @override Widget build(BuildContext context) { - return Scaffold( + return StreamScaffold( backgroundColor: context.streamColorScheme.backgroundApp, appBar: StreamAppBar(title: const Text('Custom settings')), body: Builder( builder: (context) { + final topInset = StreamScaffoldInsets.maybeOf(context)?.topPadding ?? 0.0; return Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + padding: EdgeInsets.fromLTRB(16, 16 + topInset, 16, 0), child: Form( key: _formKey, child: Column( diff --git a/sample_app/lib/pages/channel_file_display_screen.dart b/sample_app/lib/pages/channel_file_display_screen.dart index 5cc931d83a..b0effdb1b5 100644 --- a/sample_app/lib/pages/channel_file_display_screen.dart +++ b/sample_app/lib/pages/channel_file_display_screen.dart @@ -39,50 +39,54 @@ class _ChannelFileDisplayScreenState extends State { @override Widget build(BuildContext context) { final colorScheme = context.streamColorScheme; - return Scaffold( + return StreamScaffold( backgroundColor: colorScheme.backgroundApp, appBar: StreamAppBar(title: const Text('Files')), body: ValueListenableBuilder>( valueListenable: _controller, - builder: (context, value, _) => value.when( - (items, nextPageKey, _) { - // Flatten messages → individual file attachments paired with - // the message timestamp we'll bucket on. - final entries = <_FileEntry>[ - for (final response in items) - for (final attachment in response.message.attachments) - if (attachment.type == 'file') - _FileEntry( - attachment: attachment, - sentAt: response.message.createdAt, - ), - ]; - - if (entries.isEmpty) return const Center(child: _EmptyState()); - - // Pre-build a flat row list — interleave a header row above - // each month bucket so a single ListView.builder can render - // both kinds of rows without a CustomScrollView + slivers. - final rows = _buildRows(entries); - - return LazyLoadScrollView( - onEndOfPage: () async { - if (nextPageKey != null) await _controller.loadMore(nextPageKey); - }, - child: ListView.builder( - itemCount: rows.length, - itemBuilder: (context, index) => rows[index].build(context), + builder: (context, value, _) { + final topInset = StreamScaffoldInsets.maybeOf(context)?.topPadding ?? 0.0; + return value.when( + (items, nextPageKey, _) { + // Flatten messages → individual file attachments paired with + // the message timestamp we'll bucket on. + final entries = <_FileEntry>[ + for (final response in items) + for (final attachment in response.message.attachments) + if (attachment.type == 'file') + _FileEntry( + attachment: attachment, + sentAt: response.message.createdAt, + ), + ]; + + if (entries.isEmpty) return const Center(child: _EmptyState()); + + // Pre-build a flat row list — interleave a header row above + // each month bucket so a single ListView.builder can render + // both kinds of rows without a CustomScrollView + slivers. + final rows = _buildRows(entries); + + return LazyLoadScrollView( + onEndOfPage: () async { + if (nextPageKey != null) await _controller.loadMore(nextPageKey); + }, + child: ListView.builder( + padding: EdgeInsets.only(top: topInset), + itemCount: rows.length, + itemBuilder: (context, index) => rows[index].build(context), + ), + ); + }, + loading: () => const Center(child: StreamScrollViewLoadingWidget()), + error: (_) => Center( + child: StreamScrollViewErrorWidget( + errorTitle: const Text('Failed to load files'), + onRetryPressed: _controller.refresh, ), - ); - }, - loading: () => const Center(child: StreamScrollViewLoadingWidget()), - error: (_) => Center( - child: StreamScrollViewErrorWidget( - errorTitle: const Text('Failed to load files'), - onRetryPressed: _controller.refresh, ), - ), - ), + ); + }, ), ); } diff --git a/sample_app/lib/pages/channel_list_page.dart b/sample_app/lib/pages/channel_list_page.dart index 40bb513704..4838dad340 100644 --- a/sample_app/lib/pages/channel_list_page.dart +++ b/sample_app/lib/pages/channel_list_page.dart @@ -1,4 +1,4 @@ -// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use, avoid_redundant_argument_values import 'dart:async'; import 'dart:math' as math; @@ -47,28 +47,36 @@ class _ChannelListPageState extends State { final allTabs = <_TabDef>[ _TabDef( - icon: StreamUnreadIndicator(child: Icon(icons.messageBubble)), - selectedIcon: StreamUnreadIndicator(child: Icon(icons.messageBubbleFill)), - label: 'Chats', + navItem: StreamBottomNavBarItem( + icon: StreamUnreadIndicator(child: Icon(icons.messageBubble)), + selectedIcon: StreamUnreadIndicator(child: Icon(icons.messageBubbleFill)), + label: 'Chats', + ), page: const ChannelList(), ), _TabDef( - icon: StreamUnreadIndicator.threads(child: Icon(icons.thread)), - selectedIcon: StreamUnreadIndicator.threads(child: Icon(icons.threadFill)), - label: 'Threads', + navItem: StreamBottomNavBarItem( + icon: StreamUnreadIndicator.threads(child: Icon(icons.thread)), + selectedIcon: StreamUnreadIndicator.threads(child: Icon(icons.threadFill)), + label: 'Threads', + ), page: const ThreadListPage(), ), _TabDef( - icon: const Icon(Icons.drafts_outlined), - selectedIcon: const Icon(Icons.drafts_rounded), - label: 'Drafts', + navItem: const StreamBottomNavBarItem( + icon: Icon(Icons.drafts_outlined), + selectedIcon: Icon(Icons.drafts_rounded), + label: 'Drafts', + ), page: const DraftListPage(), enabled: config.draftMessagesEnabled, ), _TabDef( - icon: const Icon(Icons.bookmark_outline_rounded), - selectedIcon: const Icon(Icons.bookmark_rounded), - label: 'Reminders', + navItem: const StreamBottomNavBarItem( + icon: Icon(Icons.bookmark_outline_rounded), + selectedIcon: Icon(Icons.bookmark_rounded), + label: 'Reminders', + ), page: const RemindersPage(), enabled: config.enableReminderActions, ), @@ -76,39 +84,16 @@ class _ChannelListPageState extends State { final enabledTabs = allTabs.where((t) => t.enabled).toList(); - return Scaffold( + return StreamScaffold( backgroundColor: colorScheme.backgroundApp, appBar: StreamChannelListHeader( - title: Text(enabledTabs[_currentIndex].label, style: textTheme.headingSm), + title: Text(enabledTabs[_currentIndex].navItem.label, style: textTheme.headingSm), ), drawer: LeftDrawer(user: user), - bottomNavigationBar: DecoratedBox( - decoration: BoxDecoration( - color: colorScheme.backgroundElevation1, - border: Border(top: BorderSide(color: colorScheme.borderSubtle)), - ), - 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(), - ), - ), + bottom: StreamBottomNavBar( + currentIndex: _currentIndex, + onTap: (i) => setState(() => _currentIndex = i), + items: [for (final tab in enabledTabs) tab.navItem], ), body: IndexedStack( index: _currentIndex, @@ -140,18 +125,18 @@ class _ChannelListPageState extends State { } } +// --------------------------------------------------------------------------- +// Tab definition +// --------------------------------------------------------------------------- + class _TabDef { const _TabDef({ - required this.icon, - required this.selectedIcon, - required this.label, + required this.navItem, required this.page, this.enabled = true, }); - final Widget icon; - final Widget selectedIcon; - final String label; + final StreamBottomNavBarItem navItem; final Widget page; final bool enabled; } diff --git a/sample_app/lib/pages/channel_media_display_screen.dart b/sample_app/lib/pages/channel_media_display_screen.dart index 3aac2fabf4..01351a702a 100644 --- a/sample_app/lib/pages/channel_media_display_screen.dart +++ b/sample_app/lib/pages/channel_media_display_screen.dart @@ -38,44 +38,49 @@ class _ChannelMediaDisplayScreenState extends State { @override Widget build(BuildContext context) { final colorScheme = context.streamColorScheme; - return Scaffold( + return StreamScaffold( backgroundColor: colorScheme.backgroundApp, appBar: StreamAppBar(title: Text(context.translations.photosAndVideosLabel)), body: ValueListenableBuilder>( valueListenable: _controller, - builder: (context, value, _) => value.when( - (items, nextPageKey, _) { - // Flatten messages → individual image/video attachments. - // Excludes link previews (`ogScrapeUrl != null`) so we don't - // render every shared URL's thumbnail in the grid. - final attachments = [ - for (final response in items) - ...response.message.toMediaGalleryAttachments( - filter: (a) => - (a.type == AttachmentType.image || a.type == AttachmentType.video) && a.ogScrapeUrl == null, - ), - ]; + builder: (context, value, _) { + final topInset = StreamScaffoldInsets.maybeOf(context)?.topPadding ?? 0.0; + final spacing = context.streamSpacing; + return value.when( + (items, nextPageKey, _) { + // Flatten messages → individual image/video attachments. + // Excludes link previews (`ogScrapeUrl != null`) so we don't + // render every shared URL's thumbnail in the grid. + final attachments = [ + for (final response in items) + ...response.message.toMediaGalleryAttachments( + filter: (a) => + (a.type == AttachmentType.image || a.type == AttachmentType.video) && a.ogScrapeUrl == null, + ), + ]; - if (attachments.isEmpty) return const Center(child: _EmptyState()); + if (attachments.isEmpty) return const Center(child: _EmptyState()); - return LazyLoadScrollView( - onEndOfPage: () async { - if (nextPageKey != null) await _controller.loadMore(nextPageKey); - }, - child: StreamMediaGallery( - attachments: attachments, - onItemTap: (index) => _openPreview(context, attachments, index), + return LazyLoadScrollView( + onEndOfPage: () async { + if (nextPageKey != null) await _controller.loadMore(nextPageKey); + }, + child: StreamMediaGallery( + attachments: attachments, + padding: EdgeInsets.fromLTRB(spacing.xxxs, topInset + spacing.xxxs, spacing.xxxs, spacing.xxxs), + onItemTap: (index) => _openPreview(context, attachments, index), + ), + ); + }, + loading: () => const Center(child: StreamScrollViewLoadingWidget()), + error: (_) => Center( + child: StreamScrollViewErrorWidget( + errorTitle: const Text('Failed to load media'), + onRetryPressed: _controller.refresh, ), - ); - }, - loading: () => const Center(child: StreamScrollViewLoadingWidget()), - error: (_) => Center( - child: StreamScrollViewErrorWidget( - errorTitle: const Text('Failed to load media'), - onRetryPressed: _controller.refresh, ), - ), - ), + ); + }, ), ); } diff --git a/sample_app/lib/pages/channel_page.dart b/sample_app/lib/pages/channel_page.dart deleted file mode 100644 index b04d751638..0000000000 --- a/sample_app/lib/pages/channel_page.dart +++ /dev/null @@ -1,209 +0,0 @@ -// ignore_for_file: deprecated_member_use, avoid_redundant_argument_values - -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:sample_app/config/sample_app_config.dart'; -import 'package:sample_app/pages/thread_page.dart'; -import 'package:sample_app/routes/routes.dart'; -import 'package:sample_app/widgets/location/location_picker_dialog.dart'; -import 'package:sample_app/widgets/location/location_picker_option.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -class ChannelPage extends StatefulWidget { - const ChannelPage({ - super.key, - this.initialScrollIndex, - this.initialAlignment, - this.highlightInitialMessage = false, - }); - final int? initialScrollIndex; - final double? initialAlignment; - final bool highlightInitialMessage; - - @override - State createState() => _ChannelPageState(); -} - -class _ChannelPageState extends State { - FocusNode? _focusNode; - final _messageComposerController = StreamMessageComposerController(); - - @override - void initState() { - _focusNode = FocusNode(); - super.initState(); - } - - @override - void dispose() { - _focusNode!.dispose(); - _messageComposerController.dispose(); - super.dispose(); - } - - void _reply(Message message) { - _messageComposerController.quotedMessage = message; - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - _focusNode!.requestFocus(); - }); - } - - void _editMessage(Message message) { - _messageComposerController.editMessage(message); - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - _focusNode!.requestFocus(); - }); - } - - @override - Widget build(BuildContext context) { - final channel = StreamChannel.of(context).channel; - final config = channel.config; - - return Scaffold( - backgroundColor: context.streamColorScheme.backgroundApp, - appBar: StreamChannelHeader( - onChannelAvatarPressed: (channel) { - final isOneToOne = channel.isOneToOne; - final currentUserId = StreamChat.of(context).currentUser?.id; - - final channelMembers = channel.state?.members ?? []; - final otherUser = isOneToOne ? channelMembers.firstWhere((m) => m.userId != currentUserId).user : null; - - _pushChannelInfo(context, channel, otherUser); - }, - ), - body: Column( - children: [ - Expanded( - child: Stack( - children: [ - StreamMessageListView( - initialScrollIndex: widget.initialScrollIndex, - initialAlignment: widget.initialAlignment, - config: StreamMessageListViewConfiguration( - swipeToReply: true, - highlightInitialMessage: widget.highlightInitialMessage, - ), - onEditMessageTap: _editMessage, - onReplyTap: _reply, - threadBuilder: (_, parentMessage) { - return ThreadPage(parent: parentMessage!); - }, - ), - Positioned( - bottom: 0, - left: 0, - right: 0, - child: Container( - alignment: Alignment.centerLeft, - color: context.streamColorScheme.backgroundApp.withOpacity(.9), - child: StreamTypingIndicator( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - style: context.streamTextTheme.captionDefault.copyWith( - color: context.streamColorScheme.textSecondary, - ), - ), - ), - ), - ], - ), - ), - Builder( - builder: (context) { - final appConfig = context.sampleAppConfig; - final locationEnabled = - appConfig.enableLocationSharing && config?.sharedLocations == true && channel.canShareLocation; - - return StreamMessageComposer( - focusNode: _focusNode, - messageComposerController: _messageComposerController, - onQuotedMessageCleared: _messageComposerController.clearQuotedMessage, - enableVoiceRecording: true, - allowedAttachmentPickerTypes: [ - ...AttachmentPickerType.values, - if (locationEnabled) const LocationPickerType(), - ], - onAttachmentPickerResult: (result) { - return _onCustomAttachmentPickerResult(channel, result); - }, - attachmentPickerOptionsBuilder: (context, defaultOptions) => [ - ...defaultOptions, - if (locationEnabled) - TabbedAttachmentPickerOption( - key: 'location-picker', - icon: context.streamIcons.location, - supportedTypes: [const LocationPickerType()], - isEnabled: (value) { - if (value.isEmpty) return true; - return value.extraData['location'] != null; - }, - optionViewBuilder: (context, controller) => LocationPicker( - onLocationPicked: (locationResult) { - if (locationResult == null) return; - - controller.notifyCustomResult( - LocationPicked(location: locationResult), - ); - }, - ), - ), - ], - ); - }, - ), - ], - ), - ); - } - - bool _onCustomAttachmentPickerResult( - Channel channel, - StreamAttachmentPickerResult result, - ) { - if (result is LocationPicked) { - _onShareLocationPicked(channel, result.location).ignore(); - return true; // Notify that the result was handled. - } - - return false; // Notify that the result was not handled. - } - - Future _onShareLocationPicked( - Channel channel, - LocationPickerResult result, - ) async { - if (result.endSharingAt case final endSharingAt?) { - return channel.startLiveLocationSharing( - endSharingAt: endSharingAt, - location: result.coordinates, - ); - } - - return channel.sendStaticLocation(location: result.coordinates); - } -} - -// Pushes the chat / group info screen depending on whether [user] was -// resolved. 1-1 channels pass the other member here (forwarded as `extra` -// to the chat-info route); group channels pass `null` and route to the -// group info screen. -Future _pushChannelInfo(BuildContext context, Channel channel, User? user) { - final router = GoRouter.of(context); - - if (user != null) { - return router.pushNamed( - Routes.CHAT_INFO_SCREEN.name, - pathParameters: Routes.CHAT_INFO_SCREEN.params(channel), - extra: user, - ); - } - - return router.pushNamed( - Routes.GROUP_INFO_SCREEN.name, - pathParameters: Routes.GROUP_INFO_SCREEN.params(channel), - ); -} diff --git a/sample_app/lib/pages/chat_info_screen.dart b/sample_app/lib/pages/chat_info_screen.dart index 77906922ed..e9d605132a 100644 --- a/sample_app/lib/pages/chat_info_screen.dart +++ b/sample_app/lib/pages/chat_info_screen.dart @@ -27,31 +27,36 @@ class ChatInfoScreen extends StatelessWidget { final spacing = context.streamSpacing; final colorScheme = context.streamColorScheme; - return Scaffold( + return StreamScaffold( backgroundColor: colorScheme.backgroundApp, appBar: StreamAppBar(title: const Text('Contact Info')), // Action / chevron icons share a uniform 20px size — set once at the // top of the body so individual rows stay style-free. - body: IconTheme.merge( - data: const IconThemeData(size: 20), - child: SingleChildScrollView( - padding: .directional( - top: spacing.xxl, - bottom: spacing.xxxl, - start: spacing.md, - end: spacing.md, - ), - child: Column( - mainAxisSize: .min, - children: [ - _ContactInfoHeader(user: user), - SizedBox(height: spacing.xxl), - const _MediaSection(), - SizedBox(height: spacing.md), - const _ActionsSection(), - ], - ), - ), + body: Builder( + builder: (context) { + final topInset = StreamScaffoldInsets.maybeOf(context)?.topPadding ?? 0.0; + return IconTheme.merge( + data: const IconThemeData(size: 20), + child: SingleChildScrollView( + padding: .directional( + top: spacing.xxl + topInset, + bottom: spacing.xxxl, + start: spacing.md, + end: spacing.md, + ), + child: Column( + mainAxisSize: .min, + children: [ + _ContactInfoHeader(user: user), + SizedBox(height: spacing.xxl), + const _MediaSection(), + SizedBox(height: spacing.md), + const _ActionsSection(), + ], + ), + ), + ); + }, ), ); } diff --git a/sample_app/lib/pages/draft_list_page.dart b/sample_app/lib/pages/draft_list_page.dart index e486ac9d05..fa50bf2464 100644 --- a/sample_app/lib/pages/draft_list_page.dart +++ b/sample_app/lib/pages/draft_list_page.dart @@ -1,7 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; -import 'package:sample_app/pages/channel_page.dart'; -import 'package:sample_app/pages/thread_page.dart'; import 'package:sample_app/widgets/stream_draft_list_view.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; @@ -71,10 +69,10 @@ class _DraftListPageState extends State { channel: channel, initialMessageId: draft.parentId, child: switch (draft.parentMessage) { - final parent? => ThreadPage( + final parent? => StreamThreadPage( parent: parent.copyWith(draft: draft), ), - _ => const ChannelPage(), + _ => const StreamChannelPage(), }, ); }, diff --git a/sample_app/lib/pages/group_chat_details_screen.dart b/sample_app/lib/pages/group_chat_details_screen.dart index 08fc6dbb62..9a7b1adc80 100644 --- a/sample_app/lib/pages/group_chat_details_screen.dart +++ b/sample_app/lib/pages/group_chat_details_screen.dart @@ -48,7 +48,7 @@ class _GroupChatDetailsScreenState extends State { GoRouter.of(context).pop(); return false; }, - child: Scaffold( + child: StreamScaffold( backgroundColor: context.streamColorScheme.backgroundApp, appBar: StreamAppBar( title: const Text('Name of Group Chat'), @@ -105,117 +105,123 @@ class _GroupChatDetailsScreenState extends State { tileAnchor: Alignment.topCenter, childAnchor: Alignment.topCenter, message: statusString, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 16), - child: Row( - children: [ - Text( - 'Name'.toUpperCase(), - style: TextStyle( - fontSize: 12, - color: context.streamColorScheme.textSecondary, - ), - ), - const SizedBox(width: 16), - Expanded( - child: TextField( - controller: _groupNameController, - decoration: InputDecoration( - isDense: true, - border: InputBorder.none, - focusedBorder: InputBorder.none, - enabledBorder: InputBorder.none, - errorBorder: InputBorder.none, - disabledBorder: InputBorder.none, - contentPadding: EdgeInsets.zero, - hintText: 'Choose a group chat name', - hintStyle: TextStyle( - fontSize: 14, + child: Builder( + builder: (context) { + final topInset = StreamScaffoldInsets.maybeOf(context)?.topPadding ?? 0.0; + return Column( + children: [ + if (topInset > 0) SizedBox(height: topInset), + Padding( + padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 16), + child: Row( + children: [ + Text( + 'Name'.toUpperCase(), + style: TextStyle( + fontSize: 12, color: context.streamColorScheme.textSecondary, ), ), - ), + const SizedBox(width: 16), + Expanded( + child: TextField( + controller: _groupNameController, + decoration: InputDecoration( + isDense: true, + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + disabledBorder: InputBorder.none, + contentPadding: EdgeInsets.zero, + hintText: 'Choose a group chat name', + hintStyle: TextStyle( + fontSize: 14, + color: context.streamColorScheme.textSecondary, + ), + ), + ), + ), + ], ), - ], - ), - ), - Container( - width: double.maxFinite, - decoration: BoxDecoration( - color: context.streamColorScheme.backgroundElevation1, - ), - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 8, - horizontal: 8, ), - child: Text( - '$_totalUsers ${_totalUsers > 1 ? 'Members' : 'Member'}', - style: TextStyle( - color: context.streamColorScheme.textSecondary, + Container( + width: double.maxFinite, + decoration: BoxDecoration( + color: context.streamColorScheme.backgroundElevation1, ), - ), - ), - ), - AnimatedBuilder( - animation: widget.groupChatState, - builder: (context, child) { - return Expanded( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onPanDown: (_) => FocusScope.of(context).unfocus(), - child: ListView.separated( - itemCount: widget.groupChatState.users.length + 1, - separatorBuilder: (_, __) => Container( - height: 1, - color: context.streamColorScheme.borderDefault, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 8, + ), + child: Text( + '$_totalUsers ${_totalUsers > 1 ? 'Members' : 'Member'}', + style: TextStyle( + color: context.streamColorScheme.textSecondary, ), - itemBuilder: (_, index) { - if (index == widget.groupChatState.users.length) { - return Container( + ), + ), + ), + AnimatedBuilder( + animation: widget.groupChatState, + builder: (context, child) { + return Expanded( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onPanDown: (_) => FocusScope.of(context).unfocus(), + child: ListView.separated( + itemCount: widget.groupChatState.users.length + 1, + separatorBuilder: (_, __) => Container( height: 1, color: context.streamColorScheme.borderDefault, - ); - } - final user = widget.groupChatState.users.elementAt(index); - return ListTile( - key: ObjectKey(user), - leading: StreamUserAvatar( - size: .lg, - user: user, - ), - title: Text( - user.name, - style: const TextStyle(fontWeight: FontWeight.bold), ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - trailing: IconButton( - icon: Icon( - Icons.clear_rounded, - color: context.streamColorScheme.textPrimary, - ), - padding: EdgeInsets.zero, - splashRadius: 24, - onPressed: () { - widget.groupChatState.removeUser(user); - if (widget.groupChatState.users.isEmpty) { - GoRouter.of(context).pop(); - } - }, - ), - ); - }, - ), - ), - ); - }, - ), - ], + itemBuilder: (_, index) { + if (index == widget.groupChatState.users.length) { + return Container( + height: 1, + color: context.streamColorScheme.borderDefault, + ); + } + final user = widget.groupChatState.users.elementAt(index); + return ListTile( + key: ObjectKey(user), + leading: StreamUserAvatar( + size: .lg, + user: user, + ), + title: Text( + user.name, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + trailing: IconButton( + icon: Icon( + Icons.clear_rounded, + color: context.streamColorScheme.textPrimary, + ), + padding: EdgeInsets.zero, + splashRadius: 24, + onPressed: () { + widget.groupChatState.removeUser(user); + if (widget.groupChatState.users.isEmpty) { + GoRouter.of(context).pop(); + } + }, + ), + ); + }, + ), + ), + ); + }, + ), + ], + ); + }, ), ); }, diff --git a/sample_app/lib/pages/group_info_screen.dart b/sample_app/lib/pages/group_info_screen.dart index 3f26366c6f..0d1ed66cea 100644 --- a/sample_app/lib/pages/group_info_screen.dart +++ b/sample_app/lib/pages/group_info_screen.dart @@ -24,7 +24,7 @@ class GroupInfoScreen extends StatelessWidget { final colorScheme = context.streamColorScheme; final channel = StreamChannel.of(context).channel; - return Scaffold( + return StreamScaffold( backgroundColor: colorScheme.backgroundApp, appBar: StreamAppBar( title: const Text('Group Info'), @@ -41,28 +41,33 @@ class GroupInfoScreen extends StatelessWidget { ), // Action / chevron icons share a uniform 20px size — set once at the // top of the body so individual rows stay style-free. - body: IconTheme.merge( - data: const IconThemeData(size: 20), - child: SingleChildScrollView( - padding: .directional( - top: spacing.xxl, - bottom: spacing.xxxl, - start: spacing.md, - end: spacing.md, - ), - child: Column( - mainAxisSize: .min, - children: [ - const _GroupInfoHeader(), - SizedBox(height: spacing.xxl), - const _MediaSection(), - SizedBox(height: spacing.md), - const _MembersSection(), - SizedBox(height: spacing.md), - const _ActionsSection(), - ], - ), - ), + body: Builder( + builder: (context) { + final topInset = StreamScaffoldInsets.maybeOf(context)?.topPadding ?? 0.0; + return IconTheme.merge( + data: const IconThemeData(size: 20), + child: SingleChildScrollView( + padding: .directional( + top: spacing.xxl + topInset, + bottom: spacing.xxxl, + start: spacing.md, + end: spacing.md, + ), + child: Column( + mainAxisSize: .min, + children: [ + const _GroupInfoHeader(), + SizedBox(height: spacing.xxl), + const _MediaSection(), + SizedBox(height: spacing.md), + const _MembersSection(), + SizedBox(height: spacing.md), + const _ActionsSection(), + ], + ), + ), + ); + }, ), ); } diff --git a/sample_app/lib/pages/new_chat_screen.dart b/sample_app/lib/pages/new_chat_screen.dart index 673292ef39..fecf2a56a4 100644 --- a/sample_app/lib/pages/new_chat_screen.dart +++ b/sample_app/lib/pages/new_chat_screen.dart @@ -138,11 +138,12 @@ class _NewChatScreenState extends State { @override Widget build(BuildContext context) { - return Scaffold( + return StreamScaffold( backgroundColor: context.streamColorScheme.backgroundApp, appBar: StreamAppBar(title: const Text('New Chat')), body: StreamConnectionStatusBuilder( statusBuilder: (context, status) { + final topInset = StreamScaffoldInsets.maybeOf(context)?.topPadding ?? 0.0; var statusString = ''; var showStatus = true; @@ -169,6 +170,7 @@ class _NewChatScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (topInset > 0) SizedBox(height: topInset), ChipsInputTextField( key: _chipInputTextFieldStateKey, controller: _controller, diff --git a/sample_app/lib/pages/new_group_chat_screen.dart b/sample_app/lib/pages/new_group_chat_screen.dart index b5d9c2eb53..54c53efa05 100644 --- a/sample_app/lib/pages/new_group_chat_screen.dart +++ b/sample_app/lib/pages/new_group_chat_screen.dart @@ -7,7 +7,9 @@ import 'package:sample_app/state/new_group_chat_state.dart'; import 'package:sample_app/widgets/search_text_field.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +/// A screen for creating a new group chat by searching for and selecting users. class NewGroupChatScreen extends StatefulWidget { + /// Creates a [NewGroupChatScreen]. const NewGroupChatScreen({super.key}); @override @@ -66,7 +68,7 @@ class _NewGroupChatScreenState extends State { animation: groupChatState, builder: (context, child) { final state = groupChatState; - return Scaffold( + return StreamScaffold( backgroundColor: context.streamColorScheme.backgroundApp, appBar: StreamAppBar( title: const Text('Add Group Members'), @@ -108,7 +110,9 @@ class _NewGroupChatScreenState extends State { child: NestedScrollView( floatHeaderSlivers: true, headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { + final topInset = StreamScaffoldInsets.maybeOf(context)?.topPadding ?? 0.0; return [ + if (topInset > 0) SliverToBoxAdapter(child: SizedBox(height: topInset)), SliverToBoxAdapter( child: SearchTextField( controller: _controller, diff --git a/sample_app/lib/pages/pinned_messages_screen.dart b/sample_app/lib/pages/pinned_messages_screen.dart index f2b52a4b16..e07c2e1aa3 100644 --- a/sample_app/lib/pages/pinned_messages_screen.dart +++ b/sample_app/lib/pages/pinned_messages_screen.dart @@ -37,13 +37,19 @@ class _PinnedMessagesScreenState extends State { Widget build(BuildContext context) { final colorScheme = context.streamColorScheme; - return Scaffold( + return StreamScaffold( backgroundColor: colorScheme.backgroundApp, appBar: StreamAppBar(title: const Text('Pinned Messages')), - body: StreamMessageSearchListView( - controller: _controller, - emptyBuilder: (_) => const Center(child: _EmptyState()), - onMessageTap: _openMessage, + body: Builder( + builder: (context) { + final topInset = StreamScaffoldInsets.maybeOf(context)?.topPadding ?? 0.0; + return StreamMessageSearchListView( + controller: _controller, + padding: EdgeInsets.only(top: topInset), + emptyBuilder: (_) => const Center(child: _EmptyState()), + onMessageTap: _openMessage, + ); + }, ), ); } diff --git a/sample_app/lib/pages/thread_list_page.dart b/sample_app/lib/pages/thread_list_page.dart index ef0bd215a3..b9cfaf29f8 100644 --- a/sample_app/lib/pages/thread_list_page.dart +++ b/sample_app/lib/pages/thread_list_page.dart @@ -1,7 +1,6 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'package:sample_app/pages/thread_page.dart'; import 'package:sample_app/routes/routes.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; @@ -63,7 +62,7 @@ class _ThreadListPageState extends State { .where((msg) => msg != null) .cast(), builder: (_, parentMessage) { - return ThreadPage( + return StreamThreadPage( parent: parentMessage, onViewInChannelTap: (message) { GoRouter.of(context).goNamed( diff --git a/sample_app/lib/pages/thread_page.dart b/sample_app/lib/pages/thread_page.dart deleted file mode 100644 index f2bd9711e9..0000000000 --- a/sample_app/lib/pages/thread_page.dart +++ /dev/null @@ -1,86 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -class ThreadPage extends StatefulWidget { - const ThreadPage({ - super.key, - required this.parent, - this.initialScrollIndex, - this.initialAlignment, - this.onViewInChannelTap, - }); - final Message parent; - final int? initialScrollIndex; - final double? initialAlignment; - final void Function(Message message)? onViewInChannelTap; - - @override - State createState() => _ThreadPageState(); -} - -class _ThreadPageState extends State { - final FocusNode _focusNode = FocusNode(); - late StreamMessageComposerController _messageComposerController; - - @override - void initState() { - super.initState(); - _messageComposerController = StreamMessageComposerController( - message: Message(parentId: widget.parent.id), - ); - } - - @override - void dispose() { - _focusNode.dispose(); - _messageComposerController.dispose(); - super.dispose(); - } - - void _reply(Message message) { - _messageComposerController.quotedMessage = message; - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - _focusNode.requestFocus(); - }); - } - - void _editMessage(Message message) { - _messageComposerController.editMessage(message); - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - _focusNode.requestFocus(); - }); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: context.streamColorScheme.backgroundApp, - appBar: StreamThreadHeader(parent: widget.parent), - body: Column( - children: [ - Expanded( - child: StreamMessageListView( - parentMessage: widget.parent, - initialScrollIndex: widget.initialScrollIndex, - initialAlignment: widget.initialAlignment, - onReplyTap: _reply, - onEditMessageTap: _editMessage, - config: const StreamMessageListViewConfiguration( - swipeToReply: true, - showScrollToBottom: false, - highlightInitialMessage: true, - ), - onViewInChannelTap: widget.onViewInChannelTap, - ), - ), - if (widget.parent.type != 'deleted') - StreamMessageComposer( - focusNode: _focusNode, - messageComposerController: _messageComposerController, - enableVoiceRecording: true, - ), - ], - ), - ); - } -} diff --git a/sample_app/lib/routes/app_routes.dart b/sample_app/lib/routes/app_routes.dart index 3e11a35f2f..c6cb1631a0 100644 --- a/sample_app/lib/routes/app_routes.dart +++ b/sample_app/lib/routes/app_routes.dart @@ -3,14 +3,12 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:sample_app/pages/advanced_options_page.dart'; import 'package:sample_app/pages/channel_list_page.dart'; -import 'package:sample_app/pages/channel_page.dart'; import 'package:sample_app/pages/chat_info_screen.dart'; import 'package:sample_app/pages/choose_user_page.dart'; import 'package:sample_app/pages/group_chat_details_screen.dart'; import 'package:sample_app/pages/group_info_screen.dart'; import 'package:sample_app/pages/new_chat_screen.dart'; import 'package:sample_app/pages/new_group_chat_screen.dart'; -import 'package:sample_app/pages/thread_page.dart'; import 'package:sample_app/routes/routes.dart'; import 'package:sample_app/state/new_group_chat_state.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; @@ -45,9 +43,33 @@ final appRoutes = [ child: Builder( builder: (context) { return (parentMessage != null) - ? ThreadPage(parent: parentMessage) - : ChannelPage( - highlightInitialMessage: messageId != null, + ? StreamThreadPage(parent: parentMessage) + : StreamChannelPage( + onChannelAvatarPressed: (context, channel) { + final isOneToOne = channel.isOneToOne; + final currentUserId = StreamChat.of(context).currentUser?.id; + + final channelMembers = channel.state?.members ?? []; + final otherUser = (isOneToOne && currentUserId != null) + ? channelMembers.firstWhereOrNull((m) => m.userId != currentUserId)?.user + : null; + + final router = GoRouter.of(context); + + if (otherUser != null) { + router.pushNamed( + Routes.CHAT_INFO_SCREEN.name, + pathParameters: Routes.CHAT_INFO_SCREEN.params(channel), + extra: otherUser, + ); + return; + } + + router.pushNamed( + Routes.GROUP_INFO_SCREEN.name, + pathParameters: Routes.GROUP_INFO_SCREEN.params(channel), + ); + }, ); }, ), diff --git a/sample_app/lib/widgets/channel_list.dart b/sample_app/lib/widgets/channel_list.dart index bb78718161..6c68eba582 100644 --- a/sample_app/lib/widgets/channel_list.dart +++ b/sample_app/lib/widgets/channel_list.dart @@ -83,14 +83,21 @@ class _ChannelList extends State { }, child: NestedScrollView( controller: _scrollController, - headerSliverBuilder: (_, __) => [ - SliverToBoxAdapter( - child: SearchTextField( - controller: _controller, - hintText: 'Search', + headerSliverBuilder: (context, __) { + // When the app bar is floating it overlaps this scroll view from + // the top. Insert a spacer sliver so the search bar starts below + // the visible bottom edge of the floating bar. + final topInset = StreamScaffoldInsets.maybeOf(context)?.topPadding ?? 0.0; + return [ + if (topInset > 0) SliverToBoxAdapter(child: SizedBox(height: topInset)), + SliverToBoxAdapter( + child: SearchTextField( + controller: _controller, + hintText: 'Search', + ), ), - ), - ], + ]; + }, body: _isSearchActive ? _ChannelListSearch(_messageSearchListController) : _ChannelListDefault(_channelListController), @@ -107,11 +114,14 @@ class _ChannelListDefault extends StatelessWidget { @override Widget build(BuildContext context) { + final bottomPadding = StreamScaffoldInsets.maybeOf(context)?.bottomPadding ?? 0.0; + return SlidableAutoCloseBehavior( child: RefreshIndicator( onRefresh: channelListController.refresh, child: StreamChannelListView( controller: channelListController, + padding: bottomPadding > 0 ? EdgeInsets.only(bottom: bottomPadding) : null, itemBuilder: (context, channels, index, defaultWidget) { final channel = channels[index];