Repository: acterglobal/a3
Pull request #2728: Chat-ng scrolling fixes
Author: login: gtalha07
Created: 2025-03-18T17:11:59Z | Merged: 2025-03-25T01:08:49Z

# PR Description:

- Fixes [meta-797](https://github.com/acterglobal/a3-meta/issues/797). 
- Added scroll to bottom functionality for easier navigation to latest message when scrolling through previous messages.
- Smooth scrolling improvements when doing back-pagination.

## Demo:

### Mobile:

https://github.com/user-attachments/assets/d06a4952-6602-4963-ad85-a1ba42e141db

### Desktop:

https://github.com/user-attachments/assets/02c0b3c9-02e9-42ac-9c73-18c8addc28b2




Diff:
diff --git a/.changes/2728-chat-ng-scrolling.md b/.changes/2728-chat-ng-scrolling.md
new file mode 100644
index 000000000000..c4c2a7754f22
--- /dev/null
+++ b/.changes/2728-chat-ng-scrolling.md
@@ -0,0 +1,4 @@
+- [Labs] Chat NG
+- [Fix] opening chat room initially should correctly set to the last message.
+- [Improvement] added scroll-to-bottom feature. Scrolling through previous messages will show scroll-to-bottom button to navigate to last message.
+- [Improvement] smoothen scrolling effect while scrolling to previous messages.
diff --git a/app/lib/features/chat_ng/providers/notifiers/chat_room_messages_notifier.dart b/app/lib/features/chat_ng/providers/notifiers/chat_room_messages_notifier.dart
index da71236b1af6..2c1c4fa8aee9 100644
--- a/app/lib/features/chat_ng/providers/notifiers/chat_room_messages_notifier.dart
+++ b/app/lib/features/chat_ng/providers/notifiers/chat_room_messages_notifier.dart
@@ -84,6 +84,7 @@ ChatRoomState handleDiff(
   RoomMessageDiff diff, // the diff to apply
 ) {
   final action = diff.action();
+  // the diff is applied in reverse order as the animated list is provided to UI in reverse .i.e. for improved scrolling
   switch (action) {
     case 'Append':
       List<RoomMessage> incoming =
@@ -96,36 +97,50 @@ ChatRoomState handleDiff(
       for (final m in incoming) {
         final uniqueId = m.uniqueId();
         messages[uniqueId] = m;
-        messageList.add(uniqueId);
+        messageList.insert(0, uniqueId);
       }
       final endLen = messageList.length;
-      animatedList?.insertAllItems(startLen, endLen - startLen);
+
+      animatedList?.insertAllItems(0, endLen - startLen);
+
       return state.copyWith(messageList: messageList, messages: messages);
+
     case 'Set': // used to update UnableToDecrypt message
       RoomMessage m = diff.value().expect('set diff must contain value');
       final index = diff.index().expect('set diff must contain index');
 
+      final reversedIndex =
+          state.messageList.isEmpty ? 0 : state.messageList.length - 1 - index;
+
       final uniqueId = m.uniqueId();
       if (state.messageList.isEmpty) {
         animatedList?.insertItem(0);
         return state.copyWith(messageList: [uniqueId], messages: {uniqueId: m});
       }
       final messageList = state.messageList.toList();
-      final removedItem = messageList.removeAt(index);
-      messageList.insert(index, uniqueId);
+      final removedItem = messageList.removeAt(reversedIndex);
+      messageList.insert(reversedIndex, uniqueId);
 
       final messages = Map.fromEntries(
         state.messages.entries.where((entry) => entry.key != removedItem),
       );
       messages[uniqueId] = m;
+
       return state.copyWith(messageList: messageList, messages: messages);
     case 'Insert':
       RoomMessage m = diff.value().expect('insert diff must contain value');
       final index = diff.index().expect('insert diff must contain index');
-      return state.copyWithNewMessageAt(index, m, animatedList);
+
+      final reversedIndex =
+          state.messageList.isEmpty ? 0 : state.messageList.length - index;
+
+      return state.copyWithNewMessageAt(reversedIndex, m, animatedList);
     case 'Remove':
       int index = diff.index().expect('remove diff must contain index');
-      return state.copyWithRemovedMessageAt(index, animatedList);
+
+      final reversedIndex =
+          state.messageList.isEmpty ? 0 : state.messageList.length - 1 - index;
+      return state.copyWithRemovedMessageAt(reversedIndex, animatedList);
     case 'PushBack':
       RoomMessage m = diff.value().expect('push back diff must contain value');
 
@@ -134,24 +149,27 @@ ChatRoomState handleDiff(
         animatedList?.insertItem(0);
         return state.copyWith(messageList: [uniqueId], messages: {uniqueId: m});
       }
+
+      return state.copyWithNewMessageAt(0, m, animatedList);
+    case 'PushFront':
+      RoomMessage m = diff.value().expect('push front diff must contain value');
+
       return state.copyWithNewMessageAt(
         state.messageList.length,
         m,
         animatedList,
       );
-    case 'PushFront':
-      RoomMessage m = diff.value().expect('push front diff must contain value');
-      return state.copyWithNewMessageAt(0, m, animatedList);
     case 'PopBack':
       if (state.messageList.isEmpty) {
         return state;
       }
+
+      return state.copyWithRemovedMessageAt(0, animatedList);
+    case 'PopFront':
       return state.copyWithRemovedMessageAt(
         state.messageList.length - 1,
         animatedList,
       );
-    case 'PopFront':
-      return state.copyWithRemovedMessageAt(0, animatedList);
     case 'Clear':
       if (state.messageList.isNotEmpty && animatedList != null) {
         animatedList.removeAllItems((b, a) => const SizedBox.shrink());
@@ -160,16 +178,18 @@ ChatRoomState handleDiff(
     case 'Reset':
       List<RoomMessage> incoming =
           diff.values().expect('reset diff must contain values').toList();
+
       final (messageList, messages) = incoming.fold(
         (List<String>.empty(growable: true), <String, RoomMessage>{}),
         (val, m) {
           final (list, map) = val;
           final uniqueId = m.uniqueId();
-          list.add(uniqueId);
+          list.insert(0, uniqueId);
           map[uniqueId] = m;
           return (list, map);
         },
       );
+
       if (animatedList != null) {
         animatedList.removeAllItems((b, a) => const SizedBox.shrink());
         animatedList.insertAllItems(0, messageList.length);
@@ -181,36 +201,32 @@ ChatRoomState handleDiff(
       }
       final index = diff.index().expect('truncate diff must contain index');
 
-      final (before, after) = state.messageList.fold((<String>[], <String>[]), (
-        f,
-        e,
-      ) {
-        final (before, after) = f;
-        if (before.length >= index) {
-          after.add(e);
-        } else {
-          before.add(e);
-        }
-        return (before, after);
-      });
-      if (after.isEmpty) {
-        animatedList?.removeAllItems((a, b) => const SizedBox.shrink());
-        return state.copyWith(
-          messageList: before,
-          messages: Map.fromEntries(state.messages.entries),
-        );
+      final reversedIndex = state.messageList.length - index;
+
+      final messageList = state.messageList.toList();
+
+      final keptMessages = messageList.take(reversedIndex).toList();
+      final removedMessages = messageList.skip(reversedIndex).toList();
+
+      if (removedMessages.isEmpty) {
+        return state;
       } else {
         if (animatedList != null) {
-          for (var x = state.messageList.length; x >= after.length; x--) {
-            // remove from the bottom up
-            animatedList.removeItem(x - 1, (a, b) => const SizedBox.shrink());
+          for (var i = 0; i < removedMessages.length; i++) {
+            animatedList.removeItem(
+              reversedIndex + i,
+              (a, b) => const SizedBox.shrink(),
+            );
           }
         }
-        // we have to remove some
+
         final messages = Map.fromEntries(
-          state.messages.entries.where((entry) => !after.contains(entry.key)),
+          state.messages.entries.where(
+            (entry) => !removedMessages.contains(entry.key),
+          ),
         );
-        return state.copyWith(messageList: before, messages: messages);
+
+        return state.copyWith(messageList: keptMessages, messages: messages);
       }
     default:
       _log.severe('Unsupported action $action when diffing room messages');
diff --git a/app/lib/features/chat_ng/widgets/chat_messages.dart b/app/lib/features/chat_ng/widgets/chat_messages.dart
index 2cabee298156..80b0c98f5a03 100644
--- a/app/lib/features/chat_ng/widgets/chat_messages.dart
+++ b/app/lib/features/chat_ng/widgets/chat_messages.dart
@@ -9,21 +9,24 @@ import 'package:acter/features/labs/model/labs_features.dart';
 import 'package:acter/features/labs/providers/labs_providers.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:scroll_to_index/scroll_to_index.dart';
 
 class ChatMessages extends ConsumerStatefulWidget {
+  static const fabScrollToBottomKey = Key('chat_messages_fab_scroll_to_bottom');
   final String roomId;
   const ChatMessages({super.key, required this.roomId});
 
   @override
-  ConsumerState<ChatMessages> createState() => _ChatMessagesConsumerState();
+  ConsumerState<ChatMessages> createState() => ChatMessagesConsumerState();
 }
 
-class _ChatMessagesConsumerState extends ConsumerState<ChatMessages> {
-  final ScrollController _scrollController = ScrollController(
-    keepScrollOffset: true,
+class ChatMessagesConsumerState extends ConsumerState<ChatMessages> {
+  final ScrollController _scrollController = AutoScrollController(
+    initialScrollOffset: 0.0,
   );
 
   Timer? markReadDebouce;
+  bool showScrollToBottom = false;
 
   bool get isLoading => ref.watch(
     chatMessagesStateProvider(widget.roomId).select((v) => v.loading.isLoading),
@@ -36,11 +39,9 @@ class _ChatMessagesConsumerState extends ConsumerState<ChatMessages> {
     ref.listenManual(
       chatMessagesStateProvider(
         widget.roomId,
-      ).select((value) => value.messageList.length),
-      (_, __) {
-        if (_scrollController.hasClients &&
-            _scrollController.position.pixels >
-                _scrollController.position.maxScrollExtent - 150) {
+      ).select((value) => value.messageList),
+      (prev, current) {
+        if (prev == null && current.isNotEmpty) {
           WidgetsBinding.instance.addPostFrameCallback((_) => scrollToEnd());
         }
       },
@@ -50,6 +51,7 @@ class _ChatMessagesConsumerState extends ConsumerState<ChatMessages> {
 
   @override
   void dispose() {
+    markReadDebouce?.cancel();
     _scrollController.dispose();
     super.dispose();
   }
@@ -57,9 +59,9 @@ class _ChatMessagesConsumerState extends ConsumerState<ChatMessages> {
   Future<void> onScroll() async {
     if (isLoading) return;
 
-    // Check if we're near the top of the list
-    if (_scrollController.position.pixels <=
-        _scrollController.position.minScrollExtent) {
+    // Check if we're near the bottom of the list (which is now the top of the chat history)
+    if (_scrollController.position.pixels >=
+        _scrollController.position.maxScrollExtent) {
       if (isLoading) return;
 
       // Get the notifier to load more messages
@@ -67,8 +69,8 @@ class _ChatMessagesConsumerState extends ConsumerState<ChatMessages> {
         chatMessagesStateProvider(widget.roomId).notifier,
       );
       await notifier.loadMore();
-    } else if (_scrollController.position.pixels >=
-        _scrollController.position.maxScrollExtent) {
+    } else if (_scrollController.position.pixels <=
+        _scrollController.position.minScrollExtent) {
       // Unread marking support
       if (!ref.watch(isActiveProvider(LabsFeature.chatUnread))) {
         final roomId = widget.roomId;
@@ -86,13 +88,25 @@ class _ChatMessagesConsumerState extends ConsumerState<ChatMessages> {
         }
       }
     }
+
+    // Update scroll to bottom button visibility
+    final shouldShowButton =
+        _scrollController.hasClients &&
+        _scrollController.position.pixels >
+            (_scrollController.position.minScrollExtent + 5);
+
+    if (shouldShowButton != showScrollToBottom) {
+      setState(() {
+        showScrollToBottom = shouldShowButton;
+      });
+    }
   }
 
   void scrollToEnd() {
     if (!mounted || !_scrollController.hasClients) return;
 
     _scrollController.animateTo(
-      _scrollController.position.maxScrollExtent,
+      _scrollController.position.minScrollExtent,
       duration: const Duration(milliseconds: 300),
       curve: Curves.easeOut,
     );
@@ -112,7 +126,11 @@ class _ChatMessagesConsumerState extends ConsumerState<ChatMessages> {
         children: [
           Expanded(
             child: Stack(
-              children: [_buildMessagesList(messages), _buildScrollIndicator()],
+              children: [
+                _buildMessagesList(messages),
+                _buildScrollIndicator(),
+                _buildScrollToBottomButton(),
+              ],
             ),
           ),
         ],
@@ -126,8 +144,8 @@ class _ChatMessagesConsumerState extends ConsumerState<ChatMessages> {
       initialItemCount: messages.length,
       key: ref.watch(animatedListChatMessagesProvider(widget.roomId)),
       controller: _scrollController,
-      reverse: false,
-      padding: const EdgeInsets.only(top: 40),
+      reverse: true,
+      padding: const EdgeInsets.only(bottom: 40),
       itemBuilder:
           (_, index, animation) =>
               ChatEvent(roomId: widget.roomId, eventId: messages[index]),
@@ -149,4 +167,24 @@ class _ChatMessagesConsumerState extends ConsumerState<ChatMessages> {
       ),
     ),
   );
+
+  //  scroll indicator widget
+  Widget _buildScrollToBottomButton() => Positioned(
+    key: ChatMessages.fabScrollToBottomKey,
+    bottom: 16,
+    right: 16,
+    child: AnimatedOpacity(
+      opacity: showScrollToBottom ? 1.0 : 0.0,
+      duration: const Duration(milliseconds: 200),
+      child: AnimatedScale(
+        scale: showScrollToBottom ? 1.0 : 0.0,
+        duration: const Duration(milliseconds: 200),
+        child: FloatingActionButton(
+          mini: true,
+          onPressed: showScrollToBottom ? scrollToEnd : null,
+          child: const Icon(Icons.arrow_downward),
+        ),
+      ),
+    ),
+  );
 }
diff --git a/app/test/features/chat_ng/diff_applier_test.dart b/app/test/features/chat_ng/diff_applier_test.dart
index 0ea21055bc16..ff7ffecd8d26 100644
--- a/app/test/features/chat_ng/diff_applier_test.dart
+++ b/app/test/features/chat_ng/diff_applier_test.dart
@@ -88,7 +88,8 @@ void main() {
           messages: messages,
         );
         final newState = handleDiff(startingState, null, mockDiff);
-        expect(newState.messageList, ['a', 'b', 'c']);
+        // reversed
+        expect(newState.messageList, ['c', 'b', 'a']);
         expect(newState.messages.length, 3);
         expect(newState.messages.keys, ['a', 'b', 'c']);
       });
@@ -132,9 +133,9 @@ void main() {
         );
         final mockDiff = MockedRoomMessageDiff(act: 'PopFront');
         final newState = handleDiff(startingState, null, mockDiff);
-        expect(newState.messageList, ['b']);
+        expect(newState.messageList, ['d']);
         expect(newState.messages.length, 1);
-        expect(newState.messages.keys, ['b']);
+        expect(newState.messages.keys, ['d']);
       });
     });
 
@@ -156,9 +157,9 @@ void main() {
         );
         final mockDiff = MockedRoomMessageDiff(act: 'PopBack');
         final newState = handleDiff(startingState, null, mockDiff);
-        expect(newState.messageList, ['d']);
+        expect(newState.messageList, ['b']);
         expect(newState.messages.length, 1);
-        expect(newState.messages.keys, ['d']);
+        expect(newState.messages.keys, ['b']);
       });
     });
 
@@ -178,27 +179,30 @@ void main() {
           null,
           MockedRoomMessageDiff(act: 'Remove', idx: 0),
         );
-        expect(newState.messageList, ['b', 'e', 'f']);
+        // reversed
+        expect(newState.messageList, ['d', 'b', 'e']);
         expect(newState.messages.length, 3);
-        expect(newState.messages.keys, ['b', 'e', 'f']);
+        expect(newState.messages.keys, ['b', 'd', 'e']);
 
         final secondNew = handleDiff(
           startingState,
           null,
           MockedRoomMessageDiff(act: 'Remove', idx: 1),
         );
-        expect(secondNew.messageList, ['d', 'e', 'f']);
+        // reversed
+        expect(secondNew.messageList, ['d', 'b', 'f']);
         expect(secondNew.messages.length, 3);
-        expect(secondNew.messages.keys, ['d', 'e', 'f']);
+        expect(secondNew.messages.keys, ['b', 'd', 'f']);
 
         final thirdNew = handleDiff(
           startingState,
           null,
           MockedRoomMessageDiff(act: 'Remove', idx: 2),
         );
-        expect(thirdNew.messageList, ['d', 'b', 'f']);
+        // reversed
+        expect(thirdNew.messageList, ['d', 'e', 'f']);
         expect(thirdNew.messages.length, 3);
-        expect(thirdNew.messages.keys, ['b', 'd', 'f']);
+        expect(thirdNew.messages.keys, ['d', 'e', 'f']);
       });
     });
 
@@ -221,7 +225,8 @@ void main() {
             messages: [MockRoomMessage(id: 'g'), MockRoomMessage(id: 'h')],
           ),
         );
-        expect(newState.messageList, ['b', 'd', 'e', 'f', 'g', 'h']);
+        // reversed
+        expect(newState.messageList, ['h', 'g', 'b', 'd', 'e', 'f']);
         expect(newState.messages.length, 6);
         expect(newState.messages.keys, ['b', 'd', 'e', 'f', 'g', 'h']);
 
@@ -233,7 +238,8 @@ void main() {
             messages: [MockRoomMessage(id: 'a')],
           ),
         );
-        expect(secondState.messageList, ['b', 'd', 'e', 'f', 'a']);
+        // reversed
+        expect(secondState.messageList, ['a', 'b', 'd', 'e', 'f']);
         expect(secondState.messages.length, 5);
         expect(secondState.messages.keys, ['b', 'd', 'e', 'f', 'a']);
       });
@@ -263,7 +269,8 @@ void main() {
           message: MockRoomMessage(id: 'a'),
         );
         final newState = handleDiff(startingState, null, mockDiff);
-        expect(newState.messageList, ['d', 'b', 'a']);
+        // reversed
+        expect(newState.messageList, ['a', 'd', 'b']);
         expect(newState.messages.keys, ['d', 'b', 'a']);
       });
     });
