diff --git a/lib/hooks/use_room_scroll.dart b/lib/hooks/use_room_scroll.dart index 80b8cd1b..681bec74 100644 --- a/lib/hooks/use_room_scroll.dart +++ b/lib/hooks/use_room_scroll.dart @@ -4,9 +4,11 @@ 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({ @@ -17,6 +19,7 @@ class RoomScrollManager { RoomScrollManager({ required this.scrollController, + required this.listController, required this.bottomGradientOpacity, required this.scrollToMessage, this.isScrollingToMessage = false, @@ -30,6 +33,7 @@ RoomScrollManager useRoomScrollManager( AsyncValue> messagesAsync, ) { final scrollController = useScrollController(); + final listController = useMemoized(() => ListController(), []); final bottomGradientOpacity = useState(ValueNotifier(0.0)); var isLoading = false; @@ -43,23 +47,14 @@ RoomScrollManager useRoomScrollManager( WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) { try { - 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: (_, _) {}, + listController.animateToItem( + index: index, + scrollController: scrollController, + alignment: 0.5, + duration: (estimatedDistance) => Duration( + milliseconds: (estimatedDistance * 0.5).clamp(200, 800).toInt(), + ), + curve: (estimatedDistance) => Curves.easeOutCubic, ); Future.delayed(const Duration(milliseconds: 800), () { @@ -114,7 +109,7 @@ RoomScrollManager useRoomScrollManager( bottomGradientOpacity.value.value = (pixels / 500.0).clamp(0.0, 1.0); }, loading: () {}, - error: (_, _) => {}, + error: (_, _) {}, ); } @@ -124,6 +119,7 @@ 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 6e3cbc09..e3b423fc 100644 --- a/lib/screens/chat/room.dart +++ b/lib/screens/chat/room.dart @@ -365,6 +365,7 @@ class ChatRoomScreen extends HookConsumerWidget { roomAsync: chatRoom, chatIdentity: chatIdentity, scrollController: scrollManager.scrollController, + listController: scrollManager.listController, isSelectionMode: isSelectionMode.value, selectedMessages: selectedMessages.value, toggleSelectionMode: toggleSelectionMode, diff --git a/lib/screens/chat/widgets/message_item_wrapper.dart b/lib/screens/chat/widgets/message_item_wrapper.dart index 1d643389..2977d263 100644 --- a/lib/screens/chat/widgets/message_item_wrapper.dart +++ b/lib/screens/chat/widgets/message_item_wrapper.dart @@ -1,11 +1,29 @@ import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.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/widgets/chat/message_item.dart'; -class MessageItemWrapper extends ConsumerWidget { +// 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 HookConsumerWidget { final LocalChatMessage message; final int index; final bool isLastInGroup; @@ -17,8 +35,8 @@ class MessageItemWrapper extends ConsumerWidget { final Function(String, LocalChatMessage) onMessageAction; final Function(String) onJump; final Map> attachmentProgress; - final DateTime roomOpenTime; final bool disableAnimation; + final DateTime roomOpenTime; const MessageItemWrapper({ super.key, @@ -33,20 +51,10 @@ class MessageItemWrapper extends ConsumerWidget { required this.onMessageAction, required this.onJump, required this.attachmentProgress, - required this.roomOpenTime, required this.disableAnimation, + required this.roomOpenTime, }); - @override - Widget build(BuildContext context, WidgetRef ref) { - return chatIdentity.when( - skipError: true, - data: (identity) => _buildContent(context, identity), - loading: () => _buildLoading(), - error: (_, _) => const SizedBox.shrink(), - ); - } - Widget _buildContent(BuildContext context, SnChatMember? identity) { final isSelected = selectedMessages.contains(message.id); final isCurrentUser = identity?.id == message.senderId; @@ -122,4 +130,88 @@ class MessageItemWrapper extends ConsumerWidget { onJump: (_) {}, ); } + + @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( + skipError: true, + data: (identity) => _buildContent(context, identity), + loading: () => _buildLoading(), + error: (_, _) => const SizedBox.shrink(), + ); + + final controller = useAnimationController( + duration: Duration(milliseconds: 400 + (index % 5) * 50), + ); + + final hasStarted = useState(false); + + useEffect(() { + if (shouldAnimate && !hasStarted.value) { + hasStarted.value = true; + controller.forward().then((_) { + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(animatedMessagesProvider.notifier).addMessage(message.id); + }); + }); + } + return null; + }, [shouldAnimate]); + + if (!shouldAnimate) { + return child; + } + + final curvedAnimation = useMemoized( + () => CurvedAnimation(parent: controller, curve: Curves.easeOutQuart), + [controller], + ); + + final sizeAnimation = useMemoized( + () => Tween(begin: 0.0, end: 1.0).animate(curvedAnimation), + [curvedAnimation], + ); + + final slideAnimation = useMemoized( + () => Tween( + begin: const Offset(0, 0.12), + end: Offset.zero, + ).animate(curvedAnimation), + [curvedAnimation], + ); + + final fadeAnimation = useMemoized( + () => Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + parent: controller, + curve: const Interval(0.1, 1.0, curve: Curves.easeOut), + ), + ), + [controller], + ); + + return AnimatedBuilder( + animation: controller, + builder: (context, child) => FadeTransition( + opacity: fadeAnimation, + child: SizeTransition( + axis: Axis.vertical, + sizeFactor: sizeAnimation, + child: SlideTransition(position: slideAnimation, child: child), + ), + ), + child: child, + ); + } } diff --git a/lib/widgets/chat/room_message_list.dart b/lib/widgets/chat/room_message_list.dart index ca59760d..163f12b2 100644 --- a/lib/widgets/chat/room_message_list.dart +++ b/lib/widgets/chat/room_message_list.dart @@ -1,15 +1,17 @@ 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; @@ -17,10 +19,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, @@ -28,6 +30,7 @@ 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, @@ -35,84 +38,45 @@ class RoomMessageList extends HookConsumerWidget { required this.onMessageAction, required this.onJump, required this.attachmentProgress, - required this.inputHeight, - required this.roomOpenTime, required this.disableAnimation, + required this.roomOpenTime, + required this.inputHeight, this.previousInputHeight, }); @override Widget build(BuildContext context, WidgetRef ref) { - final animatedListKey = useMemoized( - () => GlobalKey(), - [], - ); + final settings = ref.watch(appSettingsProvider); const messageKeyPrefix = 'message-'; final bottomPadding = inputHeight + MediaQuery.of(context).padding.bottom + 8; - 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 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 message = messages[index]; final nextMessage = index < messages.length - 1 ? messages[index + 1] @@ -130,124 +94,77 @@ class RoomMessageList extends HookConsumerWidget { '$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 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, ); }, ), ) - else - SliverPadding( + : SuperListView.builder( + listController: listController, + controller: scrollController, + reverse: true, 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(); - } + 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; - 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; + final key = Key( + '$messageKeyPrefix${message.nonce ?? message.id}', + ); - 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 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 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()), - ); - } }