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/widgets/chat/message_item.dart'; // Provider to track animated messages to prevent replay final animatedMessagesProvider = NotifierProvider.autoDispose( 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; final bool isSelectionMode; final Set selectedMessages; final AsyncValue chatIdentity; final VoidCallback toggleSelectionMode; final Function(String) toggleMessageSelection; final Function(String, LocalChatMessage) onMessageAction; final Function(String) onJump; final Map> attachmentProgress; final bool disableAnimation; final DateTime roomOpenTime; const MessageItemWrapper({ super.key, required this.message, required this.index, required this.isLastInGroup, required this.isSelectionMode, required this.selectedMessages, required this.chatIdentity, required this.toggleSelectionMode, required this.toggleMessageSelection, required this.onMessageAction, required this.onJump, required this.attachmentProgress, required this.disableAnimation, required this.roomOpenTime, }); Widget _buildContent(BuildContext context, SnChatMember? identity) { final isSelected = selectedMessages.contains(message.id); final isCurrentUser = identity?.id == message.senderId; return GestureDetector( onLongPress: () { if (!isSelectionMode) { toggleSelectionMode(); toggleMessageSelection(message.id); } }, onTap: () { if (isSelectionMode) { toggleMessageSelection(message.id); } }, child: Container( color: isSelected ? Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3) : null, child: Stack( children: [ MessageItem( // If animation is disabled, we might want to pass a key to maintain state? // But here we are inside the wrapper. key: ValueKey('item-${message.id}'), message: message, isCurrentUser: isCurrentUser, onAction: isSelectionMode ? null : (action) => onMessageAction(action, message), onJump: onJump, progress: attachmentProgress[message.id], showAvatar: isLastInGroup, isSelectionMode: isSelectionMode, isSelected: isSelected, onToggleSelection: toggleMessageSelection, onEnterSelectionMode: () { if (!isSelectionMode) toggleSelectionMode(); }, ), if (isSelected) Positioned( top: 8, right: 8, child: Container( width: 16, height: 16, decoration: BoxDecoration( color: Theme.of(context).colorScheme.primary, shape: BoxShape.circle, ), child: Icon( Icons.check, size: 12, color: Theme.of(context).colorScheme.onPrimary, ), ), ), ], ), ), ); } Widget _buildLoading() { return MessageItem( message: message, isCurrentUser: false, onAction: null, progress: null, showAvatar: false, 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, ); } }