@@ -292,7 +299,8 @@ void main() {
           message: MockRoomMessage(id: 'a'),
         );
         final newState = handleDiff(startingState, null, mockDiff);
-        expect(newState.messageList, ['a', 'd', 'b']);
+        // reversed
+        expect(newState.messageList, ['d', 'b', 'a']);
         expect(newState.messages.keys, ['d', 'b', 'a']);
       });
     });
@@ -377,8 +385,10 @@ void main() {
             'b': MockRoomMessage(id: 'b'),
           },
         );
-        final mockDiff = MockedRoomMessageDiff(act: 'Truncate', idx: 1);
+        // reversed
+        final mockDiff = MockedRoomMessageDiff(act: 'Truncate', idx: 2);
         final newState = handleDiff(startingState, null, mockDiff);
+
         expect(newState.messageList, ['d']);
         expect(newState.messages.keys, ['d']);
       });
@@ -417,7 +427,8 @@ void main() {
           messages: messages,
         );
         final newState = handleDiff(startingState, mockAnimatedState, mockDiff);
-        expect(newState.messageList, ['a', 'b', 'c']);
+        // reversed
+        expect(newState.messageList, ['c', 'b', 'a']);
         expect(newState.messages.length, 3);
         expect(newState.messages.keys, ['a', 'b', 'c']);
         verify(() => mockAnimatedState.removeAllItems(any())).called(1);
@@ -484,15 +495,17 @@ void main() {
         );
         final mockDiff = MockedRoomMessageDiff(act: 'PopFront');
         final newState = handleDiff(startingState, mockAnimatedState, mockDiff);
-        expect(newState.messageList, ['b']);
+        // reversed
+        expect(newState.messageList, ['d']);
         expect(newState.messages.length, 1);
-        expect(newState.messages.keys, ['b']);
+        expect(newState.messages.keys, ['d']);
 
         final verifier = verify(
           () => mockAnimatedState.removeItem(captureAny(), any()),
         );
         verifier.called(1);
-        expect(verifier.captured.single, 0);
+        // reversed
+        expect(verifier.captured.single, 1);
       });
     });
 
@@ -505,6 +518,7 @@ void main() {
           mockAnimatedState,
           mockDiff,
         );
+
         expect(newState.messageList.length, 0);
         expect(newState.messages.length, 0);
 
@@ -523,15 +537,17 @@ void main() {
         );
         final mockDiff = MockedRoomMessageDiff(act: 'PopBack');
         final newState = handleDiff(startingState, mockAnimatedState, mockDiff);
-        expect(newState.messageList, ['d']);
+        // reversed
+        expect(newState.messageList, ['b']);
         expect(newState.messages.length, 1);
-        expect(newState.messages.keys, ['d']);
+        expect(newState.messages.keys, ['b']);
 
         final verifier = verify(
           () => mockAnimatedState.removeItem(captureAny(), any()),
         );
         verifier.called(1);
-        expect(verifier.captured.single, 1);
+        // reversed
+        expect(verifier.captured.single, 0);
       });
     });
 
@@ -552,15 +568,17 @@ void main() {
           mockAnimatedState,
           MockedRoomMessageDiff(act: 'Remove', idx: 0),
         );
-        expect(newState.messageList, ['b', 'e', 'f']);
+        // reversed
+        expect(newState.messageList, ['d', 'b', 'e']);
         expect(newState.messages.length, 3);
-        expect(newState.messages.keys, ['b', 'e', 'f']);
+        expect(newState.messages.keys, ['b', 'd', 'e']);
 
         final verifier = verify(
           () => mockAnimatedState.removeItem(captureAny(), any()),
         );
         verifier.called(1);
-        expect(verifier.captured.single, 0);
+        // reversed
+        expect(verifier.captured.single, 3);
 
         mockAnimatedState = MockAnimatedListState();
         final secondNew = handleDiff(
@@ -568,15 +586,17 @@ void main() {
           mockAnimatedState,
           MockedRoomMessageDiff(act: 'Remove', idx: 1),
         );
-        expect(secondNew.messageList, ['d', 'e', 'f']);
+        // reversed
+        expect(secondNew.messageList, ['d', 'b', 'f']);
         expect(secondNew.messages.length, 3);
-        expect(secondNew.messages.keys, ['d', 'e', 'f']);
+        expect(secondNew.messages.keys, ['b', 'd', 'f']);
 
         final verifier2 = verify(
           () => mockAnimatedState.removeItem(captureAny(), any()),
         );
         verifier2.called(1);
-        expect(verifier2.captured.single, 1);
+        // reversed
+        expect(verifier2.captured.single, 2);
 
         mockAnimatedState = MockAnimatedListState();
 
@@ -585,15 +605,17 @@ void main() {
           mockAnimatedState,
           MockedRoomMessageDiff(act: 'Remove', idx: 2),
         );
-        expect(thirdNew.messageList, ['d', 'b', 'f']);
+        // reversed
+        expect(thirdNew.messageList, ['d', 'e', 'f']);
         expect(thirdNew.messages.length, 3);
-        expect(thirdNew.messages.keys, ['b', 'd', 'f']);
+        expect(thirdNew.messages.keys, ['d', 'e', 'f']);
 
         final verifier3 = verify(
           () => mockAnimatedState.removeItem(captureAny(), any()),
         );
         verifier3.called(1);
-        expect(verifier3.captured.single, 2);
+        // reversed
+        expect(verifier3.captured.single, 1);
       });
     });
 
@@ -617,7 +639,8 @@ void main() {
             messages: [MockRoomMessage(id: 'g'), MockRoomMessage(id: 'h')],
           ),
         );
-        expect(newState.messageList, ['b', 'd', 'e', 'f', 'g', 'h']);
+        // reversed
+        expect(newState.messageList, ['h', 'g', 'b', 'd', 'e', 'f']);
         expect(newState.messages.length, 6);
         expect(newState.messages.keys, ['b', 'd', 'e', 'f', 'g', 'h']);
 
@@ -625,7 +648,8 @@ void main() {
           () => mockAnimatedState.insertAllItems(captureAny(), captureAny()),
         );
         verifier1.called(1);
-        expect(verifier1.captured, [4, 2]);
+        // reversed
+        expect(verifier1.captured, [0, 2]);
 
         mockAnimatedState = MockAnimatedListState();
         final secondState = handleDiff(
@@ -636,7 +660,7 @@ void main() {
             messages: [MockRoomMessage(id: 'a')],
           ),
         );
-        expect(secondState.messageList, ['b', 'd', 'e', 'f', 'a']);
+        expect(secondState.messageList, ['a', 'b', 'd', 'e', 'f']);
         expect(secondState.messages.length, 5);
         expect(secondState.messages.keys, ['b', 'd', 'e', 'f', 'a']);
 
@@ -644,7 +668,7 @@ void main() {
           () => mockAnimatedState.insertAllItems(captureAny(), captureAny()),
         );
         verifier2.called(1);
-        expect(verifier2.captured, [4, 1]);
+        expect(verifier2.captured, [0, 1]);
       });
     });
 
@@ -684,14 +708,16 @@ void main() {
           message: MockRoomMessage(id: 'a'),
         );
         final newState = handleDiff(startingState, mockAnimatedState, mockDiff);
-        expect(newState.messageList, ['d', 'b', 'a']);
+        // reversed
+        expect(newState.messageList, ['a', 'd', 'b']);
         expect(newState.messages.keys, ['d', 'b', 'a']);
 
         final verifier1 = verify(
           () => mockAnimatedState.insertItem(captureAny()),
         );
         verifier1.called(1);
-        expect(verifier1.captured.single, 2);
+        // reversed
+        expect(verifier1.captured.single, 0);
       });
     });
 
@@ -731,14 +757,14 @@ void main() {
           message: MockRoomMessage(id: 'a'),
         );
         final newState = handleDiff(startingState, mockAnimatedState, mockDiff);
-        expect(newState.messageList, ['a', 'd', 'b']);
+        expect(newState.messageList, ['d', 'b', 'a']);
         expect(newState.messages.keys, ['d', 'b', 'a']);
 
         final verifier1 = verify(
           () => mockAnimatedState.insertItem(captureAny()),
         );
         verifier1.called(1);
-        expect(verifier1.captured.single, 0);
+        expect(verifier1.captured.single, 2);
       });
     });
 
@@ -861,8 +887,9 @@ void main() {
             'b': MockRoomMessage(id: 'b'),
           },
         );
