diff --git a/lib/hooks/use_room_scroll.dart b/lib/hooks/use_room_scroll.dart index 681bec74..80b8cd1b 100644 --- a/lib/hooks/use_room_scroll.dart +++ b/lib/hooks/use_room_scroll.dart @@ -4,11 +4,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/pods/chat/chat_room.dart'; import 'package:island/database/message.dart'; import 'package:island/pods/chat/messages_notifier.dart'; -import 'package:super_sliver_list/super_sliver_list.dart'; class RoomScrollManager { final ScrollController scrollController; - final ListController listController; final ValueNotifier bottomGradientOpacity; bool isScrollingToMessage; final void Function({ @@ -19,7 +17,6 @@ class RoomScrollManager { RoomScrollManager({ required this.scrollController, - required this.listController, required this.bottomGradientOpacity, required this.scrollToMessage, this.isScrollingToMessage = false, @@ -33,7 +30,6 @@ RoomScrollManager useRoomScrollManager( AsyncValue> messagesAsync, ) { final scrollController = useScrollController(); - final listController = useMemoized(() => ListController(), []); final bottomGradientOpacity = useState(ValueNotifier(0.0)); var isLoading = false; @@ -47,14 +43,23 @@ RoomScrollManager useRoomScrollManager( WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) { try { - listController.animateToItem( - index: index, - scrollController: scrollController, - alignment: 0.5, - duration: (estimatedDistance) => Duration( - milliseconds: (estimatedDistance * 0.5).clamp(200, 800).toInt(), - ), - curve: (estimatedDistance) => Curves.easeOutCubic, + messagesAsync.when( + data: (messageList) { + if (!scrollController.hasClients) return; + + final messageIndex = index; + final totalMessages = messageList.length; + + if (messageIndex < 0 || messageIndex >= totalMessages) return; + + scrollController.animateTo( + messageIndex * 80.0, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + ); + }, + loading: () {}, + error: (_, _) {}, ); Future.delayed(const Duration(milliseconds: 800), () { @@ -109,7 +114,7 @@ RoomScrollManager useRoomScrollManager( bottomGradientOpacity.value.value = (pixels / 500.0).clamp(0.0, 1.0); }, loading: () {}, - error: (_, _) {}, + error: (_, _) => {}, ); } @@ -119,7 +124,6 @@ RoomScrollManager useRoomScrollManager( return RoomScrollManager( scrollController: scrollController, - listController: listController, bottomGradientOpacity: bottomGradientOpacity.value, scrollToMessage: scrollToMessageWrapper, isScrollingToMessage: isScrollingToMessage, diff --git a/lib/screens/chat/room.dart b/lib/screens/chat/room.dart index ec7cd7c6..e49692dc 100644 --- a/lib/screens/chat/room.dart +++ b/lib/screens/chat/room.dart @@ -143,7 +143,6 @@ class ChatRoomScreen extends HookConsumerWidget { final inputKey = useMemoized(() => GlobalKey(), []); final inputHeight = useState(80.0); final inputManager = useRoomInputManager(ref, id); - final roomOpenTime = useMemoized(() => DateTime.now()); final previousInputHeightRef = useRef(null); @@ -372,7 +371,6 @@ class ChatRoomScreen extends HookConsumerWidget { roomAsync: chatRoom, chatIdentity: chatIdentity, scrollController: scrollManager.scrollController, - listController: scrollManager.listController, isSelectionMode: isSelectionMode.value, selectedMessages: selectedMessages.value, toggleSelectionMode: toggleSelectionMode, @@ -381,10 +379,10 @@ class ChatRoomScreen extends HookConsumerWidget { onJump: onJump, attachmentProgress: inputManager.attachmentProgress, - disableAnimation: settings.disableAnimation, - roomOpenTime: roomOpenTime, inputHeight: inputHeight.value, previousInputHeight: previousInputHeightRef.value, + roomOpenTime: roomOpenTime, + disableAnimation: settings.disableAnimation, ), loading: () => const Center( key: ValueKey('loading-messages'), diff --git a/lib/screens/chat/widgets/message_item_wrapper.dart b/lib/screens/chat/widgets/message_item_wrapper.dart index 11be9d9e..1d643389 100644 --- a/lib/screens/chat/widgets/message_item_wrapper.dart +++ b/lib/screens/chat/widgets/message_item_wrapper.dart @@ -5,23 +5,6 @@ import 'package:island/database/message.dart'; import 'package:island/models/chat.dart'; import 'package:island/widgets/chat/message_item.dart'; -// Provider to track animated messages to prevent replay -final animatedMessagesProvider = - NotifierProvider>( - AnimatedMessagesNotifier.new, - ); - -class AnimatedMessagesNotifier extends Notifier> { - @override - Set build() { - return {}; - } - - void addMessage(String messageId) { - state = {...state, messageId}; - } -} - class MessageItemWrapper extends ConsumerWidget { final LocalChatMessage message; final int index; @@ -34,8 +17,8 @@ class MessageItemWrapper extends ConsumerWidget { final Function(String, LocalChatMessage) onMessageAction; final Function(String) onJump; final Map> attachmentProgress; - final bool disableAnimation; final DateTime roomOpenTime; + final bool disableAnimation; const MessageItemWrapper({ super.key, @@ -50,53 +33,18 @@ class MessageItemWrapper extends ConsumerWidget { required this.onMessageAction, required this.onJump, required this.attachmentProgress, - required this.disableAnimation, required this.roomOpenTime, + required this.disableAnimation, }); @override Widget build(BuildContext context, WidgetRef ref) { - // Animation logic - final animatedMessages = ref.watch(animatedMessagesProvider); - final isNewMessage = message.createdAt.isAfter(roomOpenTime); - final hasAnimated = animatedMessages.contains(message.id); - - // Only animate if: - // 1. Animation is enabled - // 2. Message is new (created after room open) - // 3. Has not animated yet - final shouldAnimate = !disableAnimation && isNewMessage && !hasAnimated; - - final child = chatIdentity.when( + return chatIdentity.when( skipError: true, data: (identity) => _buildContent(context, identity), loading: () => _buildLoading(), error: (_, _) => const SizedBox.shrink(), ); - - if (!shouldAnimate) { - return child; - } - - return TweenAnimationBuilder( - key: ValueKey('anim-${message.id}'), // Ensure unique key for animation - tween: Tween(begin: 0.0, end: 1.0), - duration: Duration(milliseconds: 400 + (index % 5) * 50), - curve: Curves.easeOutCubic, - builder: (context, value, child) { - return Transform.translate( - offset: Offset(0, 20 * (1 - value)), - child: Opacity(opacity: value, child: child), - ); - }, - onEnd: () { - // Mark as animated - WidgetsBinding.instance.addPostFrameCallback((_) { - ref.read(animatedMessagesProvider.notifier).addMessage(message.id); - }); - }, - child: child, - ); } Widget _buildContent(BuildContext context, SnChatMember? identity) { @@ -116,12 +64,9 @@ class MessageItemWrapper extends ConsumerWidget { } }, child: Container( - color: - isSelected - ? Theme.of( - context, - ).colorScheme.primaryContainer.withOpacity(0.3) - : null, + color: isSelected + ? Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3) + : null, child: Stack( children: [ MessageItem( @@ -130,10 +75,9 @@ class MessageItemWrapper extends ConsumerWidget { key: ValueKey('item-${message.id}'), message: message, isCurrentUser: isCurrentUser, - onAction: - isSelectionMode - ? null - : (action) => onMessageAction(action, message), + onAction: isSelectionMode + ? null + : (action) => onMessageAction(action, message), onJump: onJump, progress: attachmentProgress[message.id], showAvatar: isLastInGroup, diff --git a/lib/widgets/chat/room_message_list.dart b/lib/widgets/chat/room_message_list.dart index 163f12b2..ca59760d 100644 --- a/lib/widgets/chat/room_message_list.dart +++ b/lib/widgets/chat/room_message_list.dart @@ -1,17 +1,15 @@ import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/database/message.dart'; import 'package:island/models/chat.dart'; -import 'package:island/pods/config.dart'; import 'package:island/screens/chat/widgets/message_item_wrapper.dart'; -import 'package:super_sliver_list/super_sliver_list.dart'; class RoomMessageList extends HookConsumerWidget { final List messages; final AsyncValue roomAsync; final AsyncValue chatIdentity; final ScrollController scrollController; - final ListController listController; final bool isSelectionMode; final Set selectedMessages; final VoidCallback toggleSelectionMode; @@ -19,10 +17,10 @@ class RoomMessageList extends HookConsumerWidget { final void Function(String action, LocalChatMessage message) onMessageAction; final void Function(String messageId) onJump; final Map> attachmentProgress; - final bool disableAnimation; - final DateTime roomOpenTime; final double inputHeight; final double? previousInputHeight; + final DateTime roomOpenTime; + final bool disableAnimation; const RoomMessageList({ super.key, @@ -30,7 +28,6 @@ class RoomMessageList extends HookConsumerWidget { required this.roomAsync, required this.chatIdentity, required this.scrollController, - required this.listController, required this.isSelectionMode, required this.selectedMessages, required this.toggleSelectionMode, @@ -38,45 +35,84 @@ class RoomMessageList extends HookConsumerWidget { required this.onMessageAction, required this.onJump, required this.attachmentProgress, - required this.disableAnimation, - required this.roomOpenTime, required this.inputHeight, + required this.roomOpenTime, + required this.disableAnimation, this.previousInputHeight, }); @override Widget build(BuildContext context, WidgetRef ref) { - final settings = ref.watch(appSettingsProvider); + final animatedListKey = useMemoized( + () => GlobalKey(), + [], + ); const messageKeyPrefix = 'message-'; final bottomPadding = inputHeight + MediaQuery.of(context).padding.bottom + 8; - final listWidget = - previousInputHeight != null && previousInputHeight != inputHeight - ? TweenAnimationBuilder( - tween: Tween(begin: previousInputHeight, end: inputHeight), - duration: const Duration(milliseconds: 200), - curve: Curves.easeOut, - builder: (context, height, child) => SuperListView.builder( - listController: listController, - controller: scrollController, - reverse: true, - padding: EdgeInsets.only( - top: 8, - bottom: height + MediaQuery.of(context).padding.bottom + 8, - ), - itemCount: messages.length, - findChildIndexCallback: (key) { - if (key is! ValueKey) return null; - final messageId = key.value.substring(messageKeyPrefix.length); - final index = messages.indexWhere( - (m) => (m.nonce ?? m.id) == messageId, - ); - return index >= 0 ? index : null; - }, - extentEstimation: (_, _) => 40, - itemBuilder: (context, index) { + final listKeys = useRef>([]); + final messageMap = useRef>({}); + + useEffect(() { + final currentKeys = messages + .map((m) => '$messageKeyPrefix${m.nonce ?? m.id}') + .toList(); + final previousKeys = listKeys.value; + + final addedKeys = currentKeys + .where((k) => !previousKeys.contains(k)) + .toList(); + final removedKeys = previousKeys + .where((k) => !currentKeys.contains(k)) + .toList(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + final state = animatedListKey.currentState; + if (state != null) { + for (final key in removedKeys) { + final index = messageMap.value.keys.toList().indexOf(key); + if (index != -1) { + state.removeItem( + index, + (context, animation) => _buildRemovedItem(context, animation), + ); + } + } + + for (final key in addedKeys) { + final index = currentKeys.indexOf(key); + state.insertItem( + index, + duration: Duration(milliseconds: 300 + (index % 3) * 50), + ); + } + } + }); + + listKeys.value = currentKeys; + messageMap.value = { + for (var m in messages) '$messageKeyPrefix${m.nonce ?? m.id}': m, + }; + return null; + }, [messages]); + + final listWidget = CustomScrollView( + controller: scrollController, + reverse: true, + slivers: [ + if (previousInputHeight != null && previousInputHeight != inputHeight) + SliverPadding( + padding: EdgeInsets.only(top: 8, bottom: bottomPadding), + sliver: SliverAnimatedList( + key: animatedListKey, + initialItemCount: messages.length, + itemBuilder: (context, index, animation) { + if (index >= messages.length) { + return const SizedBox.shrink(); + } + final message = messages[index]; final nextMessage = index < messages.length - 1 ? messages[index + 1] @@ -94,77 +130,124 @@ class RoomMessageList extends HookConsumerWidget { '$messageKeyPrefix${message.nonce ?? message.id}', ); - return MessageItemWrapper( - key: key, - message: message, - index: index, - isLastInGroup: isLastInGroup, - isSelectionMode: isSelectionMode, - selectedMessages: selectedMessages, - chatIdentity: chatIdentity, - toggleSelectionMode: toggleSelectionMode, - toggleMessageSelection: toggleMessageSelection, - onMessageAction: onMessageAction, - onJump: onJump, - attachmentProgress: attachmentProgress, - disableAnimation: settings.disableAnimation, - roomOpenTime: roomOpenTime, + return _buildAnimatedItem( + context, + animation, + MessageItemWrapper( + key: key, + message: message, + index: index, + isLastInGroup: isLastInGroup, + isSelectionMode: isSelectionMode, + selectedMessages: selectedMessages, + chatIdentity: chatIdentity, + toggleSelectionMode: toggleSelectionMode, + toggleMessageSelection: toggleMessageSelection, + onMessageAction: onMessageAction, + onJump: onJump, + attachmentProgress: attachmentProgress, + roomOpenTime: roomOpenTime, + disableAnimation: true, + ), ); }, ), ) - : SuperListView.builder( - listController: listController, - controller: scrollController, - reverse: true, + else + SliverPadding( padding: EdgeInsets.only(top: 8, bottom: bottomPadding), - itemCount: messages.length, - findChildIndexCallback: (key) { - if (key is! ValueKey) return null; - final messageId = key.value.substring(messageKeyPrefix.length); - final index = messages.indexWhere( - (m) => (m.nonce ?? m.id) == messageId, - ); - return index >= 0 ? index : null; - }, - extentEstimation: (_, _) => 40, - itemBuilder: (context, index) { - final message = messages[index]; - final nextMessage = index < messages.length - 1 - ? messages[index + 1] - : null; - final isLastInGroup = - nextMessage == null || - nextMessage.senderId != message.senderId || - nextMessage.createdAt - .difference(message.createdAt) - .inMinutes - .abs() > - 3; + sliver: SliverAnimatedList( + key: animatedListKey, + initialItemCount: messages.length, + itemBuilder: (context, index, animation) { + if (index >= messages.length) { + return const SizedBox.shrink(); + } - final key = Key( - '$messageKeyPrefix${message.nonce ?? message.id}', - ); + final message = messages[index]; + final nextMessage = index < messages.length - 1 + ? messages[index + 1] + : null; + final isLastInGroup = + nextMessage == null || + nextMessage.senderId != message.senderId || + nextMessage.createdAt + .difference(message.createdAt) + .inMinutes + .abs() > + 3; - return MessageItemWrapper( - key: key, - message: message, - index: index, - isLastInGroup: isLastInGroup, - isSelectionMode: isSelectionMode, - selectedMessages: selectedMessages, - chatIdentity: chatIdentity, - toggleSelectionMode: toggleSelectionMode, - toggleMessageSelection: toggleMessageSelection, - onMessageAction: onMessageAction, - onJump: onJump, - attachmentProgress: attachmentProgress, - disableAnimation: settings.disableAnimation, - roomOpenTime: roomOpenTime, - ); - }, - ); + final key = Key( + '$messageKeyPrefix${message.nonce ?? message.id}', + ); + + return _buildAnimatedItem( + context, + animation, + MessageItemWrapper( + key: key, + message: message, + index: index, + isLastInGroup: isLastInGroup, + isSelectionMode: isSelectionMode, + selectedMessages: selectedMessages, + chatIdentity: chatIdentity, + toggleSelectionMode: toggleSelectionMode, + toggleMessageSelection: toggleMessageSelection, + onMessageAction: onMessageAction, + onJump: onJump, + attachmentProgress: attachmentProgress, + roomOpenTime: roomOpenTime, + disableAnimation: true, + ), + ); + }, + ), + ), + ], + ); return listWidget; } + + Widget _buildAnimatedItem( + BuildContext context, + Animation animation, + Widget child, + ) { + final curvedAnimation = CurvedAnimation( + parent: animation, + curve: Curves.easeOutQuart, + ); + + final scaleAnimation = Tween( + begin: 0.92, + end: 1.0, + ).animate(curvedAnimation); + final slideAnimation = Tween( + begin: const Offset(0, 0.12), + end: Offset.zero, + ).animate(curvedAnimation); + final fadeAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + parent: animation, + curve: const Interval(0.1, 1.0, curve: Curves.easeOut), + ), + ); + + return FadeTransition( + opacity: fadeAnimation, + child: ScaleTransition( + scale: scaleAnimation, + child: SlideTransition(position: slideAnimation, child: child), + ), + ); + } + + Widget _buildRemovedItem(BuildContext context, Animation animation) { + return SizeTransition( + sizeFactor: animation, + child: FadeTransition(opacity: animation, child: SizedBox.shrink()), + ); + } }