diff --git a/.github/actions/pana/action.yml b/.github/actions/pana/action.yml index 5f31fa30a6..07b449548b 100644 --- a/.github/actions/pana/action.yml +++ b/.github/actions/pana/action.yml @@ -47,7 +47,7 @@ runs: working-directory: ${{ inputs.working_directory }} shell: bash run: | - PANA=$(pana . --no-warning); PANA_SCORE=$(echo $PANA | sed -n "s/.*Points: \([0-9]*\)\/\([0-9]*\)./\1\/\2/p") + PANA=$(pana . --no-warning --project-root "${{ github.workspace }}"); PANA_SCORE=$(echo $PANA | sed -n "s/.*Points: \([0-9]*\)\/\([0-9]*\)./\1\/\2/p") echo "Score: $PANA_SCORE" IFS='/'; read -a SCORE_ARR <<< "$PANA_SCORE"; SCORE=SCORE_ARR[0]; if (( $SCORE < ${{inputs.min_score}} )); then echo "The minimum score of ${{inputs.min_score}} was not met!"; exit 1; fi \ No newline at end of file diff --git a/.github/workflows/stream_flutter_workflow.yml b/.github/workflows/stream_flutter_workflow.yml index 2e40d80fb8..e03f4d3ff5 100644 --- a/.github/workflows/stream_flutter_workflow.yml +++ b/.github/workflows/stream_flutter_workflow.yml @@ -56,6 +56,12 @@ jobs: timeout-minutes: 15 if: needs.gate.outputs.should_run == 'true' runs-on: ubuntu-latest + # Overrides the workflow-level block, so `contents: read` must be repeated. + # The complexity audit posts a sticky PR comment, which needs write access; + # without it the action still annotates but silently skips the comment. + permissions: + contents: read + pull-requests: write steps: - name: "Git Checkout" uses: actions/checkout@v7 @@ -74,6 +80,29 @@ jobs: if: github.base_ref == 'master' run: | melos run lint:pub + # Gates cognitive complexity on declarations the PR actually touches. + # + # `targets` must be listed explicitly: the action defaults to `lib`, which + # does not exist at the root of this monorepo, so the default silently + # matches nothing and the gate passes unconditionally. + # + # PR-only, because `diff-base` needs a base ref. On push to master + # `github.base_ref` is empty, and an empty `diff-base` makes the action + # scan whole files rather than the diff — which would fail on the + # pre-existing hotspots this gate is not meant to block. + - name: "Cognitive Complexity" + if: github.event_name == 'pull_request' + uses: kevmoo/cognitive_complexity.dart@main + with: + targets: >- + packages/stream_chat/lib + packages/stream_chat_flutter/lib + packages/stream_chat_flutter_core/lib + packages/stream_chat_persistence/lib + packages/stream_chat_localizations/lib + diff-base: origin/${{ github.base_ref }} + fail-threshold: 15 + fail-on-increase: true format: needs: gate 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 c8ecf79d75..4100a70d43 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 @@ -8,6 +8,7 @@ import 'package:rxdart/rxdart.dart'; import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:stream_chat_flutter/src/message_list_view/floating_date_divider.dart'; import 'package:stream_chat_flutter/src/message_list_view/loading_indicator.dart'; +import 'package:stream_chat_flutter/src/message_list_view/message_list_view_layout.dart'; import 'package:stream_chat_flutter/src/message_list_view/mlv_utils.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'; @@ -455,29 +456,8 @@ class _StreamMessageListViewState extends State { double alignment = 0.5, // center the message in the viewport by default bool highlight = true, }) async { - // In a thread the parent message lives outside the `messages` list and - // is rendered as the very last item, so search for it explicitly when a - // thread reply quotes it. - final isThreadParent = _isThreadConversation && messageId == widget.parentMessage?.id; - var index = isThreadParent ? messages.length + 2 : messages.indexWhere((m) => m.id == messageId); - - if (index < 0) { - // No around-reply pagination in thread mode yet — bail rather than - // clobber the parent channel's loaded window. - if (_isThreadConversation) return; - - // Target isn't in the loaded channel window. Paginate around it, wait - // one frame for the BetterStreamBuilder rebuild to flush `messages`, - // then re-scan against the fresh list — `messages` is reassigned in - // `_buildListView` on each emission, so an index captured before the - // await would be stale. - await streamChannel!.loadChannelAtMessage(messageId); - if (!mounted) return; - await WidgetsBinding.instance.endOfFrame; - if (!mounted) return; - index = messages.indexWhere((m) => m.id == messageId); - if (index < 0) return; - } + final itemIndex = await _resolveScrollTargetIndex(messageId); + if (itemIndex == null) return; // Bail when the SPL isn't attached — `scrollTo` would throw, and // highlighting an off-screen message is meaningless. @@ -489,13 +469,50 @@ class _StreamMessageListViewState extends State { // still animating (or before the target item is even mounted) and the // user only sees the tail end of the fade. await controller.scrollTo( - index: index + 2, // +2 to account for loader and footer + index: itemIndex, alignment: alignment, ); if (highlight && mounted) _highlightMessage(messageId); } + // Resolves the scroll-view item index showing [messageId], paginating the + // channel around it when it isn't in the loaded window. + // + // Returns null when the target can't be reached. + Future _resolveScrollTargetIndex(String messageId) async { + int itemIndexOf(int messageIndex) => + MessageListLayout(messageCount: messages.length).itemIndexOfMessage(messageIndex); + + // In a thread the parent message lives outside the `messages` list and is + // rendered in its own trailing slot, so target that slot directly when a + // thread reply quotes it. + if (_isThreadConversation && messageId == widget.parentMessage?.id) { + return MessageListLayout(messageCount: messages.length).parentMessageIndex; + } + + final index = messages.indexWhere((m) => m.id == messageId); + if (index >= 0) return itemIndexOf(index); + + // No around-reply pagination in thread mode yet — bail rather than + // clobber the parent channel's loaded window. + if (_isThreadConversation) return null; + + // Target isn't in the loaded channel window. Paginate around it, wait + // one frame for the BetterStreamBuilder rebuild to flush `messages`, + // then re-scan against the fresh list — `messages` is reassigned in + // `_buildListView` on each emission, so an index captured before the + // await would be stale. + await streamChannel!.loadChannelAtMessage(messageId); + if (!mounted) return null; + await WidgetsBinding.instance.endOfFrame; + if (!mounted) return null; + + final refreshedIndex = messages.indexWhere((m) => m.id == messageId); + if (refreshedIndex < 0) return null; + return itemIndexOf(refreshedIndex); + } + // Wraps [child] in the highlight pulse if [message] is the currently // highlighted message. Holds at full color for [_kHighlightHoldDuration], // then fades to transparent over [_kHighlightFadeDuration]. @@ -591,205 +608,19 @@ class _StreamMessageListViewState extends State { Widget _buildListView(List data) { messages = data; - final itemCount = - messages.length + // total messages - 2 + // top + bottom loading indicator - 2 + // header + footer - 1; // parent message + final layout = MessageListLayout(messageCount: messages.length); final child = Stack( alignment: Alignment.center, children: [ StreamConnectionStatusBuilder( - statusBuilder: (context, status) { - var statusString = ''; - var showStatus = true; - switch (status) { - case ConnectionStatus.connected: - statusString = context.translations.connectedLabel; - showStatus = false; - break; - case ConnectionStatus.connecting: - statusString = context.translations.reconnectingLabel; - break; - case ConnectionStatus.disconnected: - statusString = context.translations.disconnectedLabel; - break; - } - - return StreamInfoTile( - showMessage: widget.config.showConnectionStateTile && showStatus, - tileAnchor: Alignment.topCenter, - childAnchor: Alignment.topCenter, - message: statusString, - child: LazyLoadScrollView( - onStartOfPage: () async { - if (_upToDate) return; - return _paginateData(.bottom); - }, - onEndOfPage: () async { - return _paginateData(.top); - }, - child: ScrollablePositionedList.separated( - key: Key('mlv-${streamChannel?.channel.cid}-${widget.parentMessage?.id}'), - padding: .symmetric(vertical: context.streamSpacing.sm), - keyboardDismissBehavior: widget.config.keyboardDismissBehavior, - itemPositionsListener: _itemPositionListener, - initialScrollIndex: initialIndex, - initialAlignment: initialAlignment, - physics: widget.config.scrollPhysics, - itemScrollController: _scrollController, - reverse: widget.config.reverse, - shrinkWrap: widget.config.shrinkWrap, - itemCount: itemCount, - itemKeyBuilder: (index) { - // Layout (see comment block below): indices 0/1 and the - // top 3 indices are fixed slots (footer, loaders, - // header, parent message). Anything in between is a - // message at `messages[index - 2]`. - if (index < 2) return null; - if (index >= itemCount - 3) return null; - final messageIndex = index - 2; - if (messageIndex >= messages.length) return null; - return messages[messageIndex].id; - }, - - // Item Count -> 8 (1 parent, 2 header+footer, 2 top+bottom, 3 messages) - // eg: |Type| rev(|Index(item)|) rev(|Index(separator)|) |Index(item)| |Index(separator)| - // ParentMessage -> 7 (count-1) - // Separator(ThreadSeparator) -> 6 (count-2) - // Header -> 6 (count-2) - // Separator(Header -> 8??T -> 0||52) -> 5 (count-3) - // TopLoader -> 5 (count-3) - // Separator(0) -> 4 (count-4) - // Message -> 4 (count-4) - // Separator(2||8) -> 3 (count-5) - // Message -> 3 (count-5) - // Separator(2||8) -> 2 (count-6) - // Message -> 2 (count-6) - // Separator(0) -> 1 (count-7) - // BottomLoader -> 1 (count-7) - // Separator(Footer -> 8??30) -> 0 (count-8) - // Footer -> 0 (count-8) - separatorBuilder: (context, i) { - if (i == itemCount - 2) { - if (widget.parentMessage == null) { - return const Empty(); - } - - if (widget.builders.threadSeparator != null) { - return widget.builders.threadSeparator!(context, widget.parentMessage!); - } - - return ThreadSeparator(parentMessage: widget.parentMessage!); - } - if (i == itemCount - 3) { - if (widget.config.reverse ? widget.builders.header == null : widget.builders.footer == null) { - if (messages.isNotEmpty) { - final message = messages.last; - return _maybeBuildWithUnreadMessagesSeparator( - message: message, - separator: _buildDateDivider(message), - ); - } - - return const Empty(); - } - return const SizedBox(height: 8); - } - if (i == 0) { - if (widget.config.reverse ? widget.builders.footer == null : widget.builders.header == null) { - return const Empty(); - } - return const SizedBox(height: 8); - } - - if (i == 1 || i == itemCount - 4) return const Empty(); - - late final Message message, nextMessage; - if (widget.config.reverse) { - message = messages[i - 1]; - nextMessage = messages[i - 2]; - } else { - message = messages[i - 2]; - nextMessage = messages[i - 1]; - } - - Widget separator; - - final spacingRules = _resolveSpacingRules( - message: message, - nextMessage: nextMessage, - ); - - if (spacingRules == null) { - separator = _buildDateDivider(nextMessage); - } else { - separator = - widget.builders.spacing?.call(context, spacingRules) ?? - _defaultSpacingWidget(context, spacingRules); - } - - return _maybeBuildWithUnreadMessagesSeparator( - message: nextMessage, - separator: separator, - ); - }, - itemBuilder: (context, i) { - if (i == itemCount - 1) { - if (widget.parentMessage == null) { - return const Empty(); - } - return buildParentMessage(widget.parentMessage!); - } - - if (i == itemCount - 2) { - if (widget.config.reverse) { - return widget.builders.header?.call(context) ?? const Empty(); - } else { - return widget.builders.footer?.call(context) ?? const Empty(); - } - } - - if (i == itemCount - 3) { - return _buildPaginationLoadingIndicator( - context: context, - direction: QueryDirection.top, - ); - } - - if (i == 1) { - return _buildPaginationLoadingIndicator( - context: context, - direction: QueryDirection.bottom, - ); - } - - if (i == 0) { - if (widget.config.reverse) { - return widget.builders.footer?.call(context) ?? const Empty(); - } else { - return widget.builders.header?.call(context) ?? const Empty(); - } - } - - // Offset the index to account for two extra items - // (loader and footer) at the bottom of the ListView. - final messageIndex = i - 2; - final message = messages[messageIndex]; - - return buildMessage(message, messages, messageIndex); - }, - ), - ), - ); - }, + statusBuilder: (context, status) => _buildConnectionStatusTile(context, status, layout), ), if (widget.config.showFloatingDateDivider) Positioned( top: context.streamSpacing.sm, child: FloatingDateDivider( - itemCount: itemCount, + itemCount: layout.itemCount, reverse: widget.config.reverse, fadeNearInlineDivider: widget.config.fadeFloatingDateDividerNearInline, itemPositionListener: _itemPositionListener.itemPositions, @@ -840,6 +671,160 @@ class _StreamMessageListViewState extends State { return child; } + Widget _buildConnectionStatusTile( + BuildContext context, + ConnectionStatus status, + MessageListLayout layout, + ) { + final (message: statusMessage, showStatus: showStatus) = switch (status) { + ConnectionStatus.connected => (message: context.translations.connectedLabel, showStatus: false), + ConnectionStatus.connecting => (message: context.translations.reconnectingLabel, showStatus: true), + ConnectionStatus.disconnected => (message: context.translations.disconnectedLabel, showStatus: true), + }; + + return StreamInfoTile( + showMessage: widget.config.showConnectionStateTile && showStatus, + tileAnchor: Alignment.topCenter, + childAnchor: Alignment.topCenter, + message: statusMessage, + child: LazyLoadScrollView( + onStartOfPage: () async { + if (_upToDate) return; + return _paginateData(.bottom); + }, + onEndOfPage: () async { + return _paginateData(.top); + }, + child: ScrollablePositionedList.separated( + key: Key('mlv-${streamChannel?.channel.cid}-${widget.parentMessage?.id}'), + padding: .symmetric(vertical: context.streamSpacing.sm), + keyboardDismissBehavior: widget.config.keyboardDismissBehavior, + itemPositionsListener: _itemPositionListener, + initialScrollIndex: initialIndex, + initialAlignment: initialAlignment, + physics: widget.config.scrollPhysics, + itemScrollController: _scrollController, + reverse: widget.config.reverse, + shrinkWrap: widget.config.shrinkWrap, + itemCount: layout.itemCount, + itemKeyBuilder: (index) => _messageIdAt(layout, index), + separatorBuilder: (context, index) => _buildSeparator(context, layout, index), + itemBuilder: (context, index) => _buildItem(context, layout, index), + ), + ), + ); + } + + // Anchor key for the item at [index]; null for every non-message slot so the + // scroll view opts out of anchor preservation there. + String? _messageIdAt(MessageListLayout layout, int index) { + if (layout.itemSlotAt(index) != MessageListItemSlot.message) return null; + + final messageIndex = layout.messageIndexAt(index); + if (messageIndex < 0 || messageIndex >= messages.length) return null; + return messages[messageIndex].id; + } + + Widget _buildItem(BuildContext context, MessageListLayout layout, int index) { + // The header and footer swap ends when the list is reversed. + final (startEdge: startEdgeBuilder, endEdge: endEdgeBuilder) = switch (widget.config.reverse) { + true => (startEdge: widget.builders.footer, endEdge: widget.builders.header), + false => (startEdge: widget.builders.header, endEdge: widget.builders.footer), + }; + + switch (layout.itemSlotAt(index)) { + case MessageListItemSlot.parentMessage: + final parentMessage = widget.parentMessage; + if (parentMessage == null) return const Empty(); + return buildParentMessage(parentMessage); + + case MessageListItemSlot.endEdge: + return endEdgeBuilder?.call(context) ?? const Empty(); + + case MessageListItemSlot.topLoader: + return _buildPaginationLoadingIndicator(context: context, direction: QueryDirection.top); + + case MessageListItemSlot.bottomLoader: + return _buildPaginationLoadingIndicator(context: context, direction: QueryDirection.bottom); + + case MessageListItemSlot.startEdge: + return startEdgeBuilder?.call(context) ?? const Empty(); + + case MessageListItemSlot.message: + final messageIndex = layout.messageIndexAt(index); + return buildMessage(messages[messageIndex], messages, messageIndex); + } + } + + Widget _buildSeparator(BuildContext context, MessageListLayout layout, int index) { + switch (layout.separatorSlotAt(index)) { + case MessageListSeparatorSlot.threadSeparator: + return _buildThreadSeparator(context); + + case MessageListSeparatorSlot.endEdgeGap: + return _buildEndEdgeGap(context); + + case MessageListSeparatorSlot.startEdgeGap: + final builder = widget.config.reverse ? widget.builders.footer : widget.builders.header; + if (builder == null) return const Empty(); + return SizedBox(height: context.streamSpacing.xs); + + case MessageListSeparatorSlot.loaderGap: + return const Empty(); + + case MessageListSeparatorSlot.betweenMessages: + return _buildMessageSeparator(context, layout, index); + } + } + + Widget _buildThreadSeparator(BuildContext context) { + final parentMessage = widget.parentMessage; + if (parentMessage == null) return const Empty(); + + final builder = widget.builders.threadSeparator; + if (builder != null) return builder(context, parentMessage); + return ThreadSeparator(parentMessage: parentMessage); + } + + // The gap adjoining the trailing edge widget. When that edge has no builder + // the gap carries the date divider for the last message instead. + Widget _buildEndEdgeGap(BuildContext context) { + final builder = widget.config.reverse ? widget.builders.header : widget.builders.footer; + if (builder != null) return SizedBox(height: context.streamSpacing.xs); + if (messages.isEmpty) return const Empty(); + + final message = messages.last; + return _maybeBuildWithUnreadMessagesSeparator( + message: message, + separator: _buildDateDivider(message), + ); + } + + Widget _buildMessageSeparator(BuildContext context, MessageListLayout layout, int index) { + // Separator `index` sits between items `index` and `index + 1`, so it + // divides those two items' messages. Which of the pair is "next" depends on + // the list direction. + final first = messages[layout.messageIndexAt(index)]; + final second = messages[layout.messageIndexAt(index + 1)]; + + final (message, nextMessage) = switch (widget.config.reverse) { + true => (second, first), + false => (first, second), + }; + + final spacingRules = _resolveSpacingRules(message: message, nextMessage: nextMessage); + + final separator = switch (spacingRules) { + null => _buildDateDivider(nextMessage), + final rules => widget.builders.spacing?.call(context, rules) ?? _defaultSpacingWidget(context, rules), + }; + + return _maybeBuildWithUnreadMessagesSeparator( + message: nextMessage, + separator: separator, + ); + } + // Default spacing widget between adjacent messages — mirrors the old // _defaultSpacingWidgetBuilder static method. Widget _defaultSpacingWidget(BuildContext context, List spacingTypes) { @@ -921,9 +906,10 @@ class _StreamMessageListViewState extends State { final firstUnreadMessageIndex = messages.lastIndexWhere((it) => it.id == firstUnreadId); if (firstUnreadMessageIndex == -1) return; + final layout = MessageListLayout(messageCount: messages.length); if (_scrollController case final controller? when controller.isAttached) { return controller.scrollTo( - index: max(firstUnreadMessageIndex + 2, 0), + index: max(layout.itemIndexOfMessage(firstUnreadMessageIndex), 0), alignment: 0.5, // center the message in the viewport ); } @@ -1197,9 +1183,9 @@ class _StreamMessageListViewState extends State { final itemPositions = _itemPositionListener.itemPositions.value; if (itemPositions.isEmpty) return; - // Index of the last item in the list view is 2 as 1 is the progress - // indicator and 0 is the footer. - const lastItemIndex = 2; + // The list is reversed, so the newest message — the first in `messages` — + // is the one that sits at the visual bottom. + const lastItemIndex = MessageListLayout.firstMessageItemIndex; final lastItemPosition = itemPositions.firstWhereOrNull( (position) => position.index == lastItemIndex, ); diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view_layout.dart b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view_layout.dart new file mode 100644 index 0000000000..514d2b2182 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view_layout.dart @@ -0,0 +1,114 @@ +import 'package:flutter/foundation.dart'; + +/// The role a single item index plays in [MessageListLayout]. +enum MessageListItemSlot { + /// The widget at the list's leading edge. + /// + /// This is the footer for a reversed list and the header otherwise. + startEdge, + + /// The pagination loading indicator nearest the list's leading edge. + bottomLoader, + + /// A message from the loaded messages. + /// + /// Use [MessageListLayout.messageIndexAt] to resolve the message position. + message, + + /// The pagination loading indicator nearest the list's trailing edge. + topLoader, + + /// The widget at the list's trailing edge. + /// + /// This is the header for a reversed list and the footer otherwise. + endEdge, + + /// The parent message of the thread being displayed, if any. + parentMessage, +} + +/// The role a single separator index plays in [MessageListLayout]. +enum MessageListSeparatorSlot { + /// The gap adjoining [MessageListItemSlot.startEdge]. + startEdgeGap, + + /// The gap adjoining one of the pagination loading indicators. + loaderGap, + + /// The separator between two adjacent messages. + betweenMessages, + + /// The gap adjoining [MessageListItemSlot.endEdge]. + endEdgeGap, + + /// The separator introducing [MessageListItemSlot.parentMessage]. + threadSeparator, +} + +/// Maps the item and separator indices of the message list's scroll view onto +/// the role each index plays. +/// +/// The scroll view interleaves the loaded messages with five fixed slots — a +/// header, a footer, a pagination loading indicator at either end and the +/// thread's parent message. This type is the single place that knows how those +/// slots are laid out, so index arithmetic is not repeated across the item, +/// separator and item-key builders. +@immutable +final class MessageListLayout { + /// Creates a layout for a list holding [messageCount] messages. + const MessageListLayout({required this.messageCount}); + + /// The number of loaded messages in the list. + final int messageCount; + + /// The number of fixed slots surrounding the messages: a header, a footer, a + /// pagination loading indicator at either end and the thread parent message. + static const fixedSlotCount = 5; + + /// The item index of the first message, past the leading edge widget and the + /// pagination loading indicator that precede it. + static const firstMessageItemIndex = 2; + + /// The total number of items in the scroll view. + int get itemCount => messageCount + fixedSlotCount; + + /// The item index of the thread parent message, which occupies the final slot + /// and lives outside the loaded messages. + int get parentMessageIndex => itemCount - 1; + + /// The role of the item at [index]. + MessageListItemSlot itemSlotAt(int index) => switch (index) { + _ when index == itemCount - 1 => MessageListItemSlot.parentMessage, + _ when index == itemCount - 2 => MessageListItemSlot.endEdge, + _ when index == itemCount - 3 => MessageListItemSlot.topLoader, + 1 => MessageListItemSlot.bottomLoader, + 0 => MessageListItemSlot.startEdge, + _ => MessageListItemSlot.message, + }; + + /// The role of the separator at [index]. + MessageListSeparatorSlot separatorSlotAt(int index) => switch (index) { + _ when index == itemCount - 2 => MessageListSeparatorSlot.threadSeparator, + _ when index == itemCount - 3 => MessageListSeparatorSlot.endEdgeGap, + 0 => MessageListSeparatorSlot.startEdgeGap, + 1 => MessageListSeparatorSlot.loaderGap, + _ when index == itemCount - 4 => MessageListSeparatorSlot.loaderGap, + _ => MessageListSeparatorSlot.betweenMessages, + }; + + /// The position in the loaded messages of the message shown at item [index]. + /// + /// Only meaningful when [itemSlotAt] returns [MessageListItemSlot.message]. + int messageIndexAt(int index) => index - firstMessageItemIndex; + + /// The item index at which the message at [messageIndex] is shown. + /// + /// This is the inverse of [messageIndexAt]. + int itemIndexOfMessage(int messageIndex) => messageIndex + firstMessageItemIndex; + + @override + bool operator ==(Object other) => other is MessageListLayout && other.messageCount == messageCount; + + @override + int get hashCode => messageCount.hashCode; +} diff --git a/packages/stream_chat_flutter/test/src/message_list_view/message_list_view_layout_test.dart b/packages/stream_chat_flutter/test/src/message_list_view/message_list_view_layout_test.dart new file mode 100644 index 0000000000..461b57c3d4 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_list_view/message_list_view_layout_test.dart @@ -0,0 +1,205 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/src/message_list_view/message_list_view_layout.dart'; + +// Reference implementations of the index arithmetic as it was written inline in +// StreamMessageListView's item, separator and item-key builders. The layout +// type must agree with these for every index, so the extraction cannot silently +// change which widget lands in which slot. +MessageListItemSlot referenceItemSlot(int index, int itemCount) { + if (index == itemCount - 1) return MessageListItemSlot.parentMessage; + if (index == itemCount - 2) return MessageListItemSlot.endEdge; + if (index == itemCount - 3) return MessageListItemSlot.topLoader; + if (index == 1) return MessageListItemSlot.bottomLoader; + if (index == 0) return MessageListItemSlot.startEdge; + return MessageListItemSlot.message; +} + +MessageListSeparatorSlot referenceSeparatorSlot(int index, int itemCount) { + if (index == itemCount - 2) return MessageListSeparatorSlot.threadSeparator; + if (index == itemCount - 3) return MessageListSeparatorSlot.endEdgeGap; + if (index == 0) return MessageListSeparatorSlot.startEdgeGap; + if (index == 1 || index == itemCount - 4) return MessageListSeparatorSlot.loaderGap; + return MessageListSeparatorSlot.betweenMessages; +} + +// Mirrors the original `itemKeyBuilder` closure. +bool referenceHasMessageKey(int index, int itemCount, int messageCount) { + if (index < 2) return false; + if (index >= itemCount - 3) return false; + return index - 2 < messageCount; +} + +void main() { + group('itemCount', () { + test('reserves five fixed slots around the messages', () { + expect(const MessageListLayout(messageCount: 0).itemCount, 5); + expect(const MessageListLayout(messageCount: 1).itemCount, 6); + expect(const MessageListLayout(messageCount: 10).itemCount, 15); + }); + }); + + group('itemSlotAt', () { + test('lays out an empty list as fixed slots only', () { + const layout = MessageListLayout(messageCount: 0); + + expect( + [for (var i = 0; i < layout.itemCount; i++) layout.itemSlotAt(i)], + [ + MessageListItemSlot.startEdge, + MessageListItemSlot.bottomLoader, + MessageListItemSlot.topLoader, + MessageListItemSlot.endEdge, + MessageListItemSlot.parentMessage, + ], + ); + }); + + test('places messages between the two pagination loaders', () { + const layout = MessageListLayout(messageCount: 3); + + expect( + [for (var i = 0; i < layout.itemCount; i++) layout.itemSlotAt(i)], + [ + MessageListItemSlot.startEdge, + MessageListItemSlot.bottomLoader, + MessageListItemSlot.message, + MessageListItemSlot.message, + MessageListItemSlot.message, + MessageListItemSlot.topLoader, + MessageListItemSlot.endEdge, + MessageListItemSlot.parentMessage, + ], + ); + }); + + test('agrees with the original inline arithmetic for every index', () { + for (var messageCount = 0; messageCount <= 20; messageCount++) { + final layout = MessageListLayout(messageCount: messageCount); + for (var i = 0; i < layout.itemCount; i++) { + expect( + layout.itemSlotAt(i), + referenceItemSlot(i, layout.itemCount), + reason: 'item slot mismatch at index $i of $messageCount messages', + ); + } + } + }); + + test('yields exactly messageCount message slots', () { + for (var messageCount = 0; messageCount <= 20; messageCount++) { + final layout = MessageListLayout(messageCount: messageCount); + final messageSlots = [ + for (var i = 0; i < layout.itemCount; i++) + if (layout.itemSlotAt(i) == MessageListItemSlot.message) i, + ]; + + expect(messageSlots.length, messageCount); + } + }); + }); + + group('separatorSlotAt', () { + test('agrees with the original inline arithmetic for every index', () { + for (var messageCount = 0; messageCount <= 20; messageCount++) { + final layout = MessageListLayout(messageCount: messageCount); + // A separated list builds itemCount - 1 separators. + for (var i = 0; i < layout.itemCount - 1; i++) { + expect( + layout.separatorSlotAt(i), + referenceSeparatorSlot(i, layout.itemCount), + reason: 'separator slot mismatch at index $i of $messageCount messages', + ); + } + } + }); + + test('brackets the messages with loader gaps', () { + const layout = MessageListLayout(messageCount: 3); + + expect( + [for (var i = 0; i < layout.itemCount - 1; i++) layout.separatorSlotAt(i)], + [ + MessageListSeparatorSlot.startEdgeGap, + MessageListSeparatorSlot.loaderGap, + MessageListSeparatorSlot.betweenMessages, + MessageListSeparatorSlot.betweenMessages, + MessageListSeparatorSlot.loaderGap, + MessageListSeparatorSlot.endEdgeGap, + MessageListSeparatorSlot.threadSeparator, + ], + ); + }); + }); + + group('fixed slot indices', () { + test('parentMessageIndex is the final item and matches its slot', () { + for (var messageCount = 0; messageCount <= 20; messageCount++) { + final layout = MessageListLayout(messageCount: messageCount); + + expect(layout.parentMessageIndex, layout.itemCount - 1); + expect(layout.itemSlotAt(layout.parentMessageIndex), MessageListItemSlot.parentMessage); + } + }); + + test('parentMessageIndex matches the original messages.length + 4 target', () { + // The old call site computed `messages.length + 2` and then added 2 again + // at the scroll site. + for (var messageCount = 0; messageCount <= 20; messageCount++) { + expect(MessageListLayout(messageCount: messageCount).parentMessageIndex, messageCount + 4); + } + }); + + test('firstMessageItemIndex is the first message slot', () { + const layout = MessageListLayout(messageCount: 3); + + expect(MessageListLayout.firstMessageItemIndex, 2); + expect(layout.itemSlotAt(MessageListLayout.firstMessageItemIndex), MessageListItemSlot.message); + expect(layout.messageIndexAt(MessageListLayout.firstMessageItemIndex), 0); + }); + }); + + group('message index mapping', () { + test('maps the first message slot to message 0', () { + const layout = MessageListLayout(messageCount: 5); + + expect(layout.messageIndexAt(2), 0); + expect(layout.messageIndexAt(6), 4); + }); + + test('itemIndexOfMessage inverts messageIndexAt', () { + const layout = MessageListLayout(messageCount: 5); + + for (var messageIndex = 0; messageIndex < 5; messageIndex++) { + final itemIndex = layout.itemIndexOfMessage(messageIndex); + expect(layout.messageIndexAt(itemIndex), messageIndex); + expect(layout.itemSlotAt(itemIndex), MessageListItemSlot.message); + } + }); + + test('message slots resolve to in-range message indices', () { + for (var messageCount = 0; messageCount <= 20; messageCount++) { + final layout = MessageListLayout(messageCount: messageCount); + for (var i = 0; i < layout.itemCount; i++) { + if (layout.itemSlotAt(i) != MessageListItemSlot.message) continue; + + final messageIndex = layout.messageIndexAt(i); + expect(messageIndex, greaterThanOrEqualTo(0)); + expect(messageIndex, lessThan(messageCount)); + } + } + }); + + test('message slots match where the original built an item key', () { + for (var messageCount = 0; messageCount <= 20; messageCount++) { + final layout = MessageListLayout(messageCount: messageCount); + for (var i = 0; i < layout.itemCount; i++) { + expect( + layout.itemSlotAt(i) == MessageListItemSlot.message, + referenceHasMessageKey(i, layout.itemCount, messageCount), + reason: 'item key mismatch at index $i of $messageCount messages', + ); + } + } + }); + }); +} diff --git a/packages/stream_chat_flutter_core/lib/src/paged_value_scroll_view.dart b/packages/stream_chat_flutter_core/lib/src/paged_value_scroll_view.dart index 003da2a4bc..dcda35796f 100644 --- a/packages/stream_chat_flutter_core/lib/src/paged_value_scroll_view.dart +++ b/packages/stream_chat_flutter_core/lib/src/paged_value_scroll_view.dart @@ -1,6 +1,8 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'package:stream_chat/stream_chat.dart'; +// `Success` is hidden so that it unambiguously refers to the PagedValue +// variant rather than the attachment-upload state of the same name. +import 'package:stream_chat/stream_chat.dart' hide Success; import 'package:stream_chat_flutter_core/src/paged_value_notifier.dart'; /// Signature for a function that creates a widget for a given index, e.g., in a @@ -290,62 +292,69 @@ class _PagedValueListViewState extends State> { @override Widget build(BuildContext context) => PagedValueListenableBuilder( valueListenable: _controller, - builder: (context, value, _) => value.when( - (items, nextPageKey, error) { - if (items.isEmpty) { - return widget.emptyBuilder(context); - } - - return ListView.separated( - scrollDirection: widget.scrollDirection, - padding: widget.padding, - physics: widget.physics, - reverse: widget.reverse, - controller: widget.scrollController, - primary: widget.primary, - shrinkWrap: widget.shrinkWrap, - addAutomaticKeepAlives: widget.addAutomaticKeepAlives, - addRepaintBoundaries: widget.addRepaintBoundaries, - addSemanticIndexes: widget.addSemanticIndexes, - keyboardDismissBehavior: widget.keyboardDismissBehavior, - restorationId: widget.restorationId, - dragStartBehavior: widget.dragStartBehavior, - // ignore: deprecated_member_use - cacheExtent: widget.cacheExtent, - clipBehavior: widget.clipBehavior, - itemCount: value.itemCount, - separatorBuilder: (context, index) => widget.separatorBuilder(context, items, index), - itemBuilder: (context, index) { - if (!_hasRequestedNextPage) { - final newPageRequestTriggerIndex = items.length - widget.loadMoreTriggerIndex; - final isBuildingTriggerIndexItem = index == newPageRequestTriggerIndex; - if (nextPageKey != null && isBuildingTriggerIndexItem) { - // Schedules the request for the end of this frame. - WidgetsBinding.instance.addPostFrameCallback((_) async { - if (error == null) { - await _controller.loadMore(nextPageKey); - } - _hasRequestedNextPage = false; - }); - _hasRequestedNextPage = true; - } - } - - if (index == items.length) { - if (error != null) { - return widget.loadMoreErrorBuilder(context, error); - } - return widget.loadMoreIndicatorBuilder(context); - } - - return widget.itemBuilder(context, items, index); - }, - ); - }, - loading: () => widget.loadingBuilder(context), - error: (error) => widget.errorBuilder(context, error), - ), + builder: (context, value, _) => switch (value) { + Loading() => widget.loadingBuilder(context), + Error(:final error) => widget.errorBuilder(context, error), + Success(:final items) when items.isEmpty => widget.emptyBuilder(context), + final Success success => _buildListView(context, success), + }, ); + + Widget _buildListView(BuildContext context, Success value) { + final items = value.items; + + return ListView.separated( + scrollDirection: widget.scrollDirection, + padding: widget.padding, + physics: widget.physics, + reverse: widget.reverse, + controller: widget.scrollController, + primary: widget.primary, + shrinkWrap: widget.shrinkWrap, + addAutomaticKeepAlives: widget.addAutomaticKeepAlives, + addRepaintBoundaries: widget.addRepaintBoundaries, + addSemanticIndexes: widget.addSemanticIndexes, + keyboardDismissBehavior: widget.keyboardDismissBehavior, + restorationId: widget.restorationId, + dragStartBehavior: widget.dragStartBehavior, + // ignore: deprecated_member_use + cacheExtent: widget.cacheExtent, + clipBehavior: widget.clipBehavior, + itemCount: value.itemCount, + separatorBuilder: (context, index) => widget.separatorBuilder(context, items, index), + itemBuilder: (context, index) => _buildItem(context, value, index), + ); + } + + /// Builds the item at [index], which is either one of [Success.items] or the + /// trailing load-more slot. + Widget _buildItem(BuildContext context, Success value, int index) { + _maybeRequestNextPage(value, index); + + final items = value.items; + if (index != items.length) return widget.itemBuilder(context, items, index); + + final error = value.error; + if (error != null) return widget.loadMoreErrorBuilder(context, error); + return widget.loadMoreIndicatorBuilder(context); + } + + /// Schedules a next-page request when [index] is the load-more trigger index. + void _maybeRequestNextPage(Success value, int index) { + if (_hasRequestedNextPage) return; + + final nextPageKey = value.nextPageKey; + if (nextPageKey == null) return; + if (index != value.items.length - widget.loadMoreTriggerIndex) return; + + // Schedules the request for the end of this frame. + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (value.error == null) await _controller.loadMore(nextPageKey); + _hasRequestedNextPage = false; + }); + + _hasRequestedNextPage = true; + } } /// A [GridView] that loads more pages when the user scrolls to the end of the @@ -651,66 +660,73 @@ class _PagedValueGridViewState extends State> { @override Widget build(BuildContext context) => PagedValueListenableBuilder( valueListenable: _controller, - builder: (context, value, _) => value.when( - (items, nextPageKey, error) { - if (items.isEmpty) { - return widget.emptyBuilder(context); - } - - return GridView.builder( - scrollDirection: widget.scrollDirection, - reverse: widget.reverse, - controller: widget.scrollController, - primary: widget.primary, - physics: widget.physics, - shrinkWrap: widget.shrinkWrap, - padding: widget.padding, - addAutomaticKeepAlives: widget.addAutomaticKeepAlives, - addRepaintBoundaries: widget.addRepaintBoundaries, - addSemanticIndexes: widget.addSemanticIndexes, - // ignore: deprecated_member_use - cacheExtent: widget.cacheExtent, - semanticChildCount: widget.semanticChildCount, - dragStartBehavior: widget.dragStartBehavior, - keyboardDismissBehavior: widget.keyboardDismissBehavior, - restorationId: widget.restorationId, - clipBehavior: widget.clipBehavior, - itemCount: value.itemCount + (widget.leadingItemBuilder != null ? 1 : 0), - gridDelegate: widget.gridDelegate, - itemBuilder: (context, index) { - var adjustedIndex = index; - if (widget.leadingItemBuilder != null) { - if (index == 0) return widget.leadingItemBuilder!(context); - adjustedIndex = index - 1; - } - - if (!_hasRequestedNextPage) { - final newPageRequestTriggerIndex = items.length - widget.loadMoreTriggerIndex; - if (nextPageKey != null && adjustedIndex == newPageRequestTriggerIndex) { - // Schedules the request for the end of this frame. - WidgetsBinding.instance.addPostFrameCallback((_) async { - if (error == null) { - await _controller.loadMore(nextPageKey); - } - _hasRequestedNextPage = false; - }); - _hasRequestedNextPage = true; - } - } - - if (adjustedIndex == items.length) { - if (error != null) { - return widget.loadMoreErrorBuilder(context, error); - } - return widget.loadMoreIndicatorBuilder(context); - } - - return widget.itemBuilder(context, items, adjustedIndex); - }, - ); - }, - loading: () => widget.loadingBuilder(context), - error: (error) => widget.errorBuilder(context, error), - ), + builder: (context, value, _) => switch (value) { + Loading() => widget.loadingBuilder(context), + Error(:final error) => widget.errorBuilder(context, error), + Success(:final items) when items.isEmpty => widget.emptyBuilder(context), + final Success success => _buildGridView(context, success), + }, + ); + + Widget _buildGridView(BuildContext context, Success value) => GridView.builder( + scrollDirection: widget.scrollDirection, + reverse: widget.reverse, + controller: widget.scrollController, + primary: widget.primary, + physics: widget.physics, + shrinkWrap: widget.shrinkWrap, + padding: widget.padding, + addAutomaticKeepAlives: widget.addAutomaticKeepAlives, + addRepaintBoundaries: widget.addRepaintBoundaries, + addSemanticIndexes: widget.addSemanticIndexes, + // ignore: deprecated_member_use + cacheExtent: widget.cacheExtent, + semanticChildCount: widget.semanticChildCount, + dragStartBehavior: widget.dragStartBehavior, + keyboardDismissBehavior: widget.keyboardDismissBehavior, + restorationId: widget.restorationId, + clipBehavior: widget.clipBehavior, + itemCount: value.itemCount + (widget.leadingItemBuilder != null ? 1 : 0), + gridDelegate: widget.gridDelegate, + itemBuilder: (context, index) => _buildItem(context, value, index), ); + + /// Builds the item at [index], which is either the optional leading item, one + /// of [Success.items], or the trailing load-more slot. + Widget _buildItem(BuildContext context, Success value, int index) { + final leadingItemBuilder = widget.leadingItemBuilder; + + // The leading item occupies index 0 and shifts the paged items by one. + var itemIndex = index; + if (leadingItemBuilder != null) { + if (index == 0) return leadingItemBuilder(context); + itemIndex = index - 1; + } + + _maybeRequestNextPage(value, itemIndex); + + final items = value.items; + if (itemIndex != items.length) return widget.itemBuilder(context, items, itemIndex); + + final error = value.error; + if (error != null) return widget.loadMoreErrorBuilder(context, error); + return widget.loadMoreIndicatorBuilder(context); + } + + /// Schedules a next-page request when [index] is the load-more trigger index. + void _maybeRequestNextPage(Success value, int index) { + if (_hasRequestedNextPage) return; + + final nextPageKey = value.nextPageKey; + if (nextPageKey == null) return; + if (index != value.items.length - widget.loadMoreTriggerIndex) return; + + // Schedules the request for the end of this frame. + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (value.error == null) await _controller.loadMore(nextPageKey); + _hasRequestedNextPage = false; + }); + + _hasRequestedNextPage = true; + } } diff --git a/packages/stream_chat_flutter_core/test/paged_value_grid_view_test.dart b/packages/stream_chat_flutter_core/test/paged_value_grid_view_test.dart index c2877cdaf5..ea5cc2084b 100644 --- a/packages/stream_chat_flutter_core/test/paged_value_grid_view_test.dart +++ b/packages/stream_chat_flutter_core/test/paged_value_grid_view_test.dart @@ -3,13 +3,20 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; class _TestController extends PagedValueNotifier { - _TestController(List items, {int? nextPageKey}) : super(PagedValue(items: items, nextPageKey: nextPageKey)); + _TestController(List items, {int? nextPageKey, StreamChatError? error}) + : super(PagedValue(items: items, nextPageKey: nextPageKey, error: error)); + + _TestController.loading() : super(const PagedValue.loading()); + + _TestController.error(StreamChatError error) : super(PagedValue.error(error)); + + final loadMoreCalls = []; @override Future doInitialLoad() async {} @override - Future loadMore(int nextPageKey) async {} + Future loadMore(int nextPageKey) async => loadMoreCalls.add(nextPageKey); } Widget _wrap(Widget child) => MaterialApp(home: Scaffold(body: child)); @@ -36,6 +43,95 @@ PagedValueGridView _buildGrid( } void main() { + group('PagedValueGridView value states', () { + testWidgets('renders loadingBuilder while loading', (tester) async { + final controller = _TestController.loading(); + + await tester.pumpWidget(_wrap(_buildGrid(controller, builtIndices: []))); + await tester.pump(); + + expect(find.text('loading'), findsOneWidget); + }); + + testWidgets('renders errorBuilder on error', (tester) async { + final controller = _TestController.error(const StreamChatError('Network error')); + + await tester.pumpWidget(_wrap(_buildGrid(controller, builtIndices: []))); + await tester.pump(); + + expect(find.text('error'), findsOneWidget); + }); + + testWidgets('renders emptyBuilder when there are no items', (tester) async { + final controller = _TestController([]); + final builtIndices = []; + + await tester.pumpWidget(_wrap(_buildGrid(controller, builtIndices: builtIndices))); + await tester.pump(); + + expect(find.text('empty'), findsOneWidget); + expect(builtIndices, isEmpty); + }); + + testWidgets('renders emptyBuilder even when a leading item is configured', (tester) async { + final controller = _TestController([]); + + await tester.pumpWidget( + _wrap(_buildGrid(controller, leadingItemBuilder: (_) => const Text('leading'), builtIndices: [])), + ); + await tester.pump(); + + expect(find.text('empty'), findsOneWidget); + expect(find.text('leading'), findsNothing); + }); + }); + + group('PagedValueGridView load more', () { + testWidgets('renders load-more error instead of the indicator when errored', (tester) async { + final controller = _TestController(['a', 'b'], nextPageKey: 1, error: const StreamChatError('Load more failed')); + + await tester.pumpWidget(_wrap(_buildGrid(controller, builtIndices: []))); + await tester.pump(); + + expect(find.text('load-more-error'), findsOneWidget); + expect(find.text('load-more-indicator'), findsNothing); + }); + + testWidgets('requests the next page when the trigger index is built', (tester) async { + final controller = _TestController(['a', 'b', 'c'], nextPageKey: 7); + + // items.length (3) - triggerIndex (3) == 0, so building item 0 triggers. + await tester.pumpWidget(_wrap(_buildGrid(controller, builtIndices: []))); + await tester.pump(); + + expect(controller.loadMoreCalls, [7]); + }); + + testWidgets('trigger index accounts for the leading item offset', (tester) async { + final controller = _TestController(['a', 'b', 'c'], nextPageKey: 7); + + await tester.pumpWidget( + _wrap(_buildGrid(controller, leadingItemBuilder: (_) => const Text('leading'), builtIndices: [])), + ); + await tester.pump(); + + expect(controller.loadMoreCalls, [7]); + }); + + testWidgets('does not request the next page while an error is present', (tester) async { + final controller = _TestController( + ['a', 'b', 'c'], + nextPageKey: 7, + error: const StreamChatError('Load more failed'), + ); + + await tester.pumpWidget(_wrap(_buildGrid(controller, builtIndices: []))); + await tester.pump(); + + expect(controller.loadMoreCalls, isEmpty); + }); + }); + group('PagedValueGridView without leadingItemBuilder', () { testWidgets('renders items starting at index 0', (tester) async { final controller = _TestController(['a', 'b', 'c']); diff --git a/packages/stream_chat_flutter_core/test/paged_value_list_view_test.dart b/packages/stream_chat_flutter_core/test/paged_value_list_view_test.dart new file mode 100644 index 0000000000..e5acc532fd --- /dev/null +++ b/packages/stream_chat_flutter_core/test/paged_value_list_view_test.dart @@ -0,0 +1,189 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +class _TestController extends PagedValueNotifier { + _TestController(super.initialValue); + + _TestController.success(List items, {int? nextPageKey, StreamChatError? error}) + : this(PagedValue(items: items, nextPageKey: nextPageKey, error: error)); + + _TestController.loading() : this(const PagedValue.loading()); + + _TestController.error(StreamChatError error) : this(PagedValue.error(error)); + + final loadMoreCalls = []; + int initialLoadCount = 0; + + @override + Future doInitialLoad() async => initialLoadCount++; + + @override + Future loadMore(int nextPageKey) async => loadMoreCalls.add(nextPageKey); +} + +Widget _wrap(Widget child) => MaterialApp(home: Scaffold(body: child)); + +PagedValueListView _buildList( + _TestController controller, { + required List builtIndices, + int loadMoreTriggerIndex = 3, +}) { + return PagedValueListView( + controller: controller, + loadMoreTriggerIndex: loadMoreTriggerIndex, + itemBuilder: (context, items, index) { + builtIndices.add(index); + return Text('item-$index'); + }, + separatorBuilder: (context, items, index) => const Divider(), + emptyBuilder: (_) => const Text('empty'), + loadMoreErrorBuilder: (_, __) => const Text('load-more-error'), + loadMoreIndicatorBuilder: (_) => const Text('load-more-indicator'), + loadingBuilder: (_) => const Text('loading'), + errorBuilder: (_, __) => const Text('error'), + ); +} + +void main() { + group('PagedValueListView value states', () { + testWidgets('renders loadingBuilder while loading', (tester) async { + final controller = _TestController.loading(); + + await tester.pumpWidget(_wrap(_buildList(controller, builtIndices: []))); + await tester.pump(); + + expect(find.text('loading'), findsOneWidget); + expect(find.text('empty'), findsNothing); + }); + + testWidgets('renders errorBuilder on error', (tester) async { + final controller = _TestController.error(const StreamChatError('Network error')); + + await tester.pumpWidget(_wrap(_buildList(controller, builtIndices: []))); + await tester.pump(); + + expect(find.text('error'), findsOneWidget); + }); + + testWidgets('renders emptyBuilder when there are no items', (tester) async { + final controller = _TestController.success([]); + final builtIndices = []; + + await tester.pumpWidget(_wrap(_buildList(controller, builtIndices: builtIndices))); + await tester.pump(); + + expect(find.text('empty'), findsOneWidget); + expect(builtIndices, isEmpty); + }); + + testWidgets('calls doInitialLoad on mount', (tester) async { + final controller = _TestController.success(['a']); + + await tester.pumpWidget(_wrap(_buildList(controller, builtIndices: []))); + await tester.pump(); + + expect(controller.initialLoadCount, 1); + }); + }); + + group('PagedValueListView items', () { + testWidgets('renders items starting at index 0 with separators', (tester) async { + final controller = _TestController.success(['a', 'b', 'c']); + final builtIndices = []; + + await tester.pumpWidget(_wrap(_buildList(controller, builtIndices: builtIndices))); + await tester.pump(); + + expect(find.text('item-0'), findsOneWidget); + expect(find.text('item-1'), findsOneWidget); + expect(find.text('item-2'), findsOneWidget); + expect(builtIndices, [0, 1, 2]); + expect(find.byType(Divider), findsNWidgets(2)); + }); + + testWidgets('renders no load-more slot when there is no next page', (tester) async { + final controller = _TestController.success(['a', 'b']); + + await tester.pumpWidget(_wrap(_buildList(controller, builtIndices: []))); + await tester.pump(); + + expect(find.text('load-more-indicator'), findsNothing); + expect(find.text('load-more-error'), findsNothing); + }); + }); + + group('PagedValueListView load more', () { + testWidgets('renders load-more indicator after the last item', (tester) async { + final controller = _TestController.success(['a', 'b'], nextPageKey: 1); + final builtIndices = []; + + await tester.pumpWidget(_wrap(_buildList(controller, builtIndices: builtIndices))); + await tester.pump(); + + expect(find.text('item-0'), findsOneWidget); + expect(find.text('item-1'), findsOneWidget); + expect(find.text('load-more-indicator'), findsOneWidget); + expect(builtIndices, [0, 1]); + }); + + testWidgets('renders load-more error instead of the indicator when errored', (tester) async { + final controller = _TestController.success( + ['a', 'b'], + nextPageKey: 1, + error: const StreamChatError('Load more failed'), + ); + + await tester.pumpWidget(_wrap(_buildList(controller, builtIndices: []))); + await tester.pump(); + + expect(find.text('load-more-error'), findsOneWidget); + expect(find.text('load-more-indicator'), findsNothing); + }); + + testWidgets('requests the next page when the trigger index is built', (tester) async { + final controller = _TestController.success(['a', 'b', 'c'], nextPageKey: 7); + + // items.length (3) - triggerIndex (3) == 0, so building item 0 triggers. + await tester.pumpWidget(_wrap(_buildList(controller, builtIndices: []))); + await tester.pump(); + + expect(controller.loadMoreCalls, [7]); + }); + + testWidgets('does not request the next page when there is no next page', (tester) async { + final controller = _TestController.success(['a', 'b', 'c']); + + await tester.pumpWidget(_wrap(_buildList(controller, builtIndices: []))); + await tester.pump(); + + expect(controller.loadMoreCalls, isEmpty); + }); + + testWidgets('does not request the next page while an error is present', (tester) async { + final controller = _TestController.success( + ['a', 'b', 'c'], + nextPageKey: 7, + error: const StreamChatError('Load more failed'), + ); + + await tester.pumpWidget(_wrap(_buildList(controller, builtIndices: []))); + await tester.pump(); + + expect(controller.loadMoreCalls, isEmpty); + }); + + testWidgets('releases the duplicate guard once the scheduled request settles', (tester) async { + final controller = _TestController.success(['a', 'b', 'c'], nextPageKey: 7); + + await tester.pumpWidget(_wrap(_buildList(controller, builtIndices: []))); + await tester.pump(); + // The guard is reset by the post-frame callback, so a later rebuild at + // the trigger index issues a fresh request rather than being swallowed. + await tester.pumpWidget(_wrap(_buildList(controller, builtIndices: []))); + await tester.pump(); + + expect(controller.loadMoreCalls, [7, 7]); + }); + }); +}