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);
+}