Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 5 additions & 0 deletions .github/workflows/stream_flutter_workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ jobs:
if: github.base_ref == 'master'
run: |
melos run lint:pub
- uses: kevmoo/cognitive_complexity.dart@main
with:
diff-base: origin/${{ github.base_ref }}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
fail-threshold: 15
fail-on-increase: true
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

format:
needs: gate
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
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
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 index of the first item holding a message.
static const _firstMessageIndex = 2;

/// The total number of items in the scroll view.
int get itemCount => messageCount + fixedSlotCount;

/// 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 - _firstMessageIndex;

/// The item index at which the message at [messageIndex] is shown.
///
/// This is the inverse of [messageIndexAt].
int itemIndexOfMessage(int messageIndex) => messageIndex + _firstMessageIndex;

@override
bool operator ==(Object other) => other is MessageListLayout && other.messageCount == messageCount;

@override
int get hashCode => messageCount.hashCode;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
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('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',
);
}
}
});
});
}
Loading
Loading