diff --git a/docs/docs_screenshots/pubspec.yaml b/docs/docs_screenshots/pubspec.yaml index 03fd062390..821db406d8 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: f022a26fea479b97d5a999cb8a7cdf5969b075f7 + ref: 66ee511050b8ef4f8a39edf0c2c62568b521d34d 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 ed9c30b5e4..17979a5cdb 100644 --- a/docs/docs_screenshots/test/src/mocks.dart +++ b/docs/docs_screenshots/test/src/mocks.dart @@ -132,7 +132,7 @@ void setupMockChannel({ when(() => channel.lastMessageAtStream).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.isDistinct).thenReturn(false); when(() => channel.isMuted).thenReturn(false); diff --git a/melos.yaml b/melos.yaml index b568a523f4..de07fca715 100644 --- a/melos.yaml +++ b/melos.yaml @@ -101,7 +101,7 @@ command: stream_core_flutter: git: url: https://github.com/GetStream/stream-core-flutter.git - ref: f022a26fea479b97d5a999cb8a7cdf5969b075f7 + ref: 66ee511050b8ef4f8a39edf0c2c62568b521d34d path: packages/stream_core_flutter synchronized: ^3.4.0 thumblr: ^0.0.4 diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index b8e76a472e..3375f2cb93 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -6,6 +6,15 @@ ✅ 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 `MessageComposerProps.composerLocation` — explicitly controls whether `StreamMessageComposer` renders in `floating` or `docked` mode; falls back to the theme's `appStyle.composerLocation` when null. `copyWith` now distinguishes "not passed" from "explicitly set to null" via a sentinel so callers can clear an override back to the theme default. +- 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 `appStyle.appBarBehavior` from the theme 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. +- `StreamMessageComposer` now surfaces the hold-to-record hint through `StreamSnackbar` anchored above the composer, and `StreamChat` provides an app-wide `StreamSnackbarScope` fallback. +- Re-exported `StreamScaffold`, `StreamScaffoldInsets`, `StreamBottomNavBar`, `StreamBottomNavBarItem`, `StreamAppStyle`, `AppBarBehavior`, `BottomBarBehavior`, `ComposerLocation`, and `streamFloatingFade` from `stream_core_flutter` via `package:stream_chat_flutter/stream_chat_flutter.dart`. - Added support for `@channel`, `@here`, role, and user-group mentions — parsed on incoming messages, rendered as styled tappable spans in message text, and selectable from the composer's `@` autocomplete. - Added a single `mentionItemBuilder` on `StreamMessageComposer` and `StreamMentionAutocompleteOptions` that receives `StreamMentionItemProps` and covers every mention kind. Customise globally via `streamChatComponentBuilders(mentionItem: ...)` or per-instance via the new constructor parameter. Defaults are rendered by `DefaultStreamMentionItem`. Also added `onMention*Tap` callbacks on `StreamMentionAutocompleteOptions`. - Added `StreamMessageListView.onMentionTap` and `StreamMessageItem.onMentionTap` — receives a typed `StreamMention` (`StreamUserMention`, `StreamChannelMention`, `StreamHereMention`, `StreamRoleMention`, or `StreamGroupMention`). @@ -27,6 +36,8 @@ 🐞 Fixed +- Fixed `StreamChannelPage` leaking its `StreamMessageComposerController` — the state now disposes it in `dispose()`, matching the pattern already used by `StreamThreadPage`. +- Fixed `StreamPhotoGallery` default padding having a redundant explicit `top: 0` (no visual change). - `StreamMessageItem.onUserAvatarTap` now fires when the author avatar is tapped. ([#2741](https://github.com/GetStream/stream-chat-flutter/issues/2741)) - Added `MessageComposerProps.copyWith` so factory overrides can tweak individual props (e.g. `useSystemAttachmentPicker`) without re-specifying every field. ([#2742](https://github.com/GetStream/stream-chat-flutter/issues/2742)) - `StreamMessageContent` no longer re-runs `setState` on every inherited-widget change when the measured attachment width has not changed. ([#2761](https://github.com/GetStream/stream-chat-flutter/issues/2761)) 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 3d1fb1e3e5..0950f2fc5e 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( @@ -197,10 +210,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) { @@ -221,6 +235,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..d05ffe1912 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/channel/channel_page.dart @@ -0,0 +1,148 @@ +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; + + /// Callback for when the back button is pressed. + 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( + 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 7e3974a44b..88f7497f08 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 @@ -54,6 +54,7 @@ class StreamChannelAvatar extends StatelessWidget { super.key, this.size, required this.channel, + this.isFloating, }); /// The channel whose avatar is displayed. @@ -64,6 +65,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; + @override Widget build(BuildContext context) { assert(channel.state != null, 'Channel ${channel.id} is not initialized'); @@ -76,6 +84,7 @@ class StreamChannelAvatar extends StatelessWidget { builder: (context, channelImage) => StreamAvatar( imageUrl: channelImage, size: _avatarSizeForAvatarGroupSize(effectiveSize), + isFloating: isFloating, placeholder: (_) => const _StreamChannelAvatarPlaceholder(), ), noDataBuilder: (context) => BetterStreamBuilder( @@ -95,12 +104,14 @@ class StreamChannelAvatar extends StatelessWidget { size: _avatarSizeForAvatarGroupSize(effectiveSize), // TODO: make this configurable when the online state is shown. showOnlineIndicator: otherUser.online, + isFloating: isFloating, ); } return StreamUserAvatarGroup( size: effectiveSize, 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 3be8c9a204..04e13bd0d2 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, }); /// The user whose avatar is displayed. @@ -84,6 +85,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 @@ -107,6 +115,7 @@ class StreamUserAvatar extends StatelessWidget { size: effectiveSize, imageUrl: user.image, showBorder: showBorder, + isFloating: isFloating, backgroundColor: effectiveBackgroundColor, foregroundColor: effectiveForegroundColor, placeholder: (_) => _StreamUserAvatarPlaceholder(user: user, size: effectiveSize), 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 7466d519a0..07753462a2 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, }); /// The list of users whose avatars are displayed. @@ -65,6 +66,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; + @override Widget build(BuildContext context) { return StreamAvatarGroup( @@ -73,6 +81,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 98be7287be..99a051bef2 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 60943f9e9a..bd9085cdd6 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 @@ -96,6 +96,7 @@ class StreamMessageComposer extends StatelessWidget { TextCapitalization textCapitalization = TextCapitalization.sentences, bool autofocus = false, bool autoCorrect = true, + ComposerLocation? location, }) : props = .new( onMessageSent: onMessageSent, preMessageSending: preMessageSending, @@ -133,6 +134,7 @@ class StreamMessageComposer extends StatelessWidget { textCapitalization: textCapitalization, autofocus: autofocus, autoCorrect: autoCorrect, + location: location, ); /// Creates a [StreamMessageComposer] from a pre-built [MessageComposerProps]. @@ -194,6 +196,7 @@ class MessageComposerProps { this.textCapitalization = TextCapitalization.sentences, this.autofocus = false, this.autoCorrect = true, + this.location, }); /// Function called after sending the message. @@ -403,6 +406,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({ @@ -441,6 +447,7 @@ class MessageComposerProps { TextCapitalization? textCapitalization, bool? autofocus, bool? autoCorrect, + ComposerLocation? location, }) { return MessageComposerProps( onMessageSent: onMessageSent ?? this.onMessageSent, @@ -478,6 +485,7 @@ class MessageComposerProps { textCapitalization: textCapitalization ?? this.textCapitalization, autofocus: autofocus ?? this.autofocus, autoCorrect: autoCorrect ?? this.autoCorrect, + location: location ?? this.location, ); } @@ -809,27 +817,61 @@ class DefaultStreamMessageComposerState extends State 0) { + final bandColor = context.streamColorScheme.backgroundElevation1; + return Stack( + children: [ + Positioned( + bottom: 0, + left: 0, + right: 0, + height: safeAreaPadding.bottom, + child: ColoredBox(color: bandColor), + ), + Padding(padding: safeAreaPadding, child: child), + ], + ); + } + + return Padding(padding: safeAreaPadding, child: child); + }, + child: Center(heightFactor: 1, child: messageInput), + ); + return Material( - color: context.streamColorScheme.backgroundElevation1, - child: AnimatedBuilder( - animation: _pickerAnimation, - builder: (context, child) { - final safeAreaPadding = safeAreaEnabled - ? EdgeInsets.lerp( - EdgeInsets.only( - left: viewPadding.left, - top: viewPadding.top, - right: viewPadding.right, - bottom: math.max(viewPadding.bottom, spacing.md), - ), - EdgeInsets.zero, - _pickerAnimation.value, - )! - : EdgeInsets.zero; - return Padding(padding: safeAreaPadding, child: child); - }, - child: Center(heightFactor: 1, child: messageInput), - ), + color: Colors.transparent, + child: switch (effectiveComposerLocation) { + .floating => composerBody, + .docked => DecoratedBox( + decoration: BoxDecoration(color: context.streamColorScheme.backgroundElevation1), + child: composerBody, + ), + }, ); } @@ -918,63 +960,103 @@ 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), + ), + // Gradient wraps only the pill — not the picker sibling — so + // the gradient height tracks the pill and doesn't stretch when + // the picker opens or closes. + if (isFloating) _buildFloatingComposerBand(context, pill) else pill, + ], + ), + ); + }, + ); + } + + /// Wraps [child] (the pill widget) with a floating gradient background that + /// fades from transparent at the top to solid + /// [StreamColorScheme.backgroundElevation1] at the bottom. + /// + /// Applied to just the pill [DropTarget] so the gradient height tracks the + /// pill (growing with attachments) and is unaffected by the picker opening. + Widget _buildFloatingComposerBand(BuildContext context, Widget child) { + final bandColor = context.streamColorScheme.backgroundElevation1; + + return DecoratedBox( + decoration: BoxDecoration( + gradient: streamFloatingFadeLinearGradient( + color: bandColor, + begin: Alignment.bottomCenter, + end: Alignment.topCenter, ), ), + child: child, ); } @@ -989,29 +1071,29 @@ class DefaultStreamMessageComposerState extends State _StreamMessageListViewState(); } @@ -326,6 +342,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; @@ -353,7 +377,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, @@ -549,9 +573,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, @@ -595,7 +619,7 @@ class _StreamMessageListViewState extends State { } return StreamInfoTile( - showMessage: widget.config.showConnectionStateTile && showStatus, + showMessage: _config.showConnectionStateTile && showStatus, tileAnchor: Alignment.topCenter, childAnchor: Alignment.topCenter, message: statusString, @@ -609,15 +633,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 @@ -661,7 +688,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( @@ -675,7 +702,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); @@ -684,7 +711,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 { @@ -721,7 +748,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(); @@ -743,7 +770,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(); @@ -762,13 +789,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) { @@ -777,22 +804,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, @@ -988,7 +1025,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, @@ -1042,14 +1079,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, @@ -1057,7 +1094,7 @@ class _StreamMessageListViewState extends State { } return PositionedDirectional( - bottom: 16, + bottom: max(16, widget.bottomPadding), end: 16, child: button, ); @@ -1110,7 +1147,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) @@ -1196,7 +1233,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/misc/back_button.dart b/packages/stream_chat_flutter/lib/src/misc/back_button.dart index 7b440227e2..83f2ba54f8 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,15 +23,30 @@ 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 iconData = switch (Theme.of(context).platform) { .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, icon: Icon(iconData), 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 1b224a1726..7f56461544 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 @@ -321,7 +321,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 b3663e7b09..6f01725532 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 e494f1228c..9445f79c56 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -68,7 +68,7 @@ dependencies: # ignore: invalid_dependency git: url: https://github.com/GetStream/stream-core-flutter.git - ref: f022a26fea479b97d5a999cb8a7cdf5969b075f7 + ref: 66ee511050b8ef4f8a39edf0c2c62568b521d34d path: packages/stream_core_flutter svg_icon_widget: ^0.0.1 synchronized: ^3.4.0 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/sample_app/lib/app.dart b/sample_app/lib/app.dart index ba2fc8c5d8..5a72bbbadd 100644 --- a/sample_app/lib/app.dart +++ b/sample_app/lib/app.dart @@ -170,15 +170,25 @@ class _StreamChatSampleAppState extends State child: Builder( builder: (context) { final config = context.sampleAppConfig; + + StreamAppStyle appStyle() => switch (config.appStyle) { + SampleAppStyle.regular => .regular, + SampleAppStyle.floating => .floating, + }; + + ThemeData theme(Brightness brightness) => ThemeData( + brightness: brightness, + extensions: [ + StreamTheme( + brightness: brightness, + appStyle: appStyle(), + ), + ], + ); + return MaterialApp.router( - theme: ThemeData( - brightness: .light, - extensions: [StreamTheme.light()], - ), - darkTheme: ThemeData( - brightness: .dark, - extensions: [StreamTheme.dark()], - ), + theme: theme(.light), + darkTheme: theme(.dark), themeMode: config.themeMode, locale: config.locale, supportedLocales: supportedLocales, @@ -248,6 +258,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 40bdac4021..ca3e39b5df 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 _kEnableReminderActions = 'config.enableReminderActions'; const _kEnableDeleteForMe = 'config.enableDeleteForMe'; @@ -38,6 +52,7 @@ class SampleAppConfigData { factory SampleAppConfigData({ Locale? locale, ThemeMode themeMode = .system, + SampleAppStyle appStyle = .regular, bool forceRtl = false, bool enableReminderActions = false, bool enableDeleteForMe = false, @@ -50,6 +65,7 @@ class SampleAppConfigData { }) { return SampleAppConfigData.raw( themeMode: themeMode, + appStyle: appStyle, locale: locale, forceRtl: forceRtl, enableReminderActions: enableReminderActions, @@ -66,6 +82,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.enableReminderActions, @@ -81,8 +98,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(), enableReminderActions: prefs.getBool(_kEnableReminderActions, defaultValue: false).getValue(), @@ -101,6 +120,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; @@ -141,6 +163,7 @@ class SampleAppConfigData { /// pass explicitly as `null` to reset to default/system. SampleAppConfigData copyWith({ ThemeMode? themeMode, + SampleAppStyle? appStyle, Object? locale = _sentinel, bool? forceRtl, bool? enableReminderActions, @@ -154,6 +177,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, enableReminderActions: enableReminderActions ?? this.enableReminderActions, @@ -174,6 +198,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(_kEnableReminderActions, enableReminderActions); diff --git a/sample_app/lib/config/sample_app_config_screen.dart b/sample_app/lib/config/sample_app_config_screen.dart index f6bfffa0e8..faa08bc543 100644 --- a/sample_app/lib/config/sample_app_config_screen.dart +++ b/sample_app/lib/config/sample_app_config_screen.dart @@ -13,142 +13,160 @@ 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)), - ), - _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)), + ), + _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 4b7cd032a3..98c5a6309e 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'; @@ -45,28 +45,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, ), @@ -74,39 +82,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, @@ -141,18 +126,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];