Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ec62381
fix(ui): keep the composer's side insets while the attachment picker …
xsahil03x Sep 28, 2026
817042b
fix(ui): clear the side safe-area inset with the scroll-to-bottom button
xsahil03x Sep 28, 2026
96e480b
fix(ui): center the floating date and unread pills between the side i…
xsahil03x Sep 28, 2026
f7ff913
fix(samples): keep sample screens and sheets clear of the side safe-a…
xsahil03x Sep 28, 2026
c3ef871
fix(ui): keep the message list and its states clear of the side safe-…
xsahil03x Sep 28, 2026
f415753
fix(core): keep the paged scroll view states clear of the safe area
xsahil03x Sep 28, 2026
e680790
chore: Update Goldens
xsahil03x Sep 28, 2026
3fe49c9
test(docs): stub isMarkedAsUnread in the docs channel-state mock
xsahil03x Sep 28, 2026
d539a72
chore: Update Goldens
xsahil03x Sep 28, 2026
f1feee1
fix(samples): keep the file headers and media grid clear of the side …
xsahil03x Sep 29, 2026
47e41ed
docs(ui): note the composer's full-width top border in the changelog
xsahil03x Sep 29, 2026
3c58f02
Merge remote-tracking branch 'origin/master' into HEAD
xsahil03x Oct 1, 2026
88a5b33
Merge branch 'master' into sahil/flu-820-respect-asymmetric-safe-area…
xsahil03x Oct 1, 2026
63ea0a3
Merge master into the FLU-820 branch
xsahil03x Oct 2, 2026
921454c
Merge master into the FLU-820 branch
xsahil03x Oct 12, 2026
954d4a0
fix(ui, core): inset only the default paged-list states, not custom b…
xsahil03x Oct 15, 2026
8b93903
docs(ui): describe the safe-area changes in public terms
xsahil03x Oct 15, 2026
c5f42ff
test(ui): assert both side insets and a single composer divider
xsahil03x Oct 15, 2026
ebce125
Merge master into the FLU-820 branch
xsahil03x Oct 15, 2026
c577e5c
Merge master into the FLU-820 branch
xsahil03x Oct 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/docs_screenshots/test/theming/goldens/macos/theming_red.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions packages/stream_chat_flutter/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -869,7 +869,8 @@ class DefaultStreamMessageComposerState extends State<DefaultStreamMessageCompos

final spacing = context.streamSpacing;
final platform = Theme.of(context).platform;
final hasBottomInset = MediaQuery.paddingOf(context).bottom > 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.
Expand All @@ -885,6 +886,8 @@ class DefaultStreamMessageComposerState extends State<DefaultStreamMessageCompos
_ => 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),
),
Expand All @@ -898,6 +901,7 @@ class DefaultStreamMessageComposerState extends State<DefaultStreamMessageCompos
.regular => DecoratedBox(
decoration: BoxDecoration(
color: colorScheme.backgroundElevation1,
border: Border(top: BorderSide(color: colorScheme.borderDefault)),
Comment thread
xsahil03x marked this conversation as resolved.
),
child: content,
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -603,37 +604,45 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
);
}

// 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),
retryButtonText: Text(translations.tryAgainLabel),
onRetryPressed: () => streamChannel?.reloadChannel(),
),
);

return _safeArea(child: errorWidget);
}

Widget defaultMessageListBuilder(BuildContext context, List<Message> list) {
Expand Down Expand Up @@ -956,6 +965,8 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
],
);

final content = _safeArea(top: false, bottom: false, child: child);

final backgroundColor = StreamMessageListViewTheme.of(context).backgroundColor;
final backgroundImage = StreamMessageListViewTheme.of(context).backgroundImage;

Expand All @@ -965,11 +976,11 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
color: backgroundColor,
image: backgroundImage,
),
child: child,
child: content,
);
}

return child;
return content;
}

// Default spacing widget between adjacent messages — mirrors the old
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -56,8 +59,8 @@ class StreamScrollViewEmptyWidget extends StatelessWidget {
child: emptyTitle,
);

return Padding(
padding: .symmetric(
return StreamSafeArea(
minimum: .symmetric(
horizontal: spacing.md,
vertical: spacing.xxxl,
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -110,8 +113,8 @@ class StreamScrollViewErrorWidget extends StatelessWidget {
child: effectiveRetryButtonText,
);

return Padding(
padding: .symmetric(
return StreamSafeArea(
minimum: .symmetric(
horizontal: spacing.md,
vertical: spacing.xxxl,
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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,
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,21 @@ void main() {
expect(_backgroundFillFinder(tester), findsOneWidget);
});

testWidgets('StreamMessageComposer runs the regular top divider under the side insets', (tester) async {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this passes for the wrong reason: the fill DecoratedBox always spanned the full width, so the width assertion was already true before this PR. It only fails on revert because _backgroundFillFinder now matches the exact BoxDecoration including the border.

It wouldn't catch the border being re-added inside StreamChatMessageInput (double border). Could it assert that no bordered box sits under StreamChatMessageInput, and that the bordered box starts at x=0?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, confirmed: the old assertion passed with the change reverted, and a second border inside StreamChatMessageInput passed every composer test. In c5f42ff the test finds every top-only border inside the composer: one test checks it runs from x=0 to the full width under a left and right inset, and another checks there is exactly one. Both regressions now fail.

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.
Expand Down Expand Up @@ -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.
Expand All @@ -167,14 +214,31 @@ 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),
matching: find.byWidgetPredicate((widget) => widget is DecoratedBox && widget.decoration == fill),
);
}

// 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) {
Expand All @@ -196,6 +260,8 @@ Future<void> _pumpComposer(
StreamSurfaceStyle? composerSurfaceStyle,
bool? enableSafeArea,
double bottomPadding = 0,
double leftPadding = 0,
double rightPadding = 0,
TargetPlatform? platform,
}) async {
final originalRecordPlatform = RecordPlatform.instance;
Expand Down Expand Up @@ -241,7 +307,9 @@ Future<void> _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),
Expand Down
Loading
Loading