From 78b449cf77c2630768b0a17f110c47771f1342b4 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 29 Sep 2026 18:10:55 +0200 Subject: [PATCH 1/9] fix(ui): fit the attachment picker on compact-height screens On short screens, such as a phone in landscape, the tabbed picker left no room under the app bar and overflowed. Below 480pt of height its options area is now 158pt, so with the 72pt tabs the picker takes the height of the landscape keyboard it replaces (230pt on iPhone Duo's outer display). Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/stream_chat_flutter/CHANGELOG.md | 1 + .../stream_attachment_picker.dart | 14 +++++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index f032b23ebb..e4d6a473cb 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -29,6 +29,7 @@ - Fixed `StreamMessageComposer` and its attachment picker running under the left and right safe-area insets while the picker is open. - Fixed `StreamMessageListView`'s scroll-to-bottom button, floating date divider, unread indicator and empty, loading and error states ignoring the safe-area insets when `enableSafeArea` is on. +- Fixed the attachment picker overflowing short screens, such as a phone in landscape; there it now takes the keyboard's height. - Fixed the file picker offering file types the Stream Dashboard doesn't allow; it now only offers the allowed file extensions. - Fixed `StreamAttachmentValidator` accepting or rejecting a file differently from the server when an extension list holds a compound entry like `.tar.gz`; it now compares only the last extension. - Fixed `StreamAttachmentHandler.pickFile` throwing on an empty selection; it now returns `null`. diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker.dart index b32c2db1b6..c9896748e8 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker.dart @@ -77,6 +77,13 @@ class StreamSystemAttachmentPicker extends StatelessWidget { // The height of the attachment picker when using the tabbed interface. const _kTabbedAttachmentPickerHeight = 260.0; +// The height of the attachment picker when using the tabbed interface on +// compact-height screens. +const _kCompactTabbedAttachmentPickerHeight = 158.0; + +// Screens shorter than this have a compact height, such as a phone in landscape. +const _kCompactHeightBreakpoint = 480.0; + /// Inline widget for the tabbed attachment picker interface. /// /// Displays a tabbed interface with horizontal tabs for different attachment @@ -128,6 +135,11 @@ class _StreamTabbedAttachmentPickerState extends State( valueListenable: widget.controller, builder: (context, value, _) { + final pickerHeight = switch (MediaQuery.sizeOf(context)) { + Size(height: < _kCompactHeightBreakpoint) => _kCompactTabbedAttachmentPickerHeight, + _ => _kTabbedAttachmentPickerHeight, + }; + return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, @@ -143,7 +155,7 @@ class _StreamTabbedAttachmentPickerState extends State Date: Tue, 29 Sep 2026 18:11:08 +0200 Subject: [PATCH 2/9] fix(ui): keep the message list's default states from overflowing When the list had less room than its default loading, empty or error state needed, such as in landscape with the attachment picker open, the state overflowed. Each state now scrolls instead: the empty and error states fill the space with SliverFillRemaining, so they stay centred when they fit, and the skeleton scrolls. The error state moves into its own internal widget, StreamMessageListErrorState, so the list only decides the safe-area insets. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/stream_chat_flutter/CHANGELOG.md | 1 + .../message_list_view/message_list_view.dart | 17 +++----- .../stream_message_list_empty_state.dart | 9 +++- .../stream_message_list_error_state.dart | 41 +++++++++++++++++++ .../stream_message_list_skeleton_loading.dart | 38 ++++++++--------- 5 files changed, 73 insertions(+), 33 deletions(-) create mode 100644 packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_error_state.dart diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index e4d6a473cb..f4d50fcb45 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -30,6 +30,7 @@ - Fixed `StreamMessageComposer` and its attachment picker running under the left and right safe-area insets while the picker is open. - Fixed `StreamMessageListView`'s scroll-to-bottom button, floating date divider, unread indicator and empty, loading and error states ignoring the safe-area insets when `enableSafeArea` is on. - Fixed the attachment picker overflowing short screens, such as a phone in landscape; there it now takes the keyboard's height. +- Fixed `StreamMessageListView`'s default loading, empty and error states overflowing when the list has little room, such as in landscape with the attachment picker open. - Fixed the file picker offering file types the Stream Dashboard doesn't allow; it now only offers the allowed file extensions. - Fixed `StreamAttachmentValidator` accepting or rejecting a file differently from the server when an extension list holds a compound entry like `.tar.gz`; it now compares only the last extension. - Fixed `StreamAttachmentHandler.pickFile` throwing on an empty selection; it now returns `null`. 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 18e52259c5..63d0657391 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 @@ -9,12 +9,12 @@ import 'package:rxdart/rxdart.dart'; import '../../scrollable_positioned_list/scrollable_positioned_list.dart'; import '../../stream_chat_flutter.dart'; import '../misc/empty_widget.dart'; -import '../utils/network_error_text.dart'; import 'floating_date_divider.dart'; import 'loading_indicator.dart'; import 'message_list_unread_controller.dart'; import 'mlv_utils.dart'; import 'stream_message_list_empty_state.dart'; +import 'stream_message_list_error_state.dart'; import 'stream_message_list_skeleton_loading.dart'; import 'thread_separator.dart'; @@ -624,19 +624,12 @@ class _StreamMessageListViewState extends State { Widget defaultErrorBuilder(BuildContext context, Object error) { if (widget.builders.error case final builder?) return _safeArea(child: builder(context, error)); - final translations = context.translations; - final text = resolveNetworkErrorText(context, error, fallbackTitle: translations.loadingMessagesError); - - final errorWidget = Center( - child: StreamScrollViewErrorWidget( - errorTitle: Text(text.title), - errorSubtitle: Text(text.description), - retryButtonText: Text(translations.tryAgainLabel), - onRetryPressed: () => streamChannel?.reloadChannel(), - ), + final errorState = StreamMessageListErrorState( + error: error, + onRetryPressed: () => streamChannel?.reloadChannel(), ); - return _safeArea(child: errorWidget); + return _safeArea(child: errorState); } Widget defaultMessageListBuilder(BuildContext context, List list) { diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_empty_state.dart b/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_empty_state.dart index 6c0f5ad3c0..2b965fe500 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_empty_state.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_empty_state.dart @@ -1,14 +1,17 @@ import 'package:flutter/material.dart'; + import '../../stream_chat_flutter.dart'; /// A widget that is used to display the empty state of the message list. +/// +/// Scrolls instead of overflowing when it has less room than it needs. class StreamMessageListEmptyState extends StatelessWidget { /// Creates a new instance of the [StreamMessageListEmptyState]. const StreamMessageListEmptyState({super.key}); @override Widget build(BuildContext context) { - return Center( + final content = Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, @@ -22,5 +25,9 @@ class StreamMessageListEmptyState extends StatelessWidget { ], ), ); + + return CustomScrollView( + slivers: [SliverFillRemaining(hasScrollBody: false, child: content)], + ); } } diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_error_state.dart b/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_error_state.dart new file mode 100644 index 0000000000..2cba79f701 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_error_state.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +import '../../stream_chat_flutter.dart'; +import '../utils/network_error_text.dart'; + +/// A widget that is used to display the error state of the message list. +/// +/// Scrolls instead of overflowing when it has less room than it needs. +class StreamMessageListErrorState extends StatelessWidget { + /// Creates a new instance of the [StreamMessageListErrorState]. + const StreamMessageListErrorState({ + super.key, + required this.error, + required this.onRetryPressed, + }); + + /// The error that stopped the messages from loading. + final Object error; + + /// Called when the retry button is pressed. + final VoidCallback onRetryPressed; + + @override + Widget build(BuildContext context) { + final translations = context.translations; + final text = resolveNetworkErrorText(context, error, fallbackTitle: translations.loadingMessagesError); + + final content = Center( + child: StreamScrollViewErrorWidget( + errorTitle: Text(text.title), + errorSubtitle: Text(text.description), + retryButtonText: Text(translations.tryAgainLabel), + onRetryPressed: onRetryPressed, + ), + ); + + return CustomScrollView( + slivers: [SliverFillRemaining(hasScrollBody: false, child: content)], + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_skeleton_loading.dart b/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_skeleton_loading.dart index 04937d1350..7f6a3082cc 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_skeleton_loading.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_skeleton_loading.dart @@ -6,6 +6,8 @@ import 'package:stream_core_flutter/chat.dart'; /// Displays a skeleton UI with shimmer animation that mimics a chat /// conversation with incoming (left-aligned) and outgoing (right-aligned) /// message bubbles using [StreamSkeletonLoading] and [StreamSkeletonBox]. +/// +/// Scrolls instead of overflowing when it has less room than it needs. class StreamMessageListSkeletonLoading extends StatelessWidget { /// Creates a new instance of [StreamMessageListSkeletonLoading]. const StreamMessageListSkeletonLoading({super.key}); @@ -15,26 +17,22 @@ class StreamMessageListSkeletonLoading extends StatelessWidget { final spacing = context.streamSpacing; return StreamSkeletonLoading( - child: LayoutBuilder( - builder: (context, constraints) { - return Padding( - padding: EdgeInsets.all(spacing.md), - child: Column( - children: [ - _IncomingBubble(), - SizedBox(height: spacing.lg), - _OutgoingBubble(), - SizedBox(height: spacing.lg), - _IncomingBubble(), - SizedBox(height: spacing.lg), - _OutgoingBubble(), - SizedBox(height: spacing.lg), - _IncomingBubble(), - SizedBox(height: spacing.md), - ], - ), - ); - }, + child: SingleChildScrollView( + padding: .all(spacing.md), + child: Column( + children: [ + _IncomingBubble(), + SizedBox(height: spacing.lg), + _OutgoingBubble(), + SizedBox(height: spacing.lg), + _IncomingBubble(), + SizedBox(height: spacing.lg), + _OutgoingBubble(), + SizedBox(height: spacing.lg), + _IncomingBubble(), + SizedBox(height: spacing.md), + ], + ), ), ); } From 1d9850580e7dbafdcb0ae850ea3ef0fb6af8fb92 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 29 Sep 2026 22:01:39 +0200 Subject: [PATCH 3/9] fix(ui): span the composer's autocomplete suggestions across the full width The suggestions were anchored to the inset input, so under a side safe-area inset their background and border stopped short of the screen edge while the composer and app bar ran edge to edge. The autocomplete now wraps the inset input, so its suggestions span the composer, and StreamAutocompleteOptions keeps its options clear of the side insets. Custom trigger suggestions span the full width too and handle the insets themselves. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/stream_chat_flutter/CHANGELOG.md | 2 + .../src/autocomplete/stream_autocomplete.dart | 42 +++++++------ .../stream_message_composer.dart | 61 ++++++++++--------- 3 files changed, 58 insertions(+), 47 deletions(-) diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index f4d50fcb45..30a1b7bb42 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -20,6 +20,7 @@ - `StreamImageCDN.resolveUrl` now leaves a URL that already asks for a specific size alone, rather than replacing it with the size the layout computed. - Removed the unused `shimmer` dependency; the skeleton loading widgets already use `stream_core_flutter`'s `StreamSkeletonLoading` and never depended on the `shimmer` package. - `StreamMessageComposer`'s top border now spans the full width, including under the side safe-area insets, and also shows above the no-permission message. +- Custom `customAutocompleteTriggers` suggestions now span the composer's full width, so their content has to keep clear of the side safe-area insets itself. ⚠️ Deprecated @@ -31,6 +32,7 @@ - Fixed `StreamMessageListView`'s scroll-to-bottom button, floating date divider, unread indicator and empty, loading and error states ignoring the safe-area insets when `enableSafeArea` is on. - Fixed the attachment picker overflowing short screens, such as a phone in landscape; there it now takes the keyboard's height. - Fixed `StreamMessageListView`'s default loading, empty and error states overflowing when the list has little room, such as in landscape with the attachment picker open. +- Fixed the composer's autocomplete suggestions stopping short of the screen edge under a side safe-area inset. - Fixed the file picker offering file types the Stream Dashboard doesn't allow; it now only offers the allowed file extensions. - Fixed `StreamAttachmentValidator` accepting or rejecting a file differently from the server when an extension list holds a compound entry like `.tar.gz`; it now compares only the last extension. - Fixed `StreamAttachmentHandler.pickFile` throwing on an empty selection; it now returns `null`. diff --git a/packages/stream_chat_flutter/lib/src/autocomplete/stream_autocomplete.dart b/packages/stream_chat_flutter/lib/src/autocomplete/stream_autocomplete.dart index 9f4818dd21..21ad7b67c1 100644 --- a/packages/stream_chat_flutter/lib/src/autocomplete/stream_autocomplete.dart +++ b/packages/stream_chat_flutter/lib/src/autocomplete/stream_autocomplete.dart @@ -615,6 +615,8 @@ class _TopBorderShape extends ShapeBorder { } /// A helper widget used to show the options of a [StreamAutocomplete]. +/// +/// Keeps its options clear of the safe-area insets at the sides of the screen. class StreamAutocompleteOptions extends StatelessWidget { /// Creates a [StreamAutocompleteOptions] widget. const StreamAutocompleteOptions({ @@ -669,31 +671,33 @@ class StreamAutocompleteOptions extends StatelessWidget { @override Widget build(BuildContext context) { - final height = MediaQuery.of(context).size.height; + final height = MediaQuery.sizeOf(context).height; + final content = Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (headerBuilder != null) headerBuilder!(context), + LimitedBox( + maxHeight: maxHeight ?? height * 0.5, + child: ListView.builder( + shrinkWrap: true, + padding: EdgeInsets.zero, + itemCount: options.length, + itemBuilder: (context, index) { + final option = options.elementAt(index); + return optionBuilder(context, option); + }, + ), + ), + ], + ); + return Card( margin: margin, elevation: elevation, color: color ?? context.streamColorScheme.backgroundElevation1, shape: shape, clipBehavior: clipBehavior, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (headerBuilder != null) headerBuilder!(context), - LimitedBox( - maxHeight: maxHeight ?? height * 0.5, - child: ListView.builder( - shrinkWrap: true, - padding: EdgeInsets.zero, - itemCount: options.length, - itemBuilder: (context, index) { - final option = options.elementAt(index); - return optionBuilder(context, option); - }, - ), - ), - ], - ), + child: StreamSafeArea(top: false, bottom: false, child: content), ); } } 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 a2710b1515..0989980f71 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 @@ -843,38 +843,13 @@ class DefaultStreamMessageComposerState extends State messageInput, }; - final spacing = context.streamSpacing; - final platform = Theme.of(context).platform; - final padding = MediaQuery.paddingOf(context); - final hasBottomInset = padding.bottom > 0; - - // Apple platforms rest on the bottom inset; elsewhere a margin clears it, - // and stands in when there is none. - final bottomSafeAreaMargin = switch (platform) { - .iOS || .macOS when hasBottomInset => spacing.none, - _ => spacing.md, - }; - - final content = Material( - type: .transparency, - child: switch (widget.props.enableSafeArea) { - false => Center(heightFactor: 1, child: messageInput), - _ => StreamSafeArea.driven( - top: false, - listenable: _pickerAnimation, - // Only the bottom inset gives way to the picker; the side insets stay. - to: .only(left: padding.left, right: padding.right), - margin: .only(bottom: bottomSafeAreaMargin), - child: Center(heightFactor: 1, child: messageInput), - ), - }, - ); + final content = Material(type: .transparency, child: messageInput); final colorScheme = context.streamColorScheme; final effectiveSurfaceStyle = _resolveSurfaceStyle(context); @@ -908,11 +883,41 @@ class DefaultStreamMessageComposerState extends State 0; + + // Apple platforms rest on the bottom inset; elsewhere a margin clears it, + // and stands in when there is none. + final bottomSafeAreaMargin = switch (platform) { + .iOS || .macOS when hasBottomInset => spacing.none, + _ => spacing.md, + }; + + return switch (widget.props.enableSafeArea) { + false => Center(heightFactor: 1, child: child), + _ => StreamSafeArea.driven( + top: false, + listenable: _pickerAnimation, + // Only the bottom inset gives way to the picker; the side insets stay. + to: .only(left: padding.left, right: padding.right), + margin: .only(bottom: bottomSafeAreaMargin), + child: Center(heightFactor: 1, child: child), + ), + }; + } + Widget _buildAutocompleteMessageInput(BuildContext context) { return StreamAutocomplete( focusNode: _effectiveFocusNode, messageComposerController: _effectiveController, - fieldViewBuilder: _buildMessageInput, + // Inset inside the autocomplete so its suggestions span the full width. + fieldViewBuilder: (context, controller, focusNode) { + final messageInput = _buildMessageInput(context, controller, focusNode); + return _insetContent(context, messageInput); + }, autocompleteTriggers: [ ...widget.props.customAutocompleteTriggers, StreamAutocompleteTrigger( From a78f940fc97afe3cb5b87254765bf66175f61041 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 29 Sep 2026 22:01:52 +0200 Subject: [PATCH 4/9] refactor(ui): use StreamSafeArea for the message list's safe area Co-Authored-By: Claude Opus 5.5 (1M context) --- .../lib/src/message_list_view/message_list_view.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 63d0657391..6d80d56b70 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 @@ -603,10 +603,10 @@ class _StreamMessageListViewState extends State { return MediaQuery.paddingOf(context).copyWith(left: 0, right: 0); } - // A SafeArea, only while [StreamMessageListView.enableSafeArea] is on. + // A StreamSafeArea, only while [StreamMessageListView.enableSafeArea] is on. Widget _safeArea({bool top = true, bool bottom = true, required Widget child}) { if (!widget.enableSafeArea) return child; - return SafeArea(top: top, bottom: bottom, child: child); + return StreamSafeArea(top: top, bottom: bottom, child: child); } @override From d2e6a786eacacecca037a78ff4a81ae2f5615122 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 29 Sep 2026 22:16:59 +0200 Subject: [PATCH 5/9] docs(ui): tighten the FLU-823 changelog entries Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/stream_chat_flutter/CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 30a1b7bb42..073207dbe2 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -20,7 +20,7 @@ - `StreamImageCDN.resolveUrl` now leaves a URL that already asks for a specific size alone, rather than replacing it with the size the layout computed. - Removed the unused `shimmer` dependency; the skeleton loading widgets already use `stream_core_flutter`'s `StreamSkeletonLoading` and never depended on the `shimmer` package. - `StreamMessageComposer`'s top border now spans the full width, including under the side safe-area insets, and also shows above the no-permission message. -- Custom `customAutocompleteTriggers` suggestions now span the composer's full width, so their content has to keep clear of the side safe-area insets itself. +- Suggestions from `customAutocompleteTriggers` now span the composer's full width, so their content has to keep clear of the side safe-area insets itself. ⚠️ Deprecated @@ -30,7 +30,7 @@ - Fixed `StreamMessageComposer` and its attachment picker running under the left and right safe-area insets while the picker is open. - Fixed `StreamMessageListView`'s scroll-to-bottom button, floating date divider, unread indicator and empty, loading and error states ignoring the safe-area insets when `enableSafeArea` is on. -- Fixed the attachment picker overflowing short screens, such as a phone in landscape; there it now takes the keyboard's height. +- Fixed the attachment picker overflowing short screens, such as a phone in landscape. - Fixed `StreamMessageListView`'s default loading, empty and error states overflowing when the list has little room, such as in landscape with the attachment picker open. - Fixed the composer's autocomplete suggestions stopping short of the screen edge under a side safe-area inset. - Fixed the file picker offering file types the Stream Dashboard doesn't allow; it now only offers the allowed file extensions. From a552d1d9d724ed6862f92dea2ba2ab407d6446bc Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 16 Oct 2026 01:18:18 +0200 Subject: [PATCH 6/9] docs(ui): name StreamAutocompleteOptions in the suggestions changelog entry Options built on StreamAutocompleteOptions keep clear of the side insets on their own, so only other options views need to wrap their content. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/stream_chat_flutter/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 9b83e4cfb1..71d586ddc8 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -21,7 +21,7 @@ - `StreamMessageComposer` now hides the picker's upload options and voice recording, and refuses dropped files, when the user lacks the `upload-file` capability. The attachment button is hidden when nothing else is left to offer. - `StreamMessageComposer` now refuses dropped files when `disableAttachments` is set or `allowedAttachmentPickerTypes` leaves out `AttachmentPickerType.files`. - `StreamTypingIndicator` now uses the design system's typing dots; the `lottie` dependency is removed. -- Suggestions from `customAutocompleteTriggers` now span the composer's full width, so their content has to keep clear of the side safe-area insets itself. +- Suggestions from `customAutocompleteTriggers` now span the composer's full width. `StreamAutocompleteOptions` keeps its options clear of the side safe-area insets; other options views should wrap their content in `StreamSafeArea(top: false, bottom: false)`. 🐞 Fixed From a5f716e430dcc8b7447ca2c252e0b8cfa61a1f3f Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 16 Oct 2026 01:18:20 +0200 Subject: [PATCH 7/9] test(ui): cover the composer's insets and the message list's short states Command and custom suggestions run under the side insets while their content stays clear of them, the no-permission message clears the side and bottom insets, and the message list's default states fit a 120pt-tall list. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../message_composer_insets_test.dart | 131 ++++++++++++++++++ .../message_list_short_states_test.dart | 131 ++++++++++++++++++ 2 files changed, 262 insertions(+) create mode 100644 packages/stream_chat_flutter/test/src/message_input/message_composer_insets_test.dart create mode 100644 packages/stream_chat_flutter/test/src/message_list_view/message_list_short_states_test.dart diff --git a/packages/stream_chat_flutter/test/src/message_input/message_composer_insets_test.dart b/packages/stream_chat_flutter/test/src/message_input/message_composer_insets_test.dart new file mode 100644 index 0000000000..2628368ab3 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_input/message_composer_insets_test.dart @@ -0,0 +1,131 @@ +// How the composer's suggestions and no-permission message lay out under +// side safe-area insets. + +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/stream_chat_flutter.dart'; + +import '../fakes.dart'; +import '../mocks.dart'; + +const _padding = EdgeInsets.only(left: 47, right: 84, bottom: 34); + +void main() { + testWidgets('StreamMessageComposer runs command suggestions under the side insets', (tester) async { + await _pumpComposer(tester, channel: _channelWithCommands()); + await tester.enterText(find.byType(EditableText), '/'); + await tester.pumpAndSettle(); + + final suggestions = tester.getRect(find.byType(Card)); + expect(suggestions.left, 0); + expect(suggestions.right, moreOrLessEquals(_screenWidth(tester))); + }); + + testWidgets('StreamMessageComposer keeps command suggestions clear of the side insets', (tester) async { + await _pumpComposer(tester, channel: _channelWithCommands()); + await tester.enterText(find.byType(EditableText), '/'); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(StreamMessageComposer)); + final header = tester.getRect(find.text(context.translations.instantCommandsLabel)); + expect(header.left, greaterThanOrEqualTo(_padding.left)); + expect(header.right, lessThanOrEqualTo(_screenWidth(tester) - _padding.right)); + }); + + testWidgets('StreamMessageComposer runs custom trigger suggestions under the side insets', (tester) async { + const suggestionsKey = Key('custom-suggestions'); + final trigger = StreamAutocompleteTrigger( + trigger: '#', + optionsViewBuilder: (_, _, _) => const SizedBox(key: suggestionsKey, width: double.infinity, height: 48), + ); + + await _pumpComposer(tester, channel: MockChannel(), customAutocompleteTriggers: [trigger]); + await tester.enterText(find.byType(EditableText), '#'); + await tester.pumpAndSettle(); + + final suggestions = tester.getRect(find.byKey(suggestionsKey)); + expect(suggestions.left, 0); + expect(suggestions.right, moreOrLessEquals(_screenWidth(tester))); + }); + + testWidgets('StreamMessageComposer keeps the no-permission message clear of the insets', (tester) async { + await _pumpComposer(tester, channel: MockChannel(ownCapabilities: const [])); + + final context = tester.element(find.byType(StreamMessageComposer)); + final message = tester.getRect(find.text(context.translations.sendMessagePermissionError)); + final screenHeight = tester.view.physicalSize.height / tester.view.devicePixelRatio; + expect(message.left, greaterThanOrEqualTo(_padding.left)); + expect(message.right, lessThanOrEqualTo(_screenWidth(tester) - _padding.right)); + expect(message.bottom, lessThanOrEqualTo(screenHeight - _padding.bottom)); + }); +} + +// A channel whose config offers a single command. +MockChannel _channelWithCommands() { + final channel = MockChannel(); + when(() => channel.config).thenReturn( + ChannelConfig( + commands: [Command(name: 'giphy', description: 'Post a random gif', args: '[text]')], + ), + ); + return channel; +} + +double _screenWidth(WidgetTester tester) => tester.view.physicalSize.width / tester.view.devicePixelRatio; + +// Pumps a [StreamMessageComposer] for [channel] at the bottom of a screen with +// [_padding] as its safe-area insets. +Future _pumpComposer( + WidgetTester tester, { + required MockChannel channel, + List customAutocompleteTriggers = const [], +}) async { + final originalRecordPlatform = RecordPlatform.instance; + RecordPlatform.instance = FakeRecordPlatform(); + addTearDown(() => RecordPlatform.instance = originalRecordPlatform); + + final client = MockClient(); + final clientState = MockClientState(); + final channelState = MockChannelState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(OwnUser(id: 'user-id'))); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(channel.getRemainingCooldown).thenReturn(0); + when(() => channel.lastMessageAt).thenReturn(null); + when(() => channel.extraData).thenReturn({'name': 'test'}); + when(() => channel.extraDataStream).thenAnswer((_) => Stream.value({'name': 'test'})); + when(() => channelState.members).thenReturn([]); + when(() => channelState.membersStream).thenAnswer((_) => Stream.value([])); + when(() => channelState.messages).thenReturn([]); + when(() => channelState.messagesStream).thenAnswer((_) => Stream.value([])); + when(() => channelState.draft).thenReturn(null); + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => MediaQuery( + data: MediaQuery.of(context).copyWith(padding: _padding), + child: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: Scaffold( + body: Align( + alignment: Alignment.bottomCenter, + child: StreamMessageComposer(customAutocompleteTriggers: customAutocompleteTriggers), + ), + ), + ), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); +} diff --git a/packages/stream_chat_flutter/test/src/message_list_view/message_list_short_states_test.dart b/packages/stream_chat_flutter/test/src/message_list_view/message_list_short_states_test.dart new file mode 100644 index 0000000000..bbc90b3148 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_list_view/message_list_short_states_test.dart @@ -0,0 +1,131 @@ +// How `StreamMessageListView`'s default loading, empty and error states fit a +// list with little room, such as in landscape with the attachment picker open. + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/src/message_list_view/stream_message_list_error_state.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../mocks.dart'; + +const _listHeight = 120.0; + +void main() { + testWidgets('StreamMessageListView fits its default empty state into a short list', (tester) async { + await _pumpShortMessageList(tester); + + expect(tester.takeException(), isNull); + }); + + testWidgets('StreamMessageListErrorState fits into a short list', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: _client(), + child: Align( + alignment: Alignment.topCenter, + child: SizedBox( + height: _listHeight, + child: StreamMessageListErrorState(error: const StreamChatError('error'), onRetryPressed: () {}), + ), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + + testWidgets('StreamMessageListView fits its default loading state into a short list', (tester) async { + await _pumpShortMessageList( + tester, + parentMessage: Message( + id: 'parent', + text: 'Parent', + user: User(id: 'otherid'), + ), + // A thread whose replies never arrive stays in its loading state. + stubChannel: (channel, state) { + when(() => state.threads).thenReturn(const {}); + when( + () => channel.getReplies( + any(), + options: any(named: 'options'), + preferOffline: any(named: 'preferOffline'), + ), + ).thenAnswer((_) => Completer().future); + }, + ); + + expect(find.byType(StreamSkeletonBox), findsWidgets); + expect(tester.takeException(), isNull); + }); +} + +// Pumps a [StreamMessageListView] with no messages into a box [_listHeight] +// tall. [stubChannel] adds stubs a test needs beyond the defaults. +Future _pumpShortMessageList( + WidgetTester tester, { + Message? parentMessage, + void Function(Channel channel, ChannelClientState state)? stubChannel, +}) async { + final client = _client(); + + final messagesController = StreamController>.broadcast(); + addTearDown(messagesController.close); + + final channel = MockChannel(); + final channelClientState = MockChannelState(); + when(() => channel.client).thenReturn(client); + when(() => channel.state).thenReturn(channelClientState); + + when(() => channelClientState.threadsStream).thenAnswer((_) => const Stream.empty()); + when(() => channelClientState.isUpToDate).thenReturn(true); + when(() => channelClientState.isUpToDateStream).thenAnswer((_) => Stream.value(true)); + when(() => channelClientState.unreadCount).thenReturn(0); + when(() => channelClientState.unreadCountStream).thenAnswer((_) => Stream.value(0)); + when(() => channelClientState.readStream).thenAnswer((_) => const Stream.empty()); + when(() => channelClientState.read).thenReturn([]); + when(() => channelClientState.membersStream).thenAnswer((_) => const Stream.empty()); + when(() => channelClientState.members).thenReturn([]); + when(() => channelClientState.currentUserRead).thenReturn(null); + when(() => channelClientState.currentUserReadStream).thenAnswer((_) => const Stream.empty()); + when(() => channelClientState.messagesStream).thenAnswer((_) => messagesController.stream); + when(() => channelClientState.messages).thenReturn([]); + stubChannel?.call(channel, channelClientState); + + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: Align( + alignment: Alignment.topCenter, + child: SizedBox( + height: _listHeight, + child: StreamMessageListView(parentMessage: parentMessage), + ), + ), + ), + ), + ), + ); + + await tester.pump(); + await tester.pump(); +} + +// A client signed in as a user. +StreamChatClient _client() { + final client = MockClient(); + final clientState = MockClientState(); + when(() => client.state).thenAnswer((_) => clientState); + final ownUser = OwnUser(id: 'ownid'); + when(() => clientState.currentUser).thenReturn(ownUser); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(ownUser)); + return client; +} From ad012547bce964e167b47ba55b67d61329162774 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 16 Oct 2026 01:19:57 +0200 Subject: [PATCH 8/9] docs(ui): describe StreamAutocompleteOptions' insets like the other widgets Co-Authored-By: Claude Opus 5.5 (1M context) --- .../lib/src/autocomplete/stream_autocomplete.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/stream_chat_flutter/lib/src/autocomplete/stream_autocomplete.dart b/packages/stream_chat_flutter/lib/src/autocomplete/stream_autocomplete.dart index 21ad7b67c1..c1042e039f 100644 --- a/packages/stream_chat_flutter/lib/src/autocomplete/stream_autocomplete.dart +++ b/packages/stream_chat_flutter/lib/src/autocomplete/stream_autocomplete.dart @@ -616,7 +616,8 @@ class _TopBorderShape extends ShapeBorder { /// A helper widget used to show the options of a [StreamAutocomplete]. /// -/// Keeps its options clear of the safe-area insets at the sides of the screen. +/// Its options stay clear of the display cutouts and system bars at the sides +/// of the screen, as reported by [MediaQuery]'s padding. class StreamAutocompleteOptions extends StatelessWidget { /// Creates a [StreamAutocompleteOptions] widget. const StreamAutocompleteOptions({ From d9e226a5d5aaf276c7dbedd05964566c5e04bbcc Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 16 Oct 2026 03:49:31 +0200 Subject: [PATCH 9/9] fix(ui): fit the attachment picker's tabs into its compact height The camera, video, file and poll tabs centred their prompt inside a 40pt top and bottom margin, which moved nothing but left too little room in the compact picker on a short screen, so the prompt's button overflowed. Those tabs now keep only the side margin. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../options/stream_file_picker.dart | 1 + .../options/stream_image_picker.dart | 1 + .../options/stream_poll_creator.dart | 1 + .../options/stream_video_picker.dart | 1 + ..._tabbed_attachment_picker_height_test.dart | 78 +++++++++++++++++++ 5 files changed, 82 insertions(+) create mode 100644 packages/stream_chat_flutter/test/src/message_input/attachment_picker/stream_tabbed_attachment_picker_height_test.dart diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_file_picker.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_file_picker.dart index cc98355d3a..793b60a91f 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_file_picker.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_file_picker.dart @@ -79,6 +79,7 @@ class StreamFilePicker extends StatelessWidget { } final drawer = OptionDrawer( + margin: .symmetric(horizontal: spacing.md), child: EndOfFrameCallbackWidget( child: Center( child: Column( diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_image_picker.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_image_picker.dart index 5f375dee6b..48895369b2 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_image_picker.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_image_picker.dart @@ -55,6 +55,7 @@ class StreamImagePicker extends StatelessWidget { } final drawer = OptionDrawer( + margin: .symmetric(horizontal: spacing.md), child: EndOfFrameCallbackWidget( child: Center( child: Column( diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_poll_creator.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_poll_creator.dart index 954202a355..8f47b0de22 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_poll_creator.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_poll_creator.dart @@ -37,6 +37,7 @@ class StreamPollCreator extends StatelessWidget { } final drawer = OptionDrawer( + margin: .symmetric(horizontal: spacing.md), child: EndOfFrameCallbackWidget( child: Center( child: Column( diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_video_picker.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_video_picker.dart index 7b459855b6..b7e707b390 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_video_picker.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_video_picker.dart @@ -45,6 +45,7 @@ class StreamVideoPicker extends StatelessWidget { } final drawer = OptionDrawer( + margin: .symmetric(horizontal: spacing.md), child: EndOfFrameCallbackWidget( child: Center( child: Column( diff --git a/packages/stream_chat_flutter/test/src/message_input/attachment_picker/stream_tabbed_attachment_picker_height_test.dart b/packages/stream_chat_flutter/test/src/message_input/attachment_picker/stream_tabbed_attachment_picker_height_test.dart new file mode 100644 index 0000000000..b3401a6896 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_input/attachment_picker/stream_tabbed_attachment_picker_height_test.dart @@ -0,0 +1,78 @@ +// How the tabbed attachment picker's tabs fit the shorter picker on a short +// screen, such as a phone in landscape. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +void main() { + // The tabs that show a prompt and a button until their picker opens. + const tabs = ['image-picker', 'video-picker', 'file-picker', 'poll-creator']; + + // Just below and at the height where the picker grows to its regular size. + for (final screenHeight in [479.0, 480.0]) { + for (final tab in tabs) { + testWidgets('StreamTabbedAttachmentPicker fits its $tab tab on a $screenHeight-tall screen', (tester) async { + final overflows = await _pumpPickerTab(tester, tab: tab, screenHeight: screenHeight); + + expect(overflows, isEmpty); + }); + } + } +} + +// Pumps the default tabbed picker on [tab] at the bottom of a screen +// [screenHeight] tall, and returns the layout overflows it reported. +// +// Each tab opens its platform picker once shown, which has no platform channel +// in a widget test; those errors are collected too but aren't overflows. +Future> _pumpPickerTab( + WidgetTester tester, { + required String tab, + required double screenHeight, +}) async { + tester.view + ..devicePixelRatio = 1 + ..physicalSize = Size(900, screenHeight); + addTearDown(tester.view.reset); + + final controller = StreamAttachmentPickerController(); + addTearDown(controller.dispose); + + final errors = []; + final onError = FlutterError.onError; + FlutterError.onError = errors.add; + try { + await tester.pumpWidget( + MaterialApp( + home: StreamChatTheme( + data: StreamChatThemeData(), + child: Scaffold( + body: Align( + alignment: Alignment.bottomCenter, + child: Builder( + builder: (context) { + final picker = tabbedAttachmentPickerBuilder(context: context, controller: controller); + final options = (picker as StreamTabbedAttachmentPicker).options; + return StreamTabbedAttachmentPicker( + controller: controller, + options: options, + initialOption: options.firstWhere((it) => it.key == tab), + ); + }, + ), + ), + ), + ), + ), + ); + await tester.pump(); + } finally { + FlutterError.onError = onError; + } + + return [ + for (final error in errors) + if (error.exceptionAsString().contains('overflowed')) error, + ]; +}