diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index d0dba9a563..71d586ddc8 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -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 @@ -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 diff --git a/packages/stream_chat_flutter/lib/src/autocomplete/stream_autocomplete.dart b/packages/stream_chat_flutter/lib/src/autocomplete/stream_autocomplete.dart index 9f4818dd21..c1042e039f 100644 --- a/packages/stream_chat_flutter/lib/src/autocomplete/stream_autocomplete.dart +++ b/packages/stream_chat_flutter/lib/src/autocomplete/stream_autocomplete.dart @@ -615,6 +615,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 extends StatelessWidget { /// Creates a [StreamAutocompleteOptions] widget. const StreamAutocompleteOptions({ @@ -669,31 +672,33 @@ class StreamAutocompleteOptions extends StatelessWidget { @override Widget build(BuildContext context) { - final height = MediaQuery.of(context).size.height; + final height = MediaQuery.sizeOf(context).height; + final content = Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (headerBuilder != null) headerBuilder!(context), + LimitedBox( + maxHeight: maxHeight ?? height * 0.5, + child: ListView.builder( + shrinkWrap: true, + padding: EdgeInsets.zero, + itemCount: options.length, + itemBuilder: (context, index) { + final option = options.elementAt(index); + return optionBuilder(context, option); + }, + ), + ), + ], + ); + return Card( margin: margin, elevation: elevation, color: color ?? context.streamColorScheme.backgroundElevation1, shape: shape, clipBehavior: clipBehavior, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (headerBuilder != null) headerBuilder!(context), - LimitedBox( - maxHeight: maxHeight ?? height * 0.5, - child: ListView.builder( - shrinkWrap: true, - padding: EdgeInsets.zero, - itemCount: options.length, - itemBuilder: (context, index) { - final option = options.elementAt(index); - return optionBuilder(context, option); - }, - ), - ), - ], - ), + child: StreamSafeArea(top: false, bottom: false, child: content), ); } } diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_file_picker.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_file_picker.dart index cc98355d3a..793b60a91f 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_file_picker.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_file_picker.dart @@ -79,6 +79,7 @@ class StreamFilePicker extends StatelessWidget { } final drawer = OptionDrawer( + margin: .symmetric(horizontal: spacing.md), child: EndOfFrameCallbackWidget( child: Center( child: Column( diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_image_picker.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_image_picker.dart index 5f375dee6b..48895369b2 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_image_picker.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_image_picker.dart @@ -55,6 +55,7 @@ class StreamImagePicker extends StatelessWidget { } final drawer = OptionDrawer( + margin: .symmetric(horizontal: spacing.md), child: EndOfFrameCallbackWidget( child: Center( child: Column( diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_poll_creator.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_poll_creator.dart index 954202a355..8f47b0de22 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_poll_creator.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_poll_creator.dart @@ -37,6 +37,7 @@ class StreamPollCreator extends StatelessWidget { } final drawer = OptionDrawer( + margin: .symmetric(horizontal: spacing.md), child: EndOfFrameCallbackWidget( child: Center( child: Column( diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_video_picker.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_video_picker.dart index 7b459855b6..b7e707b390 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_video_picker.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_video_picker.dart @@ -45,6 +45,7 @@ class StreamVideoPicker extends StatelessWidget { } final drawer = OptionDrawer( + margin: .symmetric(horizontal: spacing.md), child: EndOfFrameCallbackWidget( child: Center( child: Column( diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker.dart index 5119587542..c901125ad1 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker.dart @@ -77,6 +77,13 @@ class StreamSystemAttachmentPicker extends StatelessWidget { // The height of the attachment picker when using the tabbed interface. const _kTabbedAttachmentPickerHeight = 260.0; +// The height of the attachment picker when using the tabbed interface on +// compact-height screens. +const _kCompactTabbedAttachmentPickerHeight = 158.0; + +// Screens shorter than this have a compact height, such as a phone in landscape. +const _kCompactHeightBreakpoint = 480.0; + /// Inline widget for the tabbed attachment picker interface. /// /// Displays a tabbed interface with horizontal tabs for different attachment @@ -143,6 +150,11 @@ class _StreamTabbedAttachmentPickerState extends State( valueListenable: widget.controller, builder: (context, value, _) { + final pickerHeight = switch (MediaQuery.sizeOf(context)) { + Size(height: < _kCompactHeightBreakpoint) => _kCompactTabbedAttachmentPickerHeight, + _ => _kTabbedAttachmentPickerHeight, + }; + return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, @@ -158,7 +170,7 @@ class _StreamTabbedAttachmentPickerState extends State messageInput, }; - final spacing = context.streamSpacing; - final platform = Theme.of(context).platform; - final padding = MediaQuery.paddingOf(context); - final hasBottomInset = padding.bottom > 0; - - // Apple platforms rest on the bottom inset; elsewhere a margin clears it, - // and stands in when there is none. - final bottomSafeAreaMargin = switch (platform) { - .iOS || .macOS when hasBottomInset => spacing.none, - _ => spacing.md, - }; - - final content = Material( - type: .transparency, - child: switch (widget.props.enableSafeArea) { - false => Center(heightFactor: 1, child: messageInput), - _ => StreamSafeArea.driven( - top: false, - listenable: _pickerAnimation, - // Only the bottom inset gives way to the picker; the side insets stay. - to: .only(left: padding.left, right: padding.right), - margin: .only(bottom: bottomSafeAreaMargin), - child: Center(heightFactor: 1, child: messageInput), - ), - }, - ); + final content = Material(type: .transparency, child: messageInput); final colorScheme = context.streamColorScheme; final effectiveSurfaceStyle = _resolveSurfaceStyle(context); @@ -926,11 +901,41 @@ class DefaultStreamMessageComposerState extends State 0; + + // Apple platforms rest on the bottom inset; elsewhere a margin clears it, + // and stands in when there is none. + final bottomSafeAreaMargin = switch (platform) { + .iOS || .macOS when hasBottomInset => spacing.none, + _ => spacing.md, + }; + + return switch (widget.props.enableSafeArea) { + false => Center(heightFactor: 1, child: child), + _ => StreamSafeArea.driven( + top: false, + listenable: _pickerAnimation, + // Only the bottom inset gives way to the picker; the side insets stay. + to: .only(left: padding.left, right: padding.right), + margin: .only(bottom: bottomSafeAreaMargin), + child: Center(heightFactor: 1, child: child), + ), + }; + } + Widget _buildAutocompleteMessageInput(BuildContext context) { return StreamAutocomplete( focusNode: _effectiveFocusNode, messageComposerController: _effectiveController, - fieldViewBuilder: _buildMessageInput, + // Inset inside the autocomplete so its suggestions span the full width. + fieldViewBuilder: (context, controller, focusNode) { + final messageInput = _buildMessageInput(context, controller, focusNode); + return _insetContent(context, messageInput); + }, autocompleteTriggers: [ ...widget.props.customAutocompleteTriggers, StreamAutocompleteTrigger( 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 a6c24a796e..bdcad36949 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 @@ -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'; @@ -612,7 +612,7 @@ class _StreamMessageListViewState extends State { 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 @@ -630,19 +630,12 @@ class _StreamMessageListViewState extends State { Widget defaultErrorBuilder(BuildContext context, Object error) { if (widget.builders.error case final builder?) return _safeArea(child: builder(context, error)); - final translations = context.translations; - final text = resolveNetworkErrorText(context, error, fallbackTitle: translations.loadingMessagesError); - - final errorWidget = Center( - child: StreamScrollViewErrorWidget( - errorTitle: Text(text.title), - errorSubtitle: Text(text.description), - retryButtonText: Text(translations.tryAgainLabel), - onRetryPressed: () => streamChannel?.reloadChannel(), - ), + final errorState = StreamMessageListErrorState( + error: error, + onRetryPressed: () => streamChannel?.reloadChannel(), ); - return _safeArea(child: errorWidget); + return _safeArea(child: errorState); } Widget defaultMessageListBuilder(BuildContext context, List list) { diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_empty_state.dart b/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_empty_state.dart index 6c0f5ad3c0..2b965fe500 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_empty_state.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_empty_state.dart @@ -1,14 +1,17 @@ import 'package:flutter/material.dart'; + import '../../stream_chat_flutter.dart'; /// A widget that is used to display the empty state of the message list. +/// +/// Scrolls instead of overflowing when it has less room than it needs. class StreamMessageListEmptyState extends StatelessWidget { /// Creates a new instance of the [StreamMessageListEmptyState]. const StreamMessageListEmptyState({super.key}); @override Widget build(BuildContext context) { - return Center( + final content = Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, @@ -22,5 +25,9 @@ class StreamMessageListEmptyState extends StatelessWidget { ], ), ); + + return CustomScrollView( + slivers: [SliverFillRemaining(hasScrollBody: false, child: content)], + ); } } diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_error_state.dart b/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_error_state.dart new file mode 100644 index 0000000000..2cba79f701 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_error_state.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +import '../../stream_chat_flutter.dart'; +import '../utils/network_error_text.dart'; + +/// A widget that is used to display the error state of the message list. +/// +/// Scrolls instead of overflowing when it has less room than it needs. +class StreamMessageListErrorState extends StatelessWidget { + /// Creates a new instance of the [StreamMessageListErrorState]. + const StreamMessageListErrorState({ + super.key, + required this.error, + required this.onRetryPressed, + }); + + /// The error that stopped the messages from loading. + final Object error; + + /// Called when the retry button is pressed. + final VoidCallback onRetryPressed; + + @override + Widget build(BuildContext context) { + final translations = context.translations; + final text = resolveNetworkErrorText(context, error, fallbackTitle: translations.loadingMessagesError); + + final content = Center( + child: StreamScrollViewErrorWidget( + errorTitle: Text(text.title), + errorSubtitle: Text(text.description), + retryButtonText: Text(translations.tryAgainLabel), + onRetryPressed: onRetryPressed, + ), + ); + + return CustomScrollView( + slivers: [SliverFillRemaining(hasScrollBody: false, child: content)], + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_skeleton_loading.dart b/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_skeleton_loading.dart index 04937d1350..7f6a3082cc 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_skeleton_loading.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_skeleton_loading.dart @@ -6,6 +6,8 @@ import 'package:stream_core_flutter/chat.dart'; /// Displays a skeleton UI with shimmer animation that mimics a chat /// conversation with incoming (left-aligned) and outgoing (right-aligned) /// message bubbles using [StreamSkeletonLoading] and [StreamSkeletonBox]. +/// +/// Scrolls instead of overflowing when it has less room than it needs. class StreamMessageListSkeletonLoading extends StatelessWidget { /// Creates a new instance of [StreamMessageListSkeletonLoading]. const StreamMessageListSkeletonLoading({super.key}); @@ -15,26 +17,22 @@ class StreamMessageListSkeletonLoading extends StatelessWidget { final spacing = context.streamSpacing; return StreamSkeletonLoading( - child: LayoutBuilder( - builder: (context, constraints) { - return Padding( - padding: EdgeInsets.all(spacing.md), - child: Column( - children: [ - _IncomingBubble(), - SizedBox(height: spacing.lg), - _OutgoingBubble(), - SizedBox(height: spacing.lg), - _IncomingBubble(), - SizedBox(height: spacing.lg), - _OutgoingBubble(), - SizedBox(height: spacing.lg), - _IncomingBubble(), - SizedBox(height: spacing.md), - ], - ), - ); - }, + child: SingleChildScrollView( + padding: .all(spacing.md), + child: Column( + children: [ + _IncomingBubble(), + SizedBox(height: spacing.lg), + _OutgoingBubble(), + SizedBox(height: spacing.lg), + _IncomingBubble(), + SizedBox(height: spacing.lg), + _OutgoingBubble(), + SizedBox(height: spacing.lg), + _IncomingBubble(), + SizedBox(height: spacing.md), + ], + ), ), ); } diff --git a/packages/stream_chat_flutter/test/src/message_input/attachment_picker/stream_tabbed_attachment_picker_height_test.dart b/packages/stream_chat_flutter/test/src/message_input/attachment_picker/stream_tabbed_attachment_picker_height_test.dart new file mode 100644 index 0000000000..b3401a6896 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_input/attachment_picker/stream_tabbed_attachment_picker_height_test.dart @@ -0,0 +1,78 @@ +// How the tabbed attachment picker's tabs fit the shorter picker on a short +// screen, such as a phone in landscape. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +void main() { + // The tabs that show a prompt and a button until their picker opens. + const tabs = ['image-picker', 'video-picker', 'file-picker', 'poll-creator']; + + // Just below and at the height where the picker grows to its regular size. + for (final screenHeight in [479.0, 480.0]) { + for (final tab in tabs) { + testWidgets('StreamTabbedAttachmentPicker fits its $tab tab on a $screenHeight-tall screen', (tester) async { + final overflows = await _pumpPickerTab(tester, tab: tab, screenHeight: screenHeight); + + expect(overflows, isEmpty); + }); + } + } +} + +// Pumps the default tabbed picker on [tab] at the bottom of a screen +// [screenHeight] tall, and returns the layout overflows it reported. +// +// Each tab opens its platform picker once shown, which has no platform channel +// in a widget test; those errors are collected too but aren't overflows. +Future> _pumpPickerTab( + WidgetTester tester, { + required String tab, + required double screenHeight, +}) async { + tester.view + ..devicePixelRatio = 1 + ..physicalSize = Size(900, screenHeight); + addTearDown(tester.view.reset); + + final controller = StreamAttachmentPickerController(); + addTearDown(controller.dispose); + + final errors = []; + final onError = FlutterError.onError; + FlutterError.onError = errors.add; + try { + await tester.pumpWidget( + MaterialApp( + home: StreamChatTheme( + data: StreamChatThemeData(), + child: Scaffold( + body: Align( + alignment: Alignment.bottomCenter, + child: Builder( + builder: (context) { + final picker = tabbedAttachmentPickerBuilder(context: context, controller: controller); + final options = (picker as StreamTabbedAttachmentPicker).options; + return StreamTabbedAttachmentPicker( + controller: controller, + options: options, + initialOption: options.firstWhere((it) => it.key == tab), + ); + }, + ), + ), + ), + ), + ), + ); + await tester.pump(); + } finally { + FlutterError.onError = onError; + } + + return [ + for (final error in errors) + if (error.exceptionAsString().contains('overflowed')) error, + ]; +} diff --git a/packages/stream_chat_flutter/test/src/message_input/message_composer_insets_test.dart b/packages/stream_chat_flutter/test/src/message_input/message_composer_insets_test.dart new file mode 100644 index 0000000000..2628368ab3 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_input/message_composer_insets_test.dart @@ -0,0 +1,131 @@ +// How the composer's suggestions and no-permission message lay out under +// side safe-area insets. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:record/record.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../fakes.dart'; +import '../mocks.dart'; + +const _padding = EdgeInsets.only(left: 47, right: 84, bottom: 34); + +void main() { + testWidgets('StreamMessageComposer runs command suggestions under the side insets', (tester) async { + await _pumpComposer(tester, channel: _channelWithCommands()); + await tester.enterText(find.byType(EditableText), '/'); + await tester.pumpAndSettle(); + + final suggestions = tester.getRect(find.byType(Card)); + expect(suggestions.left, 0); + expect(suggestions.right, moreOrLessEquals(_screenWidth(tester))); + }); + + testWidgets('StreamMessageComposer keeps command suggestions clear of the side insets', (tester) async { + await _pumpComposer(tester, channel: _channelWithCommands()); + await tester.enterText(find.byType(EditableText), '/'); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(StreamMessageComposer)); + final header = tester.getRect(find.text(context.translations.instantCommandsLabel)); + expect(header.left, greaterThanOrEqualTo(_padding.left)); + expect(header.right, lessThanOrEqualTo(_screenWidth(tester) - _padding.right)); + }); + + testWidgets('StreamMessageComposer runs custom trigger suggestions under the side insets', (tester) async { + const suggestionsKey = Key('custom-suggestions'); + final trigger = StreamAutocompleteTrigger( + trigger: '#', + optionsViewBuilder: (_, _, _) => const SizedBox(key: suggestionsKey, width: double.infinity, height: 48), + ); + + await _pumpComposer(tester, channel: MockChannel(), customAutocompleteTriggers: [trigger]); + await tester.enterText(find.byType(EditableText), '#'); + await tester.pumpAndSettle(); + + final suggestions = tester.getRect(find.byKey(suggestionsKey)); + expect(suggestions.left, 0); + expect(suggestions.right, moreOrLessEquals(_screenWidth(tester))); + }); + + testWidgets('StreamMessageComposer keeps the no-permission message clear of the insets', (tester) async { + await _pumpComposer(tester, channel: MockChannel(ownCapabilities: const [])); + + final context = tester.element(find.byType(StreamMessageComposer)); + final message = tester.getRect(find.text(context.translations.sendMessagePermissionError)); + final screenHeight = tester.view.physicalSize.height / tester.view.devicePixelRatio; + expect(message.left, greaterThanOrEqualTo(_padding.left)); + expect(message.right, lessThanOrEqualTo(_screenWidth(tester) - _padding.right)); + expect(message.bottom, lessThanOrEqualTo(screenHeight - _padding.bottom)); + }); +} + +// A channel whose config offers a single command. +MockChannel _channelWithCommands() { + final channel = MockChannel(); + when(() => channel.config).thenReturn( + ChannelConfig( + commands: [Command(name: 'giphy', description: 'Post a random gif', args: '[text]')], + ), + ); + return channel; +} + +double _screenWidth(WidgetTester tester) => tester.view.physicalSize.width / tester.view.devicePixelRatio; + +// Pumps a [StreamMessageComposer] for [channel] at the bottom of a screen with +// [_padding] as its safe-area insets. +Future _pumpComposer( + WidgetTester tester, { + required MockChannel channel, + List customAutocompleteTriggers = const [], +}) async { + final originalRecordPlatform = RecordPlatform.instance; + RecordPlatform.instance = FakeRecordPlatform(); + addTearDown(() => RecordPlatform.instance = originalRecordPlatform); + + final client = MockClient(); + final clientState = MockClientState(); + final channelState = MockChannelState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(OwnUser(id: 'user-id'))); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(channel.getRemainingCooldown).thenReturn(0); + when(() => channel.lastMessageAt).thenReturn(null); + when(() => channel.extraData).thenReturn({'name': 'test'}); + when(() => channel.extraDataStream).thenAnswer((_) => Stream.value({'name': 'test'})); + when(() => channelState.members).thenReturn([]); + when(() => channelState.membersStream).thenAnswer((_) => Stream.value([])); + when(() => channelState.messages).thenReturn([]); + when(() => channelState.messagesStream).thenAnswer((_) => Stream.value([])); + when(() => channelState.draft).thenReturn(null); + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => MediaQuery( + data: MediaQuery.of(context).copyWith(padding: _padding), + child: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: Scaffold( + body: Align( + alignment: Alignment.bottomCenter, + child: StreamMessageComposer(customAutocompleteTriggers: customAutocompleteTriggers), + ), + ), + ), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); +} diff --git a/packages/stream_chat_flutter/test/src/message_list_view/message_list_short_states_test.dart b/packages/stream_chat_flutter/test/src/message_list_view/message_list_short_states_test.dart new file mode 100644 index 0000000000..bbc90b3148 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_list_view/message_list_short_states_test.dart @@ -0,0 +1,131 @@ +// How `StreamMessageListView`'s default loading, empty and error states fit a +// list with little room, such as in landscape with the attachment picker open. + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/src/message_list_view/stream_message_list_error_state.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../mocks.dart'; + +const _listHeight = 120.0; + +void main() { + testWidgets('StreamMessageListView fits its default empty state into a short list', (tester) async { + await _pumpShortMessageList(tester); + + expect(tester.takeException(), isNull); + }); + + testWidgets('StreamMessageListErrorState fits into a short list', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: _client(), + child: Align( + alignment: Alignment.topCenter, + child: SizedBox( + height: _listHeight, + child: StreamMessageListErrorState(error: const StreamChatError('error'), onRetryPressed: () {}), + ), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + + testWidgets('StreamMessageListView fits its default loading state into a short list', (tester) async { + await _pumpShortMessageList( + tester, + parentMessage: Message( + id: 'parent', + text: 'Parent', + user: User(id: 'otherid'), + ), + // A thread whose replies never arrive stays in its loading state. + stubChannel: (channel, state) { + when(() => state.threads).thenReturn(const {}); + when( + () => channel.getReplies( + any(), + options: any(named: 'options'), + preferOffline: any(named: 'preferOffline'), + ), + ).thenAnswer((_) => Completer().future); + }, + ); + + expect(find.byType(StreamSkeletonBox), findsWidgets); + expect(tester.takeException(), isNull); + }); +} + +// Pumps a [StreamMessageListView] with no messages into a box [_listHeight] +// tall. [stubChannel] adds stubs a test needs beyond the defaults. +Future _pumpShortMessageList( + WidgetTester tester, { + Message? parentMessage, + void Function(Channel channel, ChannelClientState state)? stubChannel, +}) async { + final client = _client(); + + final messagesController = StreamController>.broadcast(); + addTearDown(messagesController.close); + + final channel = MockChannel(); + final channelClientState = MockChannelState(); + when(() => channel.client).thenReturn(client); + when(() => channel.state).thenReturn(channelClientState); + + when(() => channelClientState.threadsStream).thenAnswer((_) => const Stream.empty()); + when(() => channelClientState.isUpToDate).thenReturn(true); + when(() => channelClientState.isUpToDateStream).thenAnswer((_) => Stream.value(true)); + when(() => channelClientState.unreadCount).thenReturn(0); + when(() => channelClientState.unreadCountStream).thenAnswer((_) => Stream.value(0)); + when(() => channelClientState.readStream).thenAnswer((_) => const Stream.empty()); + when(() => channelClientState.read).thenReturn([]); + when(() => channelClientState.membersStream).thenAnswer((_) => const Stream.empty()); + when(() => channelClientState.members).thenReturn([]); + when(() => channelClientState.currentUserRead).thenReturn(null); + when(() => channelClientState.currentUserReadStream).thenAnswer((_) => const Stream.empty()); + when(() => channelClientState.messagesStream).thenAnswer((_) => messagesController.stream); + when(() => channelClientState.messages).thenReturn([]); + stubChannel?.call(channel, channelClientState); + + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: Align( + alignment: Alignment.topCenter, + child: SizedBox( + height: _listHeight, + child: StreamMessageListView(parentMessage: parentMessage), + ), + ), + ), + ), + ), + ); + + await tester.pump(); + await tester.pump(); +} + +// A client signed in as a user. +StreamChatClient _client() { + final client = MockClient(); + final clientState = MockClientState(); + when(() => client.state).thenAnswer((_) => clientState); + final ownUser = OwnUser(id: 'ownid'); + when(() => clientState.currentUser).thenReturn(ownUser); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(ownUser)); + return client; +}