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 46d7f60ffa..72eeffd7d9 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 6bf3900dd8..2f9673af7c 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 f4f01863e8..6dfcaf53b8 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 b47fe24d23..e7670b2ea3 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 9d2248ce74..7deb35c4d9 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 be4cab0021..e0f89d771f 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 fb5c50c83b..f7b33d8380 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 9c4b043dad..22abb1372a 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 d0d1a78019..cd48f7e8d7 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 632820ef44..0404d869c1 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 d983252861..a7cac4a9eb 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 0260e48f0c..41e98b94c5 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 4361557c28..2a3c3c0ef3 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 d0d1a78019..cd48f7e8d7 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/theming/goldens/macos/theming_default.png b/docs/docs_screenshots/test/theming/goldens/macos/theming_default.png index ad1c118bcb..899861ed05 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 a6d1ab7120..8368ae1cba 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 008dcec926..15d1c60aab 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 285ed21976..c3509fb0bc 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 008dcec926..15d1c60aab 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 fda36d06f6..38028a53d1 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 6f0bff680d..355aad34d5 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_hold_recording.png b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_hold_recording.png index 6f4f0a9112..ce22064f77 100644 Binary files a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_hold_recording.png and b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_hold_recording.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 fda36d06f6..38028a53d1 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/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_idle_tooltip.png b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_idle_tooltip.png index 8839ac85b0..ebd4b7a217 100644 Binary files a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_idle_tooltip.png and b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_idle_tooltip.png differ diff --git a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_locked_recording.png b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_locked_recording.png index 7981864371..381833f001 100644 Binary files a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_locked_recording.png and b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_locked_recording.png differ diff --git a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_stopped.png b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_stopped.png index 936caf4c3e..226eca1905 100644 Binary files a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_stopped.png and b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_stopped.png differ diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 967fb2c833..d0dba9a563 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -17,6 +17,7 @@ - Allowed `equatable` 3.x alongside 2.x. - `StreamMessageComposer`'s send button now follows the message validator, so a custom validator can enable or disable it, including when editing or with a command. - `StreamMessageListView` now rebuilds only the message rows that changed; a custom row builder should read changing outside state through an inherited widget. +- `StreamMessageComposer`'s top border now spans the full width, including under the side safe-area insets, and also shows above the no-permission message; the composer is 1px shorter as a result. - `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. @@ -25,6 +26,9 @@ - Fixed `StreamMessageListView` dropping frames on each new message in busy channels. - Fixed `StreamMessageComposer` polling the audio recorder ten times a second while no recording is in progress, including when voice recording is disabled. +- Fixed `StreamMessageComposer` and its attachment picker running under the left and right safe-area insets while the picker is open. +- Fixed the default empty, loading and error states of `StreamChannelListView`, `StreamThreadListView` and the other paged list views running under the safe-area insets. +- 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 `StreamMessageComposer`'s send button looking enabled on an empty message when voice recording is off, and showing the microphone instead of send for a staged poll. - Fixed the attachment picker showing an empty commands tab in channels without commands. - Fixed dialogs and bottom sheets opened below `StreamChat` keeping the old colors when the app switched between light and dark mode. 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 1f68bf2ce1..0a68e8f1f6 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 @@ -273,15 +273,8 @@ class _StreamChatMessageInputContent extends StatelessWidget { contentInsertionConfiguration: widget.contentInsertionConfiguration, ); - return Container( + return Padding( padding: EdgeInsets.only(top: spacing.md, right: spacing.md, left: spacing.md), - decoration: widget.isFloating - ? null - : BoxDecoration( - border: Border( - top: BorderSide(color: context.streamColorScheme.borderDefault), - ), - ), child: Semantics( container: true, explicitChildNodes: true, 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 0bc75cfb5d..e5be8070df 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 @@ -869,7 +869,8 @@ class DefaultStreamMessageComposerState extends State 0; + 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. @@ -885,6 +886,8 @@ class DefaultStreamMessageComposerState extends State 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), ), @@ -898,6 +901,7 @@ class DefaultStreamMessageComposerState extends State DecoratedBox( decoration: BoxDecoration( color: colorScheme.backgroundElevation1, + border: Border(top: BorderSide(color: colorScheme.borderDefault)), ), child: content, ), 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 1ef528a0c5..a6c24a796e 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 @@ -300,11 +300,12 @@ class StreamMessageListView extends StatefulWidget { /// Defaults to [StreamMessageListViewBuilders] with no overrides. final StreamMessageListViewBuilders builders; - /// Whether this list pads its scrollable to avoid partial obstructions + /// Whether this list keeps its content clear of partial obstructions /// indicated by [MediaQuery]'s padding. /// - /// Content still scrolls through the obstructed area; only the scrollable's - /// limits are inset, so the first and last items come to rest clear of it. + /// Messages scroll under the top and bottom obstructions but come to rest + /// clear of them, and stay clear of the side ones. The loading, empty and + /// error states, including those from [builders], clear every obstruction. /// /// Defaults to false. final bool enableSafeArea; @@ -603,30 +604,36 @@ 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 => widget.enableSafeArea ? MediaQuery.paddingOf(context) : EdgeInsets.zero; + // The top and bottom insets, applied as scroll padding. [_safeArea] handles the sides. + EdgeInsets get _scaffoldInsets { + if (!widget.enableSafeArea) return .zero; + return MediaQuery.paddingOf(context).copyWith(left: 0, right: 0); + } + + Widget _safeArea({bool top = true, bool bottom = true, required Widget child}) { + if (!widget.enableSafeArea) return child; + return SafeArea(top: top, bottom: bottom, child: child); + } @override Widget build(BuildContext context) { Widget defaultLoadingBuilder(BuildContext context) { - if (widget.builders.loading case final builder?) return builder(context); - return const StreamMessageListSkeletonLoading(); + if (widget.builders.loading case final builder?) return _safeArea(child: builder(context)); + return _safeArea(child: const StreamMessageListSkeletonLoading()); } Widget defaultEmptyBuilder(BuildContext context) { - if (widget.builders.empty case final builder?) return builder(context); - return const StreamMessageListEmptyState(); + if (widget.builders.empty case final builder?) return _safeArea(child: builder(context)); + return _safeArea(child: const StreamMessageListEmptyState()); } Widget defaultErrorBuilder(BuildContext context, Object error) { - if (widget.builders.error case final builder?) return builder(context, 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); - return Center( + final errorWidget = Center( child: StreamScrollViewErrorWidget( errorTitle: Text(text.title), errorSubtitle: Text(text.description), @@ -634,6 +641,8 @@ class _StreamMessageListViewState extends State { onRetryPressed: () => streamChannel?.reloadChannel(), ), ); + + return _safeArea(child: errorWidget); } Widget defaultMessageListBuilder(BuildContext context, List list) { @@ -956,6 +965,8 @@ class _StreamMessageListViewState extends State { ], ); + final content = _safeArea(top: false, bottom: false, child: child); + final backgroundColor = StreamMessageListViewTheme.of(context).backgroundColor; final backgroundImage = StreamMessageListViewTheme.of(context).backgroundImage; @@ -965,11 +976,11 @@ class _StreamMessageListViewState extends State { color: backgroundColor, image: backgroundImage, ), - child: child, + child: content, ); } - return child; + return content; } // Default spacing widget between adjacent messages — mirrors the old 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 610f1fd774..d3106ca61b 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 @@ -43,8 +43,10 @@ class _StreamChannelListItemSkeleton extends StatelessWidget { Widget build(BuildContext context) { final spacing = context.streamSpacing; - return Padding( - padding: EdgeInsets.all(spacing.md), + return StreamSafeArea( + top: false, + bottom: false, + minimum: EdgeInsets.all(spacing.md), child: Row( crossAxisAlignment: CrossAxisAlignment.center, spacing: spacing.md, diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_empty_widget.dart b/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_empty_widget.dart index 3e33fd2e49..fdb8e0f26d 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_empty_widget.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_empty_widget.dart @@ -3,6 +3,9 @@ import '../../stream_chat_flutter.dart'; /// A widget that shows an empty view when the [StreamScrollView] loads /// empty data. +/// +/// Its content stays clear of the display cutouts and system bars reported by +/// [MediaQuery]'s padding. class StreamScrollViewEmptyWidget extends StatelessWidget { /// Creates a new instance of the [StreamScrollViewEmptyWidget]. const StreamScrollViewEmptyWidget({ @@ -56,8 +59,8 @@ class StreamScrollViewEmptyWidget extends StatelessWidget { child: emptyTitle, ); - return Padding( - padding: .symmetric( + return StreamSafeArea( + minimum: .symmetric( horizontal: spacing.md, vertical: spacing.xxxl, ), diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_error_widget.dart b/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_error_widget.dart index e0c6311738..80d4aaab13 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_error_widget.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_error_widget.dart @@ -3,6 +3,9 @@ import '../../stream_chat_flutter.dart'; /// A widget that is displayed when a [StreamScrollView] encounters an error /// while loading the initial items. +/// +/// Its content stays clear of the display cutouts and system bars reported by +/// [MediaQuery]'s padding. class StreamScrollViewErrorWidget extends StatelessWidget { /// Creates a new instance of the [StreamScrollViewErrorWidget]. const StreamScrollViewErrorWidget({ @@ -110,8 +113,8 @@ class StreamScrollViewErrorWidget extends StatelessWidget { child: effectiveRetryButtonText, ); - return Padding( - padding: .symmetric( + return StreamSafeArea( + minimum: .symmetric( horizontal: spacing.md, vertical: spacing.xxxl, ), diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_loading_widget.dart b/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_loading_widget.dart index ecd2b47d9c..8e99a3400e 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_loading_widget.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_loading_widget.dart @@ -2,6 +2,9 @@ import 'package:flutter/material.dart'; import 'package:stream_core_flutter/chat.dart'; /// A widget that is displayed while the [StreamScrollView] is loading. +/// +/// Its content stays clear of the display cutouts and system bars reported by +/// [MediaQuery]'s padding. class StreamScrollViewLoadingWidget extends StatelessWidget { /// Creates a new instance of [StreamScrollViewLoadingWidget]. const StreamScrollViewLoadingWidget({ @@ -26,8 +29,8 @@ class StreamScrollViewLoadingWidget extends StatelessWidget { Widget build(BuildContext context) { final spacing = context.streamSpacing; - return Padding( - padding: .symmetric( + return StreamSafeArea( + minimum: .symmetric( horizontal: spacing.md, vertical: spacing.xxxl, ), 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 8e7637730c..e5b80b04af 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 @@ -41,8 +41,10 @@ class _StreamThreadListItemSkeleton extends StatelessWidget { @override Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.all(context.streamSpacing.sm), + return StreamSafeArea( + top: false, + bottom: false, + minimum: EdgeInsets.all(context.streamSpacing.sm), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ 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 a20cbff9f4..de71b648da 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 fd94565d7a..fe5e363423 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 index e4c0a5a3eb..e956e122c7 100644 --- 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 @@ -59,6 +59,21 @@ void main() { expect(_backgroundFillFinder(tester), findsOneWidget); }); + testWidgets('StreamMessageComposer runs the regular top divider under the side insets', (tester) async { + await _pumpComposer(tester, surfaceStyle: StreamSurfaceStyle.regular, leftPadding: 47, rightPadding: 84); + + final screenWidth = tester.view.physicalSize.width / tester.view.devicePixelRatio; + final divider = tester.getRect(_topDividerFinder); + expect(divider.left, 0); + expect(divider.right, moreOrLessEquals(screenWidth)); + }); + + testWidgets('StreamMessageComposer draws a single regular top divider', (tester) async { + await _pumpComposer(tester, surfaceStyle: StreamSurfaceStyle.regular); + + expect(_topDividerFinder, 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. @@ -150,6 +165,38 @@ void main() { // Open: the inset has collapsed. expect(insetBottom(), moreOrLessEquals(0)); }); + + testWidgets('StreamMessageComposer keeps its side insets while the attachment picker opens', (tester) async { + const leftInset = 47.0; + const rightInset = 84.0; + await _pumpComposer( + tester, + surfaceStyle: StreamSurfaceStyle.floating, + bottomPadding: 34, + leftPadding: leftInset, + rightPadding: rightInset, + ); + + final screenWidth = tester.view.physicalSize.width / tester.view.devicePixelRatio; + Rect inputRect() => tester.getRect(find.byType(StreamChatMessageInput)); + + final closed = inputRect(); + expect(closed.left, greaterThanOrEqualTo(leftInset)); + expect(closed.right, lessThanOrEqualTo(screenWidth - rightInset)); + + await tester.tap(_attachmentButtonFinder); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + // Mid-animation: neither edge moves. + expect(inputRect().left, moreOrLessEquals(closed.left)); + expect(inputRect().right, moreOrLessEquals(closed.right)); + + await tester.pumpAndSettle(); + + expect(inputRect().left, moreOrLessEquals(closed.left)); + expect(inputRect().right, moreOrLessEquals(closed.right)); + }); } // The composer's attachment (picker) button, in the leading slot. @@ -167,7 +214,11 @@ StreamSurfaceStyle _resolvedSurfaceStyle(WidgetTester tester) { // 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); + final colorScheme = context.streamColorScheme; + final fill = BoxDecoration( + color: colorScheme.backgroundElevation1, + border: Border(top: BorderSide(color: colorScheme.borderDefault)), + ); return find.descendant( of: find.byType(StreamMessageComposer), @@ -175,6 +226,19 @@ Finder _backgroundFillFinder(WidgetTester tester) { ); } +// Finds every box inside the composer that draws a top border and no bottom +// border, i.e. a divider rather than an outline. +final _topDividerFinder = find.descendant( + of: find.byType(StreamMessageComposer), + matching: find.byWidgetPredicate( + (widget) => switch (widget) { + DecoratedBox(decoration: BoxDecoration(border: Border(:final top, :final bottom))) => + top != BorderSide.none && bottom == BorderSide.none, + _ => false, + }, + ), +); + // The vertical distance between the bottom of the input pill and the bottom // of the composer. double _gapBelowInput(WidgetTester tester) { @@ -196,6 +260,8 @@ Future _pumpComposer( StreamSurfaceStyle? composerSurfaceStyle, bool? enableSafeArea, double bottomPadding = 0, + double leftPadding = 0, + double rightPadding = 0, TargetPlatform? platform, }) async { final originalRecordPlatform = RecordPlatform.instance; @@ -241,7 +307,9 @@ Future _pumpComposer( // brightness the test binding provides are preserved. home: Builder( builder: (context) => MediaQuery( - data: MediaQuery.of(context).copyWith(padding: EdgeInsets.only(bottom: bottomPadding)), + data: MediaQuery.of(context).copyWith( + padding: EdgeInsets.only(left: leftPadding, right: rightPadding, bottom: bottomPadding), + ), child: StreamChat( client: client, themeData: StreamChatThemeData(messageComposerTheme: globalTheme), diff --git a/packages/stream_chat_flutter/test/src/message_list_view/message_list_safe_area_test.dart b/packages/stream_chat_flutter/test/src/message_list_view/message_list_safe_area_test.dart new file mode 100644 index 0000000000..436a96f82c --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_list_view/message_list_safe_area_test.dart @@ -0,0 +1,247 @@ +// How `StreamMessageListView` clears the side safe-area insets with +// `enableSafeArea` on. + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/src/message_list_view/floating_date_divider.dart'; +import 'package:stream_chat_flutter/src/message_list_view/stream_message_list_empty_state.dart'; +import 'package:stream_chat_flutter/src/message_list_view/stream_message_list_skeleton_loading.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../../test_utils/data_generator.dart'; +import '../mocks.dart'; + +void main() { + testWidgets('StreamMessageListView keeps the scroll-to-bottom button clear of a side inset', (tester) async { + const rightInset = 84.0; + await _pumpMessageList(tester, padding: const EdgeInsets.only(left: 47, right: rightInset)); + + await tester.drag(find.byType(StreamMessageListView), const Offset(0, 400)); + await tester.pumpAndSettle(); + + final spacing = tester.element(find.byType(StreamMessageListView)).streamSpacing; + final screenWidth = tester.view.physicalSize.width / tester.view.devicePixelRatio; + expect(tester.getRect(find.byType(StreamButton)).right, moreOrLessEquals(screenWidth - rightInset - spacing.md)); + }); + + testWidgets('StreamMessageListView centers the floating date divider over the content area', (tester) async { + const padding = EdgeInsets.only(left: 47, right: 84); + await _pumpMessageList( + tester, + padding: padding, + messages: _messagesOverFourDays(), + config: _opaqueFloatingDateDividerConfig, + ); + + await tester.drag(find.byType(StreamMessageListView), const Offset(0, 300)); + await tester.pumpAndSettle(); + + final floatingDateDivider = find.descendant( + of: find.byType(FloatingDateDivider), + matching: find.byType(StreamDateDivider), + ); + expect(tester.getCenter(floatingDateDivider).dx, moreOrLessEquals(_contentCenterX(tester, padding))); + }); + + testWidgets('StreamMessageListView centers the empty state over the content area', (tester) async { + const padding = EdgeInsets.only(left: 47, right: 84); + await _pumpMessageList(tester, padding: padding, messages: []); + + expect( + tester.getCenter(find.byType(StreamMessageListEmptyState)).dx, + moreOrLessEquals(_contentCenterX(tester, padding)), + ); + }); + + testWidgets('StreamMessageListView keeps its background full width under a side inset', (tester) async { + const backgroundColor = Color(0xFF00FF00); + await _pumpMessageList( + tester, + padding: const EdgeInsets.only(left: 47, right: 84), + themeData: StreamChatThemeData( + messageListViewTheme: const StreamMessageListViewThemeData(backgroundColor: backgroundColor), + ), + ); + + final background = find.byWidgetPredicate( + (widget) => switch (widget) { + DecoratedBox(decoration: BoxDecoration(color: backgroundColor)) => true, + _ => false, + }, + ); + final screenWidth = tester.view.physicalSize.width / tester.view.devicePixelRatio; + expect(tester.getSize(background).width, moreOrLessEquals(screenWidth)); + }); + + testWidgets('StreamMessageListView insets message rows by a side inset only once', (tester) async { + const padding = EdgeInsets.only(left: 47, right: 84); + await _pumpMessageList(tester, padding: padding); + + final screenWidth = tester.view.physicalSize.width / tester.view.devicePixelRatio; + final row = tester.getRect(find.byType(StreamMessageItem).first); + expect(row.left, moreOrLessEquals(padding.left)); + expect(row.right, moreOrLessEquals(screenWidth - padding.right)); + }); + + testWidgets('StreamMessageListView centers a custom empty state over the content area', (tester) async { + const padding = EdgeInsets.only(left: 47, right: 84); + const customEmptyKey = Key('custom-empty'); + await _pumpMessageList( + tester, + padding: padding, + messages: [], + builders: StreamMessageListViewBuilders(empty: (_) => const SizedBox.expand(key: customEmptyKey)), + ); + + expect(tester.getCenter(find.byKey(customEmptyKey)).dx, moreOrLessEquals(_contentCenterX(tester, padding))); + }); + + testWidgets('StreamMessageListView keeps the loading state clear of a side inset', (tester) async { + const padding = EdgeInsets.only(left: 47, right: 84); + await _pumpMessageList( + tester, + padding: padding, + // 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); + }, + parentMessage: Message( + id: 'parent', + text: 'Parent', + user: User(id: 'otherid'), + ), + // The skeleton shimmers forever, so it never settles. + settle: false, + ); + + final screenWidth = tester.view.physicalSize.width / tester.view.devicePixelRatio; + final skeleton = tester.getRect(find.byType(StreamMessageListSkeletonLoading)); + expect(skeleton.left, moreOrLessEquals(padding.left)); + expect(skeleton.right, moreOrLessEquals(screenWidth - padding.right)); + }); + + testWidgets('StreamMessageListView leaves the empty state on the full width when enableSafeArea is off', ( + tester, + ) async { + const padding = EdgeInsets.only(left: 47, right: 84); + await _pumpMessageList(tester, padding: padding, messages: [], enableSafeArea: false); + + final screenWidth = tester.view.physicalSize.width / tester.view.devicePixelRatio; + expect(tester.getCenter(find.byType(StreamMessageListEmptyState)).dx, moreOrLessEquals(screenWidth / 2)); + }); +} + +// Pumps a [StreamMessageListView] with [padding] as the screen's safe-area +// insets. [stubChannel] adds stubs a test needs beyond the defaults. +Future _pumpMessageList( + WidgetTester tester, { + required EdgeInsets padding, + List? messages, + StreamMessageListViewConfiguration config = const StreamMessageListViewConfiguration(), + StreamChatThemeData? themeData, + StreamMessageListViewBuilders builders = const StreamMessageListViewBuilders(), + bool enableSafeArea = true, + Message? parentMessage, + bool settle = true, + void Function(Channel channel, ChannelClientState state)? stubChannel, +}) async { + 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)); + + 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(() => channel.markRead(messageId: any(named: 'messageId'))).thenAnswer((_) async => EmptyResponse()); + stubChannel?.call(channel, channelClientState); + + final effectiveMessages = messages ?? generateConversation(40, users: [User(id: 'otherid')]).reversed.toList(); + when(() => channelClientState.messages).thenReturn(effectiveMessages); + + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => MediaQuery( + data: MediaQuery.of(context).copyWith(padding: padding), + child: DefaultAssetBundle( + bundle: rootBundle, + child: StreamChat( + client: client, + themeData: themeData, + child: StreamChannel( + channel: channel, + child: StreamMessageListView( + enableSafeArea: enableSafeArea, + config: config, + builders: builders, + parentMessage: parentMessage, + ), + ), + ), + ), + ), + ), + ), + ); + messagesController.add(effectiveMessages); + if (settle) { + await tester.pumpAndSettle(); + } else { + await tester.pump(); + } + }); +} + +// The horizontal center of the area left between the side insets. +double _contentCenterX(WidgetTester tester, EdgeInsets padding) { + final screenWidth = tester.view.physicalSize.width / tester.view.devicePixelRatio; + return padding.left + (screenWidth - padding.left - padding.right) / 2; +} + +// Ten messages a day over four days, oldest first. +List _messagesOverFourDays() => [ + for (var day = 3; day >= 0; day--) + for (var i = 0; i < 10; i++) + Message( + id: 'day$day-$i', + text: 'Message $i on day $day', + user: User(id: 'otherid'), + createdAt: DateTime(2026, 9, 20 - day, 9, i), + ), +]; + +// Keeps the floating divider in the tree next to an inline one. +const _opaqueFloatingDateDividerConfig = StreamMessageListViewConfiguration(fadeFloatingDateDividerNearInline: false); diff --git a/packages/stream_chat_flutter/test/src/scroll_view/stream_scroll_view_safe_area_test.dart b/packages/stream_chat_flutter/test/src/scroll_view/stream_scroll_view_safe_area_test.dart new file mode 100644 index 0000000000..eefdcc2ced --- /dev/null +++ b/packages/stream_chat_flutter/test/src/scroll_view/stream_scroll_view_safe_area_test.dart @@ -0,0 +1,123 @@ +// How the paged lists' default loading, empty and error states keep clear of +// the side safe-area insets, while custom ones are laid out as built. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../mocks.dart'; + +const _padding = EdgeInsets.only(left: 47, right: 84); + +void main() { + testWidgets('StreamThreadListView centers its default empty state between the side insets', (tester) async { + final controller = _FakeThreadListController(const PagedValue(items: [])); + + await _pumpWithInsets(tester, StreamThreadListView(controller: controller)); + + final content = find.descendant(of: find.byType(StreamScrollViewEmptyWidget), matching: find.byType(Column)); + expect(tester.getCenter(content).dx, moreOrLessEquals(_safeCenterX(tester))); + }); + + testWidgets('StreamThreadListView centers its default error state between the side insets', (tester) async { + final controller = _FakeThreadListController(const PagedValue.error(StreamChatError('error'))); + + await _pumpWithInsets(tester, StreamThreadListView(controller: controller)); + + final content = find.descendant(of: find.byType(StreamScrollViewErrorWidget), matching: find.byType(Column)); + expect(tester.getCenter(content).dx, moreOrLessEquals(_safeCenterX(tester))); + }); + + testWidgets('StreamThreadListView keeps its default loading rows clear of the side insets', (tester) async { + final controller = _FakeThreadListController(const PagedValue.loading()); + + await _pumpWithInsets(tester, StreamThreadListView(controller: controller)); + + final avatar = tester.getRect(find.byType(StreamSkeletonBox).first); + expect(avatar.left, moreOrLessEquals(_padding.left)); + }); + + testWidgets('StreamChannelListView keeps its default loading rows clear of the side insets', (tester) async { + final controller = _FakeChannelListController(const PagedValue.loading()); + + await _pumpWithInsets(tester, StreamChannelListView(controller: controller)); + + final boxes = find.byType(StreamSkeletonBox); + expect(tester.getRect(boxes.first).left, moreOrLessEquals(_padding.left)); + // The timestamp placeholder ends each row's first line. + expect(tester.getRect(boxes.at(2)).right, moreOrLessEquals(_screenWidth(tester) - _padding.right)); + }); + + testWidgets('StreamThreadListView lays out a custom empty state as built', (tester) async { + const customEmptyKey = Key('custom-empty'); + final controller = _FakeThreadListController(const PagedValue(items: [])); + + await _pumpWithInsets( + tester, + StreamThreadListView( + controller: controller, + emptyBuilder: (_) => const SizedBox.expand(key: customEmptyKey), + ), + ); + + final rect = tester.getRect(find.byKey(customEmptyKey)); + expect(rect.left, 0); + expect(rect.right, _screenWidth(tester)); + }); + + testWidgets('StreamScrollViewLoadingWidget centers its spinner between the side insets', (tester) async { + await _pumpWithInsets(tester, const Center(child: StreamScrollViewLoadingWidget())); + + expect(tester.getCenter(find.byType(StreamLoadingSpinner)).dx, moreOrLessEquals(_safeCenterX(tester))); + }); +} + +// A thread list controller that holds [value] instead of loading from the client. +class _FakeThreadListController extends StreamThreadListController { + _FakeThreadListController(PagedValue value) : super(client: MockClient()) { + this.value = value; + } + + @override + Future doInitialLoad() async {} +} + +// A channel list controller that holds [value] instead of loading from the client. +class _FakeChannelListController extends StreamChannelListController { + _FakeChannelListController(PagedValue value) : super(client: MockClient()) { + this.value = value; + } + + @override + Future doInitialLoad() async {} +} + +// Pumps [child] as a scaffold body on a screen with [_padding] as its safe-area insets. +Future _pumpWithInsets(WidgetTester tester, Widget child) async { + final client = MockClient(); + final clientState = MockClientState(); + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'current-user-id')); + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => MediaQuery( + data: MediaQuery.of(context).copyWith(padding: _padding), + child: StreamChat( + client: client, + connectivityStream: Stream.value([ConnectivityResult.wifi]), + child: Scaffold(body: child), + ), + ), + ), + ), + ); + await tester.pump(); +} + +double _screenWidth(WidgetTester tester) => tester.view.physicalSize.width / tester.view.devicePixelRatio; + +// The horizontal center of the area between the side insets. +double _safeCenterX(WidgetTester tester) => (_padding.left + _screenWidth(tester) - _padding.right) / 2; diff --git a/sample_app/lib/config/sample_app_config_screen.dart b/sample_app/lib/config/sample_app_config_screen.dart index 689cf95b4b..c12169a802 100644 --- a/sample_app/lib/config/sample_app_config_screen.dart +++ b/sample_app/lib/config/sample_app_config_screen.dart @@ -29,7 +29,7 @@ class SampleAppConfigScreen extends StatelessWidget { body: Builder( builder: (context) { final topInset = MediaQuery.paddingOf(context).top; - return SingleChildScrollView( + final content = SingleChildScrollView( padding: EdgeInsets.symmetric(horizontal: spacing.md) + EdgeInsets.only(top: topInset), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -182,6 +182,8 @@ class SampleAppConfigScreen extends StatelessWidget { ], ), ); + + return SafeArea(top: false, bottom: false, child: content); }, ), ); @@ -544,7 +546,6 @@ class _LocaleRow extends StatelessWidget { final colorScheme = context.streamColorScheme; final textTheme = context.streamTextTheme; final spacing = context.streamSpacing; - final radius = context.streamRadius; final items = [ const _LocaleOption(label: 'System', code: null), @@ -556,94 +557,61 @@ class _LocaleRow extends StatelessWidget { ), ]; - showModalBottomSheet( + showStreamSheet( context: context, - isScrollControlled: true, - backgroundColor: colorScheme.backgroundSurfaceCard, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: radius.xxl), - ), - builder: (sheetContext) { - return DraggableScrollableSheet( - expand: false, - initialChildSize: 0.5, - maxChildSize: 0.8, - minChildSize: 0.3, - builder: (context, scrollController) => SafeArea( - child: Column( - children: [ - Padding( - padding: EdgeInsets.only(top: spacing.md, bottom: spacing.sm), - child: Container( - width: 32, - height: 4, - decoration: BoxDecoration( - color: colorScheme.borderDefault, - borderRadius: BorderRadius.all(radius.max), + isDismissible: true, + builder: (sheetContext, scrollController) => Column( + children: [ + StreamSheetHeader(title: const Text('Select Language')), + Expanded( + child: ListView.separated( + controller: scrollController, + itemCount: items.length, + separatorBuilder: (_, __) => Divider( + height: 1, + indent: spacing.lg, + endIndent: spacing.lg, + color: colorScheme.borderSubtle, + ), + itemBuilder: (context, index) { + final item = items[index]; + final isSelected = item.code == config.locale?.languageCode; + + return StreamListTile( + title: Text( + item.label, + style: textTheme.bodyDefault.copyWith( + color: isSelected ? colorScheme.accentPrimary : colorScheme.textPrimary, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, ), ), - ), - Padding( - padding: EdgeInsets.only(bottom: spacing.sm), - child: Text( - 'Select Language', - style: textTheme.headingSm.copyWith(color: colorScheme.textPrimary), - ), - ), - Divider(height: 1, color: colorScheme.borderSubtle), - Expanded( - child: ListView.separated( - controller: scrollController, - padding: EdgeInsets.symmetric(vertical: spacing.xs), - itemCount: items.length, - separatorBuilder: (_, __) => Divider( - height: 1, - indent: spacing.lg, - endIndent: spacing.lg, - color: colorScheme.borderSubtle, + subtitle: switch (item.code) { + final code? => Text( + code, + style: textTheme.captionDefault.copyWith(color: colorScheme.textTertiary), ), - itemBuilder: (context, index) { - final item = items[index]; - final isSelected = item.code == config.locale?.languageCode; - - return StreamListTile( - title: Text( - item.label, - style: textTheme.bodyDefault.copyWith( - color: isSelected ? colorScheme.accentPrimary : colorScheme.textPrimary, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, - ), - ), - subtitle: switch (item.code) { - final code? => Text( - code, - style: textTheme.captionDefault.copyWith(color: colorScheme.textTertiary), - ), - null => null, - }, - trailing: StreamCheckbox.circular( - value: isSelected, - size: StreamCheckboxSize.sm, - onChanged: (_) { - final locale = item.code != null ? Locale(item.code!) : null; - SampleAppConfig.update(context, config.copyWith(locale: locale)); - Navigator.of(sheetContext).pop(); - }, - ), - onTap: () { - final locale = item.code != null ? Locale(item.code!) : null; - SampleAppConfig.update(context, config.copyWith(locale: locale)); - Navigator.of(sheetContext).pop(); - }, - ); + null => null, + }, + trailing: StreamCheckbox.circular( + value: isSelected, + size: StreamCheckboxSize.sm, + onChanged: (_) { + final locale = item.code != null ? Locale(item.code!) : null; + SampleAppConfig.update(context, config.copyWith(locale: locale)); + Navigator.of(sheetContext).pop(); }, ), - ), - ], + onTap: () { + final locale = item.code != null ? Locale(item.code!) : null; + SampleAppConfig.update(context, config.copyWith(locale: locale)); + Navigator.of(sheetContext).pop(); + }, + ); + }, ), ), - ); - }, + ], + ), ); } } diff --git a/sample_app/lib/pages/advanced_options_page.dart b/sample_app/lib/pages/advanced_options_page.dart index 1a503e106e..ecfde9085e 100644 --- a/sample_app/lib/pages/advanced_options_page.dart +++ b/sample_app/lib/pages/advanced_options_page.dart @@ -117,7 +117,7 @@ class _AdvancedOptionsPageState extends State { body: Builder( builder: (context) { final topInset = MediaQuery.paddingOf(context).top; - return Padding( + final content = Padding( padding: EdgeInsets.fromLTRB(16, 16 + topInset, 16, 0), child: Form( key: _formKey, @@ -334,6 +334,8 @@ class _AdvancedOptionsPageState extends State { ), ), ); + + return SafeArea(top: false, bottom: false, child: content); }, ), ); diff --git a/sample_app/lib/pages/channel_file_display_screen.dart b/sample_app/lib/pages/channel_file_display_screen.dart index 21d4d3d7a5..1794f9efee 100644 --- a/sample_app/lib/pages/channel_file_display_screen.dart +++ b/sample_app/lib/pages/channel_file_display_screen.dart @@ -136,14 +136,18 @@ class _HeaderRow implements _Row { return Container( color: colorScheme.backgroundSurfaceCard, - padding: EdgeInsets.symmetric( - horizontal: spacing.md, - vertical: spacing.xs, - ), width: double.infinity, - child: Text( - label, - style: textTheme.captionEmphasis.copyWith(color: colorScheme.textPrimary), + child: SafeArea( + top: false, + bottom: false, + minimum: EdgeInsets.symmetric( + horizontal: spacing.md, + vertical: spacing.xs, + ), + child: Text( + label, + style: textTheme.captionEmphasis.copyWith(color: colorScheme.textPrimary), + ), ), ); } diff --git a/sample_app/lib/pages/channel_media_display_screen.dart b/sample_app/lib/pages/channel_media_display_screen.dart index 66f39d3b3f..7de339c6e7 100644 --- a/sample_app/lib/pages/channel_media_display_screen.dart +++ b/sample_app/lib/pages/channel_media_display_screen.dart @@ -59,13 +59,17 @@ class _ChannelMediaDisplayScreenState extends State { 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 SafeArea( + top: false, + bottom: false, + child: LazyLoadScrollView( + onEndOfPage: () async { + if (nextPageKey != null) await _controller.loadMore(nextPageKey); + }, + child: StreamMediaGallery( + attachments: attachments, + onItemTap: (index) => _openPreview(context, attachments, index), + ), ), ); }, diff --git a/sample_app/lib/pages/chat_info_screen.dart b/sample_app/lib/pages/chat_info_screen.dart index 5e66d7777c..9be97aee0e 100644 --- a/sample_app/lib/pages/chat_info_screen.dart +++ b/sample_app/lib/pages/chat_info_screen.dart @@ -36,7 +36,7 @@ class ChatInfoScreen extends StatelessWidget { body: Builder( builder: (context) { final topInset = MediaQuery.paddingOf(context).top; - return IconTheme.merge( + final content = IconTheme.merge( data: const IconThemeData(size: 20), child: SingleChildScrollView( padding: .directional( @@ -57,6 +57,8 @@ class ChatInfoScreen extends StatelessWidget { ), ), ); + + return SafeArea(top: false, bottom: false, child: content); }, ), ); diff --git a/sample_app/lib/pages/group_chat_details_screen.dart b/sample_app/lib/pages/group_chat_details_screen.dart index 255660f044..458dfde4f8 100644 --- a/sample_app/lib/pages/group_chat_details_screen.dart +++ b/sample_app/lib/pages/group_chat_details_screen.dart @@ -109,7 +109,7 @@ class _GroupChatDetailsScreenState extends State { child: Builder( builder: (context) { final topInset = MediaQuery.paddingOf(context).top; - return Column( + final content = Column( children: [ if (topInset > 0) SizedBox(height: topInset), Padding( @@ -223,6 +223,8 @@ class _GroupChatDetailsScreenState extends State { ), ], ); + + return SafeArea(top: false, bottom: false, child: content); }, ), ); diff --git a/sample_app/lib/pages/group_info_screen.dart b/sample_app/lib/pages/group_info_screen.dart index 06b905614f..9284638b3b 100644 --- a/sample_app/lib/pages/group_info_screen.dart +++ b/sample_app/lib/pages/group_info_screen.dart @@ -42,7 +42,7 @@ class GroupInfoScreen extends StatelessWidget { body: Builder( builder: (context) { final topInset = MediaQuery.paddingOf(context).top; - return IconTheme.merge( + final content = IconTheme.merge( data: const IconThemeData(size: 20), child: SingleChildScrollView( padding: .directional( @@ -65,6 +65,8 @@ class GroupInfoScreen extends StatelessWidget { ), ), ); + + return SafeArea(top: false, bottom: false, child: content); }, ), ); diff --git a/sample_app/lib/pages/new_chat_screen.dart b/sample_app/lib/pages/new_chat_screen.dart index a93ec8d9d3..a777cae8fc 100644 --- a/sample_app/lib/pages/new_chat_screen.dart +++ b/sample_app/lib/pages/new_chat_screen.dart @@ -162,7 +162,7 @@ class _NewChatScreenState extends State { statusString = 'Disconnected'; break; } - return StreamInfoTile( + final content = StreamInfoTile( showMessage: showStatus, tileAnchor: Alignment.topCenter, childAnchor: Alignment.topCenter, @@ -377,6 +377,8 @@ class _NewChatScreenState extends State { ), ), ); + + return SafeArea(top: false, bottom: false, child: content); }, ), ); diff --git a/sample_app/lib/widgets/message_info_sheet.dart b/sample_app/lib/widgets/message_info_sheet.dart index 0768f26fda..046ec0703e 100644 --- a/sample_app/lib/widgets/message_info_sheet.dart +++ b/sample_app/lib/widgets/message_info_sheet.dart @@ -20,27 +20,14 @@ class MessageInfoSheet extends StatelessWidget { required BuildContext context, required Message message, }) { - final colorScheme = context.streamColorScheme; - return showModalBottomSheet( + return showStreamSheet( context: context, - useSafeArea: true, - isScrollControlled: true, - backgroundColor: colorScheme.backgroundApp, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical( - top: Radius.circular(16), - ), - ), - builder: (_) => StreamChannel.value( + isDismissible: true, + builder: (_, scrollController) => StreamChannel.value( channel: StreamChannel.of(context).channel, - child: DraggableScrollableSheet( - snap: true, - expand: false, - snapSizes: const [0.5, 1], - builder: (context, controller) => MessageInfoSheet( - message: message, - scrollController: controller, - ), + child: MessageInfoSheet( + message: message, + scrollController: scrollController, ), ), ); @@ -50,14 +37,13 @@ class MessageInfoSheet extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = context.streamColorScheme; final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; final channel = StreamChannel.of(context).channel; return Column( children: [ - // Header - _buildHeader(context), - + StreamSheetHeader(title: const Text('Message Info')), // Delivery and read receipts Expanded( child: BetterStreamBuilder>( @@ -98,7 +84,7 @@ class MessageInfoSheet extends StatelessWidget { return ListView( controller: scrollController, - padding: const EdgeInsets.all(16), + padding: .symmetric(horizontal: spacing.xxs, vertical: spacing.md), children: [ // Read section if (readBy.isNotEmpty) ...[ @@ -134,42 +120,6 @@ class MessageInfoSheet extends StatelessWidget { ); } - Widget _buildHeader(BuildContext context) { - final textTheme = context.streamTextTheme; - final colorScheme = context.streamColorScheme; - - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: colorScheme.borderDefault, - ), - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Message Info', - style: textTheme.headingMd, - ), - IconButton( - iconSize: 32, - icon: Icon(context.streamIcons.xmark), - onPressed: Navigator.of(context).maybePop, - color: colorScheme.textPrimary, - padding: const EdgeInsets.all(4), - style: ButtonStyle( - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - minimumSize: WidgetStateProperty.all(const Size.square(32)), - ), - ), - ], - ), - ); - } - Widget _buildSection( BuildContext context, { required String title, @@ -178,48 +128,22 @@ class MessageInfoSheet extends StatelessWidget { }) { final colorScheme = context.streamColorScheme; final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; return Column( - spacing: 12, + spacing: spacing.xxs, crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Section title - Text( - title, - style: textTheme.captionDefault.copyWith( - color: colorScheme.textSecondary, - ), - ), - - // List of items - ClipRRect( - borderRadius: BorderRadius.circular(12), - child: DecoratedBox( - decoration: BoxDecoration( - color: colorScheme.backgroundElevation1, - ), - child: MediaQuery.removePadding( - context: context, - // Workaround for the bottom padding issue. - // Link: https://github.com/flutter/flutter/issues/156149 - removeTop: true, - removeBottom: true, - child: ListView.separated( - shrinkWrap: true, - itemCount: reads.length, - physics: const NeverScrollableScrollPhysics(), - separatorBuilder: (_, __) => Divider( - height: 1, - color: colorScheme.borderDefault, - ), - itemBuilder: (_, index) => itemBuilder( - context, - reads[index], - ), - ), + Padding( + padding: .only(left: spacing.sm, right: spacing.sm, bottom: spacing.xs), + child: Text( + title, + style: textTheme.captionDefault.copyWith( + color: colorScheme.textSecondary, ), ), ), + for (final read in reads) itemBuilder(context, read), ], ); } @@ -237,41 +161,24 @@ class _UserReadTile extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - child: Row( - children: [ - // User avatar - StreamUserAvatar( - size: .lg, - user: read.user, - ), + final spacing = context.streamSpacing; - const SizedBox(width: 12), - - // User name - Expanded( - child: Text( - read.user.name, - style: context.streamTextTheme.bodyEmphasis, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - - // Status icon - Icon( - context.streamIcons.checks, - size: 18, - color: switch (isDelivered) { - true => context.streamColorScheme.textSecondary, - false => context.streamColorScheme.accentPrimary, - }, - ), - ], + return StreamListTileTheme( + data: StreamListTileThemeData( + minTileHeight: 44, // Matches the design's tap target size + contentPadding: .symmetric(horizontal: spacing.sm), + ), + child: StreamListTile( + leading: StreamUserAvatar(user: read.user), + title: Text(read.user.name, maxLines: 1, overflow: .ellipsis), + trailing: Icon( + context.streamIcons.checks, + size: 18, + color: switch (isDelivered) { + true => context.streamColorScheme.textSecondary, + false => context.streamColorScheme.accentPrimary, + }, + ), ), ); }