feat: add proper pagination to chat_detail_screen.dart

This commit is contained in:
Freek van de Ven 2025-02-14 14:36:49 +01:00 committed by FlutterJoey
parent e1ca5aab71
commit ab8a9d9e6f
4 changed files with 184 additions and 80 deletions

View file

@ -23,7 +23,6 @@ class LocalChatRepository implements ChatRepositoryInterface {
final Map<String, int> _startIndexMap = {}; final Map<String, int> _startIndexMap = {};
final Map<String, int> _endIndexMap = {}; final Map<String, int> _endIndexMap = {};
static const int _chunkSize = 30;
@override @override
Future<void> createChat({ Future<void> createChat({
@ -127,7 +126,7 @@ class LocalChatRepository implements ChatRepositoryInterface {
); );
allMessages.sort((a, b) => a.timestamp.compareTo(b.timestamp)); allMessages.sort((a, b) => a.timestamp.compareTo(b.timestamp));
_startIndexMap[chatId] ??= math.max(0, allMessages.length - _chunkSize); _startIndexMap[chatId] ??= math.max(0, allMessages.length - chunkSize);
_endIndexMap[chatId] ??= allMessages.length; _endIndexMap[chatId] ??= allMessages.length;
var displayedMessages = allMessages.sublist( var displayedMessages = allMessages.sublist(
@ -159,7 +158,7 @@ class LocalChatRepository implements ChatRepositoryInterface {
_endIndexMap[lastMessage.chatId] ?? allMessages.length; _endIndexMap[lastMessage.chatId] ?? allMessages.length;
_endIndexMap[lastMessage.chatId] = math.min( _endIndexMap[lastMessage.chatId] = math.min(
allMessages.length, allMessages.length,
currentEndIndex + _chunkSize, currentEndIndex + chunkSize,
); );
var displayedMessages = allMessages.sublist( var displayedMessages = allMessages.sublist(
@ -187,7 +186,7 @@ class LocalChatRepository implements ChatRepositoryInterface {
var currentStartIndex = _startIndexMap[firstMessage.chatId] ?? 0; var currentStartIndex = _startIndexMap[firstMessage.chatId] ?? 0;
_startIndexMap[firstMessage.chatId] = math.max( _startIndexMap[firstMessage.chatId] = math.max(
0, 0,
currentStartIndex - _chunkSize, currentStartIndex - chunkSize,
); );
var displayedMessages = allMessages.sublist( var displayedMessages = allMessages.sublist(
@ -274,4 +273,7 @@ class LocalChatRepository implements ChatRepositoryInterface {
/// All the chats of the local memory database /// All the chats of the local memory database
List<ChatModel> get getLocalChats => chats; List<ChatModel> get getLocalChats => chats;
/// The chunkSize used for pagination
int get getChunkSize => chunkSize;
} }

View file

@ -2,6 +2,9 @@ import "package:chat_repository_interface/src/models/chat_model.dart";
import "package:chat_repository_interface/src/models/message_model.dart"; import "package:chat_repository_interface/src/models/message_model.dart";
import "package:chat_repository_interface/src/models/user_model.dart"; import "package:chat_repository_interface/src/models/user_model.dart";
/// The chunkSize for the LocalChatRepository
const int chunkSize = 10;
/// All the chats of the local memory database /// All the chats of the local memory database
final List<ChatModel> chats = []; final List<ChatModel> chats = [];

View file

@ -141,6 +141,26 @@ class ChatService {
chatId: chatId, chatId: chatId,
); );
/// Signals that new messages should be loaded after the given message.
/// The stream should emit the new messages.
Future<void> loadNewMessagesAfter({
required MessageModel lastMessage,
}) =>
chatRepository.loadNewMessagesAfter(
userId: userId,
lastMessage: lastMessage,
);
/// Signals that old messages should be loaded before the given message.
/// The stream should emit the new messages.
Future<void> loadOldMessagesBefore({
required MessageModel firstMessage,
}) =>
chatRepository.loadOldMessagesBefore(
userId: userId,
firstMessage: firstMessage,
);
/// Send a message with the given parameters. /// Send a message with the given parameters.
/// [chatId] is the chat id. /// [chatId] is the chat id.
/// [senderId] is the sender id. /// [senderId] is the sender id.

View file

@ -228,86 +228,153 @@ class _ChatBody extends HookWidget {
final List<UserModel> chatUsers; final List<UserModel> chatUsers;
final Function(UserModel) onPressUserProfile; final Function(UserModel) onPressUserProfile;
final Function(Uint8List image) onUploadImage; final Function(Uint8List image) onUploadImage;
final Function(String message) onMessageSubmit; final Function(String text) onMessageSubmit;
final Function(ChatModel chat) onReadChat; final Function(ChatModel chat) onReadChat;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var chatScope = ChatScope.of(context); var chatScope = ChatScope.of(context);
var options = chatScope.options;
var service = chatScope.service; var service = chatScope.service;
var options = chatScope.options;
var page = useState(0); var isLoadingOlder = useState(false);
var showIndicator = useState(false); var isLoadingNewer = useState(false);
var controller = useScrollController();
/// Trigger to load new page when scrolling to the bottom var messagesStream = useMemoized(
void handleScroll(PointerMoveEvent _) { () => service.getMessages(chatId: chatId),
if (!showIndicator.value && [chatId],
controller.offset >= controller.position.maxScrollExtent && );
!controller.position.outOfRange) { var messagesSnapshot = useStream(messagesStream);
showIndicator.value = true; var messages = messagesSnapshot.data ?? [];
page.value++;
Future.delayed(const Duration(seconds: 2), () { var scrollController = useScrollController();
if (!controller.hasClients) return;
showIndicator.value = false; Future<void> loadOlderMessages() async {
if (messages.isEmpty || isLoadingOlder.value) return;
isLoadingOlder.value = true;
var oldestMsg = messages.first;
var oldOffset = scrollController.offset;
var oldMaxScroll = scrollController.position.maxScrollExtent;
var oldCount = messages.length;
try {
debugPrint("loading from message: ${oldestMsg.id}");
await service.loadOldMessagesBefore(firstMessage: oldestMsg);
} finally {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!scrollController.hasClients) {
isLoadingOlder.value = false;
return;
}
var newCount = messages.length;
if (newCount > oldCount) {
var newMaxScroll = scrollController.position.maxScrollExtent;
var diff = newMaxScroll - oldMaxScroll;
scrollController.jumpTo(oldOffset + diff);
}
isLoadingOlder.value = false;
}); });
} }
} }
Future<void> loadNewerMessages() async {
if (messages.isEmpty || isLoadingNewer.value) return;
isLoadingNewer.value = true;
var newestMsg = messages.last;
try {
debugPrint("loading from message: ${newestMsg.id}");
await service.loadNewMessagesAfter(lastMessage: newestMsg);
} finally {
isLoadingNewer.value = false;
}
}
useEffect(() {
void onScroll() {
if (!scrollController.hasClients) return;
var offset = scrollController.offset;
var maxScroll = scrollController.position.maxScrollExtent;
if ((maxScroll - offset) <= 50 && !isLoadingOlder.value) {
unawaited(loadOlderMessages());
}
if (offset <= 50 && !isLoadingNewer.value) {
unawaited(loadNewerMessages());
}
}
scrollController.addListener(onScroll);
return () => scrollController.removeListener(onScroll);
}, [
scrollController,
isLoadingOlder.value,
isLoadingNewer.value,
chat,
]);
if (chat == null) { if (chat == null) {
return const Center(child: CircularProgressIndicator()); if (!options.enableLoadingIndicator) return const SizedBox.shrink();
return options.builders.loadingWidgetBuilder.call(context);
} }
var messagesStream = useMemoized( var userMap = <String, UserModel>{};
() => service.getMessages( for (var u in chatUsers) {
chatId: chat!.id, userMap[u.id] = u;
),
[chat!.id, page.value],
);
var messagesSnapshot = useStream(messagesStream);
var messages = messagesSnapshot.data?.reversed.toList() ?? [];
if (messagesSnapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
} }
var listViewChildren = messages.isEmpty && !showIndicator.value var topSpinner = (isLoadingOlder.value && options.enableLoadingIndicator)
? [ ? const _LoaderItem()
ChatNoMessages(isGroupChat: chat!.isGroupChat), : const SizedBox.shrink();
]
: [ var bottomSpinner = (isLoadingNewer.value && options.enableLoadingIndicator)
for (var (index, message) in messages.indexed) ...[ ? const _LoaderItem()
if (chat!.id == message.chatId) : const SizedBox.shrink();
var reversedMessages = messages.reversed.toList();
var bubbleChildren = <Widget>[];
if (reversedMessages.isEmpty) {
bubbleChildren
.add(ChatNoMessages(isGroupChat: chat?.isGroupChat ?? false));
} else {
for (var (index, msg) in reversedMessages.indexed) {
var nextIndex = index + 1;
var prevMsg = nextIndex < reversedMessages.length
? reversedMessages[nextIndex]
: null;
bubbleChildren.add(
ChatBubble( ChatBubble(
key: ValueKey(message.id), key: ValueKey(msg.id),
sender: chatUsers message: msg,
.where( previousMessage: prevMsg,
(u) => u.id == message.senderId, sender: userMap[msg.senderId],
)
.firstOrNull,
message: message,
previousMessage:
index < messages.length - 1 ? messages[index + 1] : null,
onPressSender: onPressUserProfile, onPressSender: onPressUserProfile,
), ),
], );
}
}
var listViewChildren = [
topSpinner,
...bubbleChildren,
bottomSpinner,
]; ];
return Stack( return Column(
children: [
Column(
children: [ children: [
Expanded( Expanded(
child: Listener( child: Align(
onPointerMove: handleScroll, alignment: options.chatAlignment ?? Alignment.bottomCenter,
child: ListView( child: ListView(
shrinkWrap: true, shrinkWrap: true,
controller: controller, reverse: true,
controller: scrollController,
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
reverse: messages.isNotEmpty, padding: const EdgeInsets.only(top: 24),
padding: const EdgeInsets.only(top: 24.0),
children: listViewChildren, children: listViewChildren,
), ),
), ),
@ -322,11 +389,23 @@ class _ChatBody extends HookWidget {
onMessageSubmit: onMessageSubmit, onMessageSubmit: onMessageSubmit,
), ),
], ],
),
if (showIndicator.value && options.enableLoadingIndicator) ...[
options.builders.loadingWidgetBuilder(context),
],
],
); );
} }
} }
/// A small row spinner item to show partial loading
class _LoaderItem extends StatelessWidget {
const _LoaderItem();
@override
Widget build(BuildContext context) => const Padding(
padding: EdgeInsets.all(8.0),
child: Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
}