Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions packages/stream_chat_flutter/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +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. `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

Expand All @@ -34,6 +35,9 @@
- Fixed dialogs and bottom sheets opened below `StreamChat` keeping the old colors when the app switched between light and dark mode.
- Fixed `StreamPollOptionVotesSheet` listing votes for other options and answers.
- Fixed `StreamPollCommentsSheet` listing votes for poll options as comments.
- 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.

## 10.5.0

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,9 @@ class _TopBorderShape extends ShapeBorder {
}

/// A helper widget used to show the options of a [StreamAutocomplete].
///
/// 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<T extends Object> extends StatelessWidget {
/// Creates a [StreamAutocompleteOptions] widget.
const StreamAutocompleteOptions({
Expand Down Expand Up @@ -669,31 +672,33 @@ class StreamAutocompleteOptions<T extends Object> 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),

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.

StreamAutocompleteOptions is exported, and it now always adds side padding, which changes any custom overlay built on it. This isn't in the changelog; could it get its own 🔄 Changed line?

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.

Added in a552d1d: the 🔄 Changed line now names StreamAutocompleteOptions and how other options views clear the insets. a5f716e checks its content stays clear of them.

);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ class StreamFilePicker extends StatelessWidget {
}

final drawer = OptionDrawer(
margin: .symmetric(horizontal: spacing.md),
child: EndOfFrameCallbackWidget(
child: Center(
child: Column(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ class StreamImagePicker extends StatelessWidget {
}

final drawer = OptionDrawer(
margin: .symmetric(horizontal: spacing.md),
child: EndOfFrameCallbackWidget(
child: Center(
child: Column(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class StreamPollCreator extends StatelessWidget {
}

final drawer = OptionDrawer(
margin: .symmetric(horizontal: spacing.md),
child: EndOfFrameCallbackWidget(
child: Center(
child: Column(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ class StreamVideoPicker extends StatelessWidget {
}

final drawer = OptionDrawer(
margin: .symmetric(horizontal: spacing.md),
child: EndOfFrameCallbackWidget(
child: Center(
child: Column(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

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 158 is too short for every tab except the gallery. The file, camera, video and poll tabs wrap their content in OptionDrawer, which defaults to vertical: spacing.xxxl (40) margins, which leaves 78pt. Their column is a 32 icon + 8 gap + ~20 text + 16 gap + 40 button ≈ 116pt, and it doesn't scroll.

So on a phone in landscape, switching to Files/Camera/Video/Poll should still hit a ~38px RenderFlex overflow, which is the overflow this PR is fixing. Only the gallery tab (margin: .zero) fits. I worked this out from the code and haven't run it, so it's worth checking on the simulator with each tab. Fix: either reduce the OptionDrawer vertical margin at compact height, or make the drawer content scrollable.

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.

Confirmed: on the iPhone Duo in landscape the file, camera, video and poll tabs overflowed by 45px at 158. Fixed in d9e226a by giving those four tabs only the side margin. Their prompt is wrapped in a Center, so the 40pt top and bottom margin never moved it; it only took away the room it needed. The regular 260 layout is unchanged, and OptionDrawer's default margin is untouched, so custom drawers don't move. The new stream_tabbed_attachment_picker_height_test.dart covers every tab at 479 and 480 tall and fails on the old margin.


// 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
Expand Down Expand Up @@ -143,6 +150,11 @@ class _StreamTabbedAttachmentPickerState extends State<StreamTabbedAttachmentPic
return ValueListenableBuilder<AttachmentPickerValue>(
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,
Expand All @@ -158,7 +170,7 @@ class _StreamTabbedAttachmentPickerState extends State<StreamTabbedAttachmentPic
Semantics(
role: SemanticsRole.tabPanel,
child: SizedBox(
height: _kTabbedAttachmentPickerHeight,
height: pickerHeight,
child: _currentOption.optionViewBuilder(
context,
widget.controller,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -861,38 +861,13 @@ class DefaultStreamMessageComposerState extends State<DefaultStreamMessageCompos
if (enabled) return messageInput;

// Otherwise, show the no permission message.
return _buildNoPermissionMessage(context);
return _insetContent(context, _buildNoPermissionMessage(context));
},
),
final messageInput => 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);
Expand Down Expand Up @@ -926,11 +901,41 @@ class DefaultStreamMessageComposerState extends State<DefaultStreamMessageCompos
);
}

Widget _insetContent(BuildContext context, Widget child) {
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,
};

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.

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.

Moving the inset into fieldViewBuilder means a customer's customAutocompleteTriggers overlay now runs under the notch/rail instead of being clipped to the inset width. It's in the changelog, which is good, but under CLAUDE.md's rules it's a behavioural break (no compile error, rendering changes), so I think it needs a ! in the title and a migration hint, e.g. "wrap custom options in SafeArea(top: false, bottom: false)".

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.

Intentional. The suggestions area now matches the composer and app bar: the background runs under the side insets, and our own StreamAutocompleteOptions keeps its rows clear of them. Customer content is laid out as built rather than wrapped, the same as the paged-list states in #3017. It's documented under 🔄 Changed with the one-line migration (a552d1d). I'm not marking it !: it's layout-only and affects only fully custom options views, like the composer border change. a5f716e adds tests for both the command and custom-trigger suggestions under the insets.

fieldViewBuilder: (context, controller, focusNode) {
final messageInput = _buildMessageInput(context, controller, focusNode);
return _insetContent(context, messageInput);
},
autocompleteTriggers: [
...widget.props.customAutocompleteTriggers,
StreamAutocompleteTrigger(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@ import '../../scrollable_positioned_list/scrollable_positioned_list.dart';
import '../../stream_chat_flutter.dart';
import '../misc/empty_widget.dart';
import '../misc/memoized_builder.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';

Expand Down Expand Up @@ -612,7 +612,7 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {

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
Expand All @@ -630,19 +630,12 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
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<Message> list) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -22,5 +25,9 @@ class StreamMessageListEmptyState extends StatelessWidget {
],
),
);

return CustomScrollView(
slivers: [SliverFillRemaining(hasScrollBody: false, child: content)],
);
}
}
Original file line number Diff line number Diff line change
@@ -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 {

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.

nit: this isn't exported (consistent with the empty/skeleton states), but the Stream prefix and "Creates a new instance" dartdoc make it look like public API. If customers are meant to reuse it in builders.error, export it and add a changelog Added line; otherwise, fine as is.

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.

Leaving it internal for now. It matches StreamMessageListEmptyState and StreamMessageListSkeletonLoading, which have the same prefix and doc style and aren't exported either. Exporting the three would be new public API, so I'd do that as its own feat when there's a use for them in builders.

/// 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)],
);
}
}
Loading
Loading