diff --git a/example/lib/chat_operations.dart b/example/lib/chat_operations.dart new file mode 100644 index 00000000..f36dc588 --- /dev/null +++ b/example/lib/chat_operations.dart @@ -0,0 +1,124 @@ +import 'dart:math'; + +import 'package:chatview/chatview.dart'; +import 'package:flutter/material.dart'; + +class ChatOperations extends StatelessWidget { + const ChatOperations({required this.controller, super.key}); + + final ChatViewListController controller; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + FloatingActionButton.small( + child: const Icon(Icons.add), + onPressed: () { + controller.addChat( + ChatViewListItem( + id: DateTime.now().millisecondsSinceEpoch.toString(), + name: 'New Chat ${DateTime.now().millisecondsSinceEpoch}', + lastMessage: Message( + message: 'Hello, this is a new chat!', + createdAt: DateTime.now(), + sentBy: 'User', + id: 'msg-${DateTime.now().millisecondsSinceEpoch}', + ), + ), + ); + }, + ), + const SizedBox(width: 6), + FloatingActionButton.small( + child: const Icon(Icons.remove), + onPressed: () { + final key = controller.chatListMap.keys.firstOrNull; + if (key == null) return; + controller.removeChat(key); + }, + ), + const SizedBox(width: 6), + FloatingActionButton.small( + child: const Icon(Icons.edit), + onPressed: () { + final randomChatIndex = + Random().nextInt(controller.chatListMap.length); + final chatId = + controller.chatListMap.keys.elementAt(randomChatIndex); + + controller.updateChat( + chatId, + (previousChat) { + final newPinStatus = switch (previousChat.settings.pinStatus) { + PinStatus.pinned => PinStatus.unpinned, + PinStatus.unpinned => PinStatus.pinned, + }; + return previousChat.copyWith( + settings: previousChat.settings.copyWith( + pinStatus: newPinStatus, + pinTime: newPinStatus.isPinned ? DateTime.now() : null, + ), + ); + }, + ); + }, + ), + const SizedBox(width: 6), + FloatingActionButton.small( + child: const Icon(Icons.person), + onPressed: () { + final randomChatIndex = + Random().nextInt(controller.chatListMap.length); + final chatId = + controller.chatListMap.keys.elementAt(randomChatIndex); + controller.updateChat( + chatId, + (previousChat) => previousChat.copyWith( + name: 'Updated Chat ${Random().nextInt(100)}', + ), + ); + }, + ), + const SizedBox(width: 6), + FloatingActionButton.small( + child: const Icon(Icons.more_horiz), + onPressed: () { + final chatId = controller.chatListMap.keys.elementAt(0); + controller.updateChat( + chatId, + (previousChat) => previousChat.copyWith( + typingUsers: previousChat.typingUsers.isEmpty + ? {const ChatUser(id: '1', name: 'John Doe')} + : {}, + ), + ); + }, + ), + const SizedBox(width: 6), + FloatingActionButton.small( + child: const Icon(Icons.message), + onPressed: () { + final randomChatIndex = + Random().nextInt(controller.chatListMap.length); + final chatId = + controller.chatListMap.keys.elementAt(randomChatIndex); + final randomId = Random().nextInt(100); + controller.updateChat( + chatId, + (previousChat) => previousChat.copyWith( + lastMessage: Message( + message: 'Random message $randomId', + createdAt: DateTime.now(), + sentBy: previousChat.lastMessage?.sentBy ?? '', + id: '$randomId', + ), + ), + ); + }, + ), + ], + ); + } +} diff --git a/lib/src/controller/chat_list_view_controller.dart b/lib/src/controller/chat_list_view_controller.dart index 74e8ca3f..e41fd427 100644 --- a/lib/src/controller/chat_list_view_controller.dart +++ b/lib/src/controller/chat_list_view_controller.dart @@ -26,6 +26,7 @@ import 'package:flutter/material.dart'; import '../models/chat_view_list_item.dart'; import '../values/enumeration.dart'; import '../values/typedefs.dart'; +import '../widgets/chat_view_list/auto_animate_sliver_list.dart'; class ChatViewListController { ChatViewListController({ @@ -80,14 +81,24 @@ class ChatViewListController { final StreamController> _chatListStreamController = StreamController>.broadcast(); + final animatedList = + GlobalKey>(); + + Map? _searchResultMap; late final Stream> chatListStream; /// Adds a chat to the chat list. void addChat(ChatViewListItem chat) { + if (_searchResultMap != null) { + chatListMap[chat.id] = chat; + return; + } + chatListMap[chat.id] = chat; if (_chatListStreamController.isClosed) return; _chatListStreamController.add(chatListMap); + animatedList.currentState?.addChatItem(chat); } /// Function for loading data while pagination. @@ -109,6 +120,23 @@ class ChatViewListController { /// If the chat with [chatId] does not exist, the method returns without /// making changes. void updateChat(String chatId, UpdateChatCallback newChat) { + if (_searchResultMap != null) { + final searchChat = _searchResultMap?[chatId]; + if (searchChat == null) { + final chat = chatListMap[chatId]; + if (chat == null) return; + chatListMap[chatId] = newChat(chat); + return; + } + + final updatedChat = newChat(searchChat); + _searchResultMap?[chatId] = updatedChat; + chatListMap[chatId] = updatedChat; + if (_chatListStreamController.isClosed) return; + _chatListStreamController.add(_searchResultMap ?? chatListMap); + return; + } + final chat = chatListMap[chatId]; if (chat == null) return; @@ -124,23 +152,31 @@ class ChatViewListController { void removeChat(String chatId) { if (!chatListMap.containsKey(chatId)) return; chatListMap.remove(chatId); + + if (_searchResultMap?.containsKey(chatId) ?? false) { + _searchResultMap?.remove(chatId); + } + if (_chatListStreamController.isClosed) return; + animatedList.currentState?.removeItemByKey(chatId); _chatListStreamController.add(chatListMap); } /// Adds the given chat search results to the stream after the current frame. void setSearchChats(List searchResults) { final searchResultLength = searchResults.length; - final searchResultMap = { + _searchResultMap = { for (var i = 0; i < searchResultLength; i++) if (searchResults[i] case final chat) chat.id: chat, }; if (_chatListStreamController.isClosed) return; - _chatListStreamController.add(searchResultMap); + _chatListStreamController.add(_searchResultMap ?? chatListMap); } /// Function to clear the search results and show the original chat list. void clearSearch() { + _searchResultMap?.clear(); + _searchResultMap = null; if (_chatListStreamController.isClosed) return; _chatListStreamController.add(chatListMap); } diff --git a/lib/src/models/config_models/chat_view_list/chat_view_list_config.dart b/lib/src/models/config_models/chat_view_list/chat_view_list_config.dart index ec534a1a..57ccb523 100644 --- a/lib/src/models/config_models/chat_view_list/chat_view_list_config.dart +++ b/lib/src/models/config_models/chat_view_list/chat_view_list_config.dart @@ -33,7 +33,6 @@ class ChatViewListConfig { this.enablePagination = false, this.loadMoreConfig = const LoadMoreConfig(), this.tileConfig = const ListTileConfig(), - this.separator = const Divider(height: 12), this.extraSpaceAtLast = 32, this.searchConfig, }); @@ -41,11 +40,6 @@ class ChatViewListConfig { /// Configuration for the search text field in the chat list. final SearchConfig? searchConfig; - /// Widget to be used as a separator between chat items. - /// - /// Defaults to a `Divider` with a height of `12`. - final Widget separator; - /// Configuration for the chat tile widget in the chat list. final ListTileConfig tileConfig; diff --git a/lib/src/values/typedefs.dart b/lib/src/values/typedefs.dart index 314b8cfc..32df09c3 100644 --- a/lib/src/values/typedefs.dart +++ b/lib/src/values/typedefs.dart @@ -144,3 +144,11 @@ typedef MenuBuilderCallback = Widget Function( Widget child, ); typedef MenuActionBuilder = List Function(ChatViewListItem chat); +typedef AutoAnimateItemBuilder = Widget Function( + BuildContext context, + int index, + bool isLastItem, + T item, +); +typedef AutoAnimateItemExtractor = String Function(T item); +typedef ChatPinnedCallback = bool Function(T chat); diff --git a/lib/src/widgets/chat_groupedlist_widget.dart b/lib/src/widgets/chat_groupedlist_widget.dart index 78bfc72e..37d46840 100644 --- a/lib/src/widgets/chat_groupedlist_widget.dart +++ b/lib/src/widgets/chat_groupedlist_widget.dart @@ -92,6 +92,8 @@ class _ChatGroupedListWidgetState extends State ChatBackgroundConfiguration get chatBackgroundConfig => chatListConfig.chatBackgroundConfig; + final Map _messageKeys = {}; + @override void initState() { super.initState(); @@ -189,17 +191,17 @@ class _ChatGroupedListWidgetState extends State ); } - Future _onReplyTap(String id, List? messages) async { + Future _onReplyTap(String id) async { // Finds the replied message if exists - final repliedMessages = messages?.firstWhere((message) => id == message.id); + final replyMsgCurrentState = _messageKeys[id]?.currentState; final repliedMsgAutoScrollConfig = chatListConfig.repliedMessageConfig?.repliedMsgAutoScrollConfig; final highlightDuration = repliedMsgAutoScrollConfig?.highlightDuration ?? const Duration(milliseconds: 300); // Scrolls to replied message and highlights - if (repliedMessages != null && repliedMessages.key.currentState != null) { + if (replyMsgCurrentState != null) { await Scrollable.ensureVisible( - repliedMessages.key.currentState!.context, + replyMsgCurrentState.context, // This value will make widget to be in center when auto scrolled. alignment: 0.5, curve: @@ -266,6 +268,8 @@ class _ChatGroupedListWidgetState extends State messages, lastMatchedDate, ); + } else { + _initMessageKeys(messages); } /// [count] that indicates how many separators @@ -299,13 +303,14 @@ class _ChatGroupedListWidgetState extends State valueListenable: _replyId, builder: (context, state, child) { final message = messages[newIndex]; + final messageKey = _messageKeys[message.id] ??= GlobalKey(); final enableScrollToRepliedMsg = chatListConfig .repliedMessageConfig ?.repliedMsgAutoScrollConfig .enableScrollToRepliedMsg ?? false; return ChatBubbleWidget( - key: message.key, + key: messageKey, message: message, slideAnimation: _slideAnimation, onLongPress: (yCoordinate, xCoordinate) => @@ -316,9 +321,7 @@ class _ChatGroupedListWidgetState extends State ), onSwipe: widget.assignReplyMessage, shouldHighlight: state == message.id, - onReplyTap: enableScrollToRepliedMsg - ? (replyId) => _onReplyTap(replyId, snapshot.data) - : null, + onReplyTap: enableScrollToRepliedMsg ? _onReplyTap : null, ); }, ); @@ -379,13 +382,15 @@ class _ChatGroupedListWidgetState extends State /// Holds index and separator mapping to display in chat for (var i = 0; i < messages.length; i++) { + final message = messages[i]; + _messageKeys.putIfAbsent(message.id, () => GlobalKey()); if (messageSeparator.isEmpty) { /// Separator for initial message - messageSeparator[0] = messages[0].createdAt; + messageSeparator[0] = message.createdAt; continue; } lastMatchedDate = _groupBy( - messages[i], + message, lastMatchedDate, ); var previousDate = _groupBy( @@ -404,6 +409,14 @@ class _ChatGroupedListWidgetState extends State return (messageSeparator, lastMatchedDate); } + + void _initMessageKeys(List messages) { + final messagesLength = messages.length; + for (var i = 0; i < messagesLength; i++) { + final message = messages[i]; + _messageKeys.putIfAbsent(message.id, () => GlobalKey()); + } + } } class _GroupSeparatorBuilder extends StatelessWidget { diff --git a/lib/src/widgets/chat_view_list/auto_animate_sliver_list.dart b/lib/src/widgets/chat_view_list/auto_animate_sliver_list.dart new file mode 100644 index 00000000..0314e9d8 --- /dev/null +++ b/lib/src/widgets/chat_view_list/auto_animate_sliver_list.dart @@ -0,0 +1,563 @@ +import 'package:flutter/material.dart'; + +import '../../models/chat_view_list_item.dart'; +import '../../values/typedefs.dart'; + +class _ItemState { + _ItemState({ + required this.moveController, + required this.item, + required this.currentIndex, + this.isMoving = false, + this.isRemoving = false, + }); + + final AnimationController moveController; + T item; + int currentIndex; + double? moveOffset; + bool isMoving; + bool isRemoving; +} + +/// A sliver list that automatically animates only the items that +/// move to different positions +class AutoAnimateSliverList extends StatefulWidget { + const AutoAnimateSliverList({ + required this.items, + required this.builder, + required this.itemKeyExtractor, + this.enableMoveAnimation = true, + this.animationCurve = Curves.easeInOut, + this.animationDuration = const Duration(milliseconds: 400), + super.key, + }); + + final List items; + final AutoAnimateItemBuilder builder; + final AutoAnimateItemExtractor itemKeyExtractor; + final Duration animationDuration; + final Curve animationCurve; + final bool enableMoveAnimation; + + @override + State> createState() => + AutoAnimateSliverListState(); +} + +class AutoAnimateSliverListState + extends State> with TickerProviderStateMixin { + final _itemStates = >{}; + final _itemKeys = {}; + final _removingItems = {}; + + late var _currentItems = List.from(widget.items); + var _isNewItemAddedAtTop = false; + var _newItemHeight = 0.0; + var _isAddingItem = false; + var _isRemovingItem = false; + + @override + void initState() { + super.initState(); + _initializeItemStates(); + } + + @override + void didUpdateWidget(AutoAnimateSliverList oldWidget) { + super.didUpdateWidget(oldWidget); + // Don't update items if we're currently adding or removing an item + if (!_isAddingItem && !_isRemovingItem) { + _updateItems(); + } + } + + @override + Widget build(BuildContext context) { + return SliverList( + delegate: SliverChildBuilderDelegate( + childCount: _currentItems.length, + (context, index) { + final currentItemsLength = _currentItems.length; + if (index >= currentItemsLength) return const SizedBox.shrink(); + + final item = _currentItems[index]; + final key = widget.itemKeyExtractor(item); + final itemState = _itemStates[key]; + + if (itemState == null) return const SizedBox.shrink(); + + final isMoving = itemState.isMoving; + final movingOffset = itemState.moveOffset; + + final moveAnimation = CurvedAnimation( + parent: itemState.moveController, + curve: widget.animationCurve, + ); + + return AnimatedBuilder( + key: _itemKeys[key], + animation: moveAnimation, + builder: (context, child) { + double currentOffset = 0; + double opacity = 1; + double scale = 1; + + // Handle removal animation + if (itemState.isRemoving) { + // Fade out and scale down while sliding up + opacity = + Tween(begin: 1, end: 0).evaluate(moveAnimation); + scale = + Tween(begin: 1, end: 0.8).evaluate(CurvedAnimation( + parent: itemState.moveController, + curve: Curves.easeInBack, + )); + } + // Animate all items down if a new item is added at the top + if (_isNewItemAddedAtTop && + index >= 0 && + !isMoving && + !itemState.isRemoving) { + // Animate new item at top + if (index == 0) { + opacity = moveAnimation.value; + } + + // Animate from -_newItemHeight to 0 (slide down over new item) + currentOffset = Tween(begin: -_newItemHeight, end: 0) + .evaluate(moveAnimation); + } + // Animate move for reordered items + if (isMoving && movingOffset != null) { + currentOffset = Tween(begin: 0, end: movingOffset) + .evaluate(moveAnimation); + } + + return Opacity( + opacity: opacity, + child: Transform.scale( + scale: scale, + child: Transform.translate( + offset: Offset(0, currentOffset), + child: child, + ), + ), + ); + }, + child: widget.builder( + context, + index, + index == _currentItems.length - 1, + item, + ), + ); + }, + ), + ); + } + + @override + void dispose() { + final values = _itemStates.values.toList(); + final valuesLength = values.length; + for (var i = 0; i < valuesLength; i++) { + values[i].moveController.dispose(); + } + super.dispose(); + } + + void _initializeItemStates() { + final currentItemsLength = _currentItems.length; + for (var i = 0; i < currentItemsLength; i++) { + final item = _currentItems[i]; + final key = widget.itemKeyExtractor(item); + if (_itemStates.containsKey(key)) continue; + _addItemState( + key: key, + currentIndex: i, + item: item, + ); + } + } + + void _updateItems() { + final newItems = List.from(widget.items); + final newItemKeys = {}; + + // Create maps for old and new positions + final oldPositions = {}; + final newPositions = {}; + final removedKeys = []; + + // Map new positions + var newItemsLength = newItems.length; + for (var i = 0; i < newItemsLength; i++) { + final key = widget.itemKeyExtractor(newItems[i]); + newItemKeys.add(key); + newPositions[key] = i; + } + + // Map current positions + final currentItemsLength = _currentItems.length; + for (var i = 0; i < currentItemsLength; i++) { + final key = widget.itemKeyExtractor(_currentItems[i]); + oldPositions[key] = i; + if (!newItemKeys.contains(key)) { + removedKeys.add(key); + } + } + + if (removedKeys.isNotEmpty) { + // If items are removed, update immediately without animating moves + _currentItems = newItems; + _cleanupRemovedItems(newItemKeys); + setState(() {}); + return; + } + + // Capture current positions before any changes + final renderBoxes = {}; + if (widget.enableMoveAnimation) { + final entries = _itemKeys.entries.toList(); + final entriesLength = entries.length; + for (var i = 0; i < entriesLength; i++) { + final entry = entries[i]; + final renderObject = entry.value.currentContext?.findRenderObject(); + if (renderObject is RenderBox && renderObject.hasSize) { + renderBoxes[entry.key] = renderObject; + } + } + } + + var needsReorder = false; + + // Process all items + newItemsLength = newItems.length; + for (var i = 0; i < newItemsLength; i++) { + final key = widget.itemKeyExtractor(newItems[i]); + final oldIndex = oldPositions[key]; + final newIndex = i; + + if (_itemStates.containsKey(key)) { + final itemState = _itemStates[key]!; + // Only animate if item actually moved and + // not just shifted due to insertion + if (oldIndex != null && + oldIndex != newIndex && + widget.enableMoveAnimation && + oldPositions.length == newItemsLength // No new item inserted + ) { + needsReorder = true; + + // Calculate the offset this item needs to move + final renderBox = renderBoxes[key]; + // Default fallback + var itemHeight = 80.0; + + if (renderBox != null && renderBox.hasSize) { + itemHeight = renderBox.size.height; + } + + // This item moved - calculate the offset and animate only this item + _animateItemMove( + key, + itemState, + oldIndex, + newIndex, + itemHeight: itemHeight, + onAnimationEnd: () { + if (!mounted) return; + // Apply reorder + setState(() => _currentItems = List.from(newItems)); + }, + ); + } + + itemState + ..item = newItems[i] + ..currentIndex = i; + } else { + if (_itemStates.containsKey(key)) { + // Item already exists (was added via addItem), just update its data + _itemStates[key]! + ..item = newItems[i] + ..currentIndex = i; + } else { + _addItemState( + key: key, + currentIndex: i, + item: newItems[i], + ); + } + } + } + + // Clean up removed items + _cleanupRemovedItems(newItemKeys); + + // If nothing moved, apply immediately + if (!needsReorder) { + _currentItems = newItems; + } + } + + void _addItemState({ + required String key, + required T item, + required int currentIndex, + AnimationController? moveController, + }) { + final controller = moveController ?? + AnimationController( + value: 1, + duration: widget.animationDuration, + vsync: this, + ); + + _itemStates[key] = _ItemState( + item: item, + moveController: controller, + currentIndex: currentIndex, + ); + _itemKeys[key] = GlobalKey(); + } + + void _animateItemMove( + String key, + _ItemState itemState, + int oldIndex, + int newIndex, { + required double itemHeight, + VoidCallback? onAnimationEnd, + }) { + // Calculate how many positions this item moved + final positionDiff = newIndex - oldIndex; + final moveOffset = positionDiff * itemHeight; + + itemState + ..moveOffset = moveOffset + ..isMoving = true; + + // Two-phase animation: first go on top, then come back behind + itemState.moveController + ..reset() + ..forward().then((_) { + if (!mounted) return; + setState(() { + itemState + ..isMoving = false + ..moveOffset = null; + onAnimationEnd?.call(); + }); + }); + } + + void _cleanupRemovedItems(Set newItemKeys) { + final keysToRemove = + _itemStates.keys.where((key) => !newItemKeys.contains(key)).toList(); + final keysToRemoveLength = keysToRemove.length; + for (var i = 0; i < keysToRemoveLength; i++) { + final key = keysToRemove[i]; + _itemStates[key]?.moveController.dispose(); + _itemStates.remove(key); + _itemKeys.remove(key); + } + } + + /// Adds a new item with animation at the specified + /// position (typically at top) + void _addItem(T newItem, {int position = 0, bool shouldAnimate = true}) { + _isAddingItem = true; + + final newItems = List.from(_currentItems)..insert(position, newItem); + + final key = widget.itemKeyExtractor(newItem); + + // Only animate if adding at top AND shouldAnimate is true + _isNewItemAddedAtTop = position == 0 && shouldAnimate; + _newItemHeight = 0.0; + + // Only setup animation parameters if adding at top and should animate + if (_isNewItemAddedAtTop && _currentItems.isNotEmpty) { + // Estimate new item height based on existing items + final firstOldKey = widget.itemKeyExtractor(_currentItems[0]); + final renderBox = + _itemKeys[firstOldKey]?.currentContext?.findRenderObject(); + if (renderBox is RenderBox && renderBox.hasSize) { + _newItemHeight = renderBox.size.height; + } else { + _newItemHeight = 80.0; + } + } + + // Update the list immediately so new item is rendered + _currentItems = newItems; + + // Create animation controller for new item + final moveController = AnimationController( + duration: widget.animationDuration, + vsync: this, + ); + + _addItemState( + key: key, + item: newItem, + currentIndex: position, + moveController: moveController, + ); + + // Update indices for existing items after the insertion point + for (var i = position + 1; i < _currentItems.length; i++) { + final itemKey = widget.itemKeyExtractor(_currentItems[i]); + final itemState = _itemStates[itemKey]; + if (itemState != null) { + itemState.currentIndex = i; + } + } + + setState(() { + if (!_isNewItemAddedAtTop) { + // No animation for items added at other positions or + // when shouldAnimate is false + moveController.value = 1; + } else { + // Only animate if new item is added at top + moveController + ..value = 0 + ..forward(); + + // Animate all existing items down when new item added at top + for (var i = position + 1; i < _currentItems.length; i++) { + final itemKey = widget.itemKeyExtractor(_currentItems[i]); + final itemState = _itemStates[itemKey]; + if (itemState != null) { + itemState.moveController + ..value = 0 + ..forward(); + } + } + } + + // Reset the flag after a brief delay to allow any pending widget updates + Future.microtask(() { + if (mounted) { + _isAddingItem = false; + } + }); + }); + } + + /// Adds a new chat item, considering pinned items at the top + /// If there are pinned items, the new item will be added after + /// them without animation If no pinned items, it will be added + /// at top with animation + void addChatItem( + T newItem, { + ChatPinnedCallback? isPinned, + }) { + // Count pinned items at the top + var pinnedCount = 0; + + final currentItemsLength = _currentItems.length; + for (var i = 0; i < currentItemsLength; i++) { + final item = _currentItems[i]; + if (isPinned?.call(item) ?? item.settings.pinStatus.isPinned) { + pinnedCount++; + } else { + // Stop at first non-pinned item + break; // Stop at first non-pinned item + } + } + + // If there are pinned items, add after them without animation + // If no pinned items, add at top with animation + final shouldAnimate = pinnedCount == 0; + _addItem(newItem, position: pinnedCount, shouldAnimate: shouldAnimate); + } + + /// Removes an item with a smooth animation + /// The item will fade out, scale down, and slide up before being removed + void _removeItem(T item, {bool shouldAnimate = true}) { + final key = widget.itemKeyExtractor(item); + final itemState = _itemStates[key]; + + if (itemState == null) { + // Item doesn't exist, nothing to remove + return; + } + + final itemIndex = + _currentItems.indexWhere((i) => widget.itemKeyExtractor(i) == key); + if (itemIndex == -1) { + return; + } + + if (!shouldAnimate) { + // Remove immediately without animation + _currentItems.removeAt(itemIndex); + final currentItemsLength = _currentItems.length; + _cleanupRemovedItems( + { + for (var i = 0; i < currentItemsLength; i++) + if (_currentItems[i] case final item) widget.itemKeyExtractor(item), + }, + ); + setState(() {}); + return; + } + + _isRemovingItem = true; + _removingItems.add(key); + + // Mark item as removing + itemState.isRemoving = true; + + // Start removal animation + itemState.moveController + ..reset() + ..forward().then((_) { + if (!mounted) return; + + // Remove the item from the list + final newItems = List.from(_currentItems) + ..removeWhere((i) => widget.itemKeyExtractor(i) == key); + _currentItems = newItems; + + // Update indices for remaining items + final currentItemsLength = _currentItems.length; + for (var i = 0; i < currentItemsLength; i++) { + final itemKey = widget.itemKeyExtractor(_currentItems[i]); + final state = _itemStates[itemKey]; + if (state != null) { + state.currentIndex = i; + } + } + + // Cleanup the removed item + _removingItems.remove(key); + _itemStates[key]?.moveController.dispose(); + _itemStates.remove(key); + _itemKeys.remove(key); + + // Reset the removing flag + _isRemovingItem = _removingItems.isNotEmpty; + + setState(() {}); + }); + + setState(() {}); + } + + /// Removes an item by its key with animation + void removeItemByKey(String key, {bool shouldAnimate = true}) { + final item = _currentItems.cast().firstWhere( + (item) => item != null && widget.itemKeyExtractor(item) == key, + orElse: () => null, + ); + + if (item == null) return; + + _removeItem(item, shouldAnimate: shouldAnimate); + } +} diff --git a/lib/src/widgets/chat_view_list/chat_view_list.dart b/lib/src/widgets/chat_view_list/chat_view_list.dart index b91bf00a..ef3612ab 100644 --- a/lib/src/widgets/chat_view_list/chat_view_list.dart +++ b/lib/src/widgets/chat_view_list/chat_view_list.dart @@ -30,6 +30,7 @@ import '../../models/config_models/chat_view_list/chat_view_list_config.dart'; import '../../models/config_models/chat_view_list/load_more_config.dart'; import '../../values/typedefs.dart'; import '../../utils/constants/constants.dart'; +import 'auto_animate_sliver_list.dart'; import 'chat_list_tile_context_menu.dart'; import 'chat_view_list_item_tile.dart'; import 'search_text_field.dart'; @@ -122,38 +123,29 @@ class _ChatViewListState extends State { if (widget.header case final header?) SliverToBoxAdapter(child: header), StreamBuilder>( stream: widget.controller.chatListStream, - builder: (context, snapshot) { - final chats = snapshot.data ?? List.empty(); - final itemCount = chats.isEmpty ? 0 : chats.length * 2 - 1; - return SliverList( - delegate: SliverChildBuilderDelegate( - childCount: itemCount, - (context, index) { - final itemIndex = index ~/ 2; - if (index.isOdd) { - return widget.config.separator; - } - - final chat = chats[itemIndex]; - final child = widget.chatBuilder?.call(context, chat) ?? - ChatViewListItemTile( - chat: chat, - config: widget.config.tileConfig, - ); - - return widget.menuConfig.enabled - ? ChatListTileContextMenu( - key: ValueKey(chat.id), - chat: chat, - config: widget.menuConfig, - chatTileColor: widget.config.backgroundColor, - child: child, - ) - : child; - }, - ), - ); - }, + builder: (context, snapshot) => + AutoAnimateSliverList( + key: widget.controller.animatedList, + items: snapshot.data ?? List.empty(), + itemKeyExtractor: (chat) => chat.id, + builder: (context, index, _, chat) { + final child = widget.chatBuilder?.call(context, chat) ?? + ChatViewListItemTile( + chat: chat, + config: widget.config.tileConfig, + ); + + return widget.menuConfig.enabled + ? ChatListTileContextMenu( + key: ValueKey(chat.id), + chat: chat, + config: widget.menuConfig, + chatTileColor: widget.config.backgroundColor, + child: child, + ) + : child; + }, + ), ), // Show loading indicator at the bottom when loading next page SliverToBoxAdapter( diff --git a/lib/src/widgets/chat_view_list/chat_view_list_item_tile.dart b/lib/src/widgets/chat_view_list/chat_view_list_item_tile.dart index ced84190..f83eac80 100644 --- a/lib/src/widgets/chat_view_list/chat_view_list_item_tile.dart +++ b/lib/src/widgets/chat_view_list/chat_view_list_item_tile.dart @@ -25,6 +25,7 @@ import '../../extensions/extensions.dart'; import '../../models/chat_view_list_item.dart'; import '../../models/config_models/chat_view_list/list_tile_config.dart'; import '../../utils/helper.dart'; +import 'icon_scale_animation.dart'; import 'last_message_view.dart'; import 'unread_count_view.dart'; import 'user_avatar_view.dart'; @@ -82,54 +83,73 @@ class ChatViewListItemTile extends StatelessWidget { Expanded( child: Padding( padding: config.middleWidgetPadding, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - config.userNameBuilder?.call(chat) ?? - Text( - chat.name, - maxLines: config.userNameMaxLines, - overflow: config.userNameTextOverflow, - style: config.userNameTextStyle ?? - const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, + child: AnimatedSize( + alignment: Alignment.topCenter, + duration: const Duration(milliseconds: 400), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + config.userNameBuilder?.call(chat) ?? + AnimatedSwitcher( + switchInCurve: Curves.easeIn, + switchOutCurve: Curves.easeOut, + duration: const Duration(milliseconds: 200), + reverseDuration: const Duration(milliseconds: 150), + child: Align( + key: ValueKey(chat.name), + alignment: Alignment.centerLeft, + child: Text( + chat.name, + maxLines: config.userNameMaxLines, + overflow: config.userNameTextOverflow, + style: config.userNameTextStyle ?? + const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), ), - ), - if (isAnyUserTyping || lastMessage != null) - AnimatedSwitcher( - switchOutCurve: Curves.easeOut, - switchInCurve: Curves.easeIn, - duration: const Duration(milliseconds: 200), - reverseDuration: const Duration(milliseconds: 150), - child: isAnyUserTyping - ? typingStatusConfig.widgetBuilder?.call(chat) ?? - Text( - key: ValueKey(typingStatusText), - typingStatusText, - maxLines: typingStatusConfig.maxLines, - overflow: typingStatusConfig.overflow, - style: typingStatusConfig.textStyle, + ), + ), + if (isAnyUserTyping || lastMessage != null) + AnimatedSwitcher( + switchOutCurve: Curves.easeOut, + switchInCurve: Curves.easeIn, + duration: const Duration(milliseconds: 200), + reverseDuration: const Duration(milliseconds: 150), + child: isAnyUserTyping + ? Align( + alignment: Alignment.centerLeft, + child: typingStatusConfig.widgetBuilder + ?.call(chat) ?? + Text( + key: ValueKey(typingStatusText), + typingStatusText, + maxLines: typingStatusConfig.maxLines, + overflow: typingStatusConfig.overflow, + style: typingStatusConfig.textStyle, + ), ) - : LastMessageView( - key: lastMessage?.id.isEmpty ?? true - ? null - : ValueKey(lastMessage?.id), - unreadCount: unreadCount, - lastMessage: lastMessage, - lastMessageType: lastMessage?.messageType, - lastMessageMaxLines: config.lastMessageMaxLines, - lastMessageTextOverflow: - config.lastMessageTextOverflow, - lastMessageTextStyle: - config.lastMessageTextStyle, - lastMessageBuilder: config - .lastMessageTileBuilder - ?.call(lastMessage), - ), - ), - ], + : LastMessageView( + key: lastMessage?.id.isEmpty ?? true + ? null + : ValueKey(lastMessage?.id), + unreadCount: unreadCount, + lastMessage: lastMessage, + lastMessageType: lastMessage?.messageType, + lastMessageMaxLines: + config.lastMessageMaxLines, + lastMessageTextOverflow: + config.lastMessageTextOverflow, + lastMessageTextStyle: + config.lastMessageTextStyle, + lastMessageBuilder: config + .lastMessageTileBuilder + ?.call(lastMessage), + ), + ), + ], + ), ), ), ), @@ -158,24 +178,29 @@ class ChatViewListItemTile extends StatelessWidget { ], Row( children: [ - if (isPinned) ...[ - pinIconConfig.widget ?? + IconScaleAnimation( + enable: isPinned, + tag: 'pin', + child: pinIconConfig.widget ?? Icon( Icons.push_pin, size: pinIconConfig.size, color: pinIconConfig.color, ), - if (isMuted) const SizedBox(width: 10), - ], - if (isMuted) ...[ - muteIconConfig.widget ?? + ), + if (isPinned && isMuted) const SizedBox(width: 10), + IconScaleAnimation( + enable: isMuted, + tag: 'mute', + child: muteIconConfig.widget ?? Icon( Icons.notifications_off, size: muteIconConfig.size, color: muteIconConfig.color, ), - if (showUnreadCount) const SizedBox(width: 10), - ], + ), + if ((isPinned || isMuted) && showUnreadCount) + const SizedBox(width: 10), if (showUnreadCount) unreadCountConfig.countWidgetBuilder ?.call(unreadCount) ?? diff --git a/lib/src/widgets/chat_view_list/icon_scale_animation.dart b/lib/src/widgets/chat_view_list/icon_scale_animation.dart new file mode 100644 index 00000000..6085384d --- /dev/null +++ b/lib/src/widgets/chat_view_list/icon_scale_animation.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +class IconScaleAnimation extends StatelessWidget { + const IconScaleAnimation({ + required this.child, + required this.tag, + this.enable = true, + super.key, + }); + + final String tag; + final Widget child; + final bool enable; + + @override + Widget build(BuildContext context) { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + transitionBuilder: (child, animation) => ScaleTransition( + scale: CurvedAnimation( + parent: animation, + curve: Curves.easeInToLinear, + ), + child: child, + ), + child: enable + ? SizedBox( + key: ValueKey('$tag-icon'), + child: child, + ) + : SizedBox.shrink( + key: ValueKey('$tag-icon-empty'), + ), + ); + } +} diff --git a/lib/src/widgets/chat_view_list/last_message_view.dart b/lib/src/widgets/chat_view_list/last_message_view.dart index fb566119..ff0418ce 100644 --- a/lib/src/widgets/chat_view_list/last_message_view.dart +++ b/lib/src/widgets/chat_view_list/last_message_view.dart @@ -35,43 +35,56 @@ class LastMessageView extends StatelessWidget { @override Widget build(BuildContext context) { return lastMessageBuilder ?? - switch (lastMessageType) { - MessageType.image => Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.photo, size: 14), - const SizedBox(width: 5), - Text( - PackageStrings.currentLocale.photo, - style: TextStyle( - fontWeight: - unreadCount > 0 ? FontWeight.bold : FontWeight.normal, - ), + AnimatedSwitcher( + switchOutCurve: Curves.easeOut, + switchInCurve: Curves.easeIn, + duration: const Duration(milliseconds: 300), + reverseDuration: const Duration(milliseconds: 200), + child: Align( + key: ValueKey('${lastMessage?.id}_${lastMessage?.message}'), + alignment: Alignment.centerLeft, + child: switch (lastMessageType) { + MessageType.image => Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.photo, size: 14), + const SizedBox(width: 5), + Text( + PackageStrings.currentLocale.photo, + style: TextStyle( + fontWeight: unreadCount > 0 + ? FontWeight.bold + : FontWeight.normal, + ), + ), + ], ), - ], - ), - MessageType.text => Text( - lastMessage?.message ?? '', - maxLines: lastMessageMaxLines, - overflow: lastMessageTextOverflow, - style: lastMessageTextStyle ?? - TextStyle( - fontSize: 14, - fontWeight: - unreadCount > 0 ? FontWeight.bold : FontWeight.normal, - ), - ), - MessageType.voice => Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.mic, size: 14), - const SizedBox(width: 5), - Text(PackageStrings.currentLocale.voice), - ], - ), - // Provides the view for the custom message type in - // `lastMessageTileBuilder` - MessageType.custom || null => const SizedBox.shrink(), - }; + MessageType.text => Text( + lastMessage?.message ?? '', + textAlign: TextAlign.left, + maxLines: lastMessageMaxLines, + overflow: lastMessageTextOverflow, + style: lastMessageTextStyle ?? + TextStyle( + fontSize: 14, + fontWeight: unreadCount > 0 + ? FontWeight.bold + : FontWeight.normal, + ), + ), + MessageType.voice => Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.mic, size: 14), + const SizedBox(width: 5), + Text(PackageStrings.currentLocale.voice), + ], + ), + // Provides the view for the custom message type in + // `lastMessageTileBuilder` + MessageType.custom || null => const SizedBox.shrink(), + }, + ), + ); } }