From 5f7650536c0e27653cce59a1b587df40f27140aa Mon Sep 17 00:00:00 2001 From: Freek van de Ven Date: Fri, 14 Feb 2025 14:36:49 +0100 Subject: [PATCH] feat: add proper pagination to chat_detail_screen.dart --- .../lib/src/local/local_chat_repository.dart | 10 +- .../lib/src/local/local_memory_db.dart | 3 + .../lib/src/services/chat_service.dart | 20 ++ .../chat_detail/chat_detail_screen.dart | 231 ++++++++++++------ 4 files changed, 184 insertions(+), 80 deletions(-) diff --git a/packages/chat_repository_interface/lib/src/local/local_chat_repository.dart b/packages/chat_repository_interface/lib/src/local/local_chat_repository.dart index 44565c0..94c5059 100644 --- a/packages/chat_repository_interface/lib/src/local/local_chat_repository.dart +++ b/packages/chat_repository_interface/lib/src/local/local_chat_repository.dart @@ -23,7 +23,6 @@ class LocalChatRepository implements ChatRepositoryInterface { final Map _startIndexMap = {}; final Map _endIndexMap = {}; - static const int _chunkSize = 30; @override Future createChat({ @@ -127,7 +126,7 @@ class LocalChatRepository implements ChatRepositoryInterface { ); 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; var displayedMessages = allMessages.sublist( @@ -159,7 +158,7 @@ class LocalChatRepository implements ChatRepositoryInterface { _endIndexMap[lastMessage.chatId] ?? allMessages.length; _endIndexMap[lastMessage.chatId] = math.min( allMessages.length, - currentEndIndex + _chunkSize, + currentEndIndex + chunkSize, ); var displayedMessages = allMessages.sublist( @@ -187,7 +186,7 @@ class LocalChatRepository implements ChatRepositoryInterface { var currentStartIndex = _startIndexMap[firstMessage.chatId] ?? 0; _startIndexMap[firstMessage.chatId] = math.max( 0, - currentStartIndex - _chunkSize, + currentStartIndex - chunkSize, ); var displayedMessages = allMessages.sublist( @@ -274,4 +273,7 @@ class LocalChatRepository implements ChatRepositoryInterface { /// All the chats of the local memory database List get getLocalChats => chats; + + /// The chunkSize used for pagination + int get getChunkSize => chunkSize; } diff --git a/packages/chat_repository_interface/lib/src/local/local_memory_db.dart b/packages/chat_repository_interface/lib/src/local/local_memory_db.dart index 043b066..221794f 100644 --- a/packages/chat_repository_interface/lib/src/local/local_memory_db.dart +++ b/packages/chat_repository_interface/lib/src/local/local_memory_db.dart @@ -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/user_model.dart"; +/// The chunkSize for the LocalChatRepository +const int chunkSize = 10; + /// All the chats of the local memory database final List chats = []; diff --git a/packages/chat_repository_interface/lib/src/services/chat_service.dart b/packages/chat_repository_interface/lib/src/services/chat_service.dart index 561f694..a465f3c 100644 --- a/packages/chat_repository_interface/lib/src/services/chat_service.dart +++ b/packages/chat_repository_interface/lib/src/services/chat_service.dart @@ -141,6 +141,26 @@ class ChatService { chatId: chatId, ); + /// Signals that new messages should be loaded after the given message. + /// The stream should emit the new messages. + Future 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 loadOldMessagesBefore({ + required MessageModel firstMessage, + }) => + chatRepository.loadOldMessagesBefore( + userId: userId, + firstMessage: firstMessage, + ); + /// Send a message with the given parameters. /// [chatId] is the chat id. /// [senderId] is the sender id. diff --git a/packages/flutter_chat/lib/src/screens/chat_detail/chat_detail_screen.dart b/packages/flutter_chat/lib/src/screens/chat_detail/chat_detail_screen.dart index cd607f1..d02aae6 100644 --- a/packages/flutter_chat/lib/src/screens/chat_detail/chat_detail_screen.dart +++ b/packages/flutter_chat/lib/src/screens/chat_detail/chat_detail_screen.dart @@ -228,105 +228,184 @@ class _ChatBody extends HookWidget { final List chatUsers; final Function(UserModel) onPressUserProfile; final Function(Uint8List image) onUploadImage; - final Function(String message) onMessageSubmit; + final Function(String text) onMessageSubmit; final Function(ChatModel chat) onReadChat; @override Widget build(BuildContext context) { var chatScope = ChatScope.of(context); - var options = chatScope.options; var service = chatScope.service; + var options = chatScope.options; - var page = useState(0); - var showIndicator = useState(false); - var controller = useScrollController(); + var isLoadingOlder = useState(false); + var isLoadingNewer = useState(false); - /// Trigger to load new page when scrolling to the bottom - void handleScroll(PointerMoveEvent _) { - if (!showIndicator.value && - controller.offset >= controller.position.maxScrollExtent && - !controller.position.outOfRange) { - showIndicator.value = true; - page.value++; + var messagesStream = useMemoized( + () => service.getMessages(chatId: chatId), + [chatId], + ); + var messagesSnapshot = useStream(messagesStream); + var messages = messagesSnapshot.data ?? []; - Future.delayed(const Duration(seconds: 2), () { - if (!controller.hasClients) return; - showIndicator.value = false; + var scrollController = useScrollController(); + + Future 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 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) { - return const Center(child: CircularProgressIndicator()); + if (!options.enableLoadingIndicator) return const SizedBox.shrink(); + return options.builders.loadingWidgetBuilder.call(context); } - var messagesStream = useMemoized( - () => service.getMessages( - chatId: chat!.id, - ), - [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 userMap = {}; + for (var u in chatUsers) { + userMap[u.id] = u; } - var listViewChildren = messages.isEmpty && !showIndicator.value - ? [ - ChatNoMessages(isGroupChat: chat!.isGroupChat), - ] - : [ - for (var (index, message) in messages.indexed) ...[ - if (chat!.id == message.chatId) - ChatBubble( - key: ValueKey(message.id), - sender: chatUsers - .where( - (u) => u.id == message.senderId, - ) - .firstOrNull, - message: message, - previousMessage: - index < messages.length - 1 ? messages[index + 1] : null, - onPressSender: onPressUserProfile, - ), - ], - ]; + var topSpinner = (isLoadingOlder.value && options.enableLoadingIndicator) + ? const _LoaderItem() + : const SizedBox.shrink(); - return Stack( + var bottomSpinner = (isLoadingNewer.value && options.enableLoadingIndicator) + ? const _LoaderItem() + : const SizedBox.shrink(); + + var reversedMessages = messages.reversed.toList(); + var bubbleChildren = []; + 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( + key: ValueKey(msg.id), + message: msg, + previousMessage: prevMsg, + sender: userMap[msg.senderId], + onPressSender: onPressUserProfile, + ), + ); + } + } + + var listViewChildren = [ + topSpinner, + ...bubbleChildren, + bottomSpinner, + ]; + + return Column( children: [ - Column( - children: [ - Expanded( - child: Listener( - onPointerMove: handleScroll, - child: ListView( - shrinkWrap: true, - controller: controller, - physics: const AlwaysScrollableScrollPhysics(), - reverse: messages.isNotEmpty, - padding: const EdgeInsets.only(top: 24.0), - children: listViewChildren, - ), - ), + Expanded( + child: Align( + alignment: options.chatAlignment ?? Alignment.bottomCenter, + child: ListView( + shrinkWrap: true, + reverse: true, + controller: scrollController, + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.only(top: 24), + children: listViewChildren, ), - ChatBottomInputSection( - chat: chat!, - onPressSelectImage: () async => onPressSelectImage( - context, - options, - onUploadImage, - ), - onMessageSubmit: onMessageSubmit, - ), - ], + ), + ), + ChatBottomInputSection( + chat: chat!, + onPressSelectImage: () async => onPressSelectImage( + context, + options, + onUploadImage, + ), + 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), + ), + ), + ); +}