diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemDimens.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemDimens.kt index 8c85ac6339..14449b2c45 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemDimens.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemDimens.kt @@ -20,6 +20,7 @@ data class TangemDimens internal constructor( val elevation24: Dp = 24.dp, // endregion Elevation // region Radius + val radius0: Dp = 0.dp, val radius2: Dp = 2.dp, val radius3: Dp = 3.dp, val radius4: Dp = 4.dp, 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 d64e296c88..42f47eeb7f 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 @@ -106,11 +106,17 @@ internal object WalletPreviewData { val draggableItems = List(networksSize) { it } .flatMap { index -> val lastNetworkIndex = networksSize - 1 + val lastTokenIndex = tokensSize - 1 val networkNumber = index + 1 val group = DraggableItem.GroupHeader( id = "group_$networkNumber", networkName = "$networkNumber", + roundingMode = when (index) { + 0 -> DraggableItem.RoundingMode.Top() + lastNetworkIndex -> DraggableItem.RoundingMode.Bottom() + else -> DraggableItem.RoundingMode.None + }, ) val tokens: MutableList = mutableListOf() @@ -120,10 +126,14 @@ internal object WalletPreviewData { DraggableItem.Token( tokenItemState = tokenItemDragState.copy( id = "${group.id}_token_$tokenNumber", - name = "Token $tokenNumber", + name = "Token $tokenNumber from $networkNumber network", networkIconResId = R.drawable.img_eth_22.takeIf { i != 0 }, ), groupId = group.id, + roundingMode = when { + i == lastTokenIndex && index == lastNetworkIndex -> DraggableItem.RoundingMode.Bottom() + else -> DraggableItem.RoundingMode.None + }, ), ) } @@ -142,6 +152,10 @@ internal object WalletPreviewData { val draggableTokens = draggableItems .filterIsInstance() + .toMutableList() + .also { + it[0] = it[0].copy(roundingMode = DraggableItem.RoundingMode.Top()) + } .toPersistentList() val groupedOrganizeTokensState = OrganizeTokensStateHolder( 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 685d1ab9f5..8aacb95163 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 @@ -1,6 +1,6 @@ package com.tangem.feature.wallet.presentation.organizetokens -import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.* import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* @@ -13,10 +13,10 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.composed -import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter @@ -30,10 +30,7 @@ import com.tangem.feature.wallet.impl.R import com.tangem.feature.wallet.presentation.common.WalletPreviewData import com.tangem.feature.wallet.presentation.common.component.DraggableNetworkGroupItem import com.tangem.feature.wallet.presentation.common.component.DraggableTokenItem -import org.burnoutcrew.reorderable.ReorderableItem -import org.burnoutcrew.reorderable.ReorderableLazyListState -import org.burnoutcrew.reorderable.rememberReorderableLazyListState -import org.burnoutcrew.reorderable.reorderable +import org.burnoutcrew.reorderable.* @Composable internal fun OrganizeTokensScreen(state: OrganizeTokensStateHolder, modifier: Modifier = Modifier) { @@ -60,7 +57,6 @@ internal fun OrganizeTokensScreen(state: OrganizeTokensStateHolder, modifier: Mo ) } -// TODO: Fix list animations @Composable private fun TokenList( listState: LazyListState, @@ -76,7 +72,6 @@ private fun TokenList( onDragEnd = { _, _ -> dragConfig.onItemDragEnd() }, ) val items = state.items - val lastItemIndex = items.lastIndex LazyColumn( modifier = Modifier @@ -99,9 +94,8 @@ private fun TokenList( } DraggableItem( - item = item, index = index, - lastItemIndex = lastItemIndex, + item = item, reorderableState = reorderableListState, onDragStart = onDragStart, ) @@ -121,7 +115,6 @@ private fun TokenList( private fun LazyItemScope.DraggableItem( index: Int, item: DraggableItem, - lastItemIndex: Int, reorderableState: ReorderableLazyListState, onDragStart: () -> Unit, ) { @@ -135,8 +128,7 @@ private fun LazyItemScope.DraggableItem( onDragStart() } - val itemModifier = Modifier - .clipFirstLastAndDraggingItems(index, lastItemIndex, isDragging) + val itemModifier = Modifier.applyShapeAndShadow(item.roundingMode, item.showShadow) when (item) { is DraggableItem.GroupHeader -> DraggableNetworkGroupItem( @@ -256,35 +248,56 @@ private fun Actions(config: OrganizeTokensStateHolder.ActionsConfig, modifier: M } } -private fun Modifier.clipFirstLastAndDraggingItems(index: Int, lastItemIndex: Int, isDragging: Boolean): Modifier = +private fun Modifier.applyShapeAndShadow(roundingMode: DraggableItem.RoundingMode, showShadow: Boolean): Modifier = composed { - when { - isDragging -> { - val elevation by animateDpAsState( - targetValue = TangemTheme.dimens.elevation12, - label = "dragging_item_shadow_elevation", - ) - - this.shadow(elevation, shape = TangemTheme.shapes.roundedCornersXMedium) - } - index == 0 -> { - this.clip( - RoundedCornerShape( - topStart = TangemTheme.dimens.radius16, - topEnd = TangemTheme.dimens.radius16, - ), - ) - } - index == lastItemIndex -> { - this.clip( - RoundedCornerShape( - bottomStart = TangemTheme.dimens.radius16, - bottomEnd = TangemTheme.dimens.radius16, - ), - ) - } - else -> this + val radius by animateDpAsState( + targetValue = if (roundingMode !is DraggableItem.RoundingMode.None) { + TangemTheme.dimens.radius16 + } else { + TangemTheme.dimens.radius0 + }, + label = "item_shape_radius", + ) + val shape = when (roundingMode) { + is DraggableItem.RoundingMode.None -> RectangleShape + is DraggableItem.RoundingMode.Top -> RoundedCornerShape( + topStart = radius, + topEnd = radius, + ) + is DraggableItem.RoundingMode.Bottom -> RoundedCornerShape( + bottomStart = radius, + bottomEnd = radius, + ) + is DraggableItem.RoundingMode.All -> RoundedCornerShape( + size = radius, + ) } + + val paddingValue = TangemTheme.dimens.spacing4 + val padding = if (roundingMode.showGap) { + when (roundingMode) { + is DraggableItem.RoundingMode.None -> null + is DraggableItem.RoundingMode.All -> PaddingValues(vertical = paddingValue) + is DraggableItem.RoundingMode.Top -> PaddingValues(top = paddingValue) + is DraggableItem.RoundingMode.Bottom -> PaddingValues(bottom = paddingValue) + } + } else { + null + } + + this + .let { + if (padding != null) { + it.padding(padding) + } else { + it + } + } + .shadow( + elevation = if (showShadow) TangemTheme.dimens.elevation12 else TangemTheme.dimens.elevation0, + shape = shape, + clip = true, + ) } // region Preview 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 693b9ce419..4da6dcb29c 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 @@ -58,20 +58,28 @@ internal sealed interface OrganizeTokensListState { * Helper class for the DND list items * * @property id ID of the item + * @property roundingMode item [RoundingMode] + * @property showShadow if true then item should be elevated * */ @Immutable internal sealed interface DraggableItem { val id: String + val roundingMode: RoundingMode + val showShadow: Boolean /** * Item for network group header. * * @property id ID of the network group * @property networkName network group name + * @property roundingMode item [RoundingMode] + * @property showShadow if true then item should be elevated * */ data class GroupHeader( override val id: String, val networkName: String, + override val roundingMode: RoundingMode = RoundingMode.None, + override val showShadow: Boolean = false, ) : DraggableItem /** @@ -80,10 +88,14 @@ internal sealed interface DraggableItem { * @property tokenItemState state of the token item * @property groupId ID of the network group which contains this token * @property id ID of the token + * @property roundingMode item [RoundingMode] + * @property showShadow if true then item should be elevated * */ data class Token( val tokenItemState: TokenItemState.Draggable, val groupId: String, + override val showShadow: Boolean = false, + override val roundingMode: RoundingMode = RoundingMode.None, ) : DraggableItem { override val id: String = tokenItemState.id } @@ -96,5 +108,72 @@ internal sealed interface DraggableItem { * */ data class GroupPlaceholder( override val id: String, - ) : DraggableItem + ) : DraggableItem { + override val showShadow: Boolean = false + override val roundingMode: RoundingMode = RoundingMode.None + } + + /** + * Update item [RoundingMode] + * + * @param mode new [RoundingMode] + * + * @return updated [DraggableItem] + * */ + fun roundingMode(mode: RoundingMode): DraggableItem = when (this) { + is GroupPlaceholder -> this + is GroupHeader -> this.copy(roundingMode = mode) + is Token -> this.copy(roundingMode = mode) + } + + /** + * Update item shadow visibility + * + * @param show if true then item should be elevated + * + * @return updated [DraggableItem] + * */ + fun showShadow(show: Boolean): DraggableItem = when (this) { + is GroupPlaceholder -> this + is GroupHeader -> this.copy(showShadow = show) + is Token -> this.copy(showShadow = show) + } + + /** + * Rounding mode of the [DraggableItem] + * + * @property showGap if true then item should have padding on rounded side + * */ + @Immutable + sealed interface RoundingMode { + val showGap: Boolean + + /** + * In this mode, item is not rounded + * */ + object None : RoundingMode { + override val showGap: Boolean = false + } + + /** + * In this mode, item should have a rounded top side + * + * @property showGap if true then item should have top padding + * */ + data class Top(override val showGap: Boolean = false) : RoundingMode + + /** + * In this mode, item should have a rounded bottom side + * + * @property showGap if true then item should have bottom padding + * */ + data class Bottom(override val showGap: Boolean = false) : RoundingMode + + /** + * In this mode, item should have a rounded all sides + * + * @property showGap if true then item should have top and bottom padding + * */ + data class All(override val showGap: Boolean = false) : RoundingMode + } } \ 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 9334ce7f8b..e81b764058 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 @@ -4,16 +4,16 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.tangem.feature.wallet.presentation.common.WalletPreviewData import com.tangem.feature.wallet.presentation.organizetokens.OrganizeTokensStateHolder.DragConfig import com.tangem.feature.wallet.presentation.organizetokens.OrganizeTokensStateHolder.HeaderConfig -import com.tangem.feature.wallet.presentation.organizetokens.utils.checkCanMoveHeaderOver -import com.tangem.feature.wallet.presentation.organizetokens.utils.checkCanMoveTokenOver -import com.tangem.feature.wallet.presentation.organizetokens.utils.findItemsToMove -import com.tangem.feature.wallet.presentation.organizetokens.utils.moveItem +import com.tangem.feature.wallet.presentation.organizetokens.utils.* import com.tangem.feature.wallet.presentation.router.InnerWalletRouter import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import org.burnoutcrew.reorderable.ItemPosition import javax.inject.Inject import kotlin.properties.Delegates @@ -22,9 +22,8 @@ import kotlin.properties.Delegates @HiltViewModel internal class OrganizeTokensViewModel @Inject constructor() : ViewModel() { - // TODO: Move to domain @Volatile - private var groupIdToTokens: Map>? = null + private var movingItem: DraggableItem? = null var router: InnerWalletRouter by Delegates.notNull() @@ -38,8 +37,8 @@ internal class OrganizeTokensViewModel @Inject constructor() : ViewModel() { dragConfig = DragConfig( onItemDragged = this::moveItem, canDragItemOver = this::checkCanMoveItemOver, - onItemDragEnd = this::expandGroups, - onDragStart = this::collapseGroup, + onItemDragEnd = this::endMoving, + onDragStart = this::startMoving, ), header = HeaderConfig( onSortByBalanceClick = { /* no-op */ }, @@ -78,46 +77,42 @@ internal class OrganizeTokensViewModel @Inject constructor() : ViewModel() { } } - private fun collapseGroup(item: DraggableItem) { - if (!groupIdToTokens.isNullOrEmpty() || item is DraggableItem.Token) return + private fun startMoving(movingItem: DraggableItem) = viewModelScope.launch(Dispatchers.Default) { + if (this@OrganizeTokensViewModel.movingItem != null) return@launch + this@OrganizeTokensViewModel.movingItem = movingItem - val itemsState = uiState.itemsState as? OrganizeTokensListState.GroupedByNetwork ?: return - groupIdToTokens = itemsState.items - .asSequence() - .filterIsInstance() - .groupBy(DraggableItem.Token::groupId) - - uiState = uiState.copy( - itemsState = itemsState.updateItems { items -> - items.filterNot { it is DraggableItem.Token && it.groupId == item.id } - }, - ) - } - - private fun expandGroups() { - if (groupIdToTokens.isNullOrEmpty()) return - - val itemsState = uiState.itemsState as? OrganizeTokensListState.GroupedByNetwork ?: return - val currentGroups = itemsState.items.filterIsInstance() - val newItems = currentGroups - .flatMapIndexed { index, group -> - mutableListOf(group) - .also { it.addAll(groupIdToTokens?.get(group.id).orEmpty()) } - .also { - if (index != currentGroups.lastIndex) { - it.add(DraggableItem.GroupPlaceholder(id = "group_divider_$index")) - } - } + val updatedItemsState = uiState.itemsState.updateItems { items -> + when (movingItem) { + is DraggableItem.GroupHeader -> items.collapseGroup(movingItem) + is DraggableItem.Token -> when (uiState.itemsState) { + is OrganizeTokensListState.GroupedByNetwork -> items.divideGroups(movingItem) + is OrganizeTokensListState.Ungrouped -> items.divideItems(movingItem) + } + is DraggableItem.GroupPlaceholder -> items } + } - uiState = uiState.copy( - itemsState = itemsState.updateItems { newItems }, - ) - - groupIdToTokens = null + uiState = uiState.copy(itemsState = updatedItemsState) } - private fun moveItem(from: ItemPosition, to: ItemPosition) { + private fun endMoving() = viewModelScope.launch(Dispatchers.Default) { + if (movingItem == null) return@launch + + val updatedItemsState = uiState.itemsState.updateItems { items -> + when (movingItem) { + is DraggableItem.GroupHeader -> items.expandGroups() + is DraggableItem.Token -> items.uniteItems() + is DraggableItem.GroupPlaceholder, + null, + -> items + } + } + + uiState = uiState.copy(itemsState = updatedItemsState) + movingItem = null + } + + private fun moveItem(from: ItemPosition, to: ItemPosition) = viewModelScope.launch(Dispatchers.Default) { uiState = uiState.copy( itemsState = uiState.itemsState.updateItems { it.moveItem(from.index, to.index) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/utils/DraggableItemsOperations.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/utils/DraggableItemsOperations.kt index d8f3733dc3..9bad24193c 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/utils/DraggableItemsOperations.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/organizetokens/utils/DraggableItemsOperations.kt @@ -31,6 +31,7 @@ internal fun checkCanMoveHeaderOver( 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 @@ -40,6 +41,7 @@ internal fun checkCanMoveHeaderOver( } 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 @@ -52,4 +54,129 @@ internal fun PersistentList.moveItem(fromIndex: Int, toIndex: Int return this .removeAt(fromIndex) .add(toIndex, fromItem) +} + +internal fun List.divideItems(movingItem: DraggableItem): List { + return this.map { + it + .roundingMode(DraggableItem.RoundingMode.All(showGap = true)) + .showShadow(show = it.id == movingItem.id) + } +} + +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 + } + + item + .roundingMode(mode) + .showShadow(show = false) + } +} + +// 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 + .roundingMode(DraggableItem.RoundingMode.All(showGap = true)) + .showShadow(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 + .roundingMode(DraggableItem.RoundingMode.All(showGap = true)) + .showShadow(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 + .roundingMode(DraggableItem.RoundingMode.All(showGap = true)) + .showShadow(show = false) + } + // Case when current item is the first item in the list + index == 0 -> { + item + .roundingMode(DraggableItem.RoundingMode.Top()) + .showShadow(show = false) + } + // Case when current item is the last item in the list + index == lastItemIndex -> { + item + .roundingMode(DraggableItem.RoundingMode.Bottom()) + .showShadow(show = false) + } + // Case when previous item is a GroupPlaceholder + this[index - 1] is DraggableItem.GroupPlaceholder -> { + item + .roundingMode(DraggableItem.RoundingMode.Top(showGap = true)) + .showShadow(show = false) + } + // Case when next item is a GroupPlaceholder + this[index + 1] is DraggableItem.GroupPlaceholder -> { + item + .roundingMode(DraggableItem.RoundingMode.Bottom(showGap = true)) + .showShadow(show = false) + } + // Default case when none of the above conditions are met + else -> { + item + .roundingMode(DraggableItem.RoundingMode.None) + .showShadow(show = false) + } + } + } } \ No newline at end of file