diff --git a/docs/docs_screenshots/pubspec.yaml b/docs/docs_screenshots/pubspec.yaml index 32f1c81594..bd71853ce1 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: 3ba1a36b6a3d9d3a194ccb8f0deaf4d69c5842eb + ref: c4308b3a2ea286483fc24c09c0168b10ad9de8a8 path: packages/stream_core_flutter dev_dependencies: diff --git a/docs/docs_screenshots/test/localization/goldens/macos/localization_rtl_arabic.png b/docs/docs_screenshots/test/localization/goldens/macos/localization_rtl_arabic.png index e2b174248b..fa60ed826e 100644 Binary files a/docs/docs_screenshots/test/localization/goldens/macos/localization_rtl_arabic.png and b/docs/docs_screenshots/test/localization/goldens/macos/localization_rtl_arabic.png differ diff --git a/docs/docs_screenshots/test/localization/goldens/macos/localization_support.png b/docs/docs_screenshots/test/localization/goldens/macos/localization_support.png index f6a61a05e0..e6c978dba7 100644 Binary files a/docs/docs_screenshots/test/localization/goldens/macos/localization_support.png and b/docs/docs_screenshots/test/localization/goldens/macos/localization_support.png differ diff --git a/docs/docs_screenshots/test/message_input/goldens/macos/autocomplete_trigger_commands.png b/docs/docs_screenshots/test/message_input/goldens/macos/autocomplete_trigger_commands.png index efb2ce0903..c1fc9ce622 100644 Binary files a/docs/docs_screenshots/test/message_input/goldens/macos/autocomplete_trigger_commands.png and b/docs/docs_screenshots/test/message_input/goldens/macos/autocomplete_trigger_commands.png differ diff --git a/docs/docs_screenshots/test/message_input/goldens/macos/autocomplete_trigger_emoji.png b/docs/docs_screenshots/test/message_input/goldens/macos/autocomplete_trigger_emoji.png index c7dead56fa..525b32b3ec 100644 Binary files a/docs/docs_screenshots/test/message_input/goldens/macos/autocomplete_trigger_emoji.png and b/docs/docs_screenshots/test/message_input/goldens/macos/autocomplete_trigger_emoji.png differ diff --git a/docs/docs_screenshots/test/message_input/goldens/macos/autocomplete_trigger_mention.png b/docs/docs_screenshots/test/message_input/goldens/macos/autocomplete_trigger_mention.png index 9c85d903d6..cdd6f1565b 100644 Binary files a/docs/docs_screenshots/test/message_input/goldens/macos/autocomplete_trigger_mention.png and b/docs/docs_screenshots/test/message_input/goldens/macos/autocomplete_trigger_mention.png differ diff --git a/docs/docs_screenshots/test/message_input/goldens/macos/autocomplete_trigger_mention_types.png b/docs/docs_screenshots/test/message_input/goldens/macos/autocomplete_trigger_mention_types.png index 4d0082b9ed..b565a29182 100644 Binary files a/docs/docs_screenshots/test/message_input/goldens/macos/autocomplete_trigger_mention_types.png and b/docs/docs_screenshots/test/message_input/goldens/macos/autocomplete_trigger_mention_types.png differ diff --git a/docs/docs_screenshots/test/message_input/goldens/macos/message_composer_input_header.png b/docs/docs_screenshots/test/message_input/goldens/macos/message_composer_input_header.png index 0d29871c8f..29b919b966 100644 Binary files a/docs/docs_screenshots/test/message_input/goldens/macos/message_composer_input_header.png and b/docs/docs_screenshots/test/message_input/goldens/macos/message_composer_input_header.png differ diff --git a/docs/docs_screenshots/test/message_input/goldens/macos/message_composer_slow_mode.png b/docs/docs_screenshots/test/message_input/goldens/macos/message_composer_slow_mode.png index ffa2f5e8d9..e2cb3375eb 100644 Binary files a/docs/docs_screenshots/test/message_input/goldens/macos/message_composer_slow_mode.png and b/docs/docs_screenshots/test/message_input/goldens/macos/message_composer_slow_mode.png differ diff --git a/docs/docs_screenshots/test/message_input/goldens/macos/message_input.png b/docs/docs_screenshots/test/message_input/goldens/macos/message_input.png index 65ce0b64e0..fb8d13dac2 100644 Binary files a/docs/docs_screenshots/test/message_input/goldens/macos/message_input.png and b/docs/docs_screenshots/test/message_input/goldens/macos/message_input.png differ diff --git a/docs/docs_screenshots/test/message_input/goldens/macos/message_input_change_position.png b/docs/docs_screenshots/test/message_input/goldens/macos/message_input_change_position.png index ee7a9ba43d..a35f60b2cb 100644 Binary files a/docs/docs_screenshots/test/message_input/goldens/macos/message_input_change_position.png and b/docs/docs_screenshots/test/message_input/goldens/macos/message_input_change_position.png differ diff --git a/docs/docs_screenshots/test/message_input/goldens/macos/message_input_custom_buttons.png b/docs/docs_screenshots/test/message_input/goldens/macos/message_input_custom_buttons.png index 811e1a4580..4b0f0b5a76 100644 Binary files a/docs/docs_screenshots/test/message_input/goldens/macos/message_input_custom_buttons.png and b/docs/docs_screenshots/test/message_input/goldens/macos/message_input_custom_buttons.png differ diff --git a/docs/docs_screenshots/test/message_input/goldens/macos/message_input_custom_send_icon.png b/docs/docs_screenshots/test/message_input/goldens/macos/message_input_custom_send_icon.png index ca3617381d..cb4038d36e 100644 Binary files a/docs/docs_screenshots/test/message_input/goldens/macos/message_input_custom_send_icon.png and b/docs/docs_screenshots/test/message_input/goldens/macos/message_input_custom_send_icon.png differ diff --git a/docs/docs_screenshots/test/message_input/goldens/macos/message_input_quoted_message.png b/docs/docs_screenshots/test/message_input/goldens/macos/message_input_quoted_message.png index 6a4226705b..44e8c79996 100644 Binary files a/docs/docs_screenshots/test/message_input/goldens/macos/message_input_quoted_message.png and b/docs/docs_screenshots/test/message_input/goldens/macos/message_input_quoted_message.png differ diff --git a/docs/docs_screenshots/test/message_input/goldens/macos/stream_message_composer_default.png b/docs/docs_screenshots/test/message_input/goldens/macos/stream_message_composer_default.png index 65ce0b64e0..fb8d13dac2 100644 Binary files a/docs/docs_screenshots/test/message_input/goldens/macos/stream_message_composer_default.png and b/docs/docs_screenshots/test/message_input/goldens/macos/stream_message_composer_default.png differ diff --git a/docs/docs_screenshots/test/message_list/goldens/macos/message_list_view.png b/docs/docs_screenshots/test/message_list/goldens/macos/message_list_view.png index e04384e5fe..a659c591cf 100644 Binary files a/docs/docs_screenshots/test/message_list/goldens/macos/message_list_view.png and b/docs/docs_screenshots/test/message_list/goldens/macos/message_list_view.png differ diff --git a/docs/docs_screenshots/test/src/mocks.dart b/docs/docs_screenshots/test/src/mocks.dart index 34717d004d..465e22a2e1 100644 --- a/docs/docs_screenshots/test/src/mocks.dart +++ b/docs/docs_screenshots/test/src/mocks.dart @@ -137,7 +137,7 @@ void setupMockChannel({ ).thenAnswer((_) => Stream.value(DateTime.parse('2020-06-22 12:00:00'))); when(() => channel.state).thenReturn(channelState); when(() => channel.client).thenReturn(client); - when(() => channel.config).thenReturn(ChannelConfig(mutes: true)); + when(() => channel.config).thenReturn(ChannelConfig(mutes: true, replies: true)); when(channel.getRemainingCooldown).thenReturn(0); when(() => channel.getRemainingCooldown(lastMessageAt: any(named: 'lastMessageAt'))).thenReturn(0); when(() => channel.isDistinct).thenReturn(false); diff --git a/docs/docs_screenshots/test/theming/goldens/macos/theming_default.png b/docs/docs_screenshots/test/theming/goldens/macos/theming_default.png index 600e53a658..4cd6f30521 100644 Binary files a/docs/docs_screenshots/test/theming/goldens/macos/theming_default.png and b/docs/docs_screenshots/test/theming/goldens/macos/theming_default.png differ diff --git a/docs/docs_screenshots/test/theming/goldens/macos/theming_red.png b/docs/docs_screenshots/test/theming/goldens/macos/theming_red.png index 23a3952377..f22015ea42 100644 Binary files a/docs/docs_screenshots/test/theming/goldens/macos/theming_red.png and b/docs/docs_screenshots/test/theming/goldens/macos/theming_red.png differ diff --git a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_attachment.png b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_attachment.png index 02f2a17c68..d01acabfcf 100644 Binary files a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_attachment.png and b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_attachment.png differ diff --git a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_attachment_custom.png b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_attachment_custom.png index 0d4fac648b..2cd165f6ca 100644 Binary files a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_attachment_custom.png and b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_attachment_custom.png differ diff --git a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_attachment_playing.png b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_attachment_playing.png index 02f2a17c68..d01acabfcf 100644 Binary files a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_attachment_playing.png and b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_attachment_playing.png differ diff --git a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_enabled.png b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_enabled.png index a3410502f9..93b7c24078 100644 Binary files a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_enabled.png and b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_enabled.png differ diff --git a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_finished.png b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_finished.png index 070492a202..6e45ec3922 100644 Binary files a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_finished.png and b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_finished.png differ diff --git a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_idle.png b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_idle.png index a3410502f9..93b7c24078 100644 Binary files a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_idle.png and b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_idle.png differ diff --git a/melos.yaml b/melos.yaml index 6feb428682..dd79fb9ed5 100644 --- a/melos.yaml +++ b/melos.yaml @@ -107,7 +107,7 @@ command: stream_core_flutter: git: url: https://github.com/GetStream/stream-core-flutter.git - ref: 3ba1a36b6a3d9d3a194ccb8f0deaf4d69c5842eb + ref: c4308b3a2ea286483fc24c09c0168b10ad9de8a8 path: packages/stream_core_flutter stream_thumbnail: ^0.1.0 synchronized: ^3.4.0 diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index f1bab37cfe..583e988ac2 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -1,7 +1,22 @@ ## Upcoming +⚠️ Changed + +- `StreamPhotoGallery` now defaults its `padding` to the bottom safe-area inset instead of no padding, so the last row clears the home indicator. Pass an explicit `padding` to opt out. +- `StreamMessageListView.config` is now nullable (`StreamMessageListViewConfiguration?`) instead of defaulting to `const StreamMessageListViewConfiguration()`. Passing a value is unchanged; code that *reads* `config` off a `StreamMessageListView` instance now gets a nullable value. +- `StreamMediaGalleryPreview` chrome now follows the app style — when floating, the header and footer float over full-bleed media and the footer's share / gallery buttons outline + elevate to match the back button; when regular, the chrome is docked and the media is inset between the bars. + ✅ 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, plus an `onBackPressed` callback that replaces the header back button's default pop. +- Added `onBackPressed` to `StreamChannelHeader` and `StreamThreadHeader` — replaces the default back button's `Navigator.maybePop` without rebuilding the whole `leading`, so callers keep the header's default unread badge. Ignored when `leading` is supplied. +- Added `MessageComposerProps.surfaceStyle` (a `StreamSurfaceStyle`) — explicitly controls whether `StreamMessageComposer` renders `floating` or `regular`. When null it falls back to `StreamMessageComposerThemeData.surfaceStyle`, and then to the ambient `StreamSurfaceStyle`. +- Added `StreamMessageComposerTheme` and `StreamMessageComposerThemeData` — a component theme for the composer, also available globally as `StreamChatThemeData.messageComposerTheme`. +- Added `StreamMessageListView.config` — accepts an explicit `StreamMessageListViewConfiguration` per widget; falls back to `StreamChatConfigurationData.messageListViewConfiguration` from the nearest ancestor when omitted. +- Added an `isFloating` parameter to `StreamBackButton`, controlling floating vs pinned back-button appearance. Falls back to `StreamAppBarTheme`, and then to the ambient `StreamSurfaceStyle`, when null. `StreamChannelHeader`, `StreamChannelListHeader`, and `StreamThreadHeader` now apply their per-header theme (`channelHeaderTheme` / `channelListHeaderTheme` / `threadHeaderTheme`) as a `StreamAppBarTheme` and pass `style` to the bar, which republishes its resolved behavior to its slots — so every slot in the bar — the default avatar's drop shadow, the default back button, and any `leading`/`trailing` the caller supplies — resolves the same chain the bar itself uses: `style`, then the per-header theme, then the ambient `StreamAppBarTheme`, then `StreamSurfaceStyle`. +- Added `messageListViewConfiguration` field to `StreamChatConfigurationData`, allowing a global `StreamMessageListViewConfiguration` default for all `StreamMessageListView` widgets. Pass it via `StreamChat.configData` to configure behaviors like `swipeToReply` and `highlightInitialMessage` app-wide without wiring them per-page. +- Re-exported `StreamScaffold`, `StreamScaffoldInsets`, `StreamBottomNavBar`, `StreamBottomNavBarItem`, `StreamSurfaceStyle`, and `streamFloatingFadeLinearGradient` from `stream_core_flutter` via `package:stream_chat_flutter/stream_chat_flutter.dart`. - Added `StreamMessageListViewConfiguration.autoScrollPolicy` to control whether and how `StreamMessageListView` scrolls to the newest message when a new message arrives. Use `StreamAutoScrollPolicy.disabled` to fully control scrolling yourself. - Added `onReactionSelected` to `StreamMessageReactionPicker`, a context-aware callback that provides the `BuildContext` for navigation. - Added an `errorSubtitle` to `StreamScrollViewErrorWidget`, which now falls back to the design's generic error copy (title, description, and a "Try Again" retry label) when values aren't provided. @@ -22,6 +37,8 @@ - Fixed the default list/scroll-view error states (channel, message, member, user, thread, poll-vote, reaction, search, and photo) showing raw or fixed errors; they are now connection-aware (no internet / slow connection), falling back to each view's specific error text. - Fixed `StreamTypingIndicator` briefly showing typing users from a different context (main channel vs. thread) on its first frame. - Fixed the attachment picker throwing a `Tooltip` assertion error when a custom `TabbedAttachmentPickerOption` is added without a `title`; the tooltip is now only shown when a title is provided. +- Fixed the "Message deleted" bubble overflowing its maximum width when the localized label is long; the label now wraps instead. +- Fixed the scroll-to-bottom button in thread views keying off the parent channel's up-to-date state instead of the thread's own scroll position, so it no longer appears while already at the newest reply. Note that thread views now show the button at all: it follows `showScrollToBottom` from the effective `StreamMessageListViewConfiguration`, so set that to `false` to restore the previous always-hidden behavior. - Fixed the `StreamBackButton` unread badge including the currently open channel in its total count. - Fixed `StreamMessageListView` jumping several screens when selecting text in a message on desktop or web. The `ScrollablePositionedList` viewports now account for their `anchor` in `getOffsetToReveal`, so implicit reveals (`Scrollable.ensureVisible`, `RenderObject.showOnScreen`) no longer overshoot. [#2862](https://github.com/GetStream/stream-chat-flutter/issues/2862) - Fixed modal dialogs (message actions, delete/flag confirmation) rendering over a white scrim in light theme; they now use the design system's scrim token. diff --git a/packages/stream_chat_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme.backup b/packages/stream_chat_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme.backup new file mode 100644 index 0000000000..e67b2808af --- /dev/null +++ b/packages/stream_chat_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme.backup @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/item_positions_listener.dart b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/item_positions_listener.dart index 808a2906fd..7827e3ef93 100644 --- a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/item_positions_listener.dart +++ b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/item_positions_listener.dart @@ -24,6 +24,8 @@ class ItemPosition { required this.index, required this.itemLeadingEdge, required this.itemTrailingEdge, + this.contentLeadingEdge, + this.contentTrailingEdge, }); /// Index of the item. @@ -41,6 +43,22 @@ class ItemPosition { /// May be greater than one if the item is partially visible. final double itemTrailingEdge; + /// Distance in proportion of the visible content's main axis length from the + /// leading edge of that content to the leading edge of the item. + /// + /// The visible content is the area inside the list's padding. Consider using + /// this for overlays inside that area, such as under a floating bar; scroll + /// logic keeps using [itemLeadingEdge]. Equal to [itemLeadingEdge] when the list has no + /// inset, and `null` only when the source omits it (a list always provides + /// it). + final double? contentLeadingEdge; + + /// Distance in proportion of the visible content's main axis length from the + /// leading edge of that content to the trailing edge of the item. + /// + /// See [contentLeadingEdge]. + final double? contentTrailingEdge; + @override bool operator ==(Object other) => identical(this, other) || @@ -48,12 +66,20 @@ class ItemPosition { runtimeType == other.runtimeType && index == other.index && itemLeadingEdge == other.itemLeadingEdge && - itemTrailingEdge == other.itemTrailingEdge; + itemTrailingEdge == other.itemTrailingEdge && + contentLeadingEdge == other.contentLeadingEdge && + contentTrailingEdge == other.contentTrailingEdge; @override - int get hashCode => 31 * (31 * (index.hashCode + 7) + itemLeadingEdge.hashCode) + itemTrailingEdge.hashCode; + int get hashCode => Object.hash( + index, + itemLeadingEdge, + itemTrailingEdge, + contentLeadingEdge, + contentTrailingEdge, + ); @override String toString() => - '''ItemPosition(index: $index, itemLeadingEdge: $itemLeadingEdge, itemTrailingEdge: $itemTrailingEdge)'''; + '''ItemPosition(index: $index, itemLeadingEdge: $itemLeadingEdge, itemTrailingEdge: $itemTrailingEdge, contentLeadingEdge: $contentLeadingEdge, contentTrailingEdge: $contentTrailingEdge)'''; } diff --git a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/positioned_list.dart b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/positioned_list.dart index a38fd0023e..9c17e6e4a6 100644 --- a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/positioned_list.dart +++ b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/positioned_list.dart @@ -336,7 +336,7 @@ class _PositionedListState extends State { @override Widget build(BuildContext context) { _rebuildKeyIndexMap(); - return RegistryWidget( + Widget list = RegistryWidget( elementNotifier: registeredElements, child: UnboundedCustomScrollView( anchor: widget.alignment, @@ -409,6 +409,23 @@ class _PositionedListState extends State { ], ), ); + + // Match BoxScrollView: when the main-axis MediaQuery padding was + // auto-consumed (no explicit padding), strip it from descendants so nested + // widgets and scrollables don't inset for it a second time. + final mediaQuery = MediaQuery.maybeOf(context); + if (widget.padding == null && mediaQuery != null) { + list = MediaQuery( + data: mediaQuery.copyWith( + padding: widget.scrollDirection == Axis.vertical + ? mediaQuery.padding.copyWith(top: 0, bottom: 0) + : mediaQuery.padding.copyWith(left: 0, right: 0), + ), + child: list, + ); + } + + return list; } Widget _buildSeparatedListElement(int index) { @@ -440,8 +457,19 @@ class _PositionedListState extends State { ); } - EdgeInsets get _resolvedPadding => - widget.padding?.resolve(Directionality.maybeOf(context) ?? TextDirection.ltr) ?? EdgeInsets.zero; + EdgeInsets get _resolvedPadding { + final padding = widget.padding; + if (padding != null) { + return padding.resolve(Directionality.maybeOf(context) ?? TextDirection.ltr); + } + // Match BoxScrollView: with no explicit padding, consume the main-axis + // MediaQuery padding so the list auto-insets like a ListView. The cross-axis + // remainder is handed to descendants in [build]. + final mediaPadding = MediaQuery.maybeOf(context)?.padding ?? EdgeInsets.zero; + return widget.scrollDirection == Axis.vertical + ? mediaPadding.copyWith(left: 0, right: 0) + : mediaPadding.copyWith(top: 0, bottom: 0); + } AxisDirection get _axisDirection { if (widget.scrollDirection == Axis.vertical) { @@ -558,12 +586,22 @@ class _PositionedListState extends State { final reveal = viewport!.getOffsetToReveal(box, 0).offset; if (!reveal.isFinite) continue; final itemOffset = reveal - viewport.offset.pixels; + final viewportDimension = scrollController.position.viewportDimension; + // content* edges are measured from the visible content (inside the + // padding) so overlays under a floating bar can key off 0..1; + // subtract the leading padding (the bottom when reversed, which + // `_leadingSliverPadding` resolves). item* stays viewport-relative + // for scroll mechanics (alignment / anchor preservation). + final leadingPad = _leadingSliverPadding.vertical; + final contentExtent = viewportDimension - _resolvedPadding.vertical; + final contentDenom = contentExtent > 0 ? contentExtent : viewportDimension; positions.add( ItemPosition( index: key.index, - itemLeadingEdge: itemOffset.round() / scrollController.position.viewportDimension, - itemTrailingEdge: - (itemOffset + box.size.height).round() / scrollController.position.viewportDimension, + itemLeadingEdge: itemOffset.round() / viewportDimension, + itemTrailingEdge: (itemOffset + box.size.height).round() / viewportDimension, + contentLeadingEdge: (itemOffset - leadingPad).round() / contentDenom, + contentTrailingEdge: (itemOffset + box.size.height - leadingPad).round() / contentDenom, ), ); } else { @@ -574,11 +612,18 @@ class _PositionedListState extends State { final isRight = _axisDirection == AxisDirection.right; final leadingPx = isRight ? itemOffset : viewportDimension - (itemOffset + box.size.width); final trailingPx = isRight ? itemOffset + box.size.width : viewportDimension - itemOffset; + // content* edges (see the vertical branch): drop the leading + // padding so 0..1 spans the visible content. + final leadingPad = _leadingSliverPadding.horizontal; + final contentExtent = viewportDimension - _resolvedPadding.horizontal; + final contentDenom = contentExtent > 0 ? contentExtent : viewportDimension; positions.add( ItemPosition( index: key.index, itemLeadingEdge: leadingPx.round() / viewportDimension, itemTrailingEdge: trailingPx.round() / viewportDimension, + contentLeadingEdge: (leadingPx - leadingPad).round() / contentDenom, + contentTrailingEdge: (trailingPx - leadingPad).round() / contentDenom, ), ); } 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 821862d081..1dcc9759c2 100644 --- a/packages/stream_chat_flutter/lib/src/channel/channel_header.dart +++ b/packages/stream_chat_flutter/lib/src/channel/channel_header.dart @@ -14,7 +14,7 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// suppress the default, or pass [leading] to replace it entirely. /// /// The default trailing is the channel avatar (via [StreamChannelAvatar]). -/// Tap behaviour is wired through [onChannelAvatarPressed]; when the +/// Tap behavior is wired through [onChannelAvatarPressed]; when the /// callback is null the avatar is rendered non-interactive. Pass [trailing] /// to replace the avatar with a custom action — the callback is then /// ignored. @@ -76,6 +76,7 @@ class StreamChannelHeader extends StatelessWidget implements PreferredSizeWidget this.showConnectionStateTile = false, this.leading, this.automaticallyImplyLeading = true, + this.onBackPressed, this.title, this.subtitle, this.trailing, @@ -104,6 +105,12 @@ class StreamChannelHeader extends StatelessWidget implements PreferredSizeWidget /// Defaults to `true`. Set to `false` to suppress the back button. final bool automaticallyImplyLeading; + /// Called when the default [StreamBackButton] is pressed, replacing its + /// default [Navigator.maybePop]. + /// + /// Ignored when [leading] is provided. + final VoidCallback? onBackPressed; + /// {@macro StreamAppBar.title} /// /// Defaults to a [StreamChannelName] for the enclosing channel. @@ -140,6 +147,7 @@ class StreamChannelHeader extends StatelessWidget implements PreferredSizeWidget var leading = this.leading; if (leading == null && automaticallyImplyLeading) { leading = StreamBackButton( + onPressed: onBackPressed, unreadIndicator: StreamUnreadIndicator(excludeCid: channel.cid), ); } @@ -175,10 +183,8 @@ class StreamChannelHeader extends StatelessWidget implements PreferredSizeWidget return StreamInfoTile( showMessage: showConnectionStateTile && showStatus, message: statusString, - // Wrap the bar in a [StreamAppBarTheme] so the per-header chat - // theme drives all default styling (background, padding, - // typography, divider) — the bar internally merges in any - // [style] override the caller passed. + // Apply the per-header theme as a theme; the bar resolves `style` + // over it and republishes the resolved behavior to its slots. child: StreamAppBarTheme( data: headerTheme, child: StreamAppBar( @@ -207,6 +213,11 @@ class _DefaultChannelAvatar extends StatelessWidget { @override Widget build(BuildContext context) { + final surfaceStyle = StreamTheme.of(context).surfaceStyle; + final toolbarSurfaceStyle = StreamToolbarScope.maybeOf(context); + + final effectiveIsFloating = toolbarSurfaceStyle?.isFloating ?? surfaceStyle.isFloating; + final effectiveOnTap = switch (onPressed) { final cb? => () => cb(channel), _ => null, @@ -224,6 +235,7 @@ class _DefaultChannelAvatar extends StatelessWidget { child: StreamChannelAvatar( size: .lg, channel: channel, + isFloating: effectiveIsFloating, ), ), ), 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..0a458b6eb7 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 @@ -11,7 +11,7 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// loading spinner + _Searching for network…_ when connecting, and an /// _Offline_ label with a _try again_ affordance when disconnected. /// -/// The leading slot is always the signed-in user's avatar. Tap behaviour +/// The leading slot is always the signed-in user's avatar. Tap behavior /// is wired through [onUserAvatarPressed]; when the callback is null the /// avatar mirrors Material [AppBar]'s auto-implied leading by opening the /// enclosing [Scaffold]'s drawer if one exists, and is otherwise rendered @@ -157,9 +157,8 @@ class StreamChannelListHeader extends StatelessWidget implements PreferredSizeWi return StreamInfoTile( showMessage: showConnectionStateTile && showStatus, message: statusString, - // Wrap the bar in a [StreamAppBarTheme] so the per-header chat - // theme drives all default styling — the bar internally merges - // in any [style] override the caller passed. + // Apply the per-header theme as a theme; the bar resolves `style` + // over it and republishes the resolved behavior to its slots. child: StreamAppBarTheme( data: headerTheme, child: StreamAppBar( @@ -189,6 +188,11 @@ class _DefaultUserAvatar extends StatelessWidget { final user = client.state.currentUser; if (user == null) return const SizedBox.shrink(); + final surfaceStyle = StreamTheme.of(context).surfaceStyle; + final toolbarSurfaceStyle = StreamToolbarScope.maybeOf(context); + + final effectiveIsFloating = toolbarSurfaceStyle?.isFloating ?? surfaceStyle.isFloating; + // Caller-provided handler wins; otherwise mirror Material AppBar and // open the enclosing Scaffold's drawer if one exists. With no callback // and no drawer, the avatar is non-interactive. @@ -211,6 +215,7 @@ class _DefaultUserAvatar extends StatelessWidget { size: .lg, user: user, showOnlineIndicator: false, + isFloating: effectiveIsFloating, ), ), ), diff --git a/packages/stream_chat_flutter/lib/src/components/avatar/stream_channel_avatar.dart b/packages/stream_chat_flutter/lib/src/components/avatar/stream_channel_avatar.dart index 4a004c46fd..79ebc043e2 100644 --- a/packages/stream_chat_flutter/lib/src/components/avatar/stream_channel_avatar.dart +++ b/packages/stream_chat_flutter/lib/src/components/avatar/stream_channel_avatar.dart @@ -55,6 +55,7 @@ class StreamChannelAvatar extends StatelessWidget { super.key, this.size, required this.channel, + this.isFloating, this.semanticsLabel, }); @@ -66,6 +67,13 @@ class StreamChannelAvatar extends StatelessWidget { /// If null, defaults to [StreamAvatarGroupSize.lg]. final StreamAvatarGroupSize? size; + /// Whether to show a drop shadow around the avatar. + /// + /// Defaults to null, treated as not floating. The shadow depth is determined + /// by [StreamAvatarThemeData.floatingElevation], falling back to + /// [StreamElevation.level2]. + final bool? isFloating; + /// Screen-reader label for the avatar. /// /// When null (the default), a `"Group"` label is emitted for group @@ -88,6 +96,7 @@ class StreamChannelAvatar extends StatelessWidget { imageUrl: channelImage, semanticsLabel: effectiveLabel, size: _avatarSizeForAvatarGroupSize(effectiveSize), + isFloating: isFloating, placeholder: (_) => const _StreamChannelAvatarPlaceholder(), ), noDataBuilder: (context) => BetterStreamBuilder( @@ -108,6 +117,7 @@ class StreamChannelAvatar extends StatelessWidget { size: _avatarSizeForAvatarGroupSize(effectiveSize), // TODO: make this configurable when the online state is shown. showOnlineIndicator: otherUser.online, + isFloating: isFloating, ); } @@ -115,6 +125,7 @@ class StreamChannelAvatar extends StatelessWidget { size: effectiveSize, semanticsLabel: effectiveLabel, users: users.sortedBy((it) => it.id == currentUserId ? 1 : 0), + isFloating: isFloating, ); }, ), diff --git a/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar.dart b/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar.dart index dbb74b1345..d4a82df40d 100644 --- a/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar.dart +++ b/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar.dart @@ -68,6 +68,7 @@ class StreamUserAvatar extends StatelessWidget { required this.user, this.showBorder = true, this.showOnlineIndicator = true, + this.isFloating, this.semanticsLabel, }); @@ -85,6 +86,13 @@ class StreamUserAvatar extends StatelessWidget { /// Defaults to true. final bool showOnlineIndicator; + /// Whether to show a drop shadow around the avatar. + /// + /// Defaults to null, treated as not floating. The shadow depth is determined + /// by [StreamAvatarThemeData.floatingElevation], falling back to + /// [StreamElevation.level2]. + final bool? isFloating; + /// The size of the avatar. /// /// If null, uses [StreamAvatarThemeData.size], or falls back to @@ -114,6 +122,7 @@ class StreamUserAvatar extends StatelessWidget { size: effectiveSize, imageUrl: user.image, showBorder: showBorder, + isFloating: isFloating, backgroundColor: effectiveBackgroundColor, foregroundColor: effectiveForegroundColor, semanticsLabel: semanticsLabel, diff --git a/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar_group.dart b/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar_group.dart index 9f0890e00a..d976aefa23 100644 --- a/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar_group.dart +++ b/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar_group.dart @@ -55,6 +55,7 @@ class StreamUserAvatarGroup extends StatelessWidget { super.key, required this.users, this.size, + this.isFloating, this.semanticsLabel, }); @@ -66,6 +67,13 @@ class StreamUserAvatarGroup extends StatelessWidget { /// If null, defaults to [StreamAvatarGroupSize.lg]. final StreamAvatarGroupSize? size; + /// Whether to show a drop shadow around the avatar group. + /// + /// Defaults to null, treated as not floating. The shadow depth is determined + /// by [StreamAvatarThemeData.floatingElevation], falling back to + /// [StreamElevation.level2]. + final bool? isFloating; + /// Screen-reader label for the avatar group. /// /// When null (the default), each child avatar carries its own @@ -82,6 +90,7 @@ class StreamUserAvatarGroup extends StatelessWidget { (user) => StreamUserAvatar( user: user, showOnlineIndicator: false, + isFloating: isFloating, ), ), ); diff --git a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_component_props.dart b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_component_props.dart index 5a76b977b7..8967af8ee5 100644 --- a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_component_props.dart +++ b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_component_props.dart @@ -55,6 +55,8 @@ class MessageComposerComponentProps { final StreamMessageComposerController controller; /// Whether the message composer is floating. + /// + /// Defaults to false. final bool isFloating; /// The callback for when the send button is pressed. diff --git a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input.dart b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input.dart index a84afc98bb..82f61cc6ae 100644 --- a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input.dart +++ b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input.dart @@ -1,4 +1,4 @@ -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/src/components/message_composer/message_composer_input_center.dart'; import 'package:stream_chat_flutter/src/components/message_composer/message_composer_input_header.dart'; import 'package:stream_chat_flutter/src/components/message_composer/message_composer_input_leading.dart'; @@ -50,34 +50,40 @@ class DefaultStreamMessageComposerInput extends StatelessWidget { ? context.streamColorScheme.borderDisabled : context.streamColorScheme.borderDefault; - return Container( - clipBehavior: Clip.antiAlias, - foregroundDecoration: BoxDecoration( - borderRadius: BorderRadius.all(context.streamRadius.xxxl), + final borderRadius = BorderRadius.all(context.streamRadius.xxxl); + final elevation = context.streamElevation; + + // Material clips its children via PhysicalShape, so the border is drawn + // outside the Material to keep it from being clipped away. + return DecoratedBox( + position: DecorationPosition.foreground, + decoration: BoxDecoration( + borderRadius: borderRadius, border: Border.all(color: borderColor), ), - decoration: BoxDecoration( + child: Material( + borderRadius: borderRadius, + clipBehavior: Clip.antiAlias, color: context.streamColorScheme.backgroundElevation1, - borderRadius: BorderRadius.all(context.streamRadius.xxxl), - boxShadow: isFloating ? context.streamBoxShadow.elevation3 : null, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - StreamMessageComposerInputHeader(props: props), - Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - StreamMessageComposerInputLeading(props: props), - Expanded( - child: StreamMessageComposerInputCenter( - props: MessageComposerInputCenterProps.from(props), + elevation: isFloating ? elevation.level3 : elevation.none, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + StreamMessageComposerInputHeader(props: props), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + StreamMessageComposerInputLeading(props: props), + Expanded( + child: StreamMessageComposerInputCenter( + props: MessageComposerInputCenterProps.from(props), + ), ), - ), - StreamMessageComposerInputTrailing(props: props), - ], - ), - ], + StreamMessageComposerInputTrailing(props: props), + ], + ), + ], + ), ), ); } diff --git a/packages/stream_chat_flutter/lib/src/media_gallery/stream_media_gallery.dart b/packages/stream_chat_flutter/lib/src/media_gallery/stream_media_gallery.dart index 7dac486102..23557c0beb 100644 --- a/packages/stream_chat_flutter/lib/src/media_gallery/stream_media_gallery.dart +++ b/packages/stream_chat_flutter/lib/src/media_gallery/stream_media_gallery.dart @@ -5,10 +5,10 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// companion to [StreamMediaGalleryPreview]. /// /// Each cell is rendered by a [StreamMediaGalleryItem] in a 1:1 grid with -/// the sender's avatar surfaced on every tile. Inter-cell gutters and the -/// outer padding both default to `spacing.xxxs` (2 logical pixels) so -/// every gap in the grid is uniform; pass [StreamMediaGalleryProps.padding] -/// to override. +/// the sender's avatar surfaced on every tile. Inter-cell gutters default to +/// `spacing.xxxs` (2 logical pixels) so every gap in the grid is uniform. The +/// grid has no outer padding of its own; supply [StreamMediaGalleryProps.padding] +/// to inset it from its container. /// /// {@tool snippet} /// @@ -92,7 +92,8 @@ class StreamMediaGalleryProps { /// Number of tiles per row. Defaults to 3. final int crossAxisCount; - /// Padding around the grid. + /// Padding around the grid. Defaults to null, leaving the grid to inherit + /// any insets from its surroundings. final EdgeInsetsGeometry? padding; /// Scroll controller for the underlying [GridView]. @@ -121,31 +122,25 @@ class DefaultStreamMediaGallery extends StatelessWidget { @override Widget build(BuildContext context) { final spacing = context.streamSpacing; - final effectivePadding = props.padding ?? EdgeInsets.all(spacing.xxxs); - return MediaQuery.removePadding( - context: context, - removeTop: true, - removeBottom: true, - child: GridView.builder( - padding: effectivePadding, - controller: props.scrollController, - itemCount: props.attachments.length, - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: props.crossAxisCount, - crossAxisSpacing: spacing.xxxs, - mainAxisSpacing: spacing.xxxs, - ), - itemBuilder: (context, index) { - final ga = props.attachments[index]; - return StreamMediaGalleryItem( - attachment: ga.attachment, - author: ga.message.user, - onTap: props.onItemTap == null ? null : () => props.onItemTap!(index), - onLongPress: props.onItemLongPress == null ? null : () => props.onItemLongPress!(index), - ); - }, + return GridView.builder( + padding: props.padding, + controller: props.scrollController, + itemCount: props.attachments.length, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: props.crossAxisCount, + crossAxisSpacing: spacing.xxxs, + mainAxisSpacing: spacing.xxxs, ), + itemBuilder: (context, index) { + final ga = props.attachments[index]; + return StreamMediaGalleryItem( + attachment: ga.attachment, + author: ga.message.user, + onTap: props.onItemTap == null ? null : () => props.onItemTap!(index), + onLongPress: props.onItemLongPress == null ? null : () => props.onItemLongPress!(index), + ); + }, ); } } diff --git a/packages/stream_chat_flutter/lib/src/media_gallery_preview/stream_media_gallery_preview_footer.dart b/packages/stream_chat_flutter/lib/src/media_gallery_preview/stream_media_gallery_preview_footer.dart index b9a07ba71d..b516f63553 100644 --- a/packages/stream_chat_flutter/lib/src/media_gallery_preview/stream_media_gallery_preview_footer.dart +++ b/packages/stream_chat_flutter/lib/src/media_gallery_preview/stream_media_gallery_preview_footer.dart @@ -65,16 +65,14 @@ class StreamMediaGalleryPreviewFooter extends StatelessWidget implements Preferr final icons = context.streamIcons; return StreamBottomAppBar( - leading: StreamButton.icon( - type: StreamButtonType.ghost, - style: StreamButtonStyle.secondary, + leading: StreamToolbarButton.icon( + style: .secondary, icon: Icon(icons.export), onPressed: onSharePressed, ), title: title, - trailing: StreamButton.icon( - type: StreamButtonType.ghost, - style: StreamButtonStyle.secondary, + trailing: StreamToolbarButton.icon( + style: .secondary, icon: Icon(icons.gallery), onPressed: onGalleryPressed, ), diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_gallery_picker.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_gallery_picker.dart index 9526853147..a8c953de0f 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_gallery_picker.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_gallery_picker.dart @@ -6,7 +6,6 @@ import 'package:path_provider/path_provider.dart'; import 'package:photo_manager/photo_manager.dart'; import 'package:stream_chat_flutter/src/message_input/attachment_picker/stream_attachment_picker.dart'; import 'package:stream_chat_flutter/src/message_input/attachment_picker/stream_attachment_picker_controller.dart'; -import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; import 'package:stream_chat_flutter/src/scroll_view/photo_gallery/stream_photo_gallery.dart'; import 'package:stream_chat_flutter/src/scroll_view/photo_gallery/stream_photo_gallery_controller.dart'; import 'package:stream_chat_flutter/src/utils/utils.dart'; @@ -75,7 +74,7 @@ class _StreamGalleryPickerState extends State { return FutureBuilder( future: requestPermission, builder: (context, snapshot) { - if (!snapshot.hasData) return const Empty(); + if (!snapshot.hasData) return const SizedBox.expand(); final spacing = context.streamSpacing; final textTheme = context.streamTextTheme; diff --git a/packages/stream_chat_flutter/lib/src/message_input/stream_chat_message_input.dart b/packages/stream_chat_flutter/lib/src/message_input/stream_chat_message_input.dart index 60ab8ccdb5..3a67ecf434 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/stream_chat_message_input.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/stream_chat_message_input.dart @@ -96,6 +96,8 @@ class StreamChatMessageInput extends StatefulWidget { final bool autocorrect; /// Whether the message composer is floating. + /// + /// Defaults to false. final bool isFloating; @override diff --git a/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart b/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart index 2b58cd8ba2..78abe5f5bd 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart @@ -3,7 +3,9 @@ import 'dart:math' as math; import 'package:desktop_drop/desktop_drop.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/semantics.dart'; +// Needed for RenderProxyBox; it also re-exports the semantics library that +// Assertiveness comes from, which is why flutter/semantics.dart isn't imported. +import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:stream_chat_flutter/src/message_input/audio_recorder/audio_recorder_announcer.dart'; import 'package:stream_chat_flutter/src/message_input/composer_attachment_announcer.dart'; @@ -99,6 +101,7 @@ class StreamMessageComposer extends StatelessWidget { TextCapitalization textCapitalization = TextCapitalization.sentences, bool autofocus = false, bool autoCorrect = true, + StreamSurfaceStyle? surfaceStyle, }) : props = .new( onMessageSent: onMessageSent, preMessageSending: preMessageSending, @@ -136,6 +139,7 @@ class StreamMessageComposer extends StatelessWidget { textCapitalization: textCapitalization, autofocus: autofocus, autoCorrect: autoCorrect, + surfaceStyle: surfaceStyle, ); /// Creates a [StreamMessageComposer] from a pre-built [MessageComposerProps]. @@ -197,6 +201,7 @@ class MessageComposerProps { this.textCapitalization = TextCapitalization.sentences, this.autofocus = false, this.autoCorrect = true, + this.surfaceStyle, }); /// Function called after sending the message. @@ -406,6 +411,12 @@ class MessageComposerProps { /// Defaults to true. final bool autoCorrect; + /// The surface style of the message composer. + /// + /// When null, falls back to [StreamMessageComposerThemeData.surfaceStyle], + /// then the ambient [StreamSurfaceStyle]. + final StreamSurfaceStyle? surfaceStyle; + /// Returns a copy of this [MessageComposerProps] with the given fields /// replaced with new values. MessageComposerProps copyWith({ @@ -444,6 +455,7 @@ class MessageComposerProps { TextCapitalization? textCapitalization, bool? autofocus, bool? autoCorrect, + StreamSurfaceStyle? surfaceStyle, }) { return MessageComposerProps( onMessageSent: onMessageSent ?? this.onMessageSent, @@ -481,6 +493,7 @@ class MessageComposerProps { textCapitalization: textCapitalization ?? this.textCapitalization, autofocus: autofocus ?? this.autofocus, autoCorrect: autoCorrect ?? this.autoCorrect, + surfaceStyle: surfaceStyle ?? this.surfaceStyle, ); } @@ -552,7 +565,7 @@ class DefaultStreamMessageComposer extends StatefulWidget { /// State of [DefaultStreamMessageComposer]. class DefaultStreamMessageComposerState extends State - with RestorationMixin, SingleTickerProviderStateMixin { + with RestorationMixin, SingleTickerProviderStateMixin, WidgetsBindingObserver { bool get _commandEnabled => _effectiveController.message.command != null; bool get _isPickerVisible => _pickerController != null; @@ -599,9 +612,15 @@ class DefaultStreamMessageComposerState extends State? _messageUpdatedSubscription; StreamSubscription? _messageDeletedSubscription; + // Height of the floating pill, reported from layout and read back at paint + // time by _FloatingComposerBackdropPainter. Layout always runs before paint, + // so the backdrop's fade is never a frame behind the pill. + final _pillHeight = ValueNotifier(0); + @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); _pickerAnimationController = AnimationController( duration: const Duration(milliseconds: 200), vsync: this, @@ -627,6 +646,9 @@ class DefaultStreamMessageComposerState extends State setState(() {}); // Rebuilds when the keyboard opens/closes. + void _initializeState() { // Call the listener once to make sure the initial state is reflected // correctly in the UI. @@ -815,32 +837,42 @@ class DefaultStreamMessageComposerState extends State 0; + + final content = Material( + type: .transparency, + child: switch (widget.props.enableSafeArea) { + false => Center(heightFactor: 1, child: messageInput), + _ => StreamSafeArea.driven( + top: false, + listenable: _pickerAnimation, + minimum: .only(bottom: keyboardVisible ? spacing.md : spacing.safeAreaBottom()), + child: Center(heightFactor: 1, child: messageInput), + ), + }, ); + final colorScheme = context.streamColorScheme; + final effectiveSurfaceStyle = _resolveSurfaceStyle(context); + + final composer = switch (effectiveSurfaceStyle) { + .regular => DecoratedBox( + decoration: BoxDecoration( + color: colorScheme.backgroundElevation1, + ), + child: content, + ), + .floating => CustomPaint( + painter: _FloatingComposerBackdropPainter( + animation: _pickerAnimation, + restColor: colorScheme.backgroundElevation0, + raisedColor: colorScheme.backgroundElevation1, + fadeExtent: _pillHeight, + ), + child: content, + ), + }; + return ComposerAttachmentAnnouncer( controller: _effectiveController, child: AudioRecorderAnnouncer( @@ -930,69 +962,98 @@ 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, - ), - ), - ), - SizeTransition( - sizeFactor: _pickerAnimation, - // ignore: deprecated_member_use - axisAlignment: -1, - child: _buildInlineAttachmentPicker(context), + textInputAction: widget.props.textInputAction, + keyboardType: widget.props.keyboardType, + textCapitalization: widget.props.textCapitalization, + autofocus: widget.props.autofocus, + autocorrect: widget.props.autoCorrect, + isFloating: isFloating, ), - ], - ), - ), + ), + ); + + return PopScope( + canPop: !_isPickerVisible, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) _hidePicker(); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Both the pill and the picker sit on one backdrop painted behind + // the whole composer. Reporting the pill's height keeps that + // backdrop's fade confined to the pill, so the fade grows with the + // pill but never stretches when the picker opens or closes. + if (isFloating) + _ReportHeight( + onHeightChanged: (height) => _pillHeight.value = height, + child: pill, + ) + else + pill, + SizeTransition( + sizeFactor: _pickerAnimation, + // ignore: deprecated_member_use, alternative is only available since Flutter 3.44 + axisAlignment: -1, + child: _buildInlineAttachmentPicker(context), + ), + ], + ), + ); + }, ); } @@ -1566,6 +1627,7 @@ class DefaultStreamMessageComposerState extends State animation; + + // The backdrop color at rest, with the picker closed. + final Color restColor; + + // The backdrop color with the picker fully open. + final Color raisedColor; + + final ValueNotifier fadeExtent; + + @override + void paint(Canvas canvas, Size size) { + if (size.isEmpty) return; + + final color = Color.lerp(restColor, raisedColor, animation.value)!; + + // The gradient runs bottom-to-top, so the solid run is measured from the + // bottom and the fade occupies the top `extent` pixels. + final extent = math.min(fadeExtent.value, size.height); + final solidFraction = (1 - extent / size.height).clamp(0.0, 1.0); + + final rect = Offset.zero & size; + final gradient = streamFloatingFadeLinearGradient( + color: color, + solidFraction: solidFraction, + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + ); + + canvas.drawRect(rect, Paint()..shader = gradient.createShader(rect)); + } + + @override + bool shouldRepaint(_FloatingComposerBackdropPainter oldDelegate) { + return oldDelegate.restColor != restColor || + oldDelegate.raisedColor != raisedColor || + oldDelegate.animation != animation || + oldDelegate.fadeExtent != fadeExtent; + } +} + +/// Reports [child]'s laid-out height via [onHeightChanged] during layout. +/// +/// Used to feed the pill's height to [_FloatingComposerBackdropPainter]. The +/// callback fires inside `performLayout`, which is safe for the listener to turn +/// into a repaint: the framework flushes all layout before any painting, so the +/// backdrop sees the current height in the same frame. +class _ReportHeight extends SingleChildRenderObjectWidget { + const _ReportHeight({required this.onHeightChanged, required super.child}); + + final ValueChanged onHeightChanged; + + @override + _RenderReportHeight createRenderObject(BuildContext context) { + return _RenderReportHeight(onHeightChanged: onHeightChanged); + } + + @override + void updateRenderObject(BuildContext context, _RenderReportHeight renderObject) { + renderObject.onHeightChanged = onHeightChanged; + } +} + +class _RenderReportHeight extends RenderProxyBox { + _RenderReportHeight({required this.onHeightChanged}); + + ValueChanged onHeightChanged; + + @override + void performLayout() { + super.performLayout(); + onHeightChanged(size.height); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/floating_date_divider.dart b/packages/stream_chat_flutter/lib/src/message_list_view/floating_date_divider.dart index 908d0d9dda..99a717fda3 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/floating_date_divider.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/floating_date_divider.dart @@ -138,24 +138,30 @@ class FloatingDateDivider extends StatelessWidget { for (final p in positions) { if (p.index != itemIndex) continue; + // Measured against the visible content (inside the list's padding), so a + // floating bar's inset doesn't shift where the fade hands off. Falls back + // to the viewport-relative edges when content edges aren't provided. + final leadingEdge = p.contentLeadingEdge ?? p.itemLeadingEdge; + final trailingEdge = p.contentTrailingEdge ?? p.itemTrailingEdge; + var opacity = 1.0; if (reverse) { // Fade as the inline divider ABOVE becomes visible - // (trailing edge = top of item, 1.0 = viewport top). - if (hasDateDividerAbove && p.itemTrailingEdge < 1) { + // (trailing edge = top of item, 1.0 = content top). + if (hasDateDividerAbove && trailingEdge < 1) { opacity = clampDouble( - (p.itemTrailingEdge - (1.0 - _fadeRange)) / _fadeRange, + (trailingEdge - (1.0 - _fadeRange)) / _fadeRange, 0, 1, ); } - // Fade as the inline divider BELOW approaches the viewport top + // Fade as the inline divider BELOW approaches the content top // (leading edge = bottom of item, approaching 1.0). if (hasDateDividerBelow) { final t = clampDouble( - ((1.0 - _fadeRange) - p.itemLeadingEdge) / _fadeRange, + ((1.0 - _fadeRange) - leadingEdge) / _fadeRange, 0, 1, ); @@ -163,20 +169,20 @@ class FloatingDateDivider extends StatelessWidget { } } else { // Fade as the inline divider ABOVE becomes visible - // (leading edge = top of item, 0.0 = viewport top). - if (hasDateDividerAbove && p.itemLeadingEdge > 0) { + // (leading edge = top of item, 0.0 = content top). + if (hasDateDividerAbove && leadingEdge > 0) { opacity = clampDouble( - (_fadeRange - p.itemLeadingEdge) / _fadeRange, + (_fadeRange - leadingEdge) / _fadeRange, 0, 1, ); } - // Fade as the inline divider BELOW approaches the viewport top + // Fade as the inline divider BELOW approaches the content top // (trailing edge = bottom of item, approaching 0.0). if (hasDateDividerBelow) { final t = clampDouble( - (p.itemTrailingEdge - _fadeRange) / _fadeRange, + (trailingEdge - _fadeRange) / _fadeRange, 0, 1, ); diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart index c8ecf79d75..3d6973ce38 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart @@ -1,5 +1,5 @@ import 'dart:async'; -import 'dart:math'; +import 'dart:math' as math; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; @@ -125,7 +125,7 @@ class StreamMessageListView extends StatefulWidget { this.onEphemeralMessageTap, this.onModeratedMessageTap, this.onMessageLongPress, - this.config = const StreamMessageListViewConfiguration(), + this.config, this.builders = const StreamMessageListViewBuilders(), }); @@ -273,8 +273,10 @@ class StreamMessageListView extends StatefulWidget { /// [StreamMessageListViewConfiguration.markReadWhenAtTheBottom], scroll /// physics, and other non-builder, non-theme settings. /// - /// Defaults to [StreamMessageListViewConfiguration] with all defaults. - final StreamMessageListViewConfiguration config; + /// When null, falls back to + /// [StreamChatConfigurationData.messageListViewConfiguration] from the + /// nearest [StreamChatConfiguration] ancestor. + final StreamMessageListViewConfiguration? config; /// Custom slot builders for this message list view. /// @@ -336,6 +338,22 @@ class _StreamMessageListViewState extends State { MessageListController get _messageListController => widget.messageListController ?? _defaultController; + // The effective StreamMessageListViewConfiguration for this list. + // + // StreamMessageListView.config when explicitly provided, otherwise + // StreamChatConfigurationData.messageListViewConfiguration from the nearest + // StreamChatConfiguration ancestor. + // + // Resolved into a field rather than read through a getter because the + // fallback is an inherited-widget lookup, which asserts the element is active + // and so must not run from a stream callback — _messageNewListener reads + // this. + late StreamMessageListViewConfiguration _config; + StreamMessageListViewConfiguration _resolveConfig() { + if (widget.config case final config?) return config; + return StreamChatConfiguration.of(context).messageListViewConfiguration; + } + StreamSubscription? _messageNewListener; StreamSubscription? _userReadListener; @@ -353,6 +371,9 @@ class _StreamMessageListViewState extends State { @override void didChangeDependencies() { super.didChangeDependencies(); + // Before anything else — the branches below read `_config`. + _config = _resolveConfig(); + final newStreamChannel = StreamChannel.of(context); if (newStreamChannel != streamChannel) { @@ -363,7 +384,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, @@ -398,7 +419,7 @@ class _StreamMessageListViewState extends State { isAtBottom: isAtBottom, ); - final behavior = widget.config.autoScrollPolicy.resolve(details); + final behavior = _config.autoScrollPolicy.resolve(details); // Synchronous (not post-frame) so the scroll clears SPL's anchor key // before the rebuild's `didUpdateWidget`; otherwise anchor @@ -422,6 +443,14 @@ class _StreamMessageListViewState extends State { } } + @override + void didUpdateWidget(covariant StreamMessageListView oldWidget) { + super.didUpdateWidget(oldWidget); + // `config` can change without dependencies changing, so re-resolve here as + // well as in `didChangeDependencies`. + if (widget.config != oldWidget.config) _config = _resolveConfig(); + } + @override void dispose() { // Tear down anything that could write to [_unreadState] or @@ -531,6 +560,11 @@ class _StreamMessageListViewState extends State { ); } + // Safe-area insets injected into MediaQuery by the enclosing scaffold (a + // floating app bar / composer, or the system safe area). Read directly so the + // list self-insets without the caller threading padding in. + EdgeInsets get _scaffoldInsets => MediaQuery.paddingOf(context); + @override Widget build(BuildContext context) { Widget defaultLoadingBuilder(BuildContext context) { @@ -572,9 +606,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, @@ -618,7 +652,7 @@ class _StreamMessageListViewState extends State { } return StreamInfoTile( - showMessage: widget.config.showConnectionStateTile && showStatus, + showMessage: _config.showConnectionStateTile && showStatus, tileAnchor: Alignment.topCenter, childAnchor: Alignment.topCenter, message: statusString, @@ -632,15 +666,15 @@ 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: _scaffoldInsets + .symmetric(vertical: 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 @@ -684,7 +718,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( @@ -698,7 +732,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); @@ -707,7 +741,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 { @@ -744,7 +778,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(); @@ -766,7 +800,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(); @@ -785,13 +819,13 @@ class _StreamMessageListViewState extends State { ); }, ), - if (widget.config.showFloatingDateDivider) + if (_config.showFloatingDateDivider) Positioned( - top: context.streamSpacing.sm, + top: math.max(_scaffoldInsets.top, 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) { @@ -800,22 +834,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: math.max(_scaffoldInsets.top, context.streamSpacing.sm), child: UnreadIndicatorButton( onJumpTap: scrollToUnreadDefaultTapAction, onDismissTap: _markMessagesAsRead, @@ -923,7 +967,7 @@ class _StreamMessageListViewState extends State { if (_scrollController case final controller? when controller.isAttached) { return controller.scrollTo( - index: max(firstUnreadMessageIndex + 2, 0), + index: math.max(firstUnreadMessageIndex + 2, 0), alignment: 0.5, // center the message in the viewport ); } @@ -1022,7 +1066,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, @@ -1077,14 +1121,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, @@ -1092,8 +1136,8 @@ class _StreamMessageListViewState extends State { } return PositionedDirectional( - bottom: 16, - end: 16, + bottom: math.max(_scaffoldInsets.bottom, context.streamSpacing.md), + end: context.streamSpacing.md, child: button, ); }, @@ -1145,7 +1189,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) @@ -1206,9 +1250,10 @@ class _StreamMessageListViewState extends State { var isLastItemFullyVisible = false; if (lastItemPosition != null) { - // We consider the last item fully visible if its leading edge (reversed) - // is greater than or equal to 0. - isLastItemFullyVisible = lastItemPosition.itemLeadingEdge >= 0; + // Fully visible = its leading edge (reversed) sits within the visible + // content (>= 0), not merely within the raw viewport — so an item behind + // the floating composer counts as hidden and the scroll-to-bottom shows. + isLastItemFullyVisible = (lastItemPosition.contentLeadingEdge ?? lastItemPosition.itemLeadingEdge) >= 0; } if (mounted) _showScrollToBottom.value = !isLastItemFullyVisible; @@ -1232,7 +1277,7 @@ class _StreamMessageListViewState extends State { _lastFullyVisibleMessage = newLastFullyVisibleMessage; // Mark messages as read if needed. - if (widget.config.markReadWhenAtTheBottom) { + if (_config.markReadWhenAtTheBottom) { _maybeMarkMessagesAsRead().ignore(); } } diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/mlv_utils.dart b/packages/stream_chat_flutter/lib/src/message_list_view/mlv_utils.dart index 5977cbefe5..04909f5620 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/mlv_utils.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/mlv_utils.dart @@ -43,8 +43,11 @@ int getInitialIndex( return 0; } -/// Gets the index of the top element in the viewport. +/// Gets the index of the top element in the visible content. int? getTopElementIndex(Iterable values) { + // Measured against the visible content (inside the list's padding) so a + // floating bar's inset doesn't skew which item counts as "on top". + double trailing(ItemPosition p) => p.contentTrailingEdge ?? p.itemTrailingEdge; final inView = values.where((position) { if (position.itemLeadingEdge == position.itemTrailingEdge) { // If the item's leading and trailing edges are the same, it means the @@ -52,17 +55,18 @@ int? getTopElementIndex(Iterable values) { return false; } - return position.itemTrailingEdge > 0; + return trailing(position) > 0; }); if (inView.isEmpty) return null; return inView.reduce((min, position) { - return position.itemTrailingEdge < min.itemTrailingEdge ? position : min; + return trailing(position) < trailing(min) ? position : min; }).index; } -/// Gets the index of the bottom element in the viewport. +/// Gets the index of the bottom element in the visible content. int? getBottomElementIndex(Iterable values) { + double leading(ItemPosition p) => p.contentLeadingEdge ?? p.itemLeadingEdge; final inView = values.where((position) { if (position.itemLeadingEdge == position.itemTrailingEdge) { // If the item's leading and trailing edges are the same, it means the @@ -70,12 +74,12 @@ int? getBottomElementIndex(Iterable values) { return false; } - return position.itemLeadingEdge < 1; + return leading(position) < 1; }); if (inView.isEmpty) return null; return inView.reduce((max, position) { - return position.itemLeadingEdge > max.itemLeadingEdge ? position : max; + return leading(position) > leading(max) ? position : max; }).index; } diff --git a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_deleted.dart b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_deleted.dart index 3e6a7bdeb0..ae0a3316f3 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_deleted.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_deleted.dart @@ -29,7 +29,11 @@ class StreamMessageDeleted extends StatelessWidget { mainAxisSize: .min, children: [ Icon(icons.noSign, size: 16), - core.StreamMessageText(padding: .zero, context.translations.messageDeletedLabel), + // Flexible so a long translation wraps inside the bubble instead of + // overflowing it — the bubble has a fixed maximum width. + Flexible( + child: core.StreamMessageText(padding: .zero, context.translations.messageDeletedLabel), + ), ], ), ); diff --git a/packages/stream_chat_flutter/lib/src/misc/back_button.dart b/packages/stream_chat_flutter/lib/src/misc/back_button.dart index e7bceedbde..803d1163aa 100644 --- a/packages/stream_chat_flutter/lib/src/misc/back_button.dart +++ b/packages/stream_chat_flutter/lib/src/misc/back_button.dart @@ -57,8 +57,14 @@ class StreamBackButton extends StatelessWidget { _ => context.streamIcons.arrowLeft, }; + final surfaceStyle = StreamTheme.of(context).surfaceStyle; + final toolbarSurfaceStyle = StreamToolbarScope.maybeOf(context); + + final effectiveIsFloating = toolbarSurfaceStyle?.isFloating ?? surfaceStyle.isFloating; + Widget button = StreamButton.icon( - type: .ghost, + type: effectiveIsFloating ? .outline : .ghost, + isFloating: effectiveIsFloating, size: .medium, style: .secondary, tooltip: backTooltip, diff --git a/packages/stream_chat_flutter/lib/src/misc/thread_header.dart b/packages/stream_chat_flutter/lib/src/misc/thread_header.dart index c6f373db01..0d5734871c 100644 --- a/packages/stream_chat_flutter/lib/src/misc/thread_header.dart +++ b/packages/stream_chat_flutter/lib/src/misc/thread_header.dart @@ -22,6 +22,7 @@ class StreamThreadHeader extends StatelessWidget implements PreferredSizeWidget required this.parent, this.leading, this.automaticallyImplyLeading = true, + this.onBackPressed, this.title, this.subtitle, this.trailing, @@ -38,6 +39,13 @@ class StreamThreadHeader extends StatelessWidget implements PreferredSizeWidget /// {@macro StreamAppBar.automaticallyImplyLeading} final bool automaticallyImplyLeading; + /// Called when the default [StreamBackButton] is pressed, replacing its + /// default [Navigator.maybePop]. + /// + /// Ignored when [leading] is provided or [automaticallyImplyLeading] is + /// false. + final VoidCallback? onBackPressed; + /// {@macro StreamAppBar.title} /// /// Defaults to the localized "Thread reply" label. @@ -75,7 +83,10 @@ class StreamThreadHeader extends StatelessWidget implements PreferredSizeWidget final cid? => StreamUnreadIndicator.channels(cid: cid), null => const StreamUnreadIndicator(), }; - leading = StreamBackButton(unreadIndicator: unreadIndicator); + leading = StreamBackButton( + onPressed: onBackPressed, + unreadIndicator: unreadIndicator, + ); } Widget? fallbackSubtitle; @@ -93,6 +104,8 @@ class StreamThreadHeader extends StatelessWidget implements PreferredSizeWidget alternativeWidget: fallbackSubtitle, ); + // Apply the per-header theme as a theme; the bar resolves `style` over it + // and republishes the resolved behavior to its slots. return StreamAppBarTheme( data: headerTheme, child: StreamAppBar( diff --git a/packages/stream_chat_flutter/lib/src/pages/channel_page.dart b/packages/stream_chat_flutter/lib/src/pages/channel_page.dart new file mode 100644 index 0000000000..da33fa1cf5 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/pages/channel_page.dart @@ -0,0 +1,198 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// A channel page with optional floating composer support. +/// +/// Wires up a [StreamChannelHeader], a [StreamMessageListView] and a +/// [StreamMessageComposer], laid out floating or regular according to the ambient +/// [StreamSurfaceStyle]. Expects a [StreamChannel] ancestor. +/// +/// ## Customizing this page +/// +/// The constructor is deliberately small — most customization happens through +/// the component factory and the global configuration, both of which reach +/// inside this page because the components resolve them themselves. +/// +/// Swap out components with [streamChatComponentBuilders], passed to +/// [StreamChat.componentBuilders]: +/// +/// ```dart +/// StreamChat( +/// client: client, +/// componentBuilders: StreamComponentBuilders( +/// extensions: streamChatComponentBuilders( +/// // Applies to the messages this page's list renders. +/// messageItem: (context, props) => DefaultStreamMessageItem( +/// props: props.copyWith(maxWidth: 320), +/// ), +/// // Applies to this page's composer. +/// messageComposer: (context, props) => DefaultStreamMessageComposer( +/// props: props.copyWith(disableAttachments: true), +/// ), +/// ), +/// ), +/// child: child, +/// ) +/// ``` +/// +/// `messageItem`, `messageComposer`, `quotedMessage`, `mentionItem`, the +/// attachment builders, `mediaGallery` and `videoPlayer` all apply here. +/// +/// Change list behavior — `swipeToReply`, `highlightInitialMessage`, +/// `autoScrollPolicy` and the rest — through +/// [StreamChatConfigurationData.messageListViewConfiguration] on +/// [StreamChat.configData]. +/// +/// Not reachable from here: the list-level slots on +/// [StreamMessageListViewBuilders] (`header`, `footer`, `dateDivider`, +/// `floatingDateDivider`, `threadSeparator`, `scrollToBottomButton`, `empty`, +/// `loading`, `error`) and [StreamChannelHeader]'s title, subtitle and actions. +/// Those have no component-factory entry, so customizing them means composing +/// [StreamMessageListView] and [StreamChannelHeader] directly instead of using +/// this page. +/// +/// See also: +/// +/// * [StreamThreadPage], the equivalent page for a single thread. +class StreamChannelPage extends StatefulWidget { + /// Creates a [StreamChannelPage]. + const StreamChannelPage({ + super.key, + this.initialScrollIndex, + this.initialAlignment, + this.onBackPressed, + this.onChannelAvatarPressed, + }); + + /// Initial scroll index for the message list. + final int? initialScrollIndex; + + /// Initial scroll alignment for the message list. + final double? initialAlignment; + + /// Called when the header's back button is pressed. + /// + /// Replaces the default action, which pops the current route. When null the + /// default is kept. + final VoidCallback? onBackPressed; + + /// Called when the default channel-avatar in the trailing slot is pressed. + final void Function(BuildContext context, Channel channel)? onChannelAvatarPressed; + + @override + State createState() => _StreamChannelPageState(); +} + +class _StreamChannelPageState extends State { + late final FocusNode _focusNode = FocusNode(); + late final StreamMessageComposerController _messageComposerController = StreamMessageComposerController(); + + @override + void dispose() { + _focusNode.dispose(); + _messageComposerController.dispose(); + super.dispose(); + } + + void _reply(Message message) { + _messageComposerController.quotedMessage = message; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _focusNode.requestFocus(); + }); + } + + void _editMessage(Message message) { + _messageComposerController.editMessage(message); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _focusNode.requestFocus(); + }); + } + + @override + Widget build(BuildContext context) { + final appBar = StreamChannelHeader( + // Leaving this null keeps the header's default back button, which pops + // the route. + onBackPressed: widget.onBackPressed, + 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( + appBar: appBar, + bottom: composer, + body: _ChannelPageBody( + initialScrollIndex: widget.initialScrollIndex, + initialAlignment: widget.initialAlignment, + onReply: _reply, + onEditMessage: _editMessage, + typingIndicator: typingIndicator, + ), + ); + } +} + +// The body of a channel page. +// +// Positions the typing indicator just above the composer (floating or docked) +// via a bottom SafeArea over the floating-bar insets in MediaQuery.padding +// (injected by StreamScaffold). StreamMessageListView reads the same insets +// directly to pad its scroll content. +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) { + return Stack( + children: [ + StreamMessageListView( + initialScrollIndex: initialScrollIndex, + initialAlignment: initialAlignment, + onEditMessageTap: onEditMessage, + onReplyTap: onReply, + threadBuilder: (_, parentMessage) { + return StreamThreadPage(parent: parentMessage!); + }, + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + // A bottom SafeArea lifts the indicator above the composer / bottom + // bar — the scaffold injects its extent into MediaQuery.padding.bottom. + child: SafeArea(top: false, child: typingIndicator), + ), + ], + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/pages/thread_page.dart b/packages/stream_chat_flutter/lib/src/pages/thread_page.dart new file mode 100644 index 0000000000..f32bc1e54e --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/pages/thread_page.dart @@ -0,0 +1,148 @@ +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. +/// +/// Wires up a [StreamThreadHeader], a [StreamMessageListView] scoped to [parent] +/// and a [StreamMessageComposer] that addresses new messages to the thread, laid +/// out floating or regular according to the ambient [StreamSurfaceStyle]. Expects a +/// [StreamChannel] ancestor. +/// +/// The composer is omitted when [parent] is deleted. +/// +/// ## Customizing this page +/// +/// As with [StreamChannelPage], the constructor is small because customization +/// happens through the component factory and the global configuration rather +/// than through parameters — see [StreamChannelPage] for the full rundown and an +/// example. In short: +/// +/// * Components (`messageItem`, `messageComposer`, attachments, …) — +/// [streamChatComponentBuilders] on [StreamChat.componentBuilders]. +/// * List behavior — [StreamChatConfigurationData.messageListViewConfiguration] +/// on [StreamChat.configData]. +/// * Not reachable: [StreamMessageListViewBuilders] list-level slots and +/// [StreamThreadHeader]'s title and actions. +class StreamThreadPage extends StatefulWidget { + /// Creates a [StreamThreadPage]. + const StreamThreadPage({ + super.key, + required this.parent, + this.initialScrollIndex, + this.initialAlignment, + this.onViewInChannelTap, + this.onBackPressed, + }); + + /// 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; + + /// Called when the header's back button is pressed. + /// + /// Replaces the default action, which pops the current route. When null the + /// default is kept. + final VoidCallback? onBackPressed; + + @override + State createState() => _StreamThreadPageState(); +} + +class _StreamThreadPageState extends State { + late final FocusNode _focusNode = FocusNode(); + late final StreamMessageComposerController _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((_) { + if (!mounted) return; + _focusNode.requestFocus(); + }); + } + + void _editMessage(Message message) { + _messageComposerController.editMessage(message); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _focusNode.requestFocus(); + }); + } + + @override + Widget build(BuildContext context) { + final appBar = StreamThreadHeader( + parent: widget.parent, + // Leaving this null keeps the header's default back button, which pops + // the route. + onBackPressed: widget.onBackPressed, + ); + + final composer = !widget.parent.isDeleted + ? 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) { + return StreamMessageListView( + parentMessage: parent, + initialScrollIndex: initialScrollIndex, + initialAlignment: initialAlignment, + onReplyTap: onReply, + onEditMessageTap: onEditMessageTap, + onViewInChannelTap: onViewInChannelTap, + ); + } +} 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..610f1fd774 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 @@ -10,15 +10,23 @@ class StreamChannelListSkeletonLoading extends StatelessWidget { const StreamChannelListSkeletonLoading({ super.key, this.itemCount = 7, + this.padding, }); /// The number of skeleton items to display. final int itemCount; + /// Padding around the skeleton list. + /// + /// When null, the list auto-insets from `MediaQuery.padding` — matching the + /// real [StreamChannelListView] so the layout doesn't shift once it loads. + final EdgeInsetsGeometry? padding; + @override Widget build(BuildContext context) { return StreamSkeletonLoading( child: ListView.separated( + padding: padding, physics: const NeverScrollableScrollPhysics(), itemCount: itemCount, separatorBuilder: (context, index) => const SizedBox(height: 1), diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_view.dart b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_view.dart index 79eed6e1d9..e1a8fb49ac 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_view.dart @@ -333,7 +333,7 @@ class StreamChannelListView extends StatelessWidget { child: StreamLoadingSpinner(), ), ), - loadingBuilder: (context) => loadingBuilder?.call(context) ?? const StreamChannelListSkeletonLoading(), + loadingBuilder: (context) => loadingBuilder?.call(context) ?? StreamChannelListSkeletonLoading(padding: padding), errorBuilder: (context, error) { if (errorBuilder?.call(context, error) case final builder?) return builder; 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..8e7637730c 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 @@ -10,15 +10,23 @@ class StreamThreadListSkeletonLoading extends StatelessWidget { const StreamThreadListSkeletonLoading({ super.key, this.itemCount = 6, + this.padding, }); /// The number of skeleton items to display. final int itemCount; + /// Padding around the skeleton list. + /// + /// When null, the list auto-insets from `MediaQuery.padding` — matching the + /// real [StreamThreadListView] so the layout doesn't shift once it loads. + final EdgeInsetsGeometry? padding; + @override Widget build(BuildContext context) { return StreamSkeletonLoading( child: ListView.separated( + padding: padding, physics: const NeverScrollableScrollPhysics(), itemCount: itemCount, separatorBuilder: (context, index) => const SizedBox(height: 1), diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_view.dart b/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_view.dart index 4fcd721dba..2a23ec5c72 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_view.dart @@ -334,8 +334,8 @@ class StreamThreadListView extends StatelessWidget { ), loadingBuilder: (context) => loadingBuilder?.call(context) ?? - const Center( - child: StreamThreadListSkeletonLoading(), + Center( + child: StreamThreadListSkeletonLoading(padding: padding), ), errorBuilder: (context, error) { if (errorBuilder?.call(context, error) case final builder?) return builder; 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..9ddbefafdf 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,12 @@ 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 + /// [StreamMessageListView.config]. + /// + /// Defaults to a [StreamMessageListViewConfiguration] with all fields at their + /// 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..b5dca7174a --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/theme/message_composer_theme.dart @@ -0,0 +1,110 @@ +import 'package:flutter/widgets.dart'; +import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; +import 'package:stream_core_flutter/core.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +part 'message_composer_theme.g.theme.dart'; + +/// Applies a message composer theme to descendant composer widgets. +/// +/// Wrap a subtree with [StreamMessageComposerTheme] to override the composer +/// surface style. Access the merged theme using [StreamMessageComposerTheme.of]. +/// +/// {@tool snippet} +/// +/// Override composer placement for a specific screen: +/// +/// ```dart +/// StreamMessageComposerTheme( +/// data: StreamMessageComposerThemeData( +/// surfaceStyle: StreamSurfaceStyle.floating, +/// ), +/// child: StreamChannel( +/// channel: channel, +/// child: ChannelPage(), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageComposerThemeData], which describes the theme data. +/// * [StreamMessageComposerThemeData.surfaceStyle], 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.surfaceStyle] 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 [StreamSurfaceStyle]. +/// +/// {@tool snippet} +/// +/// Override composer placement globally: +/// +/// ```dart +/// StreamChatThemeData( +/// messageComposerTheme: StreamMessageComposerThemeData( +/// surfaceStyle: StreamSurfaceStyle.floating, +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamSurfaceStyle], 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.surfaceStyle}); + + /// The visual/layout surface style of the message composer. + /// + /// When null the value falls back to the ambient [StreamSurfaceStyle]: + /// [StreamSurfaceStyle.floating] renders the composer floating, + /// [StreamSurfaceStyle.regular] renders it docked. + /// + /// Overrides the global style for the composer only, leaving other components + /// unaffected. + final StreamSurfaceStyle? surfaceStyle; + + /// 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..bea19cf48c --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/theme/message_composer_theme.g.theme.dart @@ -0,0 +1,81 @@ +// 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( + surfaceStyle: t < 0.5 ? a.surfaceStyle : b.surfaceStyle, + ); + } + + StreamMessageComposerThemeData copyWith({StreamSurfaceStyle? surfaceStyle}) { + final _this = (this as StreamMessageComposerThemeData); + + return StreamMessageComposerThemeData( + surfaceStyle: surfaceStyle ?? _this.surfaceStyle, + ); + } + + StreamMessageComposerThemeData merge(StreamMessageComposerThemeData? other) { + final _this = (this as StreamMessageComposerThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith(surfaceStyle: other.surfaceStyle); + } + + @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.surfaceStyle == _this.surfaceStyle; + } + + @override + int get hashCode { + final _this = (this as StreamMessageComposerThemeData); + + return Object.hash(runtimeType, _this.surfaceStyle); + } +} diff --git a/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart b/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart index e487996ce0..e883a3f8d8 100644 --- a/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart +++ b/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart @@ -132,6 +132,7 @@ class StreamChatThemeData extends ThemeExtension with _$Str StreamAppBarThemeData? channelHeaderTheme, StreamAppBarThemeData? channelListHeaderTheme, StreamAppBarThemeData? threadHeaderTheme, + StreamMessageComposerThemeData? messageComposerTheme, StreamMessageListViewThemeData? messageListViewTheme, StreamPollCreatorThemeData? pollCreatorTheme, StreamPollInteractorThemeData? pollInteractorTheme, @@ -149,6 +150,9 @@ class StreamChatThemeData extends ThemeExtension with _$Str channelListHeaderTheme ??= const StreamAppBarThemeData(); threadHeaderTheme ??= const StreamAppBarThemeData(); + // Message composer + messageComposerTheme ??= const StreamMessageComposerThemeData(); + // Message list messageListViewTheme ??= const StreamMessageListViewThemeData(); @@ -170,6 +174,7 @@ class StreamChatThemeData extends ThemeExtension with _$Str channelHeaderTheme: channelHeaderTheme, channelListHeaderTheme: channelListHeaderTheme, threadHeaderTheme: threadHeaderTheme, + messageComposerTheme: messageComposerTheme, messageListViewTheme: messageListViewTheme, pollCreatorTheme: pollCreatorTheme, pollInteractorTheme: pollInteractorTheme, @@ -189,6 +194,7 @@ class StreamChatThemeData extends ThemeExtension with _$Str required this.channelHeaderTheme, required this.channelListHeaderTheme, required this.threadHeaderTheme, + required this.messageComposerTheme, required this.messageListViewTheme, required this.pollCreatorTheme, required this.pollInteractorTheme, @@ -211,6 +217,9 @@ class StreamChatThemeData extends ThemeExtension with _$Str /// The thread header app bar theme for this theme. final StreamAppBarThemeData threadHeaderTheme; + /// The message composer theme for this theme. + final StreamMessageComposerThemeData messageComposerTheme; + /// The message list view theme for this theme. final StreamMessageListViewThemeData messageListViewTheme; diff --git a/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.g.theme.dart b/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.g.theme.dart index ee0012763b..105993cf0d 100644 --- a/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.g.theme.dart +++ b/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.g.theme.dart @@ -15,6 +15,7 @@ mixin _$StreamChatThemeData on ThemeExtension { StreamAppBarThemeData? channelHeaderTheme, StreamAppBarThemeData? channelListHeaderTheme, StreamAppBarThemeData? threadHeaderTheme, + StreamMessageComposerThemeData? messageComposerTheme, StreamMessageListViewThemeData? messageListViewTheme, StreamPollCreatorThemeData? pollCreatorTheme, StreamPollInteractorThemeData? pollInteractorTheme, @@ -34,6 +35,7 @@ mixin _$StreamChatThemeData on ThemeExtension { channelListHeaderTheme: channelListHeaderTheme ?? _this.channelListHeaderTheme, threadHeaderTheme: threadHeaderTheme ?? _this.threadHeaderTheme, + messageComposerTheme: messageComposerTheme ?? _this.messageComposerTheme, messageListViewTheme: messageListViewTheme ?? _this.messageListViewTheme, pollCreatorTheme: pollCreatorTheme ?? _this.pollCreatorTheme, pollInteractorTheme: pollInteractorTheme ?? _this.pollInteractorTheme, @@ -80,6 +82,11 @@ mixin _$StreamChatThemeData on ThemeExtension { other.threadHeaderTheme, t, )!, + messageComposerTheme: StreamMessageComposerThemeData.lerp( + _this.messageComposerTheme, + other.messageComposerTheme, + t, + )!, messageListViewTheme: StreamMessageListViewThemeData.lerp( _this.messageListViewTheme, other.messageListViewTheme, @@ -155,6 +162,7 @@ mixin _$StreamChatThemeData on ThemeExtension { return _other.channelHeaderTheme == _this.channelHeaderTheme && _other.channelListHeaderTheme == _this.channelListHeaderTheme && _other.threadHeaderTheme == _this.threadHeaderTheme && + _other.messageComposerTheme == _this.messageComposerTheme && _other.messageListViewTheme == _this.messageListViewTheme && _other.pollCreatorTheme == _this.pollCreatorTheme && _other.pollInteractorTheme == _this.pollInteractorTheme && @@ -178,6 +186,7 @@ mixin _$StreamChatThemeData on ThemeExtension { _this.channelHeaderTheme, _this.channelListHeaderTheme, _this.threadHeaderTheme, + _this.messageComposerTheme, _this.messageListViewTheme, _this.pollCreatorTheme, _this.pollInteractorTheme, diff --git a/packages/stream_chat_flutter/lib/src/theme/themes.dart b/packages/stream_chat_flutter/lib/src/theme/themes.dart index ab4d9de545..d247f4d95c 100644 --- a/packages/stream_chat_flutter/lib/src/theme/themes.dart +++ b/packages/stream_chat_flutter/lib/src/theme/themes.dart @@ -1,3 +1,4 @@ +export 'message_composer_theme.dart'; export 'message_list_view_theme.dart'; export 'poll_card_style.dart'; export 'poll_comments_sheet_theme.dart'; diff --git a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart index 6a1c377bcd..c8bda939e7 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -113,6 +113,8 @@ export 'src/misc/stream_neumorphic_button.dart'; export 'src/misc/swipeable.dart'; export 'src/misc/thread_header.dart'; export 'src/misc/timestamp.dart'; +export 'src/pages/channel_page.dart'; +export 'src/pages/thread_page.dart'; export 'src/poll/creator/stream_poll_creator_sheet.dart'; export 'src/poll/creator/stream_poll_creator_widget.dart'; export 'src/poll/interactor/stream_poll_interactor.dart'; diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index b784027c27..ef3fa2046e 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -66,7 +66,7 @@ dependencies: # ignore: invalid_dependency git: url: https://github.com/GetStream/stream-core-flutter.git - ref: 3ba1a36b6a3d9d3a194ccb8f0deaf4d69c5842eb + ref: c4308b3a2ea286483fc24c09c0168b10ad9de8a8 path: packages/stream_core_flutter stream_thumbnail: ^0.1.0 svg_icon_widget: ^0.0.1 diff --git a/packages/stream_chat_flutter/test/scrollable_positioned_list/content_edges_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/content_edges_test.dart new file mode 100644 index 0000000000..844bb6d444 --- /dev/null +++ b/packages/stream_chat_flutter/test/scrollable_positioned_list/content_edges_test.dart @@ -0,0 +1,79 @@ +// Verifies ItemPosition.contentLeadingEdge / contentTrailingEdge — the +// content-relative edges (measured inside the list's padding) that overlays +// like the floating date divider key off, as distinct from the +// viewport-relative itemLeadingEdge / itemTrailingEdge that scroll mechanics +// use. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; + +const screenHeight = 400.0; +const screenWidth = 400.0; +const itemHeight = 40.0; +const itemCount = 500; + +Future pumpList( + WidgetTester tester, { + required EdgeInsets? padding, + bool reverse = false, +}) async { + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = const Size(screenWidth, screenHeight); + addTearDown(tester.view.reset); + + final positionsListener = ItemPositionsListener.create(); + await tester.pumpWidget( + MaterialApp( + home: ScrollablePositionedList.builder( + itemCount: itemCount, + reverse: reverse, + padding: padding, + itemPositionsListener: positionsListener, + itemBuilder: (context, index) => SizedBox(height: itemHeight, child: Text('Item $index')), + ), + ), + ); + return positionsListener; +} + +ItemPosition positionOf(ItemPositionsListener listener, int index) { + return listener.itemPositions.value.firstWhere((p) => p.index == index); +} + +void main() { + group('ItemPosition content edges', () { + testWidgets('are content-relative under a leading inset, while the item edges stay viewport-relative', ( + tester, + ) async { + // A 100px top inset in a 400px viewport: item 0 rests flush against the + // content top, and the content area spans the remaining 300px. + final listener = await pumpList(tester, padding: const EdgeInsets.only(top: 100)); + + // Sanity: item 0 renders below the inset, not at the viewport top. + expect(tester.getTopLeft(find.text('Item 0')).dy, 100); + + final position = positionOf(listener, 0); + + // Viewport-relative: item 0 sits 100/400 down from the viewport's top. + expect(position.itemLeadingEdge, closeTo(0.25, 1e-6)); + expect(position.itemTrailingEdge, closeTo(0.35, 1e-6)); + + // Content-relative: measured inside the 100px inset (content extent 300), + // item 0 is flush with the content's leading edge, so it reads 0. + expect(position.contentLeadingEdge, closeTo(0, 1e-6)); + expect(position.contentTrailingEdge, closeTo(40 / 300, 1e-6)); + }); + + testWidgets('equal the viewport edges when the list has no inset', (tester) async { + final listener = await pumpList(tester, padding: null); + + final positions = listener.itemPositions.value; + expect(positions, isNotEmpty); + for (final position in positions) { + expect(position.contentLeadingEdge, closeTo(position.itemLeadingEdge, 1e-9)); + expect(position.contentTrailingEdge, closeTo(position.itemTrailingEdge, 1e-9)); + } + }); + }); +} diff --git a/packages/stream_chat_flutter/test/scrollable_positioned_list/padding_scrollable_positioned_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/padding_scrollable_positioned_list_test.dart new file mode 100644 index 0000000000..c09aade169 --- /dev/null +++ b/packages/stream_chat_flutter/test/scrollable_positioned_list/padding_scrollable_positioned_list_test.dart @@ -0,0 +1,189 @@ +// Verifies ScrollablePositionedList handles `padding` the same way a plain +// ListView (BoxScrollView) does — in particular, auto-consuming +// `MediaQuery.padding` when `padding` is null. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; + +const screenHeight = 400.0; +const screenWidth = 400.0; +const itemHeight = screenHeight / 10.0; +const itemCount = 500; + +Future pumpList( + WidgetTester tester, { + required EdgeInsets? padding, + EdgeInsets mediaPadding = EdgeInsets.zero, + bool reverse = false, + int initialScrollIndex = 0, + double initialAlignment = 0.0, +}) async { + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = const Size(screenWidth, screenHeight); + addTearDown(tester.view.reset); + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => MediaQuery( + data: MediaQuery.of(context).copyWith(padding: mediaPadding, viewPadding: mediaPadding), + child: ScrollablePositionedList.builder( + itemCount: itemCount, + reverse: reverse, + padding: padding, + initialScrollIndex: initialScrollIndex, + initialAlignment: initialAlignment, + itemBuilder: (context, index) => SizedBox(height: itemHeight, child: Text('Item $index')), + ), + ), + ), + ), + ); +} + +// The concrete padding on every SliverPadding SPL emits, in tree order +// (leading?, center, trailing?). ListView emits exactly one. +List sliverPaddings(WidgetTester tester) { + return tester + .widgetList(find.byType(SliverPadding)) + .map((w) => w.padding.resolve(TextDirection.ltr)) + .toList(); +} + +// A plain ListView pumped identically, used as the parity oracle. +Future listViewFirstItemTop( + WidgetTester tester, { + required EdgeInsets? padding, + EdgeInsets mediaPadding = EdgeInsets.zero, +}) async { + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = const Size(screenWidth, screenHeight); + addTearDown(tester.view.reset); + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => MediaQuery( + data: MediaQuery.of(context).copyWith(padding: mediaPadding, viewPadding: mediaPadding), + child: ListView.builder( + itemCount: itemCount, + padding: padding, + itemBuilder: (context, index) => SizedBox(height: itemHeight, child: Text('LV $index')), + ), + ), + ), + ), + ); + return tester.getTopLeft(find.text('LV 0')).dy; +} + +void main() { + group('ScrollablePositionedList padding parity with ListView', () { + testWidgets('null padding auto-consumes MediaQuery.padding.top (like ListView)', (tester) async { + await pumpList(tester, padding: null, mediaPadding: const EdgeInsets.only(top: 50, bottom: 30)); + // First item rests below the top MediaQuery inset, not at y = 0. + expect(tester.getTopLeft(find.text('Item 0')).dy, 50); + }); + + testWidgets('reversed: null padding auto-consumes MediaQuery.padding.bottom', (tester) async { + await pumpList(tester, reverse: true, padding: null, mediaPadding: const EdgeInsets.only(bottom: 30)); + // Item 0 sits at the visual bottom of a reversed list; it clears the inset. + expect(tester.getBottomLeft(find.text('Item 0')).dy, screenHeight - 30); + }); + + testWidgets('matches a plain ListView for the same null-padding + MediaQuery', (tester) async { + await pumpList(tester, padding: null, mediaPadding: const EdgeInsets.only(top: 44)); + final splTop = tester.getTopLeft(find.text('Item 0')).dy; + + final lvTop = await listViewFirstItemTop(tester, padding: null, mediaPadding: const EdgeInsets.only(top: 44)); + + expect(splTop, lvTop); + }); + + testWidgets('explicit padding is used verbatim and ignores MediaQuery', (tester) async { + await pumpList(tester, padding: const EdgeInsets.only(top: 100), mediaPadding: const EdgeInsets.only(top: 50)); + expect(tester.getTopLeft(find.text('Item 0')).dy, 100); + }); + + testWidgets('reversed + explicit bottom padding: newest item (0) clears the bottom', (tester) async { + // Mirrors the message list: reverse:true, explicit padding. Item 0 (newest) + // sits at the visual bottom and must clear the composer inset. + await pumpList(tester, reverse: true, padding: const EdgeInsets.only(bottom: 100)); + expect(tester.getBottomLeft(find.text('Item 0')).dy, screenHeight - 100); + }); + + testWidgets('no padding and no MediaQuery inset keeps the first item at the top', (tester) async { + await pumpList(tester, padding: null); + expect(tester.getTopLeft(find.text('Item 0')).dy, 0); + }); + }); + + // SPL splits its padding across up to three slivers (leading / center / + // trailing) because of its centre anchor. These verify the auto-consumed + // inset is distributed so each edge is applied EXACTLY once — matching what + // ListView does with its single SliverPadding, regardless of anchor position. + group('3-sliver split places each edge inset exactly once', () { + const mq = EdgeInsets.only(top: 50, bottom: 30); + + testWidgets('anchor at first item: center owns top, trailing owns bottom', (tester) async { + await pumpList(tester, padding: null, mediaPadding: mq, initialScrollIndex: 0); + // No leading sliver (nothing before index 0): center(item 0) + trailing. + expect(sliverPaddings(tester), const [ + EdgeInsets.only(top: 50), // center, isFirst -> owns top + EdgeInsets.only(bottom: 30), // trailing -> owns bottom + ]); + }); + + testWidgets('anchor in the middle: leading owns top, center owns neither, trailing owns bottom', (tester) async { + // Interior anchor at mid-alignment keeps all three slivers on-screen/in-cache. + await pumpList(tester, padding: null, mediaPadding: mq, initialScrollIndex: 3, initialAlignment: 0.5); + expect(sliverPaddings(tester), const [ + EdgeInsets.only(top: 50), // leading -> owns top + EdgeInsets.zero, // center, neither first nor last -> no double inset + EdgeInsets.only(bottom: 30), // trailing -> owns bottom + ]); + }); + + testWidgets('anchor at last item: leading owns top, center owns bottom', (tester) async { + // Anchor the last item mid-viewport so both it (center) and the leading sliver stay mounted. + await pumpList(tester, padding: null, mediaPadding: mq, initialScrollIndex: itemCount - 1, initialAlignment: 0.5); + // No trailing sliver (nothing after the last index): leading + center(last). + expect(sliverPaddings(tester), const [ + EdgeInsets.only(top: 50), // leading -> owns top + EdgeInsets.only(bottom: 30), // center, isLast -> owns bottom + ]); + }); + + testWidgets('aggregate (first sliver top + last sliver bottom) equals ListView single SliverPadding', ( + tester, + ) async { + // ListView oracle: one SliverPadding carrying the whole main-axis inset. + tester.view + ..devicePixelRatio = 1.0 + ..physicalSize = const Size(screenWidth, screenHeight); + addTearDown(tester.view.reset); + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => MediaQuery( + data: MediaQuery.of(context).copyWith(padding: mq, viewPadding: mq), + child: ListView.builder( + itemCount: itemCount, + itemBuilder: (context, index) => SizedBox(height: itemHeight, child: Text('LV $index')), + ), + ), + ), + ), + ); + final lvPad = sliverPaddings(tester).single; + expect(lvPad, mq); + + // SPL with an interior anchor: top from the first sliver, bottom from the last. + await pumpList(tester, padding: null, mediaPadding: mq, initialScrollIndex: 3, initialAlignment: 0.5); + final splPads = sliverPaddings(tester); + final aggregate = EdgeInsets.only(top: splPads.first.top, bottom: splPads.last.bottom); + expect(aggregate, lvPad); + }); + }); +} diff --git a/packages/stream_chat_flutter/test/src/channel/channel_header_test.dart b/packages/stream_chat_flutter/test/src/channel/channel_header_test.dart index 7f1c2662bd..6b28f7fa7a 100644 --- a/packages/stream_chat_flutter/test/src/channel/channel_header_test.dart +++ b/packages/stream_chat_flutter/test/src/channel/channel_header_test.dart @@ -444,4 +444,185 @@ void main() { expect(titleTapped, true); }, ); + + testWidgets( + 'onBackPressed replaces the default back button pop', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + final user = OwnUser(id: 'user-id'); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(user); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(user)); + when(() => clientState.totalUnreadCount).thenReturn(0); + when(() => clientState.totalUnreadCountStream).thenAnswer((_) => Stream.value(0)); + when(() => clientState.channels).thenReturn({channel.cid!: channel}); + when(() => client.wsConnectionStatusStream).thenAnswer((_) => Stream.value(ConnectionStatus.connected)); + + when(() => channel.client).thenReturn(client); + when(() => channel.state).thenReturn(channelState); + when(() => channel.lastMessageAt).thenReturn(null); + when(() => channel.name).thenReturn('test'); + when(() => channel.nameStream).thenAnswer((_) => Stream.value('test')); + when(() => channel.image).thenReturn(null); + when(() => channel.imageStream).thenAnswer((_) => Stream.value(null)); + when(() => channel.isMuted).thenReturn(false); + when(() => channel.isMutedStream).thenAnswer((_) => Stream.value(false)); + + when(() => channelState.members).thenReturn([]); + when(() => channelState.membersStream).thenAnswer((_) => Stream.value([])); + when(() => channelState.unreadCount).thenReturn(0); + when(() => channelState.unreadCountStream).thenAnswer((_) => Stream.value(0)); + + var backPressed = false; + + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: Scaffold( + body: StreamChannelHeader(onBackPressed: () => backPressed = true), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + await tester.tap(find.byType(StreamBackButton)); + + expect(backPressed, true); + }, + ); + + group('default slot floating behavior', () { + // The header installs its own StreamAppBarTheme around the bar, so both + // default slots have to resolve from inside it — otherwise + // channelHeaderTheme is invisible to them while the bar honours it, and the + // avatar and back button can disagree with each other. + Future pumpHeader( + WidgetTester tester, { + StreamSurfaceStyle surfaceStyle = StreamSurfaceStyle.regular, + StreamSurfaceStyle? themeSurfaceStyle, + StreamSurfaceStyle? styleSurfaceStyle, + }) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + final user = OwnUser(id: 'user-id'); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(user); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(user)); + when(() => clientState.totalUnreadCount).thenReturn(0); + when(() => clientState.totalUnreadCountStream).thenAnswer((_) => Stream.value(0)); + when(() => clientState.channels).thenReturn({channel.cid!: channel}); + when(() => client.wsConnectionStatusStream).thenAnswer((_) => Stream.value(ConnectionStatus.connected)); + + when(() => channel.client).thenReturn(client); + when(() => channel.state).thenReturn(channelState); + when(() => channel.lastMessageAt).thenReturn(null); + when(() => channel.name).thenReturn('test'); + when(() => channel.nameStream).thenAnswer((_) => Stream.value('test')); + when(() => channel.image).thenReturn(null); + when(() => channel.imageStream).thenAnswer((_) => Stream.value(null)); + when(() => channel.isMuted).thenReturn(false); + when(() => channel.isMutedStream).thenAnswer((_) => Stream.value(false)); + + when(() => channelState.members).thenReturn([]); + when(() => channelState.membersStream).thenAnswer((_) => Stream.value([])); + when(() => channelState.unreadCount).thenReturn(0); + when(() => channelState.unreadCountStream).thenAnswer((_) => Stream.value(0)); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(extensions: [StreamTheme(surfaceStyle: surfaceStyle)]), + home: StreamChat( + client: client, + themeData: StreamChatThemeData( + channelHeaderTheme: switch (themeSurfaceStyle) { + final surfaceStyle? => StreamAppBarThemeData(style: StreamAppBarStyle(surfaceStyle: surfaceStyle)), + _ => null, + }, + ), + child: StreamChannel( + channel: channel, + child: Scaffold( + body: StreamChannelHeader( + style: switch (styleSurfaceStyle) { + final surfaceStyle? => StreamAppBarStyle(surfaceStyle: surfaceStyle), + _ => null, + }, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + bool? avatarIsFloating(WidgetTester tester) { + return tester.widget(find.byType(StreamChannelAvatar)).isFloating; + } + + bool? backButtonIsFloating(WidgetTester tester) { + final button = find.descendant(of: find.byType(StreamBackButton), matching: find.byType(StreamButton)); + return tester.widget(button).props.isFloating; + } + + testWidgets('is not floating by default', (tester) async { + await pumpHeader(tester); + + expect(avatarIsFloating(tester), isNot(isTrue)); + }); + + testWidgets('floats when the app style is floating', (tester) async { + await pumpHeader(tester, surfaceStyle: StreamSurfaceStyle.floating); + + expect(avatarIsFloating(tester), isTrue); + }); + + testWidgets('floats when the header theme says so, over a regular app style', (tester) async { + await pumpHeader(tester, themeSurfaceStyle: StreamSurfaceStyle.floating); + + expect(avatarIsFloating(tester), isTrue); + }); + + testWidgets('the header style wins over both the header theme and the app style', (tester) async { + await pumpHeader( + tester, + surfaceStyle: StreamSurfaceStyle.floating, + themeSurfaceStyle: StreamSurfaceStyle.floating, + styleSurfaceStyle: StreamSurfaceStyle.regular, + ); + + expect(avatarIsFloating(tester), isNot(isTrue)); + }); + + testWidgets('the default back button agrees with the avatar under the header theme', (tester) async { + await pumpHeader(tester, themeSurfaceStyle: StreamSurfaceStyle.floating); + + expect(backButtonIsFloating(tester), isTrue); + expect(backButtonIsFloating(tester), avatarIsFloating(tester)); + }); + + testWidgets('the default back button agrees with the avatar under the header style', (tester) async { + await pumpHeader( + tester, + surfaceStyle: StreamSurfaceStyle.floating, + styleSurfaceStyle: StreamSurfaceStyle.regular, + ); + + expect(backButtonIsFloating(tester), isNot(isTrue)); + expect(backButtonIsFloating(tester), avatarIsFloating(tester)); + }); + }); } diff --git a/packages/stream_chat_flutter/test/src/channel/channel_list_header_test.dart b/packages/stream_chat_flutter/test/src/channel/channel_list_header_test.dart index e23e450846..60c2bbe8b4 100644 --- a/packages/stream_chat_flutter/test/src/channel/channel_list_header_test.dart +++ b/packages/stream_chat_flutter/test/src/channel/channel_list_header_test.dart @@ -155,4 +155,80 @@ void main() { expect(trailingTapped, 1); }, ); + + group('default avatar floating behavior', () { + // The header installs its own StreamAppBarTheme around the bar, so the + // avatar has to resolve from inside it — otherwise channelListHeaderTheme + // is invisible to the avatar while the bar honours it. + Future pumpHeader( + WidgetTester tester, { + StreamSurfaceStyle surfaceStyle = StreamSurfaceStyle.regular, + StreamSurfaceStyle? themeSurfaceStyle, + StreamSurfaceStyle? styleSurfaceStyle, + }) async { + final client = MockClient(); + final clientState = MockClientState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); + when(() => client.wsConnectionStatusStream).thenAnswer((_) => Stream.value(ConnectionStatus.connected)); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(extensions: [StreamTheme(surfaceStyle: surfaceStyle)]), + home: StreamChat( + client: client, + themeData: StreamChatThemeData( + channelListHeaderTheme: switch (themeSurfaceStyle) { + final surfaceStyle? => StreamAppBarThemeData(style: StreamAppBarStyle(surfaceStyle: surfaceStyle)), + _ => null, + }, + ), + child: Scaffold( + body: StreamChannelListHeader( + style: switch (styleSurfaceStyle) { + final surfaceStyle? => StreamAppBarStyle(surfaceStyle: surfaceStyle), + _ => null, + }, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + bool? avatarIsFloating(WidgetTester tester) { + return tester.widget(find.byType(StreamUserAvatar)).isFloating; + } + + testWidgets('is not floating by default', (tester) async { + await pumpHeader(tester); + + expect(avatarIsFloating(tester), isNot(isTrue)); + }); + + testWidgets('floats when the app style is floating', (tester) async { + await pumpHeader(tester, surfaceStyle: StreamSurfaceStyle.floating); + + expect(avatarIsFloating(tester), isTrue); + }); + + testWidgets('floats when the header theme says so, over a regular app style', (tester) async { + await pumpHeader(tester, themeSurfaceStyle: StreamSurfaceStyle.floating); + + expect(avatarIsFloating(tester), isTrue); + }); + + testWidgets('the header style wins over both the header theme and the app style', (tester) async { + await pumpHeader( + tester, + surfaceStyle: StreamSurfaceStyle.floating, + themeSurfaceStyle: StreamSurfaceStyle.floating, + styleSurfaceStyle: StreamSurfaceStyle.regular, + ); + + expect(avatarIsFloating(tester), isNot(isTrue)); + }); + }); } diff --git a/packages/stream_chat_flutter/test/src/channel/channel_page_test.dart b/packages/stream_chat_flutter/test/src/channel/channel_page_test.dart new file mode 100644 index 0000000000..a4e3fe5dbe --- /dev/null +++ b/packages/stream_chat_flutter/test/src/channel/channel_page_test.dart @@ -0,0 +1,263 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:record/record.dart'; +import 'package:stream_chat_flutter/src/message_input/stream_chat_message_input.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../fakes.dart'; +import '../mocks.dart'; + +void main() { + testWidgets('renders a header, a message list and a composer', (tester) async { + await _pumpChannelPage(tester); + + expect(find.byType(StreamChannelHeader), findsOneWidget); + expect(find.byType(StreamMessageListView), findsOneWidget); + expect(find.byType(StreamMessageComposer), findsOneWidget); + }); + + testWidgets('renders a typing indicator above the composer', (tester) async { + await _pumpChannelPage(tester, surfaceStyle: StreamSurfaceStyle.floating); + + final indicator = tester.getRect(_bodyTypingIndicator()); + final composer = tester.getRect(find.byType(StreamMessageComposer)); + + expect(indicator.bottom, lessThanOrEqualTo(composer.top)); + }); + + testWidgets('tapping the channel avatar invokes onChannelAvatarPressed', (tester) async { + Channel? pressedChannel; + await _pumpChannelPage(tester, onChannelAvatarPressed: (_, channel) => pressedChannel = channel); + + await tester.tap(_channelAvatarTapTarget()); + await tester.pumpAndSettle(); + + expect(pressedChannel, isNotNull); + }); + + testWidgets('tapping back invokes onBackPressed', (tester) async { + var backPressed = 0; + await _pumpChannelPage(tester, onBackPressed: () => backPressed++); + + await tester.tap(find.byType(StreamBackButton)); + await tester.pumpAndSettle(); + + expect(backPressed, 1); + }); + + testWidgets('onBackPressed replaces the default pop', (tester) async { + await _pumpChannelPage(tester, onBackPressed: () {}, pushOntoARoute: true); + + await tester.tap(find.byType(StreamBackButton)); + await tester.pumpAndSettle(); + + expect(find.byType(StreamChannelPage), findsOneWidget); + }); + + testWidgets('pops the route when onBackPressed is not set', (tester) async { + await _pumpChannelPage(tester, pushOntoARoute: true); + + await tester.tap(find.byType(StreamBackButton)); + await tester.pumpAndSettle(); + + expect(find.byType(StreamChannelPage), findsNothing); + }); + + testWidgets('replying to a message quotes it in the composer', (tester) async { + final message = Message(id: 'message-id', text: 'Hello world!'); + + await _pumpChannelPage(tester); + _messageListView(tester).onReplyTap!(message); + await tester.pumpAndSettle(); + + expect(_composerController(tester).message.quotedMessage, message); + }); + + testWidgets('replying to a message focuses the composer', (tester) async { + final message = Message(id: 'message-id', text: 'Hello world!'); + + await _pumpChannelPage(tester); + _messageListView(tester).onReplyTap!(message); + await tester.pumpAndSettle(); + + expect(_composerFocusNode(tester).hasFocus, isTrue); + }); + + testWidgets('editing a message loads it into the composer', (tester) async { + final message = Message(id: 'message-id', text: 'Hello world!'); + + await _pumpChannelPage(tester); + _messageListView(tester).onEditMessageTap!(message); + await tester.pumpAndSettle(); + + expect(_composerController(tester).messageBeingEdited, message); + }); + + testWidgets('editing a message focuses the composer', (tester) async { + final message = Message(id: 'message-id', text: 'Hello world!'); + + await _pumpChannelPage(tester); + _messageListView(tester).onEditMessageTap!(message); + await tester.pumpAndSettle(); + + expect(_composerFocusNode(tester).hasFocus, isTrue); + }); + + testWidgets('opens a thread page for the tapped parent message', (tester) async { + final parentMessage = Message(id: 'parent-id', text: 'Hello world!'); + + await _pumpChannelPage(tester); + final context = tester.element(find.byType(StreamMessageListView)); + final thread = _messageListView(tester).threadBuilder!(context, parentMessage); + + expect(thread, isA().having((it) => it.parent, 'parent', parentMessage)); + }); + + testWidgets('insets the message list behind a floating app bar and composer', (tester) async { + await _pumpChannelPage(tester, surfaceStyle: StreamSurfaceStyle.floating); + + // The floating scaffold injects the bar extents into MediaQuery.padding, + // which the message list consumes to inset its content behind the chrome. + final padding = MediaQuery.paddingOf(tester.element(find.byType(StreamMessageListView))); + + expect(padding.top, greaterThan(0)); + expect(padding.bottom, greaterThan(0)); + }); + + testWidgets('does not inset the message list when the app style is regular', (tester) async { + await _pumpChannelPage(tester); + + // Regular bars occupy their own space, so nothing is injected into padding. + final padding = MediaQuery.paddingOf(tester.element(find.byType(StreamMessageListView))); + + expect(padding.top, 0); + expect(padding.bottom, 0); + }); + + testWidgets('disposes its composer controller when removed from the tree', (tester) async { + await _pumpChannelPage(tester); + final controller = _composerController(tester); + + // A bare widget, so the whole app subtree unmounts. + await tester.pumpWidget(const SizedBox.shrink()); + + // A disposed ChangeNotifier throws when listened to again. + expect(() => controller.addListener(() {}), throwsFlutterError); + }); +} + +StreamMessageListView _messageListView(WidgetTester tester) { + return tester.widget(find.byType(StreamMessageListView)); +} + +/// The typing indicator the page renders in its body. +/// +/// The header renders one of its own as part of the channel subtitle, so the +/// plain type finder is ambiguous. +Finder _bodyTypingIndicator() { + final inHeader = find + .descendant(of: find.byType(StreamChannelHeader), matching: find.byType(StreamTypingIndicator)) + .evaluate() + .toSet(); + + return find.byElementPredicate((element) { + return element.widget is StreamTypingIndicator && !inHeader.contains(element); + }); +} + +/// The gesture detector wrapping the header's channel avatar. +Finder _channelAvatarTapTarget() { + return find.ancestor( + of: find.byType(StreamChannelAvatar), + matching: find.byType(GestureDetector), + ); +} + +/// The controller the page created and handed to its composer. +StreamMessageComposerController _composerController(WidgetTester tester) { + return tester.widget(find.byType(StreamChatMessageInput)).controller!; +} + +/// The focus node the page created and handed to its composer. +FocusNode _composerFocusNode(WidgetTester tester) { + return tester.widget(find.byType(StreamChatMessageInput)).focusNode!; +} + +Future _pumpChannelPage( + WidgetTester tester, { + StreamSurfaceStyle surfaceStyle = StreamSurfaceStyle.regular, + void Function(BuildContext context, Channel channel)? onChannelAvatarPressed, + VoidCallback? onBackPressed, + bool pushOntoARoute = false, +}) async { + final originalRecordPlatform = RecordPlatform.instance; + RecordPlatform.instance = FakeRecordPlatform(); + addTearDown(() => RecordPlatform.instance = originalRecordPlatform); + + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + final currentUser = OwnUser(id: 'user-id'); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(currentUser); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(currentUser)); + when(() => clientState.totalUnreadCount).thenReturn(0); + when(() => clientState.totalUnreadCountStream).thenAnswer((_) => Stream.value(0)); + // Keyed by cid so the header's back button can resolve the open channel's + // unread count and exclude it from the total. + when(() => clientState.channels).thenReturn({channel.cid!: channel}); + + when(() => channel.client).thenReturn(client); + when(() => channel.state).thenReturn(channelState); + when(channel.getRemainingCooldown).thenReturn(0); + when(() => channel.lastMessageAt).thenReturn(null); + when(() => channel.name).thenReturn('test'); + when(() => channel.nameStream).thenAnswer((_) => Stream.value('test')); + when(() => channel.image).thenReturn(null); + when(() => channel.imageStream).thenAnswer((_) => Stream.value(null)); + when(() => channel.isMuted).thenReturn(false); + when(() => channel.isMutedStream).thenAnswer((_) => Stream.value(false)); + when(() => channel.extraData).thenReturn({'name': 'test'}); + when(() => channel.extraDataStream).thenAnswer((_) => Stream.value({'name': 'test'})); + + when(() => channelState.members).thenReturn([]); + when(() => channelState.membersStream).thenAnswer((_) => Stream.value([])); + when(() => channelState.messages).thenReturn([]); + when(() => channelState.messagesStream).thenAnswer((_) => Stream.value([])); + when(() => channelState.threadsStream).thenAnswer((_) => const Stream.empty()); + when(() => channelState.draft).thenReturn(null); + when(() => channelState.isUpToDateStream).thenAnswer((_) => Stream.value(true)); + when(() => channelState.unreadCount).thenReturn(0); + when(() => channelState.unreadCountStream).thenAnswer((_) => Stream.value(0)); + when(() => channelState.readStream).thenAnswer((_) => Stream.value([])); + when(() => channelState.currentUserRead).thenReturn(null); + when(() => channelState.currentUserReadStream).thenAnswer((_) => const Stream.empty()); + + final page = StreamChannelPage( + onChannelAvatarPressed: onChannelAvatarPressed, + onBackPressed: onBackPressed, + ); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(extensions: [StreamTheme(surfaceStyle: surfaceStyle)]), + // Chat context lives above the navigator so it survives a pop. + builder: (context, child) => StreamChat( + client: client, + child: StreamChannel(channel: channel, child: child!), + ), + // '/channel' seeds the stack with '/' underneath it, giving the back + // button something to pop to. + initialRoute: pushOntoARoute ? '/channel' : '/', + routes: { + '/': (_) => pushOntoARoute ? const Scaffold(body: SizedBox.shrink()) : page, + '/channel': (_) => page, + }, + ), + ); + + await tester.pumpAndSettle(); +} diff --git a/packages/stream_chat_flutter/test/src/channel/thread_page_test.dart b/packages/stream_chat_flutter/test/src/channel/thread_page_test.dart new file mode 100644 index 0000000000..ef38abe506 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/channel/thread_page_test.dart @@ -0,0 +1,271 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:record/record.dart'; +import 'package:stream_chat_flutter/src/message_input/stream_chat_message_input.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../fakes.dart'; +import '../mocks.dart'; + +void main() { + testWidgets('renders a header, a message list and a composer', (tester) async { + await _pumpThreadPage(tester); + + expect(find.byType(StreamThreadHeader), findsOneWidget); + expect(find.byType(StreamMessageListView), findsOneWidget); + expect(find.byType(StreamMessageComposer), findsOneWidget); + }); + + testWidgets('shows the thread of the parent message', (tester) async { + final parent = Message(id: 'parent-id', text: 'Hello world!'); + + await _pumpThreadPage(tester, parent: parent); + + expect(_messageListView(tester).parentMessage, parent); + }); + + testWidgets('addresses new messages to the parent thread', (tester) async { + final parent = Message(id: 'parent-id', text: 'Hello world!'); + + await _pumpThreadPage(tester, parent: parent); + + expect(_composerController(tester).message.parentId, 'parent-id'); + }); + + testWidgets('hides the composer when the parent message is deleted', (tester) async { + final parent = Message(id: 'parent-id', text: 'Hello world!', type: 'deleted'); + + await _pumpThreadPage(tester, parent: parent); + + expect(find.byType(StreamMessageComposer), findsNothing); + }); + + testWidgets('still shows the thread when the parent message is deleted', (tester) async { + final parent = Message(id: 'parent-id', text: 'Hello world!', type: 'deleted'); + + await _pumpThreadPage(tester, parent: parent); + + expect(find.byType(StreamMessageListView), findsOneWidget); + }); + + testWidgets('replying to a message quotes it in the composer', (tester) async { + final message = Message(id: 'message-id', text: 'Hello world!'); + + await _pumpThreadPage(tester); + _messageListView(tester).onReplyTap!(message); + await tester.pumpAndSettle(); + + expect(_composerController(tester).message.quotedMessage, message); + }); + + testWidgets('replying to a message focuses the composer', (tester) async { + final message = Message(id: 'message-id', text: 'Hello world!'); + + await _pumpThreadPage(tester); + _messageListView(tester).onReplyTap!(message); + await tester.pumpAndSettle(); + + expect(_composerFocusNode(tester).hasFocus, isTrue); + }); + + testWidgets('editing a message loads it into the composer', (tester) async { + final message = Message(id: 'message-id', text: 'Hello world!'); + + await _pumpThreadPage(tester); + _messageListView(tester).onEditMessageTap!(message); + await tester.pumpAndSettle(); + + expect(_composerController(tester).messageBeingEdited, message); + }); + + testWidgets('editing a message focuses the composer', (tester) async { + final message = Message(id: 'message-id', text: 'Hello world!'); + + await _pumpThreadPage(tester); + _messageListView(tester).onEditMessageTap!(message); + await tester.pumpAndSettle(); + + expect(_composerFocusNode(tester).hasFocus, isTrue); + }); + + testWidgets('forwards onViewInChannelTap to the message list', (tester) async { + final message = Message(id: 'message-id', text: 'Hello world!'); + Message? viewedInChannel; + + await _pumpThreadPage(tester, onViewInChannelTap: (message) => viewedInChannel = message); + _messageListView(tester).onViewInChannelTap!(message); + await tester.pumpAndSettle(); + + expect(viewedInChannel, message); + }); + + testWidgets('insets the message list behind a floating app bar and composer', (tester) async { + await _pumpThreadPage(tester, surfaceStyle: StreamSurfaceStyle.floating); + + // The floating scaffold injects the bar extents into MediaQuery.padding, + // which the message list consumes to inset its content behind the chrome. + final padding = MediaQuery.paddingOf(tester.element(find.byType(StreamMessageListView))); + + expect(padding.top, greaterThan(0)); + expect(padding.bottom, greaterThan(0)); + }); + + testWidgets('does not inset the message list when the app style is regular', (tester) async { + await _pumpThreadPage(tester); + + // Regular bars occupy their own space, so nothing is injected into padding. + final padding = MediaQuery.paddingOf(tester.element(find.byType(StreamMessageListView))); + + expect(padding.top, 0); + expect(padding.bottom, 0); + }); + + testWidgets('tapping back invokes onBackPressed', (tester) async { + var backPressed = 0; + await _pumpThreadPage(tester, onBackPressed: () => backPressed++); + + await tester.tap(find.byType(StreamBackButton)); + await tester.pumpAndSettle(); + + expect(backPressed, 1); + }); + + testWidgets('onBackPressed replaces the default pop', (tester) async { + await _pumpThreadPage(tester, onBackPressed: () {}, pushOntoARoute: true); + + await tester.tap(find.byType(StreamBackButton)); + await tester.pumpAndSettle(); + + expect(find.byType(StreamThreadPage), findsOneWidget); + }); + + testWidgets('pops the route when onBackPressed is not set', (tester) async { + await _pumpThreadPage(tester, pushOntoARoute: true); + + await tester.tap(find.byType(StreamBackButton)); + await tester.pumpAndSettle(); + + expect(find.byType(StreamThreadPage), findsNothing); + }); + + testWidgets('disposes its composer controller when removed from the tree', (tester) async { + await _pumpThreadPage(tester); + final controller = _composerController(tester); + + // A bare widget, so the whole app subtree unmounts. + await tester.pumpWidget(const SizedBox.shrink()); + + // A disposed ChangeNotifier throws when listened to again. + expect(() => controller.addListener(() {}), throwsFlutterError); + }); +} + +StreamMessageListView _messageListView(WidgetTester tester) { + return tester.widget(find.byType(StreamMessageListView)); +} + +/// The controller the page created and handed to its composer. +StreamMessageComposerController _composerController(WidgetTester tester) { + return tester.widget(find.byType(StreamChatMessageInput)).controller!; +} + +/// The focus node the page created and handed to its composer. +FocusNode _composerFocusNode(WidgetTester tester) { + return tester.widget(find.byType(StreamChatMessageInput)).focusNode!; +} + +Future _pumpThreadPage( + WidgetTester tester, { + Message? parent, + StreamSurfaceStyle surfaceStyle = StreamSurfaceStyle.regular, + void Function(Message message)? onViewInChannelTap, + VoidCallback? onBackPressed, + bool pushOntoARoute = false, +}) async { + final originalRecordPlatform = RecordPlatform.instance; + RecordPlatform.instance = FakeRecordPlatform(); + addTearDown(() => RecordPlatform.instance = originalRecordPlatform); + + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + final currentUser = OwnUser(id: 'user-id'); + final parentMessage = parent ?? Message(id: 'parent-id', text: 'Hello world!'); + + // The thread is loaded — an empty reply list, not a missing one, is what + // takes the message list out of its skeleton-loading state. + final threads = {parentMessage.id: []}; + + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(currentUser); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(currentUser)); + when(() => clientState.totalUnreadCount).thenReturn(0); + when(() => clientState.totalUnreadCountStream).thenAnswer((_) => Stream.value(0)); + // Keyed by cid so the header's back button can resolve the channel's unread + // count. Without it StreamUnreadIndicator.channels gets a null stream and + // renders nothing, which silently makes the back button untappable. + final channelsById = {channel.cid!: channel}; + when(() => clientState.channels).thenReturn(channelsById); + when(() => clientState.channelsStream).thenAnswer((_) => Stream.value(channelsById)); + + when(() => channel.client).thenReturn(client); + when(() => channel.state).thenReturn(channelState); + when(channel.getRemainingCooldown).thenReturn(0); + when(() => channel.lastMessageAt).thenReturn(null); + when(() => channel.name).thenReturn('test'); + when(() => channel.nameStream).thenAnswer((_) => Stream.value('test')); + when(() => channel.image).thenReturn(null); + when(() => channel.imageStream).thenAnswer((_) => Stream.value(null)); + when(() => channel.isMuted).thenReturn(false); + when(() => channel.isMutedStream).thenAnswer((_) => Stream.value(false)); + when(() => channel.extraData).thenReturn({'name': 'test'}); + when(() => channel.extraDataStream).thenAnswer((_) => Stream.value({'name': 'test'})); + when( + () => channel.getReplies( + any(), + options: any(named: 'options'), + preferOffline: any(named: 'preferOffline'), + ), + ).thenAnswer((_) async => QueryRepliesResponse()..messages = []); + + when(() => channelState.members).thenReturn([]); + when(() => channelState.membersStream).thenAnswer((_) => Stream.value([])); + when(() => channelState.messages).thenReturn([]); + when(() => channelState.messagesStream).thenAnswer((_) => Stream.value([])); + when(() => channelState.threads).thenReturn(threads); + when(() => channelState.threadsStream).thenAnswer((_) => Stream.value(threads)); + when(() => channelState.draft).thenReturn(null); + when(() => channelState.isUpToDateStream).thenAnswer((_) => Stream.value(true)); + when(() => channelState.unreadCountStream).thenAnswer((_) => Stream.value(0)); + when(() => channelState.readStream).thenAnswer((_) => Stream.value([])); + when(() => channelState.currentUserRead).thenReturn(null); + when(() => channelState.currentUserReadStream).thenAnswer((_) => const Stream.empty()); + + final page = StreamThreadPage( + parent: parentMessage, + onViewInChannelTap: onViewInChannelTap, + onBackPressed: onBackPressed, + ); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(extensions: [StreamTheme(surfaceStyle: surfaceStyle)]), + // Chat context lives above the navigator so it survives a pop. + builder: (context, child) => StreamChat( + client: client, + child: StreamChannel(channel: channel, child: child!), + ), + // '/thread' seeds the stack with '/' underneath it, giving the back + // button something to pop to. + initialRoute: pushOntoARoute ? '/thread' : '/', + routes: { + '/': (_) => pushOntoARoute ? const Scaffold(body: SizedBox.shrink()) : page, + '/thread': (_) => page, + }, + ), + ); + + await tester.pumpAndSettle(); +} diff --git a/packages/stream_chat_flutter/test/src/message_action/message_actions_builder_test.dart b/packages/stream_chat_flutter/test/src/message_action/message_actions_builder_test.dart index 61655298f4..41ab757639 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 @@ -71,9 +71,10 @@ void main() { MockChannel _getChannelWithCapabilities( List capabilities, { bool enableMutes = true, + bool enableReplies = true, }) { final customChannel = MockChannel(ownCapabilities: capabilities); - final channelConfig = ChannelConfig(mutes: enableMutes); + final channelConfig = ChannelConfig(mutes: enableMutes, replies: enableReplies); when(() => customChannel.config).thenReturn(channelConfig); return customChannel; } diff --git a/packages/stream_chat_flutter/test/src/message_input/goldens/ci/composer_hold_to_record_snackbar.png b/packages/stream_chat_flutter/test/src/message_input/goldens/ci/composer_hold_to_record_snackbar.png index bbad6562b0..02a85bcc1b 100644 Binary files a/packages/stream_chat_flutter/test/src/message_input/goldens/ci/composer_hold_to_record_snackbar.png and b/packages/stream_chat_flutter/test/src/message_input/goldens/ci/composer_hold_to_record_snackbar.png differ diff --git a/packages/stream_chat_flutter/test/src/message_input/goldens/ci/message_composer_slow_mode.png b/packages/stream_chat_flutter/test/src/message_input/goldens/ci/message_composer_slow_mode.png index 538a5d1be4..42aae4206e 100644 Binary files a/packages/stream_chat_flutter/test/src/message_input/goldens/ci/message_composer_slow_mode.png and b/packages/stream_chat_flutter/test/src/message_input/goldens/ci/message_composer_slow_mode.png differ diff --git a/packages/stream_chat_flutter/test/src/message_input/message_composer_surface_style_test.dart b/packages/stream_chat_flutter/test/src/message_input/message_composer_surface_style_test.dart new file mode 100644 index 0000000000..6752f268e0 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_input/message_composer_surface_style_test.dart @@ -0,0 +1,239 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:record/record.dart'; +import 'package:stream_chat_flutter/src/message_input/stream_chat_message_input.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../fakes.dart'; +import '../mocks.dart'; + +void main() { + testWidgets('composer is regular when the app style is regular', (tester) async { + await _pumpComposer(tester, surfaceStyle: StreamSurfaceStyle.regular); + + expect(_resolvedSurfaceStyle(tester), StreamSurfaceStyle.regular); + }); + + testWidgets('composer floats when the app style is floating', (tester) async { + await _pumpComposer(tester, surfaceStyle: StreamSurfaceStyle.floating); + + expect(_resolvedSurfaceStyle(tester), StreamSurfaceStyle.floating); + }); + + testWidgets('the global composer theme overrides the app style', (tester) async { + await _pumpComposer( + tester, + surfaceStyle: StreamSurfaceStyle.regular, + globalTheme: const StreamMessageComposerThemeData(surfaceStyle: StreamSurfaceStyle.floating), + ); + + expect(_resolvedSurfaceStyle(tester), StreamSurfaceStyle.floating); + }); + + testWidgets('a local composer theme overrides the global theme', (tester) async { + await _pumpComposer( + tester, + surfaceStyle: StreamSurfaceStyle.floating, + globalTheme: const StreamMessageComposerThemeData(surfaceStyle: StreamSurfaceStyle.floating), + localTheme: const StreamMessageComposerThemeData(surfaceStyle: StreamSurfaceStyle.regular), + ); + + expect(_resolvedSurfaceStyle(tester), StreamSurfaceStyle.regular); + }); + + testWidgets('the surfaceStyle property overrides both the theme and the app style', (tester) async { + await _pumpComposer( + tester, + surfaceStyle: StreamSurfaceStyle.floating, + globalTheme: const StreamMessageComposerThemeData(surfaceStyle: StreamSurfaceStyle.floating), + composerSurfaceStyle: StreamSurfaceStyle.regular, + ); + + expect(_resolvedSurfaceStyle(tester), StreamSurfaceStyle.regular); + }); + + testWidgets('the regular composer fills its background with the elevation-1 color', (tester) async { + await _pumpComposer(tester, surfaceStyle: StreamSurfaceStyle.regular); + + expect(_backgroundFillFinder(tester), findsOneWidget); + }); + + testWidgets('the floating composer does not fill its background', (tester) async { + // Floating paints a fading backdrop instead of an opaque fill, so the + // message list stays visible behind the composer. + await _pumpComposer(tester, surfaceStyle: StreamSurfaceStyle.floating); + + expect(_backgroundFillFinder(tester), findsNothing); + }); + + testWidgets('the floating composer lifts the input above the bottom safe area', (tester) async { + const bottomInset = 34.0; + await _pumpComposer(tester, surfaceStyle: StreamSurfaceStyle.floating, bottomPadding: bottomInset); + + final gap = _gapBelowInput(tester); + + // The inset (34) is larger than the spacing.md floor, so it is used as-is — + // the composer sits flush above it rather than lifting further. + expect(gap, moreOrLessEquals(bottomInset)); + }); + + testWidgets('the floating composer floors the bottom gap at spacing.md without an inset', (tester) async { + // With no injected inset the floor keeps the composer off the edge; a larger + // inset would absorb it, so the composer never stacks a fixed gap on top of + // a system bar drawn outside the window. + await _pumpComposer(tester, surfaceStyle: StreamSurfaceStyle.floating); + + final gap = _gapBelowInput(tester); + + final context = tester.element(find.byType(StreamChatMessageInput)); + expect(gap, moreOrLessEquals(context.streamSpacing.md)); + }); + + testWidgets('the floating composer leaves no gap when the safe area is disabled', (tester) async { + await _pumpComposer( + tester, + surfaceStyle: StreamSurfaceStyle.floating, + bottomPadding: 34, + enableSafeArea: false, + ); + + final gap = _gapBelowInput(tester); + + expect(gap, moreOrLessEquals(0)); + }); + + testWidgets('the floating composer collapses the bottom inset while the attachment picker is open', (tester) async { + // The inset lifts the pill above the system inset when the picker is closed, + // but must collapse to zero while it is open so the edge-to-edge attachment + // gallery can reach the screen bottom. + await _pumpComposer(tester, surfaceStyle: StreamSurfaceStyle.floating, bottomPadding: 34); + + double insetBottom() { + // The applied inset is the outermost Padding inside the composer's safe area. + final padding = tester.widget( + find.descendant(of: find.byType(StreamSafeArea), matching: find.byType(Padding)).first, + ); + return padding.padding.resolve(TextDirection.ltr).bottom; + } + + // Closed: the injected inset (34) exceeds the spacing.md floor, so it is used as-is. + expect(insetBottom(), moreOrLessEquals(34)); + + await tester.tap(_attachmentButtonFinder); + await tester.pumpAndSettle(); + + // Open: the inset has collapsed. + expect(insetBottom(), moreOrLessEquals(0)); + }); +} + +/// The composer's attachment (picker) button, in the leading slot. +final _attachmentButtonFinder = find.descendant( + of: find.byType(DefaultStreamMessageComposerLeading), + matching: find.byType(StreamButton), +); + +/// The surface style the composer resolved, read back from the input it built. +StreamSurfaceStyle _resolvedSurfaceStyle(WidgetTester tester) { + final input = tester.widget(find.byType(StreamChatMessageInput)); + return input.isFloating ? StreamSurfaceStyle.floating : StreamSurfaceStyle.regular; +} + +/// Finds the opaque background fill the regular composer paints behind itself. +Finder _backgroundFillFinder(WidgetTester tester) { + final context = tester.element(find.byType(StreamChatMessageInput)); + final fill = BoxDecoration(color: context.streamColorScheme.backgroundElevation1); + + return find.descendant( + of: find.byType(StreamMessageComposer), + matching: find.byWidgetPredicate((widget) => widget is DecoratedBox && widget.decoration == fill), + ); +} + +/// The vertical distance between the bottom of the input pill and the bottom +/// of the composer. +double _gapBelowInput(WidgetTester tester) { + final composer = tester.getRect(find.byType(StreamMessageComposer)); + final input = tester.getRect(find.byType(StreamChatMessageInput)); + + return composer.bottom - input.bottom; +} + +/// Pumps a [StreamMessageComposer] with the given placement inputs. +/// +/// The composer is placed at the bottom of the screen so its rect can be +/// compared against the input pill's. +Future _pumpComposer( + WidgetTester tester, { + required StreamSurfaceStyle surfaceStyle, + StreamMessageComposerThemeData? globalTheme, + StreamMessageComposerThemeData? localTheme, + StreamSurfaceStyle? composerSurfaceStyle, + bool? enableSafeArea, + double bottomPadding = 0, +}) async { + final originalRecordPlatform = RecordPlatform.instance; + RecordPlatform.instance = FakeRecordPlatform(); + addTearDown(() => RecordPlatform.instance = originalRecordPlatform); + + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + + when(() => client.state).thenReturn(clientState); + // Read by the attachment validator when the picker opens. + when(() => client.appSettings).thenReturn( + const AppSettings(fileUploadConfig: UploadConfig(), imageUploadConfig: UploadConfig()), + ); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(OwnUser(id: 'user-id'))); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(channel.getRemainingCooldown).thenReturn(0); + when(() => channel.lastMessageAt).thenReturn(null); + when(() => channel.extraData).thenReturn({'name': 'test'}); + when(() => channel.extraDataStream).thenAnswer((_) => Stream.value({'name': 'test'})); + when(() => channelState.members).thenReturn([]); + when(() => channelState.membersStream).thenAnswer((_) => Stream.value([])); + when(() => channelState.messages).thenReturn([]); + when(() => channelState.messagesStream).thenAnswer((_) => Stream.value([])); + when(() => channelState.draft).thenReturn(null); + + final composer = StreamMessageComposer( + surfaceStyle: composerSurfaceStyle, + enableSafeArea: enableSafeArea, + ); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(extensions: [StreamTheme(surfaceStyle: surfaceStyle)]), + // Overrides only the padding, so the ambient size, text scale and platform + // brightness the test binding provides are preserved. + home: Builder( + builder: (context) => MediaQuery( + data: MediaQuery.of(context).copyWith(padding: EdgeInsets.only(bottom: bottomPadding)), + child: StreamChat( + client: client, + themeData: StreamChatThemeData(messageComposerTheme: globalTheme), + child: StreamChannel( + channel: channel, + child: Scaffold( + body: Align( + alignment: Alignment.bottomCenter, + child: switch (localTheme) { + final localTheme? => StreamMessageComposerTheme(data: localTheme, child: composer), + _ => composer, + }, + ), + ), + ), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); +} diff --git a/packages/stream_chat_flutter/test/src/message_list_view/message_list_view_test.dart b/packages/stream_chat_flutter/test/src/message_list_view/message_list_view_test.dart index 9ba6bbacb6..71836ff637 100644 --- a/packages/stream_chat_flutter/test/src/message_list_view/message_list_view_test.dart +++ b/packages/stream_chat_flutter/test/src/message_list_view/message_list_view_test.dart @@ -233,4 +233,155 @@ void main() { expect(find.byType(StreamButton), findsNothing); }); + + group('effective configuration', () { + // The scroll-to-bottom button is the observable: it renders as a + // StreamButton, and `showScrollToBottom` decides whether it exists at all. + // Scrolled away from the bottom so it would otherwise be visible. + Future pumpScrolledAwayList( + WidgetTester tester, { + StreamMessageListViewConfiguration? config, + StreamMessageListViewConfiguration? globalConfig, + }) async { + final messages = generateConversation( + 20, + users: [ + OwnUser(id: 'ownid'), + User(id: 'otherid'), + ], + ); + when(() => channelClientState.messagesStream).thenAnswer((_) => Stream.value(messages)); + when(() => channelClientState.messages).thenReturn(messages); + + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp( + home: DefaultAssetBundle( + bundle: rootBundle, + child: StreamChat( + client: client, + configData: switch (globalConfig) { + final globalConfig? => StreamChatConfigurationData(messageListViewConfiguration: globalConfig), + _ => null, + }, + child: StreamChannel( + channel: channel, + child: StreamMessageListView( + initialScrollIndex: 7, + config: config, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + }); + } + + testWidgets('falls back to the global configuration when config is null', (tester) async { + await pumpScrolledAwayList( + tester, + globalConfig: const StreamMessageListViewConfiguration(showScrollToBottom: false), + ); + + expect(find.byType(StreamButton), findsNothing); + }); + + testWidgets('prefers an explicit config over the global one', (tester) async { + await pumpScrolledAwayList( + tester, + globalConfig: const StreamMessageListViewConfiguration(showScrollToBottom: false), + config: const StreamMessageListViewConfiguration(), + ); + + expect(find.byType(StreamButton), findsOneWidget); + }); + + testWidgets('picks up a changed global configuration', (tester) async { + await pumpScrolledAwayList( + tester, + globalConfig: const StreamMessageListViewConfiguration(showScrollToBottom: false), + ); + expect(find.byType(StreamButton), findsNothing); + + // Guards the didChangeDependencies re-resolve. + await pumpScrolledAwayList( + tester, + globalConfig: const StreamMessageListViewConfiguration(), + ); + + expect(find.byType(StreamButton), findsOneWidget); + }); + + // Guards the didUpdateWidget re-resolve. The config is cached in a field, so + // a swapped `config` has to invalidate it. + // + // Swapping via setState below the StreamChat ancestor rather than a second + // pumpWidget, because StreamChatConfigurationData has no `==` — rebuilding + // StreamChat hands StreamChatConfiguration a fresh instance, which notifies + // every dependent and re-runs didChangeDependencies. That would resolve the + // config for the wrong reason and hide a missing didUpdateWidget. + testWidgets('picks up a changed config without a dependency change', (tester) async { + final messages = generateConversation( + 20, + users: [ + OwnUser(id: 'ownid'), + User(id: 'otherid'), + ], + ); + when(() => channelClientState.messagesStream).thenAnswer((_) => Stream.value(messages)); + when(() => channelClientState.messages).thenReturn(messages); + + final swapper = GlobalKey<_ConfigSwapperState>(); + + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp( + home: DefaultAssetBundle( + bundle: rootBundle, + child: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: _ConfigSwapper( + key: swapper, + initialConfig: const StreamMessageListViewConfiguration(), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + }); + + expect(find.byType(StreamButton), findsOneWidget); + + swapper.currentState!.swap(const StreamMessageListViewConfiguration(showScrollToBottom: false)); + await tester.pumpAndSettle(); + + expect(find.byType(StreamButton), findsNothing); + }); + }); +} + +/// Rebuilds only its [StreamMessageListView] child when [swap] is called, +/// leaving every ancestor untouched. +class _ConfigSwapper extends StatefulWidget { + const _ConfigSwapper({super.key, required this.initialConfig}); + + final StreamMessageListViewConfiguration initialConfig; + + @override + State<_ConfigSwapper> createState() => _ConfigSwapperState(); +} + +class _ConfigSwapperState extends State<_ConfigSwapper> { + late StreamMessageListViewConfiguration _config = widget.initialConfig; + + void swap(StreamMessageListViewConfiguration config) => setState(() => _config = config); + + @override + Widget build(BuildContext context) => StreamMessageListView(initialScrollIndex: 7, config: _config); } diff --git a/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/stream_message_deleted_dark.png b/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/stream_message_deleted_dark.png new file mode 100644 index 0000000000..e59943949c Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/stream_message_deleted_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/stream_message_deleted_light.png b/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/stream_message_deleted_light.png new file mode 100644 index 0000000000..5827a114aa Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/stream_message_deleted_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_widget/stream_message_deleted_test.dart b/packages/stream_chat_flutter/test/src/message_widget/stream_message_deleted_test.dart new file mode 100644 index 0000000000..8fa37a5b75 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_widget/stream_message_deleted_test.dart @@ -0,0 +1,163 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/src/message_widget/components/stream_message_deleted.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +void main() { + // The bubble is capped at the message item's max width, so the label has to + // wrap rather than run past it. Guards the `Flexible` around the label. + testWidgets('lays out a long label without overflowing', (tester) async { + await tester.pumpWidget(_wrapWithApp(_deletedBubble(_longLabel))); + + expect(tester.takeException(), isNull); + }); + + testWidgets('lays out the default label without overflowing', (tester) async { + await tester.pumpWidget(_wrapWithApp(_deletedBubble(_shortLabel))); + + expect(tester.takeException(), isNull); + }); + + testWidgets('wraps a long label onto more than one line', (tester) async { + await tester.pumpWidget(_wrapWithApp(_deletedBubble(_shortLabel))); + final singleLine = tester.getSize(find.byType(StreamMessageDeleted)).height; + + await tester.pumpWidget(_wrapWithApp(_deletedBubble(_longLabel))); + final wrapped = tester.getSize(find.byType(StreamMessageDeleted)).height; + + expect(wrapped, greaterThan(singleLine)); + }); + + testWidgets('keeps a long label within the bubble width', (tester) async { + await tester.pumpWidget(_wrapWithApp(_deletedBubble(_longLabel))); + + final width = tester.getSize(find.byType(StreamMessageDeleted)).width; + + expect(width, lessThanOrEqualTo(_bubbleMaxWidth)); + }); + + for (final brightness in Brightness.values) { + goldenTest( + '[${brightness.name}] -> StreamMessageDeleted looks fine', + fileName: 'stream_message_deleted_${brightness.name}', + constraints: const BoxConstraints.tightFor(width: 460, height: 420), + // The app is themed once around the whole group; the scenarios below + // vary only the label. + builder: () => _wrapWithApp( + brightness: brightness, + GoldenTestGroup( + columns: 1, + children: [ + GoldenTestScenario( + name: 'very short label — bubble hugs it', + child: _deletedBubble(_tinyLabel, maxWidth: _goldenBubbleMaxWidth), + ), + GoldenTestScenario( + name: 'short label', + child: _deletedBubble(_shortLabel, maxWidth: _goldenBubbleMaxWidth), + ), + GoldenTestScenario( + name: 'longest shipped translation', + child: _deletedBubble(_longestShippedLabel, maxWidth: _goldenBubbleMaxWidth), + ), + GoldenTestScenario( + name: 'long label wraps inside the bubble', + child: _deletedBubble(_longLabel, maxWidth: _goldenBubbleMaxWidth), + ), + ], + ), + ), + ); + } +} + +/// Mirrors the default `StreamMessageItemProps.maxWidth`, which is what caps +/// the bubble in a real message list. Used by the layout tests, so they pin +/// the real geometry. +const _bubbleMaxWidth = 272.0; + +/// The cap used by the goldens instead of [_bubbleMaxWidth]. +/// +/// Every glyph in the test font is a square of the font size, so text measures +/// far wider here than in a real app — at [_bubbleMaxWidth] even the English +/// label would wrap, which is not what users see. Widening the cap absorbs that +/// difference so the goldens show the line breaks a real font produces. +const _goldenBubbleMaxWidth = 350.0; + +/// Short enough that the bubble hugs it, showing it is not forced to full width. +const _tinyLabel = 'Del'; + +/// The shipped English label. +const _shortLabel = 'Message deleted'; + +/// The longest `messageDeletedLabel` in `stream_chat_localizations` that uses +/// Latin script, so the golden renders it without needing extra fonts. +const _longestShippedLabel = 'Messaggio eliminato'; + +/// Longer than any shipped translation, to show the wrapping behaviour. +const _longLabel = 'This message was deleted by a moderator'; + +/// A [StreamMessageDeleted] reading [label], capped at [maxWidth]. +Widget _deletedBubble(String label, {double maxWidth = _bubbleMaxWidth}) { + return Builder( + builder: (context) => Localizations.override( + context: context, + delegates: [_FixedLabelDelegate(label)], + child: Align( + alignment: Alignment.centerLeft, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth), + child: const StreamMessageDeleted(), + ), + ), + ), + ); +} + +Widget _wrapWithApp(Widget child, {Brightness brightness = Brightness.light}) { + return MaterialApp( + debugShowCheckedModeBanner: false, + theme: ThemeData( + brightness: brightness, + extensions: [StreamTheme(brightness: brightness)], + ), + home: Scaffold( + backgroundColor: brightness == Brightness.dark ? const Color(0xFF101418) : const Color(0xFFF7F7F8), + body: Center(child: child), + ), + ); +} + +/// Serves a [StreamChatLocalizations] whose `messageDeletedLabel` is fixed. +class _FixedLabelDelegate extends LocalizationsDelegate { + const _FixedLabelDelegate(this.label); + + final String label; + + @override + bool isSupported(Locale locale) => true; + + @override + SynchronousFuture load(Locale locale) { + return SynchronousFuture(_FixedLabelTranslations(label)); + } + + @override + bool shouldReload(_FixedLabelDelegate old) => old.label != label; +} + +/// A [StreamChatLocalizations] that only answers `messageDeletedLabel`. +/// +/// [StreamMessageDeleted] reads nothing else, so anything else reaching this +/// stub is a mistake worth failing on rather than quietly defaulting. +class _FixedLabelTranslations implements StreamChatLocalizations { + const _FixedLabelTranslations(this.messageDeletedLabel); + + @override + final String messageDeletedLabel; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/packages/stream_chat_flutter/test/src/misc/thread_header_test.dart b/packages/stream_chat_flutter/test/src/misc/thread_header_test.dart index a51b819aab..199bd5938d 100644 --- a/packages/stream_chat_flutter/test/src/misc/thread_header_test.dart +++ b/packages/stream_chat_flutter/test/src/misc/thread_header_test.dart @@ -71,6 +71,61 @@ void main() { }, ); + testWidgets( + 'onBackPressed replaces the default back button pop', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); + when(() => clientState.channels).thenReturn({channel.cid!: channel}); + when(() => clientState.totalUnreadCount).thenReturn(0); + when(() => clientState.totalUnreadCountStream).thenAnswer((_) => Stream.value(0)); + when(() => client.wsConnectionStatusStream).thenAnswer((_) => Stream.value(ConnectionStatus.connected)); + + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(() => channel.lastMessageAt).thenReturn(null); + when(() => channel.isMuted).thenReturn(false); + when(() => channel.isMutedStream).thenAnswer((_) => Stream.value(false)); + when(() => channel.name).thenReturn('test'); + when(() => channel.nameStream).thenAnswer((_) => Stream.value('test')); + + when(() => channelState.members).thenReturn([]); + when(() => channelState.membersStream).thenAnswer((_) => Stream.value([])); + when(() => channelState.unreadCount).thenReturn(0); + when(() => channelState.unreadCountStream).thenAnswer((_) => Stream.value(0)); + + var backPressed = false; + + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: Scaffold( + body: StreamThreadHeader( + parent: Message(replyCount: 1), + onBackPressed: () => backPressed = true, + ), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + await tester.tap(find.byType(StreamBackButton)); + + expect(backPressed, true); + }, + ); + testWidgets( 'it should apply passed props', (WidgetTester tester) async { diff --git a/packages/stream_chat_flutter/test/src/theme/message_composer_theme_test.dart b/packages/stream_chat_flutter/test/src/theme/message_composer_theme_test.dart new file mode 100644 index 0000000000..3f0c29a990 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/theme/message_composer_theme_test.dart @@ -0,0 +1,182 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../mocks.dart'; + +void main() { + test('copyWith with no arguments returns an equal MessageComposerThemeData', () { + const themeData = StreamMessageComposerThemeData(surfaceStyle: StreamSurfaceStyle.floating); + + expect(themeData.copyWith(), themeData); + }); + + test('copyWith with no arguments preserves the hashCode', () { + const themeData = StreamMessageComposerThemeData(surfaceStyle: StreamSurfaceStyle.floating); + + expect(themeData.copyWith().hashCode, themeData.hashCode); + }); + + test('copyWith overrides the surfaceStyle', () { + const regular = StreamMessageComposerThemeData(surfaceStyle: StreamSurfaceStyle.regular); + + expect( + regular.copyWith(surfaceStyle: StreamSurfaceStyle.floating).surfaceStyle, + StreamSurfaceStyle.floating, + ); + }); + + test('MessageComposerThemeData instances with different behaviors are not equal', () { + expect( + const StreamMessageComposerThemeData(surfaceStyle: StreamSurfaceStyle.regular), + isNot(const StreamMessageComposerThemeData(surfaceStyle: StreamSurfaceStyle.floating)), + ); + }); + + test('lerp at t = 0 resolves to the start surfaceStyle', () { + expect( + StreamMessageComposerThemeData.lerp(_regularTheme, _floatingTheme, 0)?.surfaceStyle, + StreamSurfaceStyle.regular, + ); + }); + + test('lerp below the halfway point resolves to the start surfaceStyle', () { + expect( + StreamMessageComposerThemeData.lerp(_regularTheme, _floatingTheme, 0.49)?.surfaceStyle, + StreamSurfaceStyle.regular, + ); + }); + + test('lerp at or past the halfway point resolves to the end surfaceStyle', () { + expect( + StreamMessageComposerThemeData.lerp(_regularTheme, _floatingTheme, 0.5)?.surfaceStyle, + StreamSurfaceStyle.floating, + ); + }); + + test('lerp at t = 1 resolves to the end surfaceStyle', () { + expect(StreamMessageComposerThemeData.lerp(_regularTheme, _floatingTheme, 1), _floatingTheme); + }); + + test('merge with null keeps the original surfaceStyle', () { + expect(_regularTheme.merge(null), _regularTheme); + }); + + test('merge overrides the surfaceStyle with the other theme', () { + expect(_regularTheme.merge(_floatingTheme), _floatingTheme); + }); + + test('merge with an empty theme keeps the original surfaceStyle', () { + expect(_floatingTheme.merge(const StreamMessageComposerThemeData()), _floatingTheme); + }); + + testWidgets('of returns a null surfaceStyle when no theme is configured', (tester) async { + final context = await _pumpAndCaptureContext(tester); + + expect(StreamMessageComposerTheme.of(context).surfaceStyle, isNull); + }); + + testWidgets('of returns the global theme surfaceStyle when no local theme is present', (tester) async { + final context = await _pumpAndCaptureContext(tester, globalTheme: _floatingTheme); + + expect(StreamMessageComposerTheme.of(context).surfaceStyle, StreamSurfaceStyle.floating); + }); + + testWidgets('of merges the local theme over the global theme', (tester) async { + final context = await _pumpAndCaptureContext( + tester, + globalTheme: _regularTheme, + localTheme: _floatingTheme, + ); + + expect(StreamMessageComposerTheme.of(context).surfaceStyle, StreamSurfaceStyle.floating); + }); + + testWidgets('of falls back to the global theme when the local theme sets no surfaceStyle', (tester) async { + final context = await _pumpAndCaptureContext( + tester, + globalTheme: _floatingTheme, + localTheme: const StreamMessageComposerThemeData(), + ); + + expect(StreamMessageComposerTheme.of(context).surfaceStyle, StreamSurfaceStyle.floating); + }); + + testWidgets('wrap re-establishes the theme in a detached subtree', (tester) async { + late BuildContext capturedContext; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + // `wrap` is what InheritedTheme.captureAll calls to carry the theme + // across a route boundary; assert it round-trips the same data. + final theme = StreamMessageComposerTheme( + data: _floatingTheme, + child: Builder( + builder: (context) { + capturedContext = context; + return const SizedBox.shrink(); + }, + ), + ); + + return theme.wrap(context, theme.child); + }, + ), + ), + ); + + expect(StreamMessageComposerTheme.of(capturedContext).surfaceStyle, StreamSurfaceStyle.floating); + }); + + testWidgets('updateShouldNotify is true when the data changes', (tester) async { + const oldWidget = StreamMessageComposerTheme(data: _regularTheme, child: SizedBox.shrink()); + const newWidget = StreamMessageComposerTheme(data: _floatingTheme, child: SizedBox.shrink()); + + expect(newWidget.updateShouldNotify(oldWidget), isTrue); + }); + + testWidgets('updateShouldNotify is false when the data is unchanged', (tester) async { + const oldWidget = StreamMessageComposerTheme(data: _regularTheme, child: SizedBox.shrink()); + const newWidget = StreamMessageComposerTheme(data: _regularTheme, child: SizedBox.shrink()); + + expect(newWidget.updateShouldNotify(oldWidget), isFalse); + }); +} + +const _regularTheme = StreamMessageComposerThemeData(surfaceStyle: StreamSurfaceStyle.regular); +const _floatingTheme = StreamMessageComposerThemeData(surfaceStyle: StreamSurfaceStyle.floating); + +/// Pumps a [StreamChat] configured with [globalTheme], optionally wrapped in a +/// local [StreamMessageComposerTheme] carrying [localTheme], and returns a +/// context below both. +Future _pumpAndCaptureContext( + WidgetTester tester, { + StreamMessageComposerThemeData? globalTheme, + StreamMessageComposerThemeData? localTheme, +}) async { + late BuildContext capturedContext; + + final leaf = Builder( + builder: (context) { + capturedContext = context; + return const SizedBox.shrink(); + }, + ); + + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => StreamChat( + client: MockClient(), + themeData: StreamChatThemeData(messageComposerTheme: globalTheme), + child: child, + ), + home: switch (localTheme) { + final localTheme? => StreamMessageComposerTheme(data: localTheme, child: leaf), + _ => leaf, + }, + ), + ); + + return capturedContext; +} diff --git a/sample_app/lib/app.dart b/sample_app/lib/app.dart index 0ccefffdfd..6f29d4e87f 100644 --- a/sample_app/lib/app.dart +++ b/sample_app/lib/app.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart' hide Message; import 'package:go_router/go_router.dart'; import 'package:media_kit/media_kit.dart'; @@ -16,6 +17,7 @@ import 'package:sample_app/routes/app_routes.dart'; import 'package:sample_app/routes/routes.dart'; import 'package:sample_app/widgets/custom_message_actions.dart'; import 'package:sample_app/widgets/location/location_attachment.dart'; +import 'package:sample_app/widgets/location/location_aware_message_composer.dart'; import 'package:sample_app/widgets/location/location_detail_dialog.dart'; import 'package:sample_app/widgets/video_player.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; @@ -178,16 +180,20 @@ class _StreamChatSampleAppState extends State child: Builder( builder: (context) { final config = context.sampleAppConfig; + final surfaceStyle = config.surfaceStyle; + return DynamicColorBuilder( builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) { return MaterialApp.router( theme: createTheme( dynamicColor: config.enableDynamicColor ? lightDynamic : null, brightness: Brightness.light, + surfaceStyle: surfaceStyle, ), darkTheme: createTheme( dynamicColor: config.enableDynamicColor ? darkDynamic : null, brightness: Brightness.dark, + surfaceStyle: surfaceStyle, ), themeMode: config.themeMode, locale: config.locale, @@ -197,8 +203,9 @@ class _StreamChatSampleAppState extends State GlobalMaterialLocalizations.delegate, GlobalWidgetsLocalizations.delegate, ], - builder: (context, child) { - return ListenableBuilder( + builder: (context, child) => _EdgeToEdgeSystemBars( + themeMode: config.themeMode, + child: ListenableBuilder( listenable: authController, builder: (context, cachedChild) { final wrapped = Directionality( @@ -219,6 +226,7 @@ class _StreamChatSampleAppState extends State componentBuilders: StreamComponentBuilders( extensions: streamChatComponentBuilders( messageItem: customMessageItemBuilder, + messageComposer: locationAwareMessageComposer, videoPlayer: (context, props) => SampleAppVideoPlayer(props: props), ), ), @@ -233,8 +241,8 @@ class _StreamChatSampleAppState extends State ); }, child: child, - ); - }, + ), + ), routerConfig: _setupRouter(), ); }, @@ -250,6 +258,7 @@ class _StreamChatSampleAppState extends State ThemeData createTheme({ required ColorScheme? dynamicColor, required Brightness brightness, + required StreamSurfaceStyle surfaceStyle, }) { return ThemeData( brightness: brightness, @@ -262,6 +271,7 @@ class _StreamChatSampleAppState extends State brightness: brightness, ), brightness: brightness, + surfaceStyle: surfaceStyle, ), ], ); @@ -284,6 +294,48 @@ extension on SampleAppConfigData { }, ), ], + messageListViewConfiguration: const StreamMessageListViewConfiguration( + highlightInitialMessage: true, + swipeToReply: true, + ), + ); + } +} + +/// Renders the app edge-to-edge with transparent, theme-aware system bars. +/// +/// Wrapped once below the theme so it re-resolves on theme changes. The +/// navigation bar keeps `systemNavigationBarContrastEnforced: false` so it stays +/// fully transparent (content bleeds behind it) instead of the OS painting an +/// opaque three-button-nav scrim; icon brightness carries legibility instead. +/// Pair with `SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge)` in +/// `main()`. +class _EdgeToEdgeSystemBars extends StatelessWidget { + const _EdgeToEdgeSystemBars({ + required this.themeMode, + required this.child, + }); + + final ThemeMode themeMode; + final Widget child; + + @override + Widget build(BuildContext context) { + final isDark = switch (themeMode) { + ThemeMode.light => false, + ThemeMode.dark => true, + ThemeMode.system => Theme.brightnessOf(context) == .dark, + }; + + return AnnotatedRegion( + value: SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: isDark ? .light : .dark, + systemNavigationBarColor: Colors.transparent, + systemNavigationBarContrastEnforced: false, + systemNavigationBarIconBrightness: isDark ? .light : .dark, + ), + child: child, ); } } diff --git a/sample_app/lib/config/sample_app_config.dart b/sample_app/lib/config/sample_app_config.dart index 05fd984366..bbe1e91ce6 100644 --- a/sample_app/lib/config/sample_app_config.dart +++ b/sample_app/lib/config/sample_app_config.dart @@ -8,6 +8,7 @@ import 'package:streaming_shared_preferences/streaming_shared_preferences.dart'; // --------------------------------------------------------------------------- const _kThemeMode = 'config.themeMode'; +const _kSurfaceStyle = 'config.surfaceStyle'; const _kForceRtl = 'config.forceRtl'; const _kEnableDynamicColor = 'config.enableDynamicColor'; const _kEnableReminderActions = 'config.enableReminderActions'; @@ -39,6 +40,7 @@ class SampleAppConfigData { factory SampleAppConfigData({ Locale? locale, ThemeMode themeMode = .system, + StreamSurfaceStyle surfaceStyle = .regular, bool forceRtl = false, bool enableDynamicColor = false, bool enableReminderActions = false, @@ -52,6 +54,7 @@ class SampleAppConfigData { }) { return SampleAppConfigData.raw( themeMode: themeMode, + surfaceStyle: surfaceStyle, locale: locale, forceRtl: forceRtl, enableDynamicColor: enableDynamicColor, @@ -69,6 +72,7 @@ class SampleAppConfigData { /// Raw constructor used internally and by persistence. const SampleAppConfigData.raw({ required this.themeMode, + required this.surfaceStyle, required this.locale, required this.forceRtl, required this.enableDynamicColor, @@ -85,8 +89,10 @@ class SampleAppConfigData { /// Loads config from [StreamingSharedPreferences], falling back to defaults. factory SampleAppConfigData.fromPreferences(StreamingSharedPreferences prefs) { final localeStr = prefs.getString(_kLocale, defaultValue: '').getValue(); + final surfaceStyleIndex = prefs.getInt(_kSurfaceStyle, defaultValue: StreamSurfaceStyle.regular.index).getValue(); return SampleAppConfigData.raw( themeMode: ThemeMode.values[prefs.getInt(_kThemeMode, defaultValue: ThemeMode.system.index).getValue()], + surfaceStyle: StreamSurfaceStyle.values[surfaceStyleIndex.clamp(0, StreamSurfaceStyle.values.length - 1)], locale: localeStr.isEmpty ? null : Locale(localeStr), forceRtl: prefs.getBool(_kForceRtl, defaultValue: false).getValue(), enableDynamicColor: prefs.getBool(_kEnableDynamicColor, defaultValue: false).getValue(), @@ -106,6 +112,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 StreamSurfaceStyle surfaceStyle; + /// The locale override for the app. When null, the system locale is used. final Locale? locale; @@ -150,6 +159,7 @@ class SampleAppConfigData { /// pass explicitly as `null` to reset to default/system. SampleAppConfigData copyWith({ ThemeMode? themeMode, + StreamSurfaceStyle? surfaceStyle, Object? locale = _sentinel, bool? forceRtl, bool? enableDynamicColor, @@ -164,6 +174,7 @@ class SampleAppConfigData { }) { return SampleAppConfigData.raw( themeMode: themeMode ?? this.themeMode, + surfaceStyle: surfaceStyle ?? this.surfaceStyle, locale: locale == _sentinel ? this.locale : locale as Locale?, forceRtl: forceRtl ?? this.forceRtl, enableDynamicColor: enableDynamicColor ?? this.enableDynamicColor, @@ -185,6 +196,7 @@ class SampleAppConfigData { /// Persists all fields to [StreamingSharedPreferences]. void saveToPreferences(StreamingSharedPreferences prefs) { prefs.setInt(_kThemeMode, themeMode.index); + prefs.setInt(_kSurfaceStyle, surfaceStyle.index); prefs.setString(_kLocale, locale?.languageCode ?? ''); prefs.setBool(_kForceRtl, forceRtl); prefs.setBool(_kEnableDynamicColor, enableDynamicColor); diff --git a/sample_app/lib/config/sample_app_config_screen.dart b/sample_app/lib/config/sample_app_config_screen.dart index 9a9f3d7b51..6c429d19f8 100644 --- a/sample_app/lib/config/sample_app_config_screen.dart +++ b/sample_app/lib/config/sample_app_config_screen.dart @@ -22,153 +22,171 @@ class SampleAppConfigScreen extends StatelessWidget { final spacing = context.streamSpacing; final icons = context.streamIcons; - return Scaffold( + return StreamScaffold( backgroundColor: colorScheme.backgroundApp, appBar: StreamAppBar(title: const Text('Configuration')), - body: SingleChildScrollView( - padding: EdgeInsets.symmetric(horizontal: spacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox(height: spacing.xs), - - // ── Appearance ── - const _SectionHeader(title: 'Appearance'), - SizedBox(height: spacing.xs), - _SettingsCard( + body: Builder( + builder: (context) { + final topInset = MediaQuery.paddingOf(context).top; + return SingleChildScrollView( + padding: EdgeInsets.symmetric(horizontal: spacing.md) + EdgeInsets.only(top: topInset), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - _SegmentedRow( - title: 'Theme', - value: config.themeMode, - segments: const { - ThemeMode.system: 'System', - ThemeMode.light: 'Light', - ThemeMode.dark: 'Dark', - }, - segmentIcons: const { - ThemeMode.system: Icons.brightness_auto_outlined, - ThemeMode.light: Icons.light_mode_outlined, - ThemeMode.dark: Icons.dark_mode_outlined, - }, - onChanged: (v) => SampleAppConfig.update(context, config.copyWith(themeMode: v)), - ), - _SwitchRow( - icon: Icons.palette_outlined, - title: 'Dynamic Color', - subtitle: _dynamicColorSupported - ? 'Theme colors derived from the device wallpaper/accent' - : 'Only supported on Android, macOS, Windows, and Linux', - value: _dynamicColorSupported && config.enableDynamicColor, - onChanged: _dynamicColorSupported - ? (v) => SampleAppConfig.update(context, config.copyWith(enableDynamicColor: v)) - : null, - ), - _LocaleRow(config: config), - _SwitchRow( - icon: icons.reorder, - title: 'Force RTL', - subtitle: 'Right-to-left layout direction', - value: config.forceRtl, - onChanged: (v) => SampleAppConfig.update(context, config.copyWith(forceRtl: v)), + SizedBox(height: spacing.xs), + + // ── Appearance ── + const _SectionHeader(title: 'Appearance'), + SizedBox(height: spacing.xs), + _SettingsCard( + children: [ + _SegmentedRow( + title: 'Theme', + value: config.themeMode, + segments: const { + ThemeMode.system: 'System', + ThemeMode.light: 'Light', + ThemeMode.dark: 'Dark', + }, + segmentIcons: const { + ThemeMode.system: Icons.brightness_auto_outlined, + ThemeMode.light: Icons.light_mode_outlined, + ThemeMode.dark: Icons.dark_mode_outlined, + }, + onChanged: (v) => SampleAppConfig.update(context, config.copyWith(themeMode: v)), + ), + _SegmentedRow( + title: 'Surface Style', + value: config.surfaceStyle, + segments: const { + StreamSurfaceStyle.regular: 'Regular', + StreamSurfaceStyle.floating: 'Floating', + }, + segmentIcons: const { + StreamSurfaceStyle.regular: Icons.web_asset_outlined, + StreamSurfaceStyle.floating: Icons.filter_none_outlined, + }, + onChanged: (v) => SampleAppConfig.update(context, config.copyWith(surfaceStyle: v)), + ), + _SwitchRow( + icon: Icons.palette_outlined, + title: 'Dynamic Color', + subtitle: _dynamicColorSupported + ? 'Theme colors derived from the device wallpaper/accent' + : 'Only supported on Android, macOS, Windows, and Linux', + value: _dynamicColorSupported && config.enableDynamicColor, + onChanged: _dynamicColorSupported + ? (v) => SampleAppConfig.update(context, config.copyWith(enableDynamicColor: v)) + : null, + ), + _LocaleRow(config: config), + _SwitchRow( + icon: icons.reorder, + title: 'Force RTL', + subtitle: 'Right-to-left layout direction', + value: config.forceRtl, + onChanged: (v) => SampleAppConfig.update(context, config.copyWith(forceRtl: v)), + ), + ], ), - ], - ), - - SizedBox(height: spacing.xl), - // ── Features ── - const _SectionHeader(title: 'Features'), - SizedBox(height: spacing.xs), - _SettingsCard( - children: [ - _SwitchRow( - icon: icons.bell, - title: 'Reminders', - subtitle: 'Remind me, Save for later, Edit', - value: config.enableReminderActions, - onChanged: (v) => SampleAppConfig.update(context, config.copyWith(enableReminderActions: v)), - ), - _SwitchRow( - icon: icons.delete, - title: 'Delete for Me', - subtitle: 'Delete message for current user', - value: config.enableDeleteForMe, - onChanged: (v) => SampleAppConfig.update(context, config.copyWith(enableDeleteForMe: v)), - ), - _SwitchRow( - icon: icons.info, - title: 'Message Info', - subtitle: 'Show delivery info sheet', - value: config.enableMessageInfo, - onChanged: (v) => SampleAppConfig.update(context, config.copyWith(enableMessageInfo: v)), - ), - _SwitchRow( - icon: icons.location, - title: 'Location Sharing', - subtitle: 'Attachment builder and picker', - value: config.enableLocationSharing, - onChanged: (v) => SampleAppConfig.update(context, config.copyWith(enableLocationSharing: v)), + SizedBox(height: spacing.xl), + + // ── Features ── + const _SectionHeader(title: 'Features'), + SizedBox(height: spacing.xs), + _SettingsCard( + children: [ + _SwitchRow( + icon: icons.bell, + title: 'Reminders', + subtitle: 'Remind me, Save for later, Edit', + value: config.enableReminderActions, + onChanged: (v) => SampleAppConfig.update(context, config.copyWith(enableReminderActions: v)), + ), + _SwitchRow( + icon: icons.delete, + title: 'Delete for Me', + subtitle: 'Delete message for current user', + value: config.enableDeleteForMe, + onChanged: (v) => SampleAppConfig.update(context, config.copyWith(enableDeleteForMe: v)), + ), + _SwitchRow( + icon: icons.info, + title: 'Message Info', + subtitle: 'Show delivery info sheet', + value: config.enableMessageInfo, + onChanged: (v) => SampleAppConfig.update(context, config.copyWith(enableMessageInfo: v)), + ), + _SwitchRow( + icon: icons.location, + title: 'Location Sharing', + subtitle: 'Attachment builder and picker', + value: config.enableLocationSharing, + onChanged: (v) => SampleAppConfig.update(context, config.copyWith(enableLocationSharing: v)), + ), + ], ), - ], - ), - SizedBox(height: spacing.xl), - - // ── Chat ── - const _SectionHeader(title: 'Chat'), - SizedBox(height: spacing.xs), - _SettingsCard( - children: [ - _SwitchRow( - icon: icons.edit, - title: 'Draft Messages', - subtitle: 'Enable draft message saving', - value: config.draftMessagesEnabled, - onChanged: (v) => SampleAppConfig.update(context, config.copyWith(draftMessagesEnabled: v)), - ), - _SwitchRow( - icon: icons.emoji, - title: 'Unique Reactions', - subtitle: 'New reaction replaces existing', - value: config.enforceUniqueReactions, - onChanged: (v) => SampleAppConfig.update(context, config.copyWith(enforceUniqueReactions: v)), + SizedBox(height: spacing.xl), + + // ── Chat ── + const _SectionHeader(title: 'Chat'), + SizedBox(height: spacing.xs), + _SettingsCard( + children: [ + _SwitchRow( + icon: icons.edit, + title: 'Draft Messages', + subtitle: 'Enable draft message saving', + value: config.draftMessagesEnabled, + onChanged: (v) => SampleAppConfig.update(context, config.copyWith(draftMessagesEnabled: v)), + ), + _SwitchRow( + icon: icons.emoji, + title: 'Unique Reactions', + subtitle: 'New reaction replaces existing', + value: config.enforceUniqueReactions, + onChanged: (v) => SampleAppConfig.update(context, config.copyWith(enforceUniqueReactions: v)), + ), + ], ), - ], - ), - SizedBox(height: spacing.xl), - - // ── Reactions ── - const _SectionHeader(title: 'Reactions'), - SizedBox(height: spacing.xs), - _SettingsCard( - children: [ - _SegmentedRow( - title: 'Reaction Type', - value: config.reactionType, - segments: const { - null: 'Default', - StreamReactionsType.segmented: 'Segmented', - StreamReactionsType.clustered: 'Clustered', - }, - onChanged: (v) => SampleAppConfig.update(context, config.copyWith(reactionType: v)), - ), - _SegmentedRow( - title: 'Reaction Position', - value: config.reactionPosition, - segments: const { - null: 'Default', - StreamReactionsPosition.header: 'Header', - StreamReactionsPosition.footer: 'Footer', - }, - onChanged: (v) => SampleAppConfig.update(context, config.copyWith(reactionPosition: v)), + SizedBox(height: spacing.xl), + + // ── Reactions ── + const _SectionHeader(title: 'Reactions'), + SizedBox(height: spacing.xs), + _SettingsCard( + children: [ + _SegmentedRow( + title: 'Reaction Type', + value: config.reactionType, + segments: const { + null: 'Default', + StreamReactionsType.segmented: 'Segmented', + StreamReactionsType.clustered: 'Clustered', + }, + onChanged: (v) => SampleAppConfig.update(context, config.copyWith(reactionType: v)), + ), + _SegmentedRow( + title: 'Reaction Position', + value: config.reactionPosition, + segments: const { + null: 'Default', + StreamReactionsPosition.header: 'Header', + StreamReactionsPosition.footer: 'Footer', + }, + onChanged: (v) => SampleAppConfig.update(context, config.copyWith(reactionPosition: v)), + ), + ], ), + + SizedBox(height: spacing.xxl), ], ), - - SizedBox(height: spacing.xxl), - ], - ), + ); + }, ), ); } diff --git a/sample_app/lib/main.dart b/sample_app/lib/main.dart index c6139449d1..1755178a92 100644 --- a/sample_app/lib/main.dart +++ b/sample_app/lib/main.dart @@ -2,12 +2,16 @@ import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:marionette_flutter/marionette_flutter.dart'; import 'package:sample_app/app.dart'; import 'package:sample_app/firebase_options.dart'; import 'package:sample_app/utils/platform_support.dart'; Future main() async { + // Set up edge-to-edge UI. + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + // In debug mode, initialize the Marionette binding so AI agents can drive // the running app over MCP (inspect widgets, tap, type, scroll, screenshot). // Falls back to the standard binding in release/profile builds. diff --git a/sample_app/lib/pages/advanced_options_page.dart b/sample_app/lib/pages/advanced_options_page.dart index 525e4cf2da..ff37ad2e7c 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 = MediaQuery.paddingOf(context).top; 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..21d4d3d7a5 100644 --- a/sample_app/lib/pages/channel_file_display_screen.dart +++ b/sample_app/lib/pages/channel_file_display_screen.dart @@ -39,7 +39,7 @@ 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>( diff --git a/sample_app/lib/pages/channel_list_page.dart b/sample_app/lib/pages/channel_list_page.dart index 40bb513704..412b6ebccb 100644 --- a/sample_app/lib/pages/channel_list_page.dart +++ b/sample_app/lib/pages/channel_list_page.dart @@ -1,5 +1,3 @@ -// ignore_for_file: deprecated_member_use - import 'dart:async'; import 'dart:math' as math; @@ -47,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, ), @@ -76,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, @@ -140,18 +123,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..66f39d3b3f 100644 --- a/sample_app/lib/pages/channel_media_display_screen.dart +++ b/sample_app/lib/pages/channel_media_display_screen.dart @@ -38,44 +38,46 @@ 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, _) { + 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, + 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 86af4f617d..0000000000 --- a/sample_app/lib/pages/channel_page.dart +++ /dev/null @@ -1,219 +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, - // Without a handler the composer rethrows, and since the send - // button drops the returned future that surfaces as an - // unhandled async error. The SDK already keeps the message in a - // failed state and retries it once the connection is back, so - // logging is enough here. Typing events report through here too, - // hence the neutral wording. - onError: (error, stackTrace) { - debugPrint('[composer] $error; $stackTrace'); - }, - 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', - title: 'Location', - 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..c286f7bd71 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 = MediaQuery.paddingOf(context).top; + 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..6a332a3ed7 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,124 @@ 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 = MediaQuery.paddingOf(context).top; + 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( + padding: EdgeInsets.only(bottom: MediaQuery.paddingOf(context).bottom), + 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..ebc6cb2226 100644 --- a/sample_app/lib/pages/group_info_screen.dart +++ b/sample_app/lib/pages/group_info_screen.dart @@ -24,15 +24,12 @@ 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'), trailing: switch (channel.canUpdateChannel) { - true => StreamButton( - type: .outline, - style: .secondary, - size: .small, + true => StreamToolbarButton( onPressed: () => showEditGroupSheet(context, channel), child: const Text('Edit'), ), @@ -41,28 +38,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 = MediaQuery.paddingOf(context).top; + 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..ce49b62776 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 = MediaQuery.paddingOf(context).top; 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, @@ -278,6 +280,7 @@ class _NewChatScreenState extends State { onPanDown: (_) => FocusScope.of(context).unfocus(), child: StreamUserListView( controller: userListController, + padding: EdgeInsets.only(bottom: MediaQuery.paddingOf(context).bottom), onUserTap: (user) { _controller.clear(); if (!_selectedUsers.contains(user)) { diff --git a/sample_app/lib/pages/new_group_chat_screen.dart b/sample_app/lib/pages/new_group_chat_screen.dart index b5d9c2eb53..d003f249ea 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'), @@ -109,10 +111,13 @@ class _NewGroupChatScreenState extends State { floatHeaderSlivers: true, headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { return [ - SliverToBoxAdapter( - child: SearchTextField( - controller: _controller, - hintText: 'Search', + SliverSafeArea( + bottom: false, + sliver: SliverToBoxAdapter( + child: SearchTextField( + controller: _controller, + hintText: 'Search', + ), ), ), if (state.users.isNotEmpty) @@ -199,51 +204,57 @@ class _NewGroupChatScreenState extends State { ), ]; }, - body: GestureDetector( - behavior: HitTestBehavior.opaque, - onPanDown: (_) => FocusScope.of(context).unfocus(), - child: StreamUserListView( - controller: userListController, - itemBuilder: (context, items, index, defaultWidget) { - return defaultWidget.copyWith( - selected: state.users.contains(items[index]), - ); - }, - onUserTap: groupChatState.addOrRemoveUser, - emptyBuilder: (_) { - return LayoutBuilder( - builder: (context, viewportConstraints) { - return SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: viewportConstraints.maxHeight, - ), - child: Center( - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(24), - child: Icon( - context.streamIcons.search, - size: 96, - color: context.streamColorScheme.textSecondary, + // Header handled the top inset; strip it so the body list + // (null padding) only re-adds the bottom. + body: MediaQuery.removePadding( + context: context, + removeTop: true, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onPanDown: (_) => FocusScope.of(context).unfocus(), + child: StreamUserListView( + controller: userListController, + itemBuilder: (context, items, index, defaultWidget) { + return defaultWidget.copyWith( + selected: state.users.contains(items[index]), + ); + }, + onUserTap: groupChatState.addOrRemoveUser, + emptyBuilder: (_) { + return LayoutBuilder( + builder: (context, viewportConstraints) { + return SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: viewportConstraints.maxHeight, + ), + child: Center( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(24), + child: Icon( + context.streamIcons.search, + size: 96, + color: context.streamColorScheme.textSecondary, + ), ), - ), - Text( - 'No user matches these keywords...', - style: context.streamTextTheme.captionDefault.copyWith( - color: context.streamColorScheme.textSecondary, + Text( + 'No user matches these keywords...', + style: context.streamTextTheme.captionDefault.copyWith( + color: context.streamColorScheme.textSecondary, + ), ), - ), - ], + ], + ), ), ), - ), - ); - }, - ); - }, + ); + }, + ); + }, + ), ), ), ), diff --git a/sample_app/lib/pages/pinned_messages_screen.dart b/sample_app/lib/pages/pinned_messages_screen.dart index f2b52a4b16..35efc42b4e 100644 --- a/sample_app/lib/pages/pinned_messages_screen.dart +++ b/sample_app/lib/pages/pinned_messages_screen.dart @@ -37,7 +37,7 @@ 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( 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 c0979c187f..0000000000 --- a/sample_app/lib/pages/thread_page.dart +++ /dev/null @@ -1,114 +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: Stack( - children: [ - 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, - ), - Positioned( - bottom: 0, - left: 0, - right: 0, - child: Container( - alignment: Alignment.centerLeft, - color: context.streamColorScheme.backgroundApp.withValues(alpha: .9), - child: StreamTypingIndicator( - parentId: widget.parent.id, - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - style: context.streamTextTheme.captionDefault.copyWith( - color: context.streamColorScheme.textSecondary, - ), - ), - ), - ), - ], - ), - ), - if (widget.parent.type != 'deleted') - StreamMessageComposer( - focusNode: _focusNode, - messageComposerController: _messageComposerController, - // See the channel page: an unhandled composer error would escape - // as an unhandled async error. - onError: (error, stackTrace) { - debugPrint('[composer:thread] $error; $stackTrace'); - }, - 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..dce9fb6957 100644 --- a/sample_app/lib/widgets/channel_list.dart +++ b/sample_app/lib/widgets/channel_list.dart @@ -83,17 +83,28 @@ class _ChannelList extends State { }, child: NestedScrollView( controller: _scrollController, - headerSliverBuilder: (_, __) => [ - SliverToBoxAdapter( - child: SearchTextField( - controller: _controller, - hintText: 'Search', + floatHeaderSlivers: true, + headerSliverBuilder: (context, __) => [ + SliverSafeArea( + bottom: false, + sliver: SliverToBoxAdapter( + child: SearchTextField( + controller: _controller, + hintText: 'Search', + ), ), ), ], - body: _isSearchActive - ? _ChannelListSearch(_messageSearchListController) - : _ChannelListDefault(_channelListController), + // The header already handled the top inset; strip it so the body + // lists (null padding) only re-add the bottom, clearing the bottom bar. + body: MediaQuery.removePadding( + context: context, + removeTop: true, + child: switch (_isSearchActive) { + true => _ChannelListSearch(_messageSearchListController), + false => _ChannelListDefault(_channelListController), + }, + ), ), ), ); diff --git a/sample_app/lib/widgets/location/location_aware_message_composer.dart b/sample_app/lib/widgets/location/location_aware_message_composer.dart new file mode 100644 index 0000000000..b3d317c420 --- /dev/null +++ b/sample_app/lib/widgets/location/location_aware_message_composer.dart @@ -0,0 +1,95 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:sample_app/config/sample_app_config.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'; + +/// Builds the composer, adding the location picker when location sharing is +/// available on the surrounding channel. +/// +/// Registered as the `messageComposer` component builder rather than passed to a +/// composer directly, because [StreamChannelPage] and [StreamThreadPage] own +/// their composers and expose no parameters for them. Going through the +/// component factory reaches them anyway. +Widget locationAwareMessageComposer(BuildContext context, MessageComposerProps props) { + // Null when a composer is built outside a channel; the factory is global, so + // this builder must tolerate that rather than assume a channel ancestor. + final channel = StreamChannel.maybeOf(context)?.channel; + + final locationEnabled = + channel != null && + context.sampleAppConfig.enableLocationSharing && + channel.config?.sharedLocations == true && + channel.canShareLocation; + + if (!locationEnabled) return DefaultStreamMessageComposer(props: props); + + return DefaultStreamMessageComposer( + props: props.copyWith( + // Appended to what the caller already allowed rather than replacing it, + // so an upstream narrowing of the picker types still holds. The filter + // keeps the list free of duplicates if location was already allowed. + allowedAttachmentPickerTypes: [ + ...props.allowedAttachmentPickerTypes.where((it) => it != const LocationPickerType()), + const LocationPickerType(), + ], + // Location results are handled here; anything else falls through to the + // caller's handler so wrapping the composer doesn't swallow their results. + onAttachmentPickerResult: (result) { + if (_onCustomAttachmentPickerResult(channel, result)) return true; + return props.onAttachmentPickerResult?.call(result) ?? false; + }, + attachmentPickerOptionsBuilder: (context, defaultOptions) => [ + // The caller's builder runs first so its options survive too. + ...(props.attachmentPickerOptionsBuilder?.call(context, defaultOptions) ?? defaultOptions), + TabbedAttachmentPickerOption( + key: 'location-picker', + title: 'Location', + 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); +}