-        final mockDiff = MockedRoomMessageDiff(act: 'Truncate', idx: 1);
+        final mockDiff = MockedRoomMessageDiff(act: 'Truncate', idx: 2);
         final newState = handleDiff(startingState, mockAnimatedState, mockDiff);
+        // reversed
         expect(newState.messageList, ['d']);
         expect(newState.messages.keys, ['d']);
 
@@ -870,7 +897,8 @@ void main() {
           () => mockAnimatedState.removeItem(captureAny(), any()),
         );
         verifier1.called(2);
-        expect(verifier1.captured, [2, 1]);
+        // reversed
+        expect(verifier1.captured, [1, 2]);
       });
     });
   });
diff --git a/app/test/features/chat_ng/widgets/chat_messages_list_test.dart b/app/test/features/chat_ng/widgets/chat_messages_list_test.dart
new file mode 100644
index 000000000000..8360d4ace9d1
--- /dev/null
+++ b/app/test/features/chat_ng/widgets/chat_messages_list_test.dart
@@ -0,0 +1,384 @@
+import 'package:acter/features/chat/providers/chat_providers.dart' as chat;
+import 'package:acter/features/chat_ng/models/chat_room_state/chat_room_state.dart';
+import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart';
+import 'package:acter/features/chat_ng/providers/notifiers/chat_room_messages_notifier.dart';
+import 'package:acter/features/chat_ng/widgets/chat_messages.dart';
+import 'package:acter/features/chat_ng/widgets/events/chat_event.dart';
+import 'package:acter/features/labs/model/labs_features.dart';
+import 'package:acter/features/labs/providers/labs_providers.dart';
+import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mocktail/mocktail.dart';
+
+import '../../../helpers/test_util.dart';
+
+class MockTimelineStream extends Mock implements TimelineStream {
+  @override
+  Future<bool> paginateBackwards(int count) async => false;
+}
+
+class MockRef extends Mock implements Ref {}
+
+class MockChatRoomMessagesNotifier extends Mock
+    implements ChatRoomMessagesNotifier {
+  final ChatRoomState initalState;
+  final bool useDefaultListener;
+
+  MockChatRoomMessagesNotifier(
+    this.initalState, {
+    this.useDefaultListener = true,
+  });
+
+  @override
+  ChatRoomState get state => initalState;
+
+  @override
+  void Function() addListener(
+    void Function(ChatRoomState) listener, {
+    bool fireImmediately = true,
+  }) {
+    if (useDefaultListener) {
+      if (fireImmediately) {
+        listener(state);
+      }
+      return () {};
+    }
+    return super.noSuchMethod(
+          Invocation.method(
+            #addListener,
+            [listener],
+            {#fireImmediately: fireImmediately},
+          ),
+        )
+        as void Function();
+  }
+}
+
+void main() {
+  group('ChatMessages Widget Tests', () {
+    final testRoomId = 'test-room-id';
+
+    testWidgets('renders empty state correctly', (tester) async {
+      final emptyState = ChatRoomState(
+        messageList: [],
+        loading: const ChatRoomLoadingState.initial(),
+      );
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(
+            testRoomId,
+          ).overrideWith((ref) => MockChatRoomMessagesNotifier(emptyState)),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => GlobalKey<AnimatedListState>()),
+          isActiveProvider(LabsFeature.chatUnread).overrideWith((ref) => false),
+          chat.hasUnreadMessages(testRoomId).overrideWith((ref) => false),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      await tester.pump();
+
+      // Now check for the list
+      expect(find.byType(AnimatedList), findsOneWidget);
+
+      expect(find.byType(ChatEvent), findsNothing);
+    });
+
+    testWidgets('renders messages correctly when available', (tester) async {
+      final messagesState = ChatRoomState(
+        messageList: List.generate(5, (index) => 'msg$index'),
+        loading: const ChatRoomLoadingState.loaded(),
+      );
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(
+            testRoomId,
+          ).overrideWith((ref) => MockChatRoomMessagesNotifier(messagesState)),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => GlobalKey<AnimatedListState>()),
+          isActiveProvider(LabsFeature.chatUnread).overrideWith((ref) => false),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      await tester.pump();
+
+      expect(find.byType(AnimatedList), findsOneWidget);
+
+      //  verify the list has the correct number of items
+      expect(messagesState.messageList.length, equals(5));
+    });
+
+    testWidgets('shows loading indicator and hides it when done loading', (
+      tester,
+    ) async {
+      final notifier = ChatRoomMessagesNotifier(
+        roomId: testRoomId,
+        ref: MockRef(),
+      );
+
+      // initialize with loading state
+      notifier.state = ChatRoomState(
+        messageList: ['msg1', 'msg2'],
+        loading: const ChatRoomLoadingState.loading(),
+      );
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(testRoomId).overrideWith((ref) => notifier),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => GlobalKey<AnimatedListState>()),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      // verify indicator is shown when loading
+      expect(find.byType(CircularProgressIndicator), findsOneWidget);
+
+      // update state to loaded
+      notifier.state = notifier.state.copyWith(
+        loading: const ChatRoomLoadingState.loaded(),
+      );
+
+      await tester.pump();
+      //   verify indicator is hidden when loaded
+      expect(find.byType(CircularProgressIndicator), findsNothing);
+    });
+
+    testWidgets('marks messages as read when scrolling to bottom', (
+      tester,
+    ) async {
+      final messagesState = ChatRoomState(
+        messageList: List.generate(20, (index) => 'msg$index'),
+        loading: const ChatRoomLoadingState.loaded(),
+      );
+
+      final mockTimelineStream = MockTimelineStream();
+      when(
+        () => mockTimelineStream.markAsRead(any()),
+      ).thenAnswer((_) async => false);
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(
+            testRoomId,
+          ).overrideWith((ref) => MockChatRoomMessagesNotifier(messagesState)),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(mockTimelineStream)),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => GlobalKey<AnimatedListState>()),
+          isActiveProvider(LabsFeature.chatUnread).overrideWith((ref) => false),
+          chat.hasUnreadMessages(testRoomId).overrideWith((ref) => true),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      final chatMessagesWidget = find.byType(ChatMessages);
+      final state =
+          tester.state(chatMessagesWidget) as ChatMessagesConsumerState;
+
+      await state.onScroll();
+
+      await tester.pump(const Duration(milliseconds: 300));
+
+      // verifymarkAsRead was called
+      verify(() => mockTimelineStream.markAsRead(true)).called(1);
+    });
+
+    testWidgets('handles message insertion animation correctly', (
+      tester,
+    ) async {
+      final initialState = ChatRoomState(
+        messageList: List.generate(3, (index) => 'msg$index'),
+        loading: const ChatRoomLoadingState.loaded(),
+      );
+
+      final notifier = ChatRoomMessagesNotifier(
+        roomId: testRoomId,
+        ref: MockRef(),
+      );
+      notifier.state = initialState;
+
+      final animatedListKey = GlobalKey<AnimatedListState>();
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(testRoomId).overrideWith((ref) => notifier),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => animatedListKey),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      expect(notifier.state.messageList.length, equals(3));
+
+      // verify initial message order
+      expect(notifier.state.messageList, equals(['msg0', 'msg1', 'msg2']));
+
+      // add new message
+      final updatedState = initialState.copyWith(
+        messageList: [...initialState.messageList, 'newMessage'],
+      );
+      notifier.state = updatedState;
+
+      await tester.pump();
+      await tester.pump(const Duration(milliseconds: 300));
+
+      // verify the list was updated
+      expect(notifier.state.messageList.length, equals(4));
+
+      // Verify final message order
+      expect(
+        notifier.state.messageList,
+        equals(['msg0', 'msg1', 'msg2', 'newMessage']),
+      );
+    });
+
+    testWidgets('scroll to end animates to the bottom of the list', (
+      WidgetTester tester,
+    ) async {
+      final messagesState = ChatRoomState(
+        messageList: List.generate(20, (index) => 'msg$index'),
+        loading: const ChatRoomLoadingState.loaded(),
+      );
+
+      final notifier = ChatRoomMessagesNotifier(
+        roomId: testRoomId,
+        ref: MockRef(),
+      );
+      notifier.state = messagesState;
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(testRoomId).overrideWith((ref) => notifier),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => GlobalKey<AnimatedListState>()),
+        ],
+        child: MaterialApp(
+          home: Scaffold(
+            body: SizedBox(
+              width: 800,
+              height: 600,
+              child: ChatMessages(roomId: testRoomId),
+            ),
+          ),
+        ),
+      );
+
+      final scrollController =
+          tester.widget<AnimatedList>(find.byType(AnimatedList)).controller;
+
+      // scroll up
+      scrollController?.position.jumpTo(100.0);
+      await tester.pump();
+
+      final fabFinder = find.byKey(ChatMessages.fabScrollToBottomKey);
+      expect(fabFinder, findsOneWidget);
+
+      // button is visible (opacity should be 1)
+      final opacityWidget = tester.widget<AnimatedOpacity>(
+        find.byType(AnimatedOpacity),
+      );
+      expect(opacityWidget.opacity, equals(1.0));
+
+      final buttonFinder = find.descendant(
+        of: fabFinder,
+        matching: find.byType(FloatingActionButton),
+      );
+
+      expect(buttonFinder, findsOneWidget);
+
+      await tester.ensureVisible(buttonFinder);
+      await tester.pumpAndSettle();
+      await tester.tap(buttonFinder);
+      await tester.pumpAndSettle(Durations.medium3);
+
+      expect(
+        scrollController?.position.pixels,
+        equals(scrollController!.position.minScrollExtent),
+      );
+
+      // verify button is hidden (opacity should be 0)
+      final updatedOpacityWidget = tester.widget<AnimatedOpacity>(
+        find.byType(AnimatedOpacity),
+      );
+      expect(updatedOpacityWidget.opacity, equals(0.0));
+    });
+
+    testWidgets('handles message removal correctly', (tester) async {
+      final initialMessages = List.generate(5, (index) => 'msg$index');
+
+      final notifier = ChatRoomMessagesNotifier(
+        roomId: testRoomId,
+        ref: MockRef(),
+      );
+
+      notifier.state = ChatRoomState(
+        messageList: initialMessages,
+        loading: const ChatRoomLoadingState.loaded(),
+      );
+
+      final animatedListKey = GlobalKey<AnimatedListState>();
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(testRoomId).overrideWith((ref) => notifier),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => animatedListKey),
+        ],
+        child: MaterialApp(
+          home: Scaffold(body: ChatMessages(roomId: testRoomId)),
+        ),
+      );
+
+      await tester.pump();
+
+      // initial message count
+      expect(notifier.state.messageList.length, equals(5));
+
+      // Remove a message
+      final updatedMessages = List<String>.from(initialMessages)..removeAt(2);
+      notifier.state = notifier.state.copyWith(messageList: updatedMessages);
+
+      await tester.pump();
+      await tester.pumpAndSettle();
+
+      // final message count
+      expect(notifier.state.messageList.length, equals(4));
+      expect(
+        notifier.state.messageList,
+        equals(['msg0', 'msg1', 'msg3', 'msg4']),
+      );
+    });
+  });
+}


Reviews:
user: login: gtalha07
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T04:23:22Z
user: login: gtalha07
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T04:24:27Z
user: login: kumarpalsinh25
body: Not sharing any explicit approval here even though changes addressing the exact issue. Because I am not sure whether approach of implementation is right or wrong.

Instead of managing reverse indexing and adding data from 0 indexes, why just just sorting list in reverse direction?

Because for each work which is relates to chat list, we need taken reverse indexing in consideration otherwise if we forgot then it may misbehave.

@gnunicorn
Would love to hear your feedback here.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T06:05:45Z
user: login: gnunicorn
body: This needs tests. One important thing why we wanted to do chat-ng is to also have a _more robust and well tested_ section and we are adding complexity without the appropriate _large_ number of tests ensuring this works as we want it to work. It is hard to understand the impact of this by reviewing ...

I can also totally understand @kumarpalsinh25 concern about swapping the order of the diffing algorithm. But I also see that just using `reversed` isn't going to cut it as it also implicitly manages the AnimatedList-State (for fewer rerenders). That said, while other things are covered - and I feel more tests would be even better, I am concerned that if we missed anything in there, it will be hard to debug and track down - the AnimatedList changes not being covered by tests is a problem.

In general: tests, all this should be covered by tests, also higher level widget tests that make sure the providers up to the UI update properly under various different states and circumstances . For e.g. what is totally unclear to me right now: if I scroll up and a new message comes in, does it scroll back down? How would the user know there is a new message if not? Tests!
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T12:31:51Z
user: login: gnunicorn
body: Thanks for adding tests. But some of them need some fixes/cleaning up.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-23T11:44:05Z
user: login: gtalha07
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T23:47:55Z
user: login: gtalha07
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T23:49:00Z

Review comments:
diff_hunk: @@ -84,6 +84,7 @@ ChatRoomState handleDiff(
   RoomMessageDiff diff, // the diff to apply
 ) {
   final action = diff.action();
+  // the diff is applied in reverse order as the animated list is provided to UI in reverse .i.e. for improved scrolling
path: app/lib/features/chat_ng/providers/notifiers/chat_room_messages_notifier.dart
user: login: gtalha07
body: Tried various ways to fix scrolling without reversing the list but unfortunately, couldn't find a way to have optimal scroll offsets for various requirements we have for the chat .i.e. encountering offset bugs like not scrolling over all the bottom and how the pagination loading affects the UI refresh before. 

It seems like keeping `reverse:true` is only thing fixing the offset issue, which is also a normal behavior of the chat messages.
created_at: 2025-03-20T04:23:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -84,6 +84,7 @@ ChatRoomState handleDiff(
   RoomMessageDiff diff, // the diff to apply
 ) {
   final action = diff.action();
+  // the diff is applied in reverse order as the animated list is provided to UI in reverse .i.e. for improved scrolling
path: app/lib/features/chat_ng/providers/notifiers/chat_room_messages_notifier.dart
user: login: gtalha07
body: The change-set only addresses the updates over indexing, as we have reversed the list.
created_at: 2025-03-20T04:24:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -181,36 +201,32 @@ ChatRoomState handleDiff(
       }
       final index = diff.index().expect('truncate diff must contain index');
 
-      final (before, after) = state.messageList.fold((<String>[], <String>[]), (
-        f,
-        e,
-      ) {
-        final (before, after) = f;
-        if (before.length >= index) {
-          after.add(e);
-        } else {
-          before.add(e);
-        }
-        return (before, after);
-      });
-      if (after.isEmpty) {
-        animatedList?.removeAllItems((a, b) => const SizedBox.shrink());
-        return state.copyWith(
-          messageList: before,
-          messages: Map.fromEntries(state.messages.entries),
-        );
+      final reversedIndex = state.messageList.length - index;
+
+      final messageList = state.messageList.toList();
+
+      final keptMessages = messageList.take(reversedIndex).toList();
+      final removedMessages = messageList.skip(reversedIndex).toList();
+
+      if (removedMessages.isEmpty) {
+        return state;
       } else {
         if (animatedList != null) {
-          for (var x = state.messageList.length; x >= after.length; x--) {
-            // remove from the bottom up
-            animatedList.removeItem(x - 1, (a, b) => const SizedBox.shrink());
+          for (var i = 0; i < removedMessages.length; i++) {
+            animatedList.removeItem(
+              reversedIndex + i,
+              (a, b) => const SizedBox.shrink(),
+            );
           }
         }
-        // we have to remove some
+
         final messages = Map.fromEntries(
-          state.messages.entries.where((entry) => !after.contains(entry.key)),
+          state.messages.entries.where(
+            (entry) => !removedMessages.contains(entry.key),
+          ),
         );
-        return state.copyWith(messageList: before, messages: messages);
+
+        return state.copyWith(messageList: keptMessages, messages: messages);
path: app/lib/features/chat_ng/providers/notifiers/chat_room_messages_notifier.dart
user: login: gnunicorn
body: this section not being covered by tests concerns me. 
created_at: 2025-03-20T12:24:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -36,11 +38,9 @@ class _ChatMessagesConsumerState extends ConsumerState<ChatMessages> {
     ref.listenManual(
       chatMessagesStateProvider(
         widget.roomId,
-      ).select((value) => value.messageList.length),
-      (_, __) {
-        if (_scrollController.hasClients &&
-            _scrollController.position.pixels >
-                _scrollController.position.maxScrollExtent - 150) {
+      ).select((value) => value.messageList),
path: app/lib/features/chat_ng/widgets/chat_messages.dart
user: login: gnunicorn
body: I know this doesn't sound easy, but I think especially now that we are growing its complexity, this widget _needs tests_ for all these complex problems. all these complex problems that are being tried to address are really hard to recreate in real life and even harder to follow in any review. this needs tests.
created_at: 2025-03-20T12:26:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,406 @@
+import 'package:acter/features/chat/providers/chat_providers.dart' as chat;
+import 'package:acter/features/chat_ng/models/chat_room_state/chat_room_state.dart';
+import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart';
+import 'package:acter/features/chat_ng/providers/notifiers/chat_room_messages_notifier.dart';
+import 'package:acter/features/chat_ng/widgets/chat_messages.dart';
+import 'package:acter/features/chat_ng/widgets/events/chat_event.dart';
+import 'package:acter/features/labs/model/labs_features.dart';
+import 'package:acter/features/labs/providers/labs_providers.dart';
+import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+// import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mocktail/mocktail.dart';
+
+import '../../../helpers/test_util.dart';
+
+class MockTimelineStream extends Mock implements TimelineStream {
+  @override
+  Future<bool> paginateBackwards(int count) async => false;
+}
+
+class MockRef extends Mock implements Ref {}
+
+class MockChatRoomMessagesNotifier extends Mock
+    implements ChatRoomMessagesNotifier {
+  final ChatRoomState initalState;
+
+  MockChatRoomMessagesNotifier(this.initalState);
+
+  @override
+  ChatRoomState get state => initalState;
+
+  @override
+  void Function() addListener(
+    void Function(ChatRoomState) listener, {
+    bool fireImmediately = true,
+  }) {
+    if (fireImmediately) {
+      listener(state);
+    }
+    return () {};
+  }
+
+  @override
+  Future<void> loadMore({bool failOnError = true}) async {}
+}
+
+void main() {
+  group('ChatMessages Widget Tests', () {
+    final testRoomId = 'test-room-id';
+
+    setUp(() {
+      registerFallbackValue(const ChatRoomState());
+      registerFallbackValue(true); // Register fallback for boolean parameters
+      registerFallbackValue(false); // Register fallback for boolean parameters
path: app/test/features/chat_ng/widgets/chat_messages_list_test.dart
user: login: gnunicorn
body: I assume this comes from some AI generation code, but are these really _both_ necessary? 
created_at: 2025-03-23T11:26:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,406 @@
+import 'package:acter/features/chat/providers/chat_providers.dart' as chat;
+import 'package:acter/features/chat_ng/models/chat_room_state/chat_room_state.dart';
+import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart';
+import 'package:acter/features/chat_ng/providers/notifiers/chat_room_messages_notifier.dart';
+import 'package:acter/features/chat_ng/widgets/chat_messages.dart';
+import 'package:acter/features/chat_ng/widgets/events/chat_event.dart';
+import 'package:acter/features/labs/model/labs_features.dart';
+import 'package:acter/features/labs/providers/labs_providers.dart';
+import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+// import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mocktail/mocktail.dart';
+
+import '../../../helpers/test_util.dart';
+
+class MockTimelineStream extends Mock implements TimelineStream {
+  @override
+  Future<bool> paginateBackwards(int count) async => false;
+}
+
+class MockRef extends Mock implements Ref {}
+
+class MockChatRoomMessagesNotifier extends Mock
+    implements ChatRoomMessagesNotifier {
+  final ChatRoomState initalState;
+
+  MockChatRoomMessagesNotifier(this.initalState);
+
+  @override
+  ChatRoomState get state => initalState;
+
+  @override
+  void Function() addListener(
+    void Function(ChatRoomState) listener, {
+    bool fireImmediately = true,
+  }) {
+    if (fireImmediately) {
+      listener(state);
+    }
+    return () {};
+  }
+
+  @override
+  Future<void> loadMore({bool failOnError = true}) async {}
+}
+
+void main() {
+  group('ChatMessages Widget Tests', () {
+    final testRoomId = 'test-room-id';
+
+    setUp(() {
+      registerFallbackValue(const ChatRoomState());
+      registerFallbackValue(true); // Register fallback for boolean parameters
+      registerFallbackValue(false); // Register fallback for boolean parameters
+    });
+
+    testWidgets('renders empty state correctly', (tester) async {
+      final emptyState = ChatRoomState(
+        messageList: [],
+        loading: const ChatRoomLoadingState.initial(),
+      );
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(
+            testRoomId,
+          ).overrideWith((ref) => MockChatRoomMessagesNotifier(emptyState)),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => GlobalKey<AnimatedListState>()),
+          isActiveProvider(LabsFeature.chatUnread).overrideWith((ref) => false),
+          chat.hasUnreadMessages(testRoomId).overrideWith((ref) => false),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      await tester.pump();
+
+      // Now check for the list
+      expect(find.byType(AnimatedList), findsOneWidget);
+
+      expect(find.byType(ChatEvent), findsNothing);
+    });
+
+    testWidgets('renders messages correctly when available', (tester) async {
+      final messagesState = ChatRoomState(
+        messageList: List.generate(5, (index) => 'msg$index'),
+        loading: const ChatRoomLoadingState.loaded(),
+      );
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(
+            testRoomId,
+          ).overrideWith((ref) => MockChatRoomMessagesNotifier(messagesState)),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => GlobalKey<AnimatedListState>()),
+          isActiveProvider(LabsFeature.chatUnread).overrideWith((ref) => false),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      await tester.pump();
+
+      expect(find.byType(AnimatedList), findsOneWidget);
+
+      //  verify the list has the correct number of items
+      final animatedList = tester.widget<AnimatedList>(
+        find.byType(AnimatedList),
+      );
+      expect(animatedList.initialItemCount, equals(5));
+    });
+
+    testWidgets('shows loading indicator when loading', (tester) async {
+      final loadingState = ChatRoomState(
+        messageList: ['msg1', 'msg2'],
+        loading: const ChatRoomLoadingState.loading(),
+      );
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(
+            testRoomId,
+          ).overrideWith((ref) => MockChatRoomMessagesNotifier(loadingState)),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => GlobalKey<AnimatedListState>()),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      expect(find.byType(CircularProgressIndicator), findsOneWidget);
+    });
+
+    testWidgets('hides loading indicator when not loading', (tester) async {
path: app/test/features/chat_ng/widgets/chat_messages_list_test.dart
user: login: gnunicorn
body: this is not what the tests checks for. It checks whether the loading animation isn't show _if it is started that way_. but I would like it to test what the test says instead: that it is shown in loading and when _changed_ switches to not showing. Maybe we can update the tests to check for that?
created_at: 2025-03-23T11:29:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,406 @@
+import 'package:acter/features/chat/providers/chat_providers.dart' as chat;
+import 'package:acter/features/chat_ng/models/chat_room_state/chat_room_state.dart';
+import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart';
+import 'package:acter/features/chat_ng/providers/notifiers/chat_room_messages_notifier.dart';
+import 'package:acter/features/chat_ng/widgets/chat_messages.dart';
+import 'package:acter/features/chat_ng/widgets/events/chat_event.dart';
+import 'package:acter/features/labs/model/labs_features.dart';
+import 'package:acter/features/labs/providers/labs_providers.dart';
+import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+// import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mocktail/mocktail.dart';
+
+import '../../../helpers/test_util.dart';
+
+class MockTimelineStream extends Mock implements TimelineStream {
+  @override
+  Future<bool> paginateBackwards(int count) async => false;
+}
+
+class MockRef extends Mock implements Ref {}
+
+class MockChatRoomMessagesNotifier extends Mock
+    implements ChatRoomMessagesNotifier {
+  final ChatRoomState initalState;
+
+  MockChatRoomMessagesNotifier(this.initalState);
+
+  @override
+  ChatRoomState get state => initalState;
+
+  @override
+  void Function() addListener(
+    void Function(ChatRoomState) listener, {
+    bool fireImmediately = true,
+  }) {
+    if (fireImmediately) {
+      listener(state);
+    }
+    return () {};
+  }
+
+  @override
+  Future<void> loadMore({bool failOnError = true}) async {}
+}
+
+void main() {
+  group('ChatMessages Widget Tests', () {
+    final testRoomId = 'test-room-id';
+
+    setUp(() {
+      registerFallbackValue(const ChatRoomState());
+      registerFallbackValue(true); // Register fallback for boolean parameters
+      registerFallbackValue(false); // Register fallback for boolean parameters
+    });
+
+    testWidgets('renders empty state correctly', (tester) async {
+      final emptyState = ChatRoomState(
+        messageList: [],
+        loading: const ChatRoomLoadingState.initial(),
+      );
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(
+            testRoomId,
+          ).overrideWith((ref) => MockChatRoomMessagesNotifier(emptyState)),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => GlobalKey<AnimatedListState>()),
+          isActiveProvider(LabsFeature.chatUnread).overrideWith((ref) => false),
+          chat.hasUnreadMessages(testRoomId).overrideWith((ref) => false),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      await tester.pump();
+
+      // Now check for the list
+      expect(find.byType(AnimatedList), findsOneWidget);
+
+      expect(find.byType(ChatEvent), findsNothing);
+    });
+
+    testWidgets('renders messages correctly when available', (tester) async {
+      final messagesState = ChatRoomState(
+        messageList: List.generate(5, (index) => 'msg$index'),
+        loading: const ChatRoomLoadingState.loaded(),
+      );
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(
+            testRoomId,
+          ).overrideWith((ref) => MockChatRoomMessagesNotifier(messagesState)),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => GlobalKey<AnimatedListState>()),
+          isActiveProvider(LabsFeature.chatUnread).overrideWith((ref) => false),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      await tester.pump();
+
+      expect(find.byType(AnimatedList), findsOneWidget);
+
+      //  verify the list has the correct number of items
+      final animatedList = tester.widget<AnimatedList>(
+        find.byType(AnimatedList),
+      );
+      expect(animatedList.initialItemCount, equals(5));
+    });
+
+    testWidgets('shows loading indicator when loading', (tester) async {
+      final loadingState = ChatRoomState(
+        messageList: ['msg1', 'msg2'],
+        loading: const ChatRoomLoadingState.loading(),
+      );
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(
+            testRoomId,
+          ).overrideWith((ref) => MockChatRoomMessagesNotifier(loadingState)),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => GlobalKey<AnimatedListState>()),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      expect(find.byType(CircularProgressIndicator), findsOneWidget);
+    });
+
+    testWidgets('hides loading indicator when not loading', (tester) async {
+      final loadedState = ChatRoomState(
+        messageList: ['msg1', 'msg2'],
+        loading: const ChatRoomLoadingState.loaded(),
+      );
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(
+            testRoomId,
+          ).overrideWith((ref) => MockChatRoomMessagesNotifier(loadedState)),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => GlobalKey<AnimatedListState>()),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      expect(find.byType(CircularProgressIndicator), findsNothing);
+    });
+
+    testWidgets('automatically scrolls to bottom on initial load', (
+      tester,
+    ) async {
+      final initialState = ChatRoomState(
+        messageList: [],
+        loading: const ChatRoomLoadingState.loaded(),
+      );
+
+      final notifier = ChatRoomMessagesNotifier(
+        roomId: testRoomId,
+        ref: MockRef(),
+      );
+      notifier.state = initialState;
+
+      final animatedListKey = GlobalKey<AnimatedListState>();
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(testRoomId).overrideWith((ref) => notifier),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => animatedListKey),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      // Update with messages to trigger auto-scroll
+      final updatedState = initialState.copyWith(
+        messageList: List.generate(5, (index) => 'msg$index'),
+      );
+      notifier.state = updatedState;
path: app/test/features/chat_ng/widgets/chat_messages_list_test.dart
user: login: gnunicorn
body: does this actually work? I thought it was necessary to do actually modify the notifier via the `ref` and not just standalone. And after it is not tested in here that the change actually did anything on the object, no check whether more items are rendered. I suspect the test passes because this change doesn't actually happen. Can we have additional checks that the update actually took place.
created_at: 2025-03-23T11:31:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,406 @@
+import 'package:acter/features/chat/providers/chat_providers.dart' as chat;
+import 'package:acter/features/chat_ng/models/chat_room_state/chat_room_state.dart';
+import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart';
+import 'package:acter/features/chat_ng/providers/notifiers/chat_room_messages_notifier.dart';
+import 'package:acter/features/chat_ng/widgets/chat_messages.dart';
+import 'package:acter/features/chat_ng/widgets/events/chat_event.dart';
+import 'package:acter/features/labs/model/labs_features.dart';
+import 'package:acter/features/labs/providers/labs_providers.dart';
+import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+// import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mocktail/mocktail.dart';
+
+import '../../../helpers/test_util.dart';
+
+class MockTimelineStream extends Mock implements TimelineStream {
+  @override
+  Future<bool> paginateBackwards(int count) async => false;
+}
+
+class MockRef extends Mock implements Ref {}
+
+class MockChatRoomMessagesNotifier extends Mock
+    implements ChatRoomMessagesNotifier {
+  final ChatRoomState initalState;
+
+  MockChatRoomMessagesNotifier(this.initalState);
+
+  @override
+  ChatRoomState get state => initalState;
+
+  @override
+  void Function() addListener(
+    void Function(ChatRoomState) listener, {
+    bool fireImmediately = true,
+  }) {
+    if (fireImmediately) {
+      listener(state);
+    }
+    return () {};
+  }
+
+  @override
+  Future<void> loadMore({bool failOnError = true}) async {}
+}
+
+void main() {
+  group('ChatMessages Widget Tests', () {
+    final testRoomId = 'test-room-id';
+
+    setUp(() {
+      registerFallbackValue(const ChatRoomState());
+      registerFallbackValue(true); // Register fallback for boolean parameters
+      registerFallbackValue(false); // Register fallback for boolean parameters
+    });
+
+    testWidgets('renders empty state correctly', (tester) async {
+      final emptyState = ChatRoomState(
+        messageList: [],
+        loading: const ChatRoomLoadingState.initial(),
+      );
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(
+            testRoomId,
+          ).overrideWith((ref) => MockChatRoomMessagesNotifier(emptyState)),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => GlobalKey<AnimatedListState>()),
+          isActiveProvider(LabsFeature.chatUnread).overrideWith((ref) => false),
+          chat.hasUnreadMessages(testRoomId).overrideWith((ref) => false),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      await tester.pump();
+
+      // Now check for the list
+      expect(find.byType(AnimatedList), findsOneWidget);
+
+      expect(find.byType(ChatEvent), findsNothing);
+    });
+
+    testWidgets('renders messages correctly when available', (tester) async {
+      final messagesState = ChatRoomState(
+        messageList: List.generate(5, (index) => 'msg$index'),
+        loading: const ChatRoomLoadingState.loaded(),
+      );
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(
+            testRoomId,
+          ).overrideWith((ref) => MockChatRoomMessagesNotifier(messagesState)),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => GlobalKey<AnimatedListState>()),
+          isActiveProvider(LabsFeature.chatUnread).overrideWith((ref) => false),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      await tester.pump();
+
+      expect(find.byType(AnimatedList), findsOneWidget);
+
+      //  verify the list has the correct number of items
+      final animatedList = tester.widget<AnimatedList>(
+        find.byType(AnimatedList),
+      );
+      expect(animatedList.initialItemCount, equals(5));
+    });
+
+    testWidgets('shows loading indicator when loading', (tester) async {
+      final loadingState = ChatRoomState(
+        messageList: ['msg1', 'msg2'],
+        loading: const ChatRoomLoadingState.loading(),
+      );
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(
+            testRoomId,
+          ).overrideWith((ref) => MockChatRoomMessagesNotifier(loadingState)),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => GlobalKey<AnimatedListState>()),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      expect(find.byType(CircularProgressIndicator), findsOneWidget);
+    });
+
+    testWidgets('hides loading indicator when not loading', (tester) async {
+      final loadedState = ChatRoomState(
+        messageList: ['msg1', 'msg2'],
+        loading: const ChatRoomLoadingState.loaded(),
+      );
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(
+            testRoomId,
+          ).overrideWith((ref) => MockChatRoomMessagesNotifier(loadedState)),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => GlobalKey<AnimatedListState>()),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      expect(find.byType(CircularProgressIndicator), findsNothing);
+    });
+
+    testWidgets('automatically scrolls to bottom on initial load', (
+      tester,
+    ) async {
+      final initialState = ChatRoomState(
+        messageList: [],
+        loading: const ChatRoomLoadingState.loaded(),
+      );
+
+      final notifier = ChatRoomMessagesNotifier(
+        roomId: testRoomId,
+        ref: MockRef(),
+      );
+      notifier.state = initialState;
+
+      final animatedListKey = GlobalKey<AnimatedListState>();
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(testRoomId).overrideWith((ref) => notifier),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => animatedListKey),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      // Update with messages to trigger auto-scroll
+      final updatedState = initialState.copyWith(
+        messageList: List.generate(5, (index) => 'msg$index'),
+      );
+      notifier.state = updatedState;
+
+      await tester.pump();
+      await tester.pump(const Duration(milliseconds: 300));
+
+      // Verify the scroll position is at the bottom (0.0 for reversed list)
+      final scrollController =
+          tester.widget<AnimatedList>(find.byType(AnimatedList)).controller;
+      expect(scrollController?.position.pixels, equals(0.0));
+    });
+
+    testWidgets('marks messages as read when scrolling to bottom', (
+      tester,
+    ) async {
+      final messagesState = ChatRoomState(
+        messageList: List.generate(20, (index) => 'msg$index'),
+        loading: const ChatRoomLoadingState.loaded(),
+      );
+
+      final mockTimelineStream = MockTimelineStream();
+      when(
+        () => mockTimelineStream.markAsRead(any()),
+      ).thenAnswer((_) async => false);
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(
+            testRoomId,
+          ).overrideWith((ref) => MockChatRoomMessagesNotifier(messagesState)),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(mockTimelineStream)),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => GlobalKey<AnimatedListState>()),
+          isActiveProvider(LabsFeature.chatUnread).overrideWith((ref) => false),
+          chat.hasUnreadMessages(testRoomId).overrideWith((ref) => true),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      final chatMessagesWidget = find.byType(ChatMessages);
+      final state = tester.state(chatMessagesWidget) as dynamic;
+
+      await state.onScroll();
+
+      await tester.pump(const Duration(milliseconds: 300));
+
+      // verifymarkAsRead was called
+      verify(() => mockTimelineStream.markAsRead(true)).called(1);
+    });
+
+    testWidgets('handles message insertion animation correctly', (
+      tester,
+    ) async {
+      final initialState = ChatRoomState(
+        messageList: List.generate(3, (index) => 'msg$index'),
+        loading: const ChatRoomLoadingState.loaded(),
+      );
+
+      // Create a real notifier that we can update
+      final notifier = ChatRoomMessagesNotifier(
+        roomId: testRoomId,
+        ref: MockRef(),
+      );
+      notifier.state = initialState;
+
+      final animatedListKey = GlobalKey<AnimatedListState>();
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(testRoomId).overrideWith((ref) => notifier),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => animatedListKey),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      expect(animatedListKey.currentState?.widget.initialItemCount, equals(3));
+
+      // Verify initial message order
+      expect(notifier.state.messageList, equals(['msg0', 'msg1', 'msg2']));
+
+      // add new message
+      final updatedState = initialState.copyWith(
+        messageList: [...initialState.messageList, 'newMessage'],
+      );
+      notifier.state = updatedState;
+
+      await tester.pump();
+      await tester.pump(const Duration(milliseconds: 300));
+
+      // verify the list was updated
+      expect(animatedListKey.currentState?.widget.initialItemCount, equals(4));
path: app/test/features/chat_ng/widgets/chat_messages_list_test.dart
user: login: gnunicorn
body: this doesn't actually check for what the comment says. it is checking only whether the incoming parameter was updated, not whether the actual list items updated. I am not sure we can actually check for the items of the state but this tests creates a false sense of security that will make debugging harder as it would show a false negative about the actual state of the system.

In such a case I'd rather have the diffing test extended with a mocked animatedList that is checking for the right methods to be called. In tandem they can be fairly accurate then.
created_at: 2025-03-23T11:39:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,406 @@
+import 'package:acter/features/chat/providers/chat_providers.dart' as chat;
+import 'package:acter/features/chat_ng/models/chat_room_state/chat_room_state.dart';
+import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart';
+import 'package:acter/features/chat_ng/providers/notifiers/chat_room_messages_notifier.dart';
+import 'package:acter/features/chat_ng/widgets/chat_messages.dart';
+import 'package:acter/features/chat_ng/widgets/events/chat_event.dart';
+import 'package:acter/features/labs/model/labs_features.dart';
+import 'package:acter/features/labs/providers/labs_providers.dart';
+import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+// import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mocktail/mocktail.dart';
+
+import '../../../helpers/test_util.dart';
+
+class MockTimelineStream extends Mock implements TimelineStream {
+  @override
+  Future<bool> paginateBackwards(int count) async => false;
+}
+
+class MockRef extends Mock implements Ref {}
+
+class MockChatRoomMessagesNotifier extends Mock
+    implements ChatRoomMessagesNotifier {
+  final ChatRoomState initalState;
+
+  MockChatRoomMessagesNotifier(this.initalState);
+
+  @override
+  ChatRoomState get state => initalState;
+
+  @override
+  void Function() addListener(
+    void Function(ChatRoomState) listener, {
+    bool fireImmediately = true,
+  }) {
+    if (fireImmediately) {
+      listener(state);
+    }
+    return () {};
+  }
+
+  @override
+  Future<void> loadMore({bool failOnError = true}) async {}
+}
+
+void main() {
+  group('ChatMessages Widget Tests', () {
+    final testRoomId = 'test-room-id';
+
+    setUp(() {
+      registerFallbackValue(const ChatRoomState());
+      registerFallbackValue(true); // Register fallback for boolean parameters
+      registerFallbackValue(false); // Register fallback for boolean parameters
+    });
+
+    testWidgets('renders empty state correctly', (tester) async {
+      final emptyState = ChatRoomState(
+        messageList: [],
+        loading: const ChatRoomLoadingState.initial(),
+      );
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(
+            testRoomId,
+          ).overrideWith((ref) => MockChatRoomMessagesNotifier(emptyState)),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => GlobalKey<AnimatedListState>()),
+          isActiveProvider(LabsFeature.chatUnread).overrideWith((ref) => false),
+          chat.hasUnreadMessages(testRoomId).overrideWith((ref) => false),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      await tester.pump();
+
+      // Now check for the list
+      expect(find.byType(AnimatedList), findsOneWidget);
+
+      expect(find.byType(ChatEvent), findsNothing);
+    });
+
+    testWidgets('renders messages correctly when available', (tester) async {
+      final messagesState = ChatRoomState(
+        messageList: List.generate(5, (index) => 'msg$index'),
+        loading: const ChatRoomLoadingState.loaded(),
+      );
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(
+            testRoomId,
+          ).overrideWith((ref) => MockChatRoomMessagesNotifier(messagesState)),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => GlobalKey<AnimatedListState>()),
+          isActiveProvider(LabsFeature.chatUnread).overrideWith((ref) => false),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      await tester.pump();
+
+      expect(find.byType(AnimatedList), findsOneWidget);
+
+      //  verify the list has the correct number of items
+      final animatedList = tester.widget<AnimatedList>(
+        find.byType(AnimatedList),
+      );
+      expect(animatedList.initialItemCount, equals(5));
+    });
+
+    testWidgets('shows loading indicator when loading', (tester) async {
+      final loadingState = ChatRoomState(
+        messageList: ['msg1', 'msg2'],
+        loading: const ChatRoomLoadingState.loading(),
+      );
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(
+            testRoomId,
+          ).overrideWith((ref) => MockChatRoomMessagesNotifier(loadingState)),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => GlobalKey<AnimatedListState>()),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      expect(find.byType(CircularProgressIndicator), findsOneWidget);
+    });
+
+    testWidgets('hides loading indicator when not loading', (tester) async {
+      final loadedState = ChatRoomState(
+        messageList: ['msg1', 'msg2'],
+        loading: const ChatRoomLoadingState.loaded(),
+      );
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(
+            testRoomId,
+          ).overrideWith((ref) => MockChatRoomMessagesNotifier(loadedState)),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => GlobalKey<AnimatedListState>()),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      expect(find.byType(CircularProgressIndicator), findsNothing);
+    });
+
+    testWidgets('automatically scrolls to bottom on initial load', (
+      tester,
+    ) async {
+      final initialState = ChatRoomState(
+        messageList: [],
+        loading: const ChatRoomLoadingState.loaded(),
+      );
+
+      final notifier = ChatRoomMessagesNotifier(
+        roomId: testRoomId,
+        ref: MockRef(),
+      );
+      notifier.state = initialState;
+
+      final animatedListKey = GlobalKey<AnimatedListState>();
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(testRoomId).overrideWith((ref) => notifier),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => animatedListKey),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      // Update with messages to trigger auto-scroll
+      final updatedState = initialState.copyWith(
+        messageList: List.generate(5, (index) => 'msg$index'),
+      );
+      notifier.state = updatedState;
+
+      await tester.pump();
+      await tester.pump(const Duration(milliseconds: 300));
+
+      // Verify the scroll position is at the bottom (0.0 for reversed list)
+      final scrollController =
+          tester.widget<AnimatedList>(find.byType(AnimatedList)).controller;
+      expect(scrollController?.position.pixels, equals(0.0));
+    });
+
+    testWidgets('marks messages as read when scrolling to bottom', (
+      tester,
+    ) async {
+      final messagesState = ChatRoomState(
+        messageList: List.generate(20, (index) => 'msg$index'),
+        loading: const ChatRoomLoadingState.loaded(),
+      );
+
+      final mockTimelineStream = MockTimelineStream();
+      when(
+        () => mockTimelineStream.markAsRead(any()),
+      ).thenAnswer((_) async => false);
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(
+            testRoomId,
+          ).overrideWith((ref) => MockChatRoomMessagesNotifier(messagesState)),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(mockTimelineStream)),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => GlobalKey<AnimatedListState>()),
+          isActiveProvider(LabsFeature.chatUnread).overrideWith((ref) => false),
+          chat.hasUnreadMessages(testRoomId).overrideWith((ref) => true),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      final chatMessagesWidget = find.byType(ChatMessages);
+      final state = tester.state(chatMessagesWidget) as dynamic;
+
+      await state.onScroll();
+
+      await tester.pump(const Duration(milliseconds: 300));
+
+      // verifymarkAsRead was called
+      verify(() => mockTimelineStream.markAsRead(true)).called(1);
+    });
+
+    testWidgets('handles message insertion animation correctly', (
+      tester,
+    ) async {
+      final initialState = ChatRoomState(
+        messageList: List.generate(3, (index) => 'msg$index'),
+        loading: const ChatRoomLoadingState.loaded(),
+      );
+
+      // Create a real notifier that we can update
+      final notifier = ChatRoomMessagesNotifier(
+        roomId: testRoomId,
+        ref: MockRef(),
+      );
+      notifier.state = initialState;
+
+      final animatedListKey = GlobalKey<AnimatedListState>();
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(testRoomId).overrideWith((ref) => notifier),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => animatedListKey),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      expect(animatedListKey.currentState?.widget.initialItemCount, equals(3));
+
+      // Verify initial message order
+      expect(notifier.state.messageList, equals(['msg0', 'msg1', 'msg2']));
+
+      // add new message
+      final updatedState = initialState.copyWith(
+        messageList: [...initialState.messageList, 'newMessage'],
+      );
+      notifier.state = updatedState;
+
+      await tester.pump();
+      await tester.pump(const Duration(milliseconds: 300));
+
+      // verify the list was updated
+      expect(animatedListKey.currentState?.widget.initialItemCount, equals(4));
+
+      // Verify final message order
+      expect(
+        notifier.state.messageList,
+        equals(['msg0', 'msg1', 'msg2', 'newMessage']),
+      );
+    });
+
+    testWidgets('scroll to end animates to the bottom of the list', (
+      WidgetTester tester,
+    ) async {
+      final messagesState = ChatRoomState(
+        messageList: List.generate(20, (index) => 'msg$index'),
+        loading: const ChatRoomLoadingState.loaded(),
+      );
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(
+            testRoomId,
+          ).overrideWith((ref) => MockChatRoomMessagesNotifier(messagesState)),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => GlobalKey<AnimatedListState>()),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      final scrollController =
+          tester.widget<AnimatedList>(find.byType(AnimatedList)).controller;
+      final chatMessagesWidget = find.byType(ChatMessages);
+
+      // scroll up to make the button visible
+      scrollController?.position.jumpTo(100.0);
+
+      await tester.pump();
+
+      final initialState =
+          tester.state(chatMessagesWidget) as ConsumerState<ChatMessages>;
+      expect((initialState as dynamic).showScrollToBottom, isTrue);
+
+      final finalState =
+          tester.state(chatMessagesWidget) as ConsumerState<ChatMessages>;
+
+      await (finalState as dynamic).scrollToEnd();
+
+      await tester.pump();
+      //wait for the animation to complete
+      await tester.pumpAndSettle();
+
+      expect(scrollController?.position.pixels, equals(0.0));
+      // button should be hidden
+      expect((finalState as dynamic).showScrollToBottom, isFalse);
path: app/test/features/chat_ng/widgets/chat_messages_list_test.dart
user: login: gnunicorn
body: are these `as dynamic` in here really necessary? It seems to me like we know the proper types here and call proper functions on them. I assume this is AI generated code? But we should clean that up regardless. That's a lot of `dynamic` on _known_ types.
created_at: 2025-03-23T11:41:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,406 @@
+import 'package:acter/features/chat/providers/chat_providers.dart' as chat;
+import 'package:acter/features/chat_ng/models/chat_room_state/chat_room_state.dart';
+import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart';
+import 'package:acter/features/chat_ng/providers/notifiers/chat_room_messages_notifier.dart';
+import 'package:acter/features/chat_ng/widgets/chat_messages.dart';
+import 'package:acter/features/chat_ng/widgets/events/chat_event.dart';
+import 'package:acter/features/labs/model/labs_features.dart';
+import 'package:acter/features/labs/providers/labs_providers.dart';
+import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+// import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mocktail/mocktail.dart';
+
+import '../../../helpers/test_util.dart';
+
+class MockTimelineStream extends Mock implements TimelineStream {
+  @override
+  Future<bool> paginateBackwards(int count) async => false;
+}
+
+class MockRef extends Mock implements Ref {}
+
+class MockChatRoomMessagesNotifier extends Mock
+    implements ChatRoomMessagesNotifier {
+  final ChatRoomState initalState;
+
+  MockChatRoomMessagesNotifier(this.initalState);
+
+  @override
+  ChatRoomState get state => initalState;
+
+  @override
+  void Function() addListener(
+    void Function(ChatRoomState) listener, {
+    bool fireImmediately = true,
+  }) {
+    if (fireImmediately) {
+      listener(state);
+    }
+    return () {};
+  }
+
+  @override
+  Future<void> loadMore({bool failOnError = true}) async {}
+}
+
+void main() {
+  group('ChatMessages Widget Tests', () {
+    final testRoomId = 'test-room-id';
+
+    setUp(() {
+      registerFallbackValue(const ChatRoomState());
+      registerFallbackValue(true); // Register fallback for boolean parameters
+      registerFallbackValue(false); // Register fallback for boolean parameters
+    });
+
+    testWidgets('renders empty state correctly', (tester) async {
+      final emptyState = ChatRoomState(
+        messageList: [],
+        loading: const ChatRoomLoadingState.initial(),
+      );
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(
+            testRoomId,
+          ).overrideWith((ref) => MockChatRoomMessagesNotifier(emptyState)),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => GlobalKey<AnimatedListState>()),
+          isActiveProvider(LabsFeature.chatUnread).overrideWith((ref) => false),
+          chat.hasUnreadMessages(testRoomId).overrideWith((ref) => false),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      await tester.pump();
+
+      // Now check for the list
+      expect(find.byType(AnimatedList), findsOneWidget);
+
+      expect(find.byType(ChatEvent), findsNothing);
+    });
+
+    testWidgets('renders messages correctly when available', (tester) async {
+      final messagesState = ChatRoomState(
+        messageList: List.generate(5, (index) => 'msg$index'),
+        loading: const ChatRoomLoadingState.loaded(),
+      );
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(
+            testRoomId,
+          ).overrideWith((ref) => MockChatRoomMessagesNotifier(messagesState)),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => GlobalKey<AnimatedListState>()),
+          isActiveProvider(LabsFeature.chatUnread).overrideWith((ref) => false),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      await tester.pump();
+
+      expect(find.byType(AnimatedList), findsOneWidget);
+
+      //  verify the list has the correct number of items
+      final animatedList = tester.widget<AnimatedList>(
+        find.byType(AnimatedList),
+      );
+      expect(animatedList.initialItemCount, equals(5));
+    });
+
+    testWidgets('shows loading indicator when loading', (tester) async {
+      final loadingState = ChatRoomState(
+        messageList: ['msg1', 'msg2'],
+        loading: const ChatRoomLoadingState.loading(),
+      );
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(
+            testRoomId,
+          ).overrideWith((ref) => MockChatRoomMessagesNotifier(loadingState)),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => GlobalKey<AnimatedListState>()),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      expect(find.byType(CircularProgressIndicator), findsOneWidget);
+    });
+
+    testWidgets('hides loading indicator when not loading', (tester) async {
+      final loadedState = ChatRoomState(
+        messageList: ['msg1', 'msg2'],
+        loading: const ChatRoomLoadingState.loaded(),
+      );
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(
+            testRoomId,
+          ).overrideWith((ref) => MockChatRoomMessagesNotifier(loadedState)),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => GlobalKey<AnimatedListState>()),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      expect(find.byType(CircularProgressIndicator), findsNothing);
+    });
+
+    testWidgets('automatically scrolls to bottom on initial load', (
+      tester,
+    ) async {
+      final initialState = ChatRoomState(
+        messageList: [],
+        loading: const ChatRoomLoadingState.loaded(),
+      );
+
+      final notifier = ChatRoomMessagesNotifier(
+        roomId: testRoomId,
+        ref: MockRef(),
+      );
+      notifier.state = initialState;
+
+      final animatedListKey = GlobalKey<AnimatedListState>();
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(testRoomId).overrideWith((ref) => notifier),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => animatedListKey),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      // Update with messages to trigger auto-scroll
+      final updatedState = initialState.copyWith(
+        messageList: List.generate(5, (index) => 'msg$index'),
+      );
+      notifier.state = updatedState;
+
+      await tester.pump();
+      await tester.pump(const Duration(milliseconds: 300));
+
+      // Verify the scroll position is at the bottom (0.0 for reversed list)
+      final scrollController =
+          tester.widget<AnimatedList>(find.byType(AnimatedList)).controller;
+      expect(scrollController?.position.pixels, equals(0.0));
+    });
+
+    testWidgets('marks messages as read when scrolling to bottom', (
+      tester,
+    ) async {
+      final messagesState = ChatRoomState(
+        messageList: List.generate(20, (index) => 'msg$index'),
+        loading: const ChatRoomLoadingState.loaded(),
+      );
+
+      final mockTimelineStream = MockTimelineStream();
+      when(
+        () => mockTimelineStream.markAsRead(any()),
+      ).thenAnswer((_) async => false);
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(
+            testRoomId,
+          ).overrideWith((ref) => MockChatRoomMessagesNotifier(messagesState)),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(mockTimelineStream)),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => GlobalKey<AnimatedListState>()),
+          isActiveProvider(LabsFeature.chatUnread).overrideWith((ref) => false),
+          chat.hasUnreadMessages(testRoomId).overrideWith((ref) => true),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      final chatMessagesWidget = find.byType(ChatMessages);
+      final state = tester.state(chatMessagesWidget) as dynamic;
+
+      await state.onScroll();
+
+      await tester.pump(const Duration(milliseconds: 300));
+
+      // verifymarkAsRead was called
+      verify(() => mockTimelineStream.markAsRead(true)).called(1);
+    });
+
+    testWidgets('handles message insertion animation correctly', (
+      tester,
+    ) async {
+      final initialState = ChatRoomState(
+        messageList: List.generate(3, (index) => 'msg$index'),
+        loading: const ChatRoomLoadingState.loaded(),
+      );
+
+      // Create a real notifier that we can update
+      final notifier = ChatRoomMessagesNotifier(
+        roomId: testRoomId,
+        ref: MockRef(),
+      );
+      notifier.state = initialState;
+
+      final animatedListKey = GlobalKey<AnimatedListState>();
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(testRoomId).overrideWith((ref) => notifier),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => animatedListKey),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      expect(animatedListKey.currentState?.widget.initialItemCount, equals(3));
+
+      // Verify initial message order
+      expect(notifier.state.messageList, equals(['msg0', 'msg1', 'msg2']));
+
+      // add new message
+      final updatedState = initialState.copyWith(
+        messageList: [...initialState.messageList, 'newMessage'],
+      );
+      notifier.state = updatedState;
+
+      await tester.pump();
+      await tester.pump(const Duration(milliseconds: 300));
+
+      // verify the list was updated
+      expect(animatedListKey.currentState?.widget.initialItemCount, equals(4));
+
+      // Verify final message order
+      expect(
+        notifier.state.messageList,
+        equals(['msg0', 'msg1', 'msg2', 'newMessage']),
+      );
+    });
+
+    testWidgets('scroll to end animates to the bottom of the list', (
+      WidgetTester tester,
+    ) async {
+      final messagesState = ChatRoomState(
+        messageList: List.generate(20, (index) => 'msg$index'),
+        loading: const ChatRoomLoadingState.loaded(),
+      );
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(
+            testRoomId,
+          ).overrideWith((ref) => MockChatRoomMessagesNotifier(messagesState)),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => GlobalKey<AnimatedListState>()),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      final scrollController =
+          tester.widget<AnimatedList>(find.byType(AnimatedList)).controller;
+      final chatMessagesWidget = find.byType(ChatMessages);
+
+      // scroll up to make the button visible
+      scrollController?.position.jumpTo(100.0);
+
+      await tester.pump();
+
+      final initialState =
+          tester.state(chatMessagesWidget) as ConsumerState<ChatMessages>;
+      expect((initialState as dynamic).showScrollToBottom, isTrue);
+
+      final finalState =
+          tester.state(chatMessagesWidget) as ConsumerState<ChatMessages>;
+
+      await (finalState as dynamic).scrollToEnd();
+
+      await tester.pump();
+      //wait for the animation to complete
+      await tester.pumpAndSettle();
+
+      expect(scrollController?.position.pixels, equals(0.0));
+      // button should be hidden
+      expect((finalState as dynamic).showScrollToBottom, isFalse);
+    });
+
+    testWidgets('preserves correct message order when displayed in reverse', (
path: app/test/features/chat_ng/widgets/chat_messages_list_test.dart
user: login: gnunicorn
body: does this matter to us (at all)? this feels like a pointless test of whether the builtin `reverse` of dart lists work. Let's drop this test.
created_at: 2025-03-23T11:42:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,406 @@
+import 'package:acter/features/chat/providers/chat_providers.dart' as chat;
+import 'package:acter/features/chat_ng/models/chat_room_state/chat_room_state.dart';
+import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart';
+import 'package:acter/features/chat_ng/providers/notifiers/chat_room_messages_notifier.dart';
+import 'package:acter/features/chat_ng/widgets/chat_messages.dart';
+import 'package:acter/features/chat_ng/widgets/events/chat_event.dart';
+import 'package:acter/features/labs/model/labs_features.dart';
+import 'package:acter/features/labs/providers/labs_providers.dart';
+import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+// import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mocktail/mocktail.dart';
+
+import '../../../helpers/test_util.dart';
+
+class MockTimelineStream extends Mock implements TimelineStream {
+  @override
+  Future<bool> paginateBackwards(int count) async => false;
+}
+
+class MockRef extends Mock implements Ref {}
+
+class MockChatRoomMessagesNotifier extends Mock
+    implements ChatRoomMessagesNotifier {
+  final ChatRoomState initalState;
+
+  MockChatRoomMessagesNotifier(this.initalState);
+
+  @override
+  ChatRoomState get state => initalState;
+
+  @override
+  void Function() addListener(
+    void Function(ChatRoomState) listener, {
+    bool fireImmediately = true,
+  }) {
+    if (fireImmediately) {
+      listener(state);
+    }
+    return () {};
+  }
+
+  @override
+  Future<void> loadMore({bool failOnError = true}) async {}
+}
+
+void main() {
+  group('ChatMessages Widget Tests', () {
+    final testRoomId = 'test-room-id';
+
+    setUp(() {
+      registerFallbackValue(const ChatRoomState());
+      registerFallbackValue(true); // Register fallback for boolean parameters
+      registerFallbackValue(false); // Register fallback for boolean parameters
+    });
+
+    testWidgets('renders empty state correctly', (tester) async {
+      final emptyState = ChatRoomState(
+        messageList: [],
+        loading: const ChatRoomLoadingState.initial(),
+      );
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(
+            testRoomId,
+          ).overrideWith((ref) => MockChatRoomMessagesNotifier(emptyState)),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => GlobalKey<AnimatedListState>()),
+          isActiveProvider(LabsFeature.chatUnread).overrideWith((ref) => false),
+          chat.hasUnreadMessages(testRoomId).overrideWith((ref) => false),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      await tester.pump();
+
+      // Now check for the list
+      expect(find.byType(AnimatedList), findsOneWidget);
+
+      expect(find.byType(ChatEvent), findsNothing);
+    });
+
+    testWidgets('renders messages correctly when available', (tester) async {
+      final messagesState = ChatRoomState(
+        messageList: List.generate(5, (index) => 'msg$index'),
+        loading: const ChatRoomLoadingState.loaded(),
+      );
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(
+            testRoomId,
+          ).overrideWith((ref) => MockChatRoomMessagesNotifier(messagesState)),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => GlobalKey<AnimatedListState>()),
+          isActiveProvider(LabsFeature.chatUnread).overrideWith((ref) => false),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      await tester.pump();
+
+      expect(find.byType(AnimatedList), findsOneWidget);
+
+      //  verify the list has the correct number of items
+      final animatedList = tester.widget<AnimatedList>(
+        find.byType(AnimatedList),
+      );
+      expect(animatedList.initialItemCount, equals(5));
+    });
+
+    testWidgets('shows loading indicator when loading', (tester) async {
+      final loadingState = ChatRoomState(
+        messageList: ['msg1', 'msg2'],
+        loading: const ChatRoomLoadingState.loading(),
+      );
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(
+            testRoomId,
+          ).overrideWith((ref) => MockChatRoomMessagesNotifier(loadingState)),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => GlobalKey<AnimatedListState>()),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      expect(find.byType(CircularProgressIndicator), findsOneWidget);
+    });
+
+    testWidgets('hides loading indicator when not loading', (tester) async {
+      final loadedState = ChatRoomState(
+        messageList: ['msg1', 'msg2'],
+        loading: const ChatRoomLoadingState.loaded(),
+      );
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(
+            testRoomId,
+          ).overrideWith((ref) => MockChatRoomMessagesNotifier(loadedState)),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => GlobalKey<AnimatedListState>()),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      expect(find.byType(CircularProgressIndicator), findsNothing);
+    });
+
+    testWidgets('automatically scrolls to bottom on initial load', (
+      tester,
+    ) async {
+      final initialState = ChatRoomState(
+        messageList: [],
+        loading: const ChatRoomLoadingState.loaded(),
+      );
+
+      final notifier = ChatRoomMessagesNotifier(
+        roomId: testRoomId,
+        ref: MockRef(),
+      );
+      notifier.state = initialState;
+
+      final animatedListKey = GlobalKey<AnimatedListState>();
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(testRoomId).overrideWith((ref) => notifier),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => animatedListKey),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      // Update with messages to trigger auto-scroll
+      final updatedState = initialState.copyWith(
+        messageList: List.generate(5, (index) => 'msg$index'),
+      );
+      notifier.state = updatedState;
+
+      await tester.pump();
+      await tester.pump(const Duration(milliseconds: 300));
+
+      // Verify the scroll position is at the bottom (0.0 for reversed list)
+      final scrollController =
+          tester.widget<AnimatedList>(find.byType(AnimatedList)).controller;
+      expect(scrollController?.position.pixels, equals(0.0));
+    });
+
+    testWidgets('marks messages as read when scrolling to bottom', (
+      tester,
+    ) async {
+      final messagesState = ChatRoomState(
+        messageList: List.generate(20, (index) => 'msg$index'),
+        loading: const ChatRoomLoadingState.loaded(),
+      );
+
+      final mockTimelineStream = MockTimelineStream();
+      when(
+        () => mockTimelineStream.markAsRead(any()),
+      ).thenAnswer((_) async => false);
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(
+            testRoomId,
+          ).overrideWith((ref) => MockChatRoomMessagesNotifier(messagesState)),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(mockTimelineStream)),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => GlobalKey<AnimatedListState>()),
+          isActiveProvider(LabsFeature.chatUnread).overrideWith((ref) => false),
+          chat.hasUnreadMessages(testRoomId).overrideWith((ref) => true),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      final chatMessagesWidget = find.byType(ChatMessages);
+      final state = tester.state(chatMessagesWidget) as dynamic;
+
+      await state.onScroll();
+
+      await tester.pump(const Duration(milliseconds: 300));
+
+      // verifymarkAsRead was called
+      verify(() => mockTimelineStream.markAsRead(true)).called(1);
+    });
+
+    testWidgets('handles message insertion animation correctly', (
+      tester,
+    ) async {
+      final initialState = ChatRoomState(
+        messageList: List.generate(3, (index) => 'msg$index'),
+        loading: const ChatRoomLoadingState.loaded(),
+      );
+
+      // Create a real notifier that we can update
+      final notifier = ChatRoomMessagesNotifier(
+        roomId: testRoomId,
+        ref: MockRef(),
+      );
+      notifier.state = initialState;
+
+      final animatedListKey = GlobalKey<AnimatedListState>();
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(testRoomId).overrideWith((ref) => notifier),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => animatedListKey),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      expect(animatedListKey.currentState?.widget.initialItemCount, equals(3));
+
+      // Verify initial message order
+      expect(notifier.state.messageList, equals(['msg0', 'msg1', 'msg2']));
+
+      // add new message
+      final updatedState = initialState.copyWith(
+        messageList: [...initialState.messageList, 'newMessage'],
+      );
+      notifier.state = updatedState;
+
+      await tester.pump();
+      await tester.pump(const Duration(milliseconds: 300));
+
+      // verify the list was updated
+      expect(animatedListKey.currentState?.widget.initialItemCount, equals(4));
+
+      // Verify final message order
+      expect(
+        notifier.state.messageList,
+        equals(['msg0', 'msg1', 'msg2', 'newMessage']),
+      );
+    });
+
+    testWidgets('scroll to end animates to the bottom of the list', (
+      WidgetTester tester,
+    ) async {
+      final messagesState = ChatRoomState(
+        messageList: List.generate(20, (index) => 'msg$index'),
+        loading: const ChatRoomLoadingState.loaded(),
+      );
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(
+            testRoomId,
+          ).overrideWith((ref) => MockChatRoomMessagesNotifier(messagesState)),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => GlobalKey<AnimatedListState>()),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      final scrollController =
+          tester.widget<AnimatedList>(find.byType(AnimatedList)).controller;
+      final chatMessagesWidget = find.byType(ChatMessages);
+
+      // scroll up to make the button visible
+      scrollController?.position.jumpTo(100.0);
+
+      await tester.pump();
+
+      final initialState =
+          tester.state(chatMessagesWidget) as ConsumerState<ChatMessages>;
+      expect((initialState as dynamic).showScrollToBottom, isTrue);
+
+      final finalState =
+          tester.state(chatMessagesWidget) as ConsumerState<ChatMessages>;
+
+      await (finalState as dynamic).scrollToEnd();
+
+      await tester.pump();
+      //wait for the animation to complete
+      await tester.pumpAndSettle();
+
+      expect(scrollController?.position.pixels, equals(0.0));
+      // button should be hidden
+      expect((finalState as dynamic).showScrollToBottom, isFalse);
+    });
+
+    testWidgets('preserves correct message order when displayed in reverse', (
+      tester,
+    ) async {
+      final messagesState = ChatRoomState(
+        messageList: List.generate(5, (index) => 'msg$index'),
+        loading: const ChatRoomLoadingState.loaded(),
+      );
+
+      final mockNotifier = MockChatRoomMessagesNotifier(messagesState);
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(
+            testRoomId,
+          ).overrideWith((ref) => mockNotifier),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => GlobalKey<AnimatedListState>()),
+          isActiveProvider(LabsFeature.chatUnread).overrideWith((ref) => false),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      await tester.pump();
+
+      // Get the AnimatedList
+      final animatedList = tester.widget<AnimatedList>(
+        find.byType(AnimatedList),
+      );
+
+      // Verify the list is in reverse
+      expect(animatedList.reverse, isTrue);
+
+      // Verify the correct number of items
+      expect(animatedList.initialItemCount, equals(5));
+
+      // Check the order of messages in the state
+      expect(
+        mockNotifier.state.messageList,
+        equals(['msg0', 'msg1', 'msg2', 'msg3', 'msg4']),
+      );
+    });
+  });
+}
path: app/test/features/chat_ng/widgets/chat_messages_list_test.dart
user: login: gnunicorn
body: so close! the chat room message notifier in codecov just complains about one line never being met, and that is removed items. Maybe we can add a tests for removing items as well?
created_at: 2025-03-23T11:43:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,406 @@
+import 'package:acter/features/chat/providers/chat_providers.dart' as chat;
+import 'package:acter/features/chat_ng/models/chat_room_state/chat_room_state.dart';
+import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart';
+import 'package:acter/features/chat_ng/providers/notifiers/chat_room_messages_notifier.dart';
+import 'package:acter/features/chat_ng/widgets/chat_messages.dart';
+import 'package:acter/features/chat_ng/widgets/events/chat_event.dart';
+import 'package:acter/features/labs/model/labs_features.dart';
+import 'package:acter/features/labs/providers/labs_providers.dart';
+import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+// import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mocktail/mocktail.dart';
+
+import '../../../helpers/test_util.dart';
+
+class MockTimelineStream extends Mock implements TimelineStream {
+  @override
+  Future<bool> paginateBackwards(int count) async => false;
+}
+
+class MockRef extends Mock implements Ref {}
+
+class MockChatRoomMessagesNotifier extends Mock
+    implements ChatRoomMessagesNotifier {
+  final ChatRoomState initalState;
+
+  MockChatRoomMessagesNotifier(this.initalState);
+
+  @override
+  ChatRoomState get state => initalState;
+
+  @override
+  void Function() addListener(
+    void Function(ChatRoomState) listener, {
+    bool fireImmediately = true,
+  }) {
+    if (fireImmediately) {
+      listener(state);
+    }
+    return () {};
+  }
+
+  @override
+  Future<void> loadMore({bool failOnError = true}) async {}
+}
+
+void main() {
+  group('ChatMessages Widget Tests', () {
+    final testRoomId = 'test-room-id';
+
+    setUp(() {
+      registerFallbackValue(const ChatRoomState());
+      registerFallbackValue(true); // Register fallback for boolean parameters
+      registerFallbackValue(false); // Register fallback for boolean parameters
+    });
+
+    testWidgets('renders empty state correctly', (tester) async {
+      final emptyState = ChatRoomState(
+        messageList: [],
+        loading: const ChatRoomLoadingState.initial(),
+      );
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(
+            testRoomId,
+          ).overrideWith((ref) => MockChatRoomMessagesNotifier(emptyState)),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => GlobalKey<AnimatedListState>()),
+          isActiveProvider(LabsFeature.chatUnread).overrideWith((ref) => false),
+          chat.hasUnreadMessages(testRoomId).overrideWith((ref) => false),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      await tester.pump();
+
+      // Now check for the list
+      expect(find.byType(AnimatedList), findsOneWidget);
+
+      expect(find.byType(ChatEvent), findsNothing);
+    });
+
+    testWidgets('renders messages correctly when available', (tester) async {
+      final messagesState = ChatRoomState(
+        messageList: List.generate(5, (index) => 'msg$index'),
+        loading: const ChatRoomLoadingState.loaded(),
+      );
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(
+            testRoomId,
+          ).overrideWith((ref) => MockChatRoomMessagesNotifier(messagesState)),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => GlobalKey<AnimatedListState>()),
+          isActiveProvider(LabsFeature.chatUnread).overrideWith((ref) => false),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      await tester.pump();
+
+      expect(find.byType(AnimatedList), findsOneWidget);
+
+      //  verify the list has the correct number of items
+      final animatedList = tester.widget<AnimatedList>(
+        find.byType(AnimatedList),
+      );
+      expect(animatedList.initialItemCount, equals(5));
+    });
+
+    testWidgets('shows loading indicator when loading', (tester) async {
+      final loadingState = ChatRoomState(
+        messageList: ['msg1', 'msg2'],
+        loading: const ChatRoomLoadingState.loading(),
+      );
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(
+            testRoomId,
+          ).overrideWith((ref) => MockChatRoomMessagesNotifier(loadingState)),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => GlobalKey<AnimatedListState>()),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      expect(find.byType(CircularProgressIndicator), findsOneWidget);
+    });
+
+    testWidgets('hides loading indicator when not loading', (tester) async {
+      final loadedState = ChatRoomState(
+        messageList: ['msg1', 'msg2'],
+        loading: const ChatRoomLoadingState.loaded(),
+      );
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(
+            testRoomId,
+          ).overrideWith((ref) => MockChatRoomMessagesNotifier(loadedState)),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => GlobalKey<AnimatedListState>()),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      expect(find.byType(CircularProgressIndicator), findsNothing);
+    });
+
+    testWidgets('automatically scrolls to bottom on initial load', (
+      tester,
+    ) async {
+      final initialState = ChatRoomState(
+        messageList: [],
+        loading: const ChatRoomLoadingState.loaded(),
+      );
+
+      final notifier = ChatRoomMessagesNotifier(
+        roomId: testRoomId,
+        ref: MockRef(),
+      );
+      notifier.state = initialState;
+
+      final animatedListKey = GlobalKey<AnimatedListState>();
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(testRoomId).overrideWith((ref) => notifier),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => animatedListKey),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      // Update with messages to trigger auto-scroll
+      final updatedState = initialState.copyWith(
+        messageList: List.generate(5, (index) => 'msg$index'),
+      );
+      notifier.state = updatedState;
path: app/test/features/chat_ng/widgets/chat_messages_list_test.dart
user: login: gtalha07
body: Yes, I've modified the test case to do actual update on notifier and added additional checks ! 👍🏻 
created_at: 2025-03-24T23:47:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,406 @@
+import 'package:acter/features/chat/providers/chat_providers.dart' as chat;
+import 'package:acter/features/chat_ng/models/chat_room_state/chat_room_state.dart';
+import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart';
+import 'package:acter/features/chat_ng/providers/notifiers/chat_room_messages_notifier.dart';
+import 'package:acter/features/chat_ng/widgets/chat_messages.dart';
+import 'package:acter/features/chat_ng/widgets/events/chat_event.dart';
+import 'package:acter/features/labs/model/labs_features.dart';
+import 'package:acter/features/labs/providers/labs_providers.dart';
+import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+// import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:mocktail/mocktail.dart';
+
+import '../../../helpers/test_util.dart';
+
+class MockTimelineStream extends Mock implements TimelineStream {
+  @override
+  Future<bool> paginateBackwards(int count) async => false;
+}
+
+class MockRef extends Mock implements Ref {}
+
+class MockChatRoomMessagesNotifier extends Mock
+    implements ChatRoomMessagesNotifier {
+  final ChatRoomState initalState;
+
+  MockChatRoomMessagesNotifier(this.initalState);
+
+  @override
+  ChatRoomState get state => initalState;
+
+  @override
+  void Function() addListener(
+    void Function(ChatRoomState) listener, {
+    bool fireImmediately = true,
+  }) {
+    if (fireImmediately) {
+      listener(state);
+    }
+    return () {};
+  }
+
+  @override
+  Future<void> loadMore({bool failOnError = true}) async {}
+}
+
+void main() {
+  group('ChatMessages Widget Tests', () {
+    final testRoomId = 'test-room-id';
+
+    setUp(() {
+      registerFallbackValue(const ChatRoomState());
+      registerFallbackValue(true); // Register fallback for boolean parameters
+      registerFallbackValue(false); // Register fallback for boolean parameters
+    });
+
+    testWidgets('renders empty state correctly', (tester) async {
+      final emptyState = ChatRoomState(
+        messageList: [],
+        loading: const ChatRoomLoadingState.initial(),
+      );
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(
+            testRoomId,
+          ).overrideWith((ref) => MockChatRoomMessagesNotifier(emptyState)),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => GlobalKey<AnimatedListState>()),
+          isActiveProvider(LabsFeature.chatUnread).overrideWith((ref) => false),
+          chat.hasUnreadMessages(testRoomId).overrideWith((ref) => false),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      await tester.pump();
+
+      // Now check for the list
+      expect(find.byType(AnimatedList), findsOneWidget);
+
+      expect(find.byType(ChatEvent), findsNothing);
+    });
+
+    testWidgets('renders messages correctly when available', (tester) async {
+      final messagesState = ChatRoomState(
+        messageList: List.generate(5, (index) => 'msg$index'),
+        loading: const ChatRoomLoadingState.loaded(),
+      );
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(
+            testRoomId,
+          ).overrideWith((ref) => MockChatRoomMessagesNotifier(messagesState)),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => GlobalKey<AnimatedListState>()),
+          isActiveProvider(LabsFeature.chatUnread).overrideWith((ref) => false),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      await tester.pump();
+
+      expect(find.byType(AnimatedList), findsOneWidget);
+
+      //  verify the list has the correct number of items
+      final animatedList = tester.widget<AnimatedList>(
+        find.byType(AnimatedList),
+      );
+      expect(animatedList.initialItemCount, equals(5));
+    });
+
+    testWidgets('shows loading indicator when loading', (tester) async {
+      final loadingState = ChatRoomState(
+        messageList: ['msg1', 'msg2'],
+        loading: const ChatRoomLoadingState.loading(),
+      );
+
+      await tester.pumpProviderWidget(
+        overrides: [
+          chatMessagesStateProvider(
+            testRoomId,
+          ).overrideWith((ref) => MockChatRoomMessagesNotifier(loadingState)),
+          chat
+              .timelineStreamProvider(testRoomId)
+              .overrideWith((ref) => Future.value(MockTimelineStream())),
+          animatedListChatMessagesProvider(
+            testRoomId,
+          ).overrideWith((ref) => GlobalKey<AnimatedListState>()),
+        ],
+        child: ChatMessages(roomId: testRoomId),
+      );
+
+      expect(find.byType(CircularProgressIndicator), findsOneWidget);
+    });
+
+    testWidgets('hides loading indicator when not loading', (tester) async {
path: app/test/features/chat_ng/widgets/chat_messages_list_test.dart
user: login: gtalha07
body: changed the test scenario which now includes switches loading and not loading state while checking indicator state synchronously! 👍🏻 
created_at: 2025-03-24T23:49:00Z
author_association: CONTRIBUTOR

PR comments:
user: login: codecov[bot]
created_at: 2025-03-18T17:13:34Z
body: ## [Codecov](https://app.codecov.io/gh/acterglobal/a3/pull/2728?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=acterglobal) Report
Attention: Patch coverage is `96.29630%` with `2 lines` in your changes missing coverage. Please review.
> Project coverage is 35.30%. Comparing base [(`7f1e5ff`)](https://app.codecov.io/gh/acterglobal/a3/commit/7f1e5fffd026ed373222e36fac72ed5e81f238bc?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=acterglobal) to head [(`8d99606`)](https://app.codecov.io/gh/acterglobal/a3/commit/8d99606ad24a3c09e0307322aa57c36303ad6308?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=acterglobal).
> Report is 122 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/acterglobal/a3/pull/2728?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=acterglobal) | Patch % | Lines |
|---|---|---|
| [...oviders/notifiers/chat\_room\_messages\_notifier.dart](https://app.codecov.io/gh/acterglobal/a3/pull/2728?src=pr&el=tree&filepath=app%2Flib%2Ffeatures%2Fchat_ng%2Fproviders%2Fnotifiers%2Fchat_room_messages_notifier.dart&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=acterglobal#diff-YXBwL2xpYi9mZWF0dXJlcy9jaGF0X25nL3Byb3ZpZGVycy9ub3RpZmllcnMvY2hhdF9yb29tX21lc3NhZ2VzX25vdGlmaWVyLmRhcnQ=) | 96.29% | [1 Missing :warning: ](https://app.codecov.io/gh/acterglobal/a3/pull/2728?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=acterglobal) |
| [...pp/lib/features/chat\_ng/widgets/chat\_messages.dart](https://app.codecov.io/gh/acterglobal/a3/pull/2728?src=pr&el=tree&filepath=app%2Flib%2Ffeatures%2Fchat_ng%2Fwidgets%2Fchat_messages.dart&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=acterglobal#diff-YXBwL2xpYi9mZWF0dXJlcy9jaGF0X25nL3dpZGdldHMvY2hhdF9tZXNzYWdlcy5kYXJ0) | 96.29% | [1 Missing :warning: ](https://app.codecov.io/gh/acterglobal/a3/pull/2728?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=acterglobal) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #2728      +/-   ##
==========================================
+ Coverage   34.35%   35.30%   +0.94%     
==========================================
  Files         775      790      +15     
  Lines       49634    50333     +699     
==========================================
+ Hits        17054    17768     +714     
+ Misses      32580    32565      -15     
```

| [Flag](https://app.codecov.io/gh/acterglobal/a3/pull/2728/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=acterglobal) | Coverage Δ | |
|---|---|---|
| [integration-test](https://app.codecov.io/gh/acterglobal/a3/pull/2728/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=acterglobal) | `44.28% <ø> (+1.12%)` | :arrow_up: |
| [unittest](https://app.codecov.io/gh/acterglobal/a3/pull/2728/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=acterglobal) | `26.05% <96.29%> (+0.48%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=acterglobal#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/acterglobal/a3/pull/2728?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=acterglobal).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=acterglobal).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: gtalha07
created_at: 2025-03-20T16:39:51Z
body: > Not sharing any explicit approval here even though changes addressing the exact issue. Because I am not sure whether approach of implementation is right or wrong.
> 
> Instead of managing reverse indexing and adding data from 0 indexes, why just just sorting list in reverse direction?
> 
> Because for each work which is relates to chat list, we need taken reverse indexing in consideration otherwise if we forgot then it may misbehave.
> 
> @gnunicorn Would love to hear your feedback here.

Just sorting the list in reverse doesn't help here really, all of this mechanism is handled with diff applier which would break as it directly manipulates animated list ordering and would have to take care of indexing too. 
author_association: CONTRIBUTOR
user: login: gtalha07
created_at: 2025-03-20T16:43:45Z
body: > This needs tests. One important thing why we wanted to do chat-ng is to also have a _more robust and well tested_ section and we are adding complexity without the appropriate _large_ number of tests ensuring this works as we want it to work. It is hard to understand the impact of this by reviewing ...
> 
> I can also totally understand @kumarpalsinh25 concern about swapping the order of the diffing algorithm. But I also see that just using `reversed` isn't going to cut it as it also implicitly manages the AnimatedList-State (for fewer rerenders). That said, while other things are covered - and I feel more tests would be even better, I am concerned that if we missed anything in there, it will be hard to debug and track down - the AnimatedList changes not being covered by tests is a problem.
> 
> In general: tests, all this should be covered by tests, also higher level widget tests that make sure the providers up to the UI update properly under various different states and circumstances . For e.g. what is totally unclear to me right now: if I scroll up and a new message comes in, does it scroll back down? How would the user know there is a new message if not? Tests!

Yeah, I totally understand the insecurity here. I was avoiding to take this approach by trying out various possibilities without reversal but it seemed futile.  The only thing that fixes and improves it is with reverse UI list ordering and indexing as implication of it. I'd add more tests for all code coverage here. 
author_association: CONTRIBUTOR