From f089d311060e1778affbabd0cef4816a81c05b4b Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 21 Aug 2023 11:39:42 +0300 Subject: [PATCH] Updated on 2026-08-14 --- .../com/tangem/core/ui/event/EventEffect.kt | 23 +++ .../com/tangem/core/ui/event/StateEvent.kt | 51 +++++ .../presentation/common/WalletPreviewData.kt | 6 +- .../presentation/organizetokens/Intents.kt | 28 +++ .../organizetokens/OrganizeTokensIntents.kt | 14 -- .../organizetokens/OrganizeTokensScreen.kt | 38 ++-- .../OrganizeTokensStateHolder.kt | 34 +++- .../organizetokens/OrganizeTokensViewModel.kt | 23 ++- .../model/OrganizeTokensState.kt | 2 + .../utils/common/DraggableItemsOperations.kt | 177 +----------------- .../utils/common/TokenListOperations.kt | 8 +- .../items/TokenListToListStateConverter.kt | 5 +- .../utils/dnd/DragAndDropAdapter.kt | 169 +++++++++++++++++ .../utils/dnd/DraggableGroupsOperations.kt | 106 +++++++++++ 14 files changed, 475 insertions(+), 209 deletions(-) create mode 100644 core/ui/src/main/java/com/tangem/core/ui/event/EventEffect.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/event/StateEvent.kt create mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/Intents.kt delete mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/OrganizeTokensIntents.kt create mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/utils/dnd/DragAndDropAdapter.kt create mode 100644 features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/utils/dnd/DraggableGroupsOperations.kt diff --git a/core/ui/src/main/java/com/tangem/core/ui/event/EventEffect.kt b/core/ui/src/main/java/com/tangem/core/ui/event/EventEffect.kt new file mode 100644 index 0000000000..49abda9299 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/event/EventEffect.kt @@ -0,0 +1,23 @@ +package com.tangem.core.ui.event + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.NonRestartableComposable + +/** + * A Composable function that reacts to a given [StateEvent], executing the provided action only once when the event + * is triggered. + * + * @param event The [StateEvent] to listen to. + * @param onTrigger The action to execute when the event is triggered. + */ +@Composable +@NonRestartableComposable +fun EventEffect(event: StateEvent, onTrigger: suspend () -> Unit) { + LaunchedEffect(event) { + if (event is StateEvent.Triggered) { + onTrigger() + event.consume() + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/event/StateEvent.kt b/core/ui/src/main/java/com/tangem/core/ui/event/StateEvent.kt new file mode 100644 index 0000000000..d0f495b944 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/event/StateEvent.kt @@ -0,0 +1,51 @@ +package com.tangem.core.ui.event + +import androidx.compose.runtime.Immutable + +/** + * Represents compose state event, which can be consumed or triggered. + * + * This is especially useful for handling one-off UI events like showing snack bars or navigation which should not be + * re-triggered on recompositions or state changes. + */ +@Immutable +sealed class StateEvent { + + /** Defines the action to be executed when the event is consumed. */ + protected abstract val onConsume: () -> Unit + + /** + * Represents an already consumed state event. + * Events of this type will not trigger any further actions. + */ + object Consumed : StateEvent() { + override val onConsume: () -> Unit = {} + } + + /** + * Represents a state event that has been triggered but not yet consumed. + * + * @property onConsume The action to be executed when the event is consumed. + */ + data class Triggered(override val onConsume: () -> Unit) : StateEvent() + + /** + * Consumes the event, triggering any associated action. + */ + fun consume() { + onConsume() + } +} + +/** + * Creates a [StateEvent.Triggered] instance. + * + * @param onConsume The action to be executed when the event is consumed. + * @return A triggered state event. + */ +fun triggered(onConsume: () -> Unit): StateEvent.Triggered = StateEvent.Triggered(onConsume) + +/** + * Represents a statically defined [StateEvent.Consumed] event. + */ +val consumed: StateEvent.Consumed = StateEvent.Consumed \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/WalletPreviewData.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/WalletPreviewData.kt index 080d54bbc4..68df79d297 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/WalletPreviewData.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/common/WalletPreviewData.kt @@ -6,6 +6,7 @@ import com.tangem.core.ui.components.marketprice.MarketPriceBlockState import com.tangem.core.ui.components.marketprice.PriceChangeConfig import com.tangem.core.ui.components.transactions.state.TransactionState import com.tangem.core.ui.components.transactions.state.TxHistoryState +import com.tangem.core.ui.event.consumed import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.wallets.models.UserWalletId import com.tangem.feature.wallet.presentation.common.state.TokenItemState @@ -145,7 +146,7 @@ internal object WalletPreviewData { private const val networksSize = 10 private const val tokensSize = 3 - val draggableItems by lazy { + private val draggableItems by lazy { List(networksSize) { it } .flatMap { index -> val lastNetworkIndex = networksSize - 1 @@ -194,7 +195,7 @@ internal object WalletPreviewData { .toPersistentList() } - val draggableTokens by lazy { + private val draggableTokens by lazy { draggableItems .filterIsInstance() .toMutableList() @@ -224,6 +225,7 @@ internal object WalletPreviewData { onApplyClick = {}, onCancelClick = {}, ), + scrollListToTop = consumed, ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/Intents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/Intents.kt new file mode 100644 index 0000000000..e0f4868347 --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/Intents.kt @@ -0,0 +1,28 @@ +package com.tangem.feature.wallet.presentation.organizetokens + +import com.tangem.feature.wallet.presentation.organizetokens.model.DraggableItem +import org.burnoutcrew.reorderable.ItemPosition + +internal interface OrganizeTokensIntents { + + fun onBackClick() + + fun onSortClick() + + fun onGroupClick() + + fun onApplyClick() + + fun onCancelClick() +} + +internal interface DragAndDropIntents { + + fun onItemDragged(from: ItemPosition, to: ItemPosition) + + fun canDragItemOver(dragOver: ItemPosition, dragging: ItemPosition): Boolean + + fun onItemDraggingStart(item: DraggableItem) + + fun onItemDraggingEnd() +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/OrganizeTokensIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/OrganizeTokensIntents.kt deleted file mode 100644 index 84ce246245..0000000000 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/OrganizeTokensIntents.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.tangem.feature.wallet.presentation.organizetokens - -internal interface OrganizeTokensIntents { - - fun onBackClick() - - fun onSortClick() - - fun onGroupClick() - - fun onApplyClick() - - fun onCancelClick() -} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/OrganizeTokensScreen.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/OrganizeTokensScreen.kt index beb7709ffb..cd55c2f577 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/OrganizeTokensScreen.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/OrganizeTokensScreen.kt @@ -2,6 +2,7 @@ package com.tangem.feature.wallet.presentation.organizetokens import androidx.activity.compose.BackHandler import androidx.compose.animation.core.* +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* @@ -26,6 +27,7 @@ import com.tangem.core.ui.components.PrimaryButton import com.tangem.core.ui.components.SecondaryButton import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig import com.tangem.core.ui.components.buttons.actions.RoundedActionButton +import com.tangem.core.ui.event.EventEffect import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.res.TangemTheme import com.tangem.feature.wallet.impl.R @@ -64,6 +66,10 @@ internal fun OrganizeTokensScreen(state: OrganizeTokensState, modifier: Modifier }, containerColor = TangemTheme.colors.background.secondary, ) + + EventEffect(state.scrollListToTop) { + tokensListState.animateScrollToItem(index = 0) + } } @Composable @@ -74,11 +80,16 @@ private fun TokenList( modifier: Modifier = Modifier, ) { Box(modifier = modifier) { + val onDragEnd: (Int, Int) -> Unit = remember { + { _, _ -> + dndConfig.onItemDragEnd() + } + } val reorderableListState = rememberReorderableLazyListState( onMove = dndConfig.onItemDragged, listState = listState, canDragOver = dndConfig.canDragItemOver, - onDragEnd = { _, _ -> dndConfig.onItemDragEnd() }, + onDragEnd = onDragEnd, ) val items = state.items @@ -109,11 +120,6 @@ private fun TokenList( reorderableState = reorderableListState, onDragStart = onDragStart, ) - - if (item is DraggableItem.GroupPlaceholder) { - // This item should be displayed in the list but remain invisible - Box(modifier = Modifier.fillMaxWidth()) - } } } @@ -121,6 +127,7 @@ private fun TokenList( } } +@OptIn(ExperimentalFoundationApi::class) @Composable private fun LazyItemScope.DraggableItem( index: Int, @@ -129,15 +136,13 @@ private fun LazyItemScope.DraggableItem( onDragStart: () -> Unit, ) { ReorderableItem( - reorderableState = reorderableState, + defaultDraggingModifier = Modifier.animateItemPlacement( + animationSpec = tween(easing = LinearOutSlowInEasing), + ), + state = reorderableState, index = index, key = item.id, ) { isDragging -> - - if (isDragging) { - onDragStart() - } - val itemModifier = Modifier.applyShapeAndShadow(item.roundingMode, item.showShadow) when (item) { @@ -151,7 +156,14 @@ private fun LazyItemScope.DraggableItem( state = item.tokenItemState, reorderableTokenListState = reorderableState, ) - is DraggableItem.GroupPlaceholder -> Unit + // Should be presented in the list but remain invisible + is DraggableItem.GroupPlaceholder -> Box(modifier = Modifier.fillMaxWidth()) + } + + LaunchedEffect(isDragging) { + if (isDragging) { + onDragStart() + } } } } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/OrganizeTokensStateHolder.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/OrganizeTokensStateHolder.kt index 14fecec78d..ec8c8655c2 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/OrganizeTokensStateHolder.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/OrganizeTokensStateHolder.kt @@ -1,6 +1,8 @@ package com.tangem.feature.wallet.presentation.organizetokens import com.tangem.common.Provider +import com.tangem.core.ui.event.consumed +import com.tangem.core.ui.event.triggered import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.tokens.error.TokenListError import com.tangem.domain.tokens.error.TokenListSortingError @@ -19,6 +21,7 @@ import kotlinx.coroutines.flow.* internal class OrganizeTokensStateHolder( private val intents: OrganizeTokensIntents, + private val dragAndDropIntents: DragAndDropIntents, private val appCurrencyProvider: Provider, private val onSubscription: () -> Unit, stateFlowScope: CoroutineScope, @@ -60,6 +63,14 @@ internal class OrganizeTokensStateHolder( updateState { tokenListConverter.convert(tokenList) } } + fun updateStateAfterTokenListSorting(tokenList: TokenList) { + updateState { + tokenListConverter.convert(tokenList).copy( + scrollListToTop = triggered(::consumeScrollListToTopEvent), + ) + } + } + fun updateStateToDisplayProgress() { updateState { inProgressStateConverter.convert(value = this) } } @@ -68,6 +79,15 @@ internal class OrganizeTokensStateHolder( updateState { inProgressStateConverter.convertBack(value = this) } } + fun updateStateWithManualSorting(itemsState: OrganizeTokensListState) { + updateState { + copy( + header = header.copy(isSortedByBalance = false), + itemsState = itemsState, + ) + } + } + fun updateStateWithError(error: TokenListError) { updateState { tokenListErrorConverter.convert(error) } } @@ -88,17 +108,21 @@ internal class OrganizeTokensStateHolder( onApplyClick = intents::onApplyClick, onCancelClick = intents::onCancelClick, ), - // TODO: Will be added in next MR dndConfig = OrganizeTokensState.DragAndDropConfig( - onItemDragged = { _, _ -> }, - onDragStart = { }, - onItemDragEnd = { }, - canDragItemOver = { _, _ -> false }, + onItemDragged = dragAndDropIntents::onItemDragged, + onDragStart = dragAndDropIntents::onItemDraggingStart, + onItemDragEnd = dragAndDropIntents::onItemDraggingEnd, + canDragItemOver = dragAndDropIntents::canDragItemOver, ), + scrollListToTop = consumed, ) } private fun updateState(block: OrganizeTokensState.() -> OrganizeTokensState) { stateFlowInternal.update(block) } + + private fun consumeScrollListToTopEvent() { + updateState { copy(scrollListToTop = consumed) } + } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/OrganizeTokensViewModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/OrganizeTokensViewModel.kt index 0313bf1e7a..5c24a976b3 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/OrganizeTokensViewModel.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/OrganizeTokensViewModel.kt @@ -16,6 +16,8 @@ import com.tangem.domain.wallets.models.UserWalletId import com.tangem.feature.wallet.presentation.organizetokens.model.OrganizeTokensListState import com.tangem.feature.wallet.presentation.organizetokens.model.OrganizeTokensState import com.tangem.feature.wallet.presentation.organizetokens.utils.CryptoCurrenciesIdsResolver +import com.tangem.feature.wallet.presentation.organizetokens.utils.common.disableSortingByBalance +import com.tangem.feature.wallet.presentation.organizetokens.utils.dnd.DragAndDropAdapter import com.tangem.feature.wallet.presentation.router.InnerWalletRouter import com.tangem.feature.wallet.presentation.router.WalletRoute import dagger.hilt.android.lifecycle.HiltViewModel @@ -39,12 +41,19 @@ internal class OrganizeTokensViewModel @Inject constructor( private val selectedAppCurrencyFlow = createSelectedAppCurrencyFlow() + private val dragAndDropAdapter = DragAndDropAdapter( + listStateProvider = Provider { uiState.value.itemsState }, + scope = viewModelScope, + ) + private val stateHolder = OrganizeTokensStateHolder( stateFlowScope = viewModelScope, intents = this, + dragAndDropIntents = dragAndDropAdapter, appCurrencyProvider = Provider(selectedAppCurrencyFlow::value), onSubscription = { bootstrapTokenList() + bootstrapDragAndDropUpdates() }, ) @@ -69,7 +78,7 @@ internal class OrganizeTokensViewModel @Inject constructor( toggleTokenListSortingUseCase(list).fold( ifLeft = stateHolder::updateStateWithError, ifRight = { - stateHolder.updateStateWithTokenList(it) + stateHolder.updateStateAfterTokenListSorting(it) tokenList = it }, ) @@ -83,7 +92,7 @@ internal class OrganizeTokensViewModel @Inject constructor( toggleTokenListGroupingUseCase(list).fold( ifLeft = stateHolder::updateStateWithError, ifRight = { - stateHolder.updateStateWithTokenList(it) + stateHolder.updateStateAfterTokenListSorting(it) tokenList = it }, ) @@ -133,6 +142,16 @@ internal class OrganizeTokensViewModel @Inject constructor( } } + private fun bootstrapDragAndDropUpdates() { + dragAndDropAdapter.stateFlow + .distinctUntilChanged() + .onEach { + stateHolder.updateStateWithManualSorting(it) + tokenList = tokenList?.disableSortingByBalance() + } + .launchIn(viewModelScope) + } + private fun createSelectedAppCurrencyFlow(): StateFlow { return getSelectedAppCurrencyUseCase() .map { maybeAppCurrency -> diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/model/OrganizeTokensState.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/model/OrganizeTokensState.kt index 30d8e846c1..860c4c5b24 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/model/OrganizeTokensState.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/model/OrganizeTokensState.kt @@ -1,6 +1,7 @@ package com.tangem.feature.wallet.presentation.organizetokens.model import androidx.compose.runtime.Immutable +import com.tangem.core.ui.event.StateEvent import org.burnoutcrew.reorderable.ItemPosition @Immutable @@ -10,6 +11,7 @@ internal data class OrganizeTokensState( val header: HeaderConfig, val actions: ActionsConfig, val dndConfig: DragAndDropConfig, + val scrollListToTop: StateEvent, ) { data class HeaderConfig( diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/utils/common/DraggableItemsOperations.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/utils/common/DraggableItemsOperations.kt index 74ada001e8..fee7cb1033 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/utils/common/DraggableItemsOperations.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/utils/common/DraggableItemsOperations.kt @@ -1,184 +1,27 @@ package com.tangem.feature.wallet.presentation.organizetokens.utils.common import com.tangem.feature.wallet.presentation.organizetokens.model.DraggableItem -import kotlinx.collections.immutable.PersistentList -import org.burnoutcrew.reorderable.ItemPosition -internal fun List.findItemsToMove( - moveOverItemKey: Any?, - movedItemKey: Any?, -): Pair { - var moveOverItem: DraggableItem? = null - var movedItem: DraggableItem? = null - - for (item in this) { - if (item.id == moveOverItemKey) { - moveOverItem = item - } - if (item.id == movedItemKey) { - movedItem = item - } - if (moveOverItem != null && movedItem != null) { - break - } - } - - return Pair(moveOverItem, movedItem) -} - -internal fun checkCanMoveHeaderOver( - moveOverItemPosition: ItemPosition, - moveOverItem: DraggableItem, - lastItemIndex: Int, -): Boolean { - // Group item can be moved only to group divider or to ages of the items list - return when { - moveOverItemPosition.index == 0 -> true - moveOverItemPosition.index == lastItemIndex -> true - moveOverItem is DraggableItem.GroupPlaceholder -> true - else -> false - } -} - -internal fun checkCanMoveTokenOver(item: DraggableItem.Token, moveOverItem: DraggableItem): Boolean { - // Token item can be moved only in its group - return when (moveOverItem) { - is DraggableItem.GroupHeader -> false // Token item can not be moved to group item - is DraggableItem.Token -> item.groupId == moveOverItem.groupId // Token item can not be moved over its group - is DraggableItem.GroupPlaceholder -> false - } -} - -internal fun PersistentList.moveItem(fromIndex: Int, toIndex: Int): PersistentList { - val fromItem = this[fromIndex] - return this - .removeAt(fromIndex) - .add(toIndex, fromItem) -} - -internal fun List.divideItems(movingItem: DraggableItem): List { - return this.map { - it - .updateRoundingMode(DraggableItem.RoundingMode.All(showGap = true)) - .updateShadowVisibility(show = it.id == movingItem.id) - } -} - -@Suppress("UNCHECKED_CAST") // Erased type -internal fun List.uniteItems(): List { +internal fun List.uniteItems(): List { val lastItemIndex = this.lastIndex return this.mapIndexed { index, item -> val mode = when (index) { 0 -> DraggableItem.RoundingMode.Top() lastItemIndex -> DraggableItem.RoundingMode.Bottom() - else -> DraggableItem.RoundingMode.None + else -> when (item) { + is DraggableItem.GroupHeader -> DraggableItem.RoundingMode.Top(showGap = true) + is DraggableItem.Token -> if (this[index + 1] is DraggableItem.GroupPlaceholder) { + DraggableItem.RoundingMode.Bottom(showGap = true) + } else { + DraggableItem.RoundingMode.None + } + is DraggableItem.GroupPlaceholder -> DraggableItem.RoundingMode.None + } } item .updateRoundingMode(mode) .updateShadowVisibility(show = false) - } as List -} - -// TODO: Move to domain -@Volatile -private var groupIdToTokens: Map>? = null - -internal fun List.collapseGroup(group: DraggableItem.GroupHeader): List { - if (!groupIdToTokens.isNullOrEmpty()) return this - - groupIdToTokens = this - .asSequence() - .filterIsInstance() - .groupBy { it.groupId } - - return this - .filterNot { it is DraggableItem.Token && it.groupId == group.id } - .divideGroups(group) -} - -internal fun List.expandGroups(): List { - if (groupIdToTokens.isNullOrEmpty()) return this - - val currentGroups = this.filterIsInstance() - val lastGroupIndex = currentGroups.lastIndex - - return currentGroups - .flatMapIndexed { index, group -> - buildList { - add(group) - addAll(groupIdToTokens?.get(group.id).orEmpty()) - if (index != lastGroupIndex) { - add(DraggableItem.GroupPlaceholder(id = "group_divider_$index")) - } - } - } - .uniteItems() - .also { groupIdToTokens = null } -} - -/** - * Applies the correct [DraggableItem.RoundingMode] and shadow status to each item in the list, - * based on the relationship of each item to the [movingItem] and its position in the list. - * - * @param movingItem The item that is being dragged/moved. - * @return A list of [DraggableItem]s with updated rounding modes and shadow statuses. - */ -internal fun List.divideGroups(movingItem: DraggableItem): List { - val lastItemIndex = this.lastIndex - - return this.mapIndexed { index, item -> - when { - // Case when current item is the moving item - item.id == movingItem.id -> { - item - .updateRoundingMode(DraggableItem.RoundingMode.All(showGap = true)) - .updateShadowVisibility(show = true) - } - // Case when moving item is a token and current item is the group of the moving token - movingItem is DraggableItem.Token && item.id == movingItem.groupId -> { - item - .updateRoundingMode(DraggableItem.RoundingMode.All(showGap = true)) - .updateShadowVisibility(show = true) - } - // Case when both moving item and current item are tokens and belong to the same group - movingItem is DraggableItem.Token && - item is DraggableItem.Token && item.groupId == movingItem.groupId -> { - item - .updateRoundingMode(DraggableItem.RoundingMode.All(showGap = true)) - .updateShadowVisibility(show = false) - } - // Case when current item is the first item in the list - index == 0 -> { - item - .updateRoundingMode(DraggableItem.RoundingMode.Top()) - .updateShadowVisibility(show = false) - } - // Case when current item is the last item in the list - index == lastItemIndex -> { - item - .updateRoundingMode(DraggableItem.RoundingMode.Bottom()) - .updateShadowVisibility(show = false) - } - // Case when previous item is a GroupPlaceholder - this[index - 1] is DraggableItem.GroupPlaceholder -> { - item - .updateRoundingMode(DraggableItem.RoundingMode.Top(showGap = true)) - .updateShadowVisibility(show = false) - } - // Case when next item is a GroupPlaceholder - this[index + 1] is DraggableItem.GroupPlaceholder -> { - item - .updateRoundingMode(DraggableItem.RoundingMode.Bottom(showGap = true)) - .updateShadowVisibility(show = false) - } - // Default case when none of the above conditions are met - else -> { - item - .updateRoundingMode(DraggableItem.RoundingMode.None) - .updateShadowVisibility(show = false) - } - } } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/utils/common/TokenListOperations.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/utils/common/TokenListOperations.kt index 4f1478d2bf..4e4250f5e9 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/utils/common/TokenListOperations.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/utils/common/TokenListOperations.kt @@ -3,12 +3,10 @@ package com.tangem.feature.wallet.presentation.organizetokens.utils.common import com.tangem.domain.tokens.model.TokenList import com.tangem.domain.tokens.model.TokenList.SortType -internal fun TokenList.updateSorting(isSortedByBalance: Boolean): TokenList { - val sortType = if (isSortedByBalance) SortType.BALANCE else SortType.NONE - +internal fun TokenList.disableSortingByBalance(): TokenList { return when (this) { - is TokenList.GroupedByNetwork -> this.copy(sortedBy = sortType) - is TokenList.Ungrouped -> this.copy(sortedBy = sortType) + is TokenList.GroupedByNetwork -> this.copy(sortedBy = SortType.NONE) + is TokenList.Ungrouped -> this.copy(sortedBy = SortType.NONE) is TokenList.NotInitialized -> this } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/utils/converter/items/TokenListToListStateConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/utils/converter/items/TokenListToListStateConverter.kt index bb1b829d47..dd82c1672b 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/utils/converter/items/TokenListToListStateConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/utils/converter/items/TokenListToListStateConverter.kt @@ -1,9 +1,11 @@ package com.tangem.feature.wallet.presentation.organizetokens.utils.converter.items import com.tangem.domain.tokens.model.TokenList +import com.tangem.feature.wallet.presentation.organizetokens.model.DraggableItem import com.tangem.feature.wallet.presentation.organizetokens.model.OrganizeTokensListState import com.tangem.feature.wallet.presentation.organizetokens.utils.common.uniteItems import com.tangem.utils.converter.Converter +import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.toPersistentList internal class TokenListToListStateConverter( @@ -28,11 +30,12 @@ internal class TokenListToListStateConverter( ) } + @Suppress("UNCHECKED_CAST") // Erased type private fun createListState(tokenList: TokenList.Ungrouped): OrganizeTokensListState.Ungrouped { return OrganizeTokensListState.Ungrouped( items = tokensConverter.convertList(tokenList.currencies) .uniteItems() - .toPersistentList(), + .toPersistentList() as PersistentList, ) } diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/utils/dnd/DragAndDropAdapter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/utils/dnd/DragAndDropAdapter.kt new file mode 100644 index 0000000000..c73798c77e --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/utils/dnd/DragAndDropAdapter.kt @@ -0,0 +1,169 @@ +package com.tangem.feature.wallet.presentation.organizetokens.utils.dnd + +import com.tangem.common.Provider +import com.tangem.feature.wallet.presentation.organizetokens.DragAndDropIntents +import com.tangem.feature.wallet.presentation.organizetokens.model.DraggableItem +import com.tangem.feature.wallet.presentation.organizetokens.model.OrganizeTokensListState +import com.tangem.feature.wallet.presentation.organizetokens.utils.common.uniteItems +import com.tangem.feature.wallet.presentation.organizetokens.utils.common.updateItems +import kotlinx.collections.immutable.mutate +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import org.burnoutcrew.reorderable.ItemPosition + +internal class DragAndDropAdapter( + private val listStateProvider: Provider, + private val scope: CoroutineScope, +) : DragAndDropIntents { + + private val draggableGroupsOperations = DraggableGroupsOperations() + + private val currentListState: OrganizeTokensListState + get() = listStateProvider.invoke() + + private val listStateFlowInternal: MutableSharedFlow = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + private var currentDraggingItem: DraggableItem? = null + + val stateFlow: Flow + get() = listStateFlowInternal + + override fun canDragItemOver(dragOver: ItemPosition, dragging: ItemPosition): Boolean { + val items = (currentListState as? OrganizeTokensListState.GroupedByNetwork) + ?.items + ?: return true // If ungrouped then item can be moved anywhere + + val (dragOverItem, draggingItem) = findItemsToMove( + items = items, + moveOverItemKey = dragOver.key, + movedItemKey = dragging.key, + ) + + if (dragOverItem == null || draggingItem == null) { + return false + } + + return when (draggingItem) { + is DraggableItem.GroupHeader -> checkCanMoveHeaderOver(dragOver, dragOverItem, items.lastIndex) + is DraggableItem.Token -> checkCanMoveTokenOver(draggingItem, dragOverItem) + is DraggableItem.GroupPlaceholder -> false + } + } + + override fun onItemDraggingStart(item: DraggableItem) { + if (currentDraggingItem != null) return + currentDraggingItem = item + + updateListState { + when (item) { + is DraggableItem.GroupPlaceholder -> items + is DraggableItem.GroupHeader -> draggableGroupsOperations.collapseGroup(items, item) + is DraggableItem.Token -> when (this) { + is OrganizeTokensListState.GroupedByNetwork -> draggableGroupsOperations.divideGroups(items, item) + is OrganizeTokensListState.Ungrouped -> divideTokens(items, item) + is OrganizeTokensListState.Empty -> items + } + } + } + } + + override fun onItemDraggingEnd() { + scope.launch(Dispatchers.IO) { + val draggingItem = currentDraggingItem ?: return@launch + + delay(FINISH_DRAGGING_DELAY_MILLIS) + + updateListState { + when (draggingItem) { + is DraggableItem.GroupHeader -> draggableGroupsOperations.expandGroups(items) + is DraggableItem.Token -> items.uniteItems() + is DraggableItem.GroupPlaceholder -> items + } + } + + currentDraggingItem = null + } + } + + override fun onItemDragged(from: ItemPosition, to: ItemPosition) = updateListState { + items.mutate { + it.add(to.index, it.removeAt(from.index)) + } + } + + private fun updateListState(block: OrganizeTokensListState.() -> List) { + val updatedState = currentListState.updateItems { block(currentListState) } + + listStateFlowInternal.tryEmit(updatedState) + } + + private fun findItemsToMove( + items: List, + moveOverItemKey: Any?, + movedItemKey: Any?, + ): Pair { + var moveOverItem: DraggableItem? = null + var movedItem: DraggableItem? = null + + for (item in items) { + if (item.id == moveOverItemKey) { + moveOverItem = item + } + if (item.id == movedItemKey) { + movedItem = item + } + if (moveOverItem != null && movedItem != null) { + break + } + } + + return Pair(moveOverItem, movedItem) + } + + private fun checkCanMoveHeaderOver( + moveOverItemPosition: ItemPosition, + moveOverItem: DraggableItem, + lastItemIndex: Int, + ): Boolean { + // Group item can be moved only to group divider or to ages of the items list + return when { + moveOverItemPosition.index == 0 -> true + moveOverItemPosition.index == lastItemIndex -> true + moveOverItem is DraggableItem.GroupPlaceholder -> true + else -> false + } + } + + private fun checkCanMoveTokenOver(item: DraggableItem.Token, moveOverItem: DraggableItem): Boolean { + // Token item can be moved only in its group + return when (moveOverItem) { + is DraggableItem.GroupHeader -> false // Token item can not be moved to group item + is DraggableItem.Token -> item.groupId == moveOverItem.groupId // Token item can not be moved over its group + is DraggableItem.GroupPlaceholder -> false + } + } + + @Suppress("UNCHECKED_CAST") // Erased type + private fun divideTokens( + items: List, + movingItem: DraggableItem.Token, + ): List { + return items.map { token -> + token + .updateRoundingMode(DraggableItem.RoundingMode.All(showGap = true)) + .updateShadowVisibility(show = token.id == movingItem.id) + } as List + } + + private companion object { + const val FINISH_DRAGGING_DELAY_MILLIS = 200L + } +} \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/utils/dnd/DraggableGroupsOperations.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/utils/dnd/DraggableGroupsOperations.kt new file mode 100644 index 0000000000..d28133195a --- /dev/null +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/utils/dnd/DraggableGroupsOperations.kt @@ -0,0 +1,106 @@ +package com.tangem.feature.wallet.presentation.organizetokens.utils.dnd + +import com.tangem.feature.wallet.presentation.organizetokens.model.DraggableItem +import com.tangem.feature.wallet.presentation.organizetokens.utils.common.getGroupPlaceholder +import com.tangem.feature.wallet.presentation.organizetokens.utils.common.uniteItems + +internal class DraggableGroupsOperations { + + private var groupIdToTokens: Map>? = null + + fun collapseGroup(items: List, movingGroup: DraggableItem.GroupHeader): List { + if (!groupIdToTokens.isNullOrEmpty()) return items + + groupIdToTokens = items + .asSequence() + .filterIsInstance() + .groupBy { it.groupId } + + val itemsWithoutGroupTokens = items.filterNot { + it is DraggableItem.Token && it.groupId == movingGroup.id + } + + return divideGroups(itemsWithoutGroupTokens, movingGroup) + } + + fun expandGroups(items: List): List { + if (groupIdToTokens.isNullOrEmpty()) return items + + val currentGroups = items.filterIsInstance() + val lastGroupIndex = currentGroups.lastIndex + + val expandedGroups = currentGroups + .flatMapIndexed { index, group -> + buildList { + add(group) + addAll(groupIdToTokens?.get(group.id).orEmpty()) + if (index != lastGroupIndex) { + add(getGroupPlaceholder(index)) + } + } + } + .uniteItems() + + groupIdToTokens = null + + return expandedGroups + } + + fun divideGroups(items: List, movingItem: DraggableItem): List { + val lastItemIndex = items.lastIndex + + return items.mapIndexed { index, item -> + when { + // Case when current item is the moving item + item.id == movingItem.id -> { + item + .updateRoundingMode(DraggableItem.RoundingMode.All(showGap = true)) + .updateShadowVisibility(show = true) + } + // Case when moving item is a token and current item is the group of the moving token + movingItem is DraggableItem.Token && item.id == movingItem.groupId -> { + item + .updateRoundingMode(DraggableItem.RoundingMode.All(showGap = true)) + .updateShadowVisibility(show = true) + } + // Case when both moving item and current item are tokens and belong to the same group + movingItem is DraggableItem.Token && + item is DraggableItem.Token && item.groupId == movingItem.groupId -> { + item + .updateRoundingMode(DraggableItem.RoundingMode.All(showGap = true)) + .updateShadowVisibility(show = false) + } + // Case when current item is the first item in the list + index == 0 -> { + item + .updateRoundingMode(DraggableItem.RoundingMode.Top()) + .updateShadowVisibility(show = false) + } + // Case when current item is the last item in the list + index == lastItemIndex -> { + item + .updateRoundingMode(DraggableItem.RoundingMode.Bottom()) + .updateShadowVisibility(show = false) + } + // Case when previous item is a GroupPlaceholder + items[index - 1] is DraggableItem.GroupPlaceholder -> { + item + .updateRoundingMode(DraggableItem.RoundingMode.Top(showGap = true)) + .updateShadowVisibility(show = false) + } + // Case when next item is a GroupPlaceholder + items[index + 1] is DraggableItem.GroupPlaceholder -> { + item + .updateRoundingMode(DraggableItem.RoundingMode.Bottom(showGap = true)) + .updateShadowVisibility(show = false) + } + // Default case when none of the above conditions are met + else -> { + item + .updateRoundingMode(DraggableItem.RoundingMode.None) + .updateShadowVisibility(show = false) + } + } + } + } +} \ No newline at end of file