Updated on 2026-08-14

This commit is contained in:
Tangem 2023-06-06 14:25:38 +03:00
parent 7551148abe
commit 6d73ea6b3c
6 changed files with 314 additions and 85 deletions

View file

@ -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,

View file

@ -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<DraggableItem.Token> = 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<DraggableItem.Token>()
.toMutableList()
.also {
it[0] = it[0].copy(roundingMode = DraggableItem.RoundingMode.Top())
}
.toPersistentList()
val groupedOrganizeTokensState = OrganizeTokensStateHolder(

View file

@ -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

View file

@ -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
}
}

View file

@ -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<String, List<DraggableItem.Token>>? = 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<DraggableItem.Token>()
.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<DraggableItem.GroupHeader>()
val newItems = currentGroups
.flatMapIndexed { index, group ->
mutableListOf<DraggableItem>(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)

View file

@ -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<DraggableItem>.moveItem(fromIndex: Int, toIndex: Int
return this
.removeAt(fromIndex)
.add(toIndex, fromItem)
}
internal fun List<DraggableItem>.divideItems(movingItem: DraggableItem): List<DraggableItem> {
return this.map {
it
.roundingMode(DraggableItem.RoundingMode.All(showGap = true))
.showShadow(show = it.id == movingItem.id)
}
}
internal fun List<DraggableItem>.uniteItems(): List<DraggableItem> {
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<String, List<DraggableItem.Token>>? = null
internal fun List<DraggableItem>.collapseGroup(group: DraggableItem.GroupHeader): List<DraggableItem> {
if (!groupIdToTokens.isNullOrEmpty()) return this
groupIdToTokens = this
.asSequence()
.filterIsInstance<DraggableItem.Token>()
.groupBy { it.groupId }
return this
.filterNot { it is DraggableItem.Token && it.groupId == group.id }
.divideGroups(group)
}
internal fun List<DraggableItem>.expandGroups(): List<DraggableItem> {
if (groupIdToTokens.isNullOrEmpty()) return this
val currentGroups = this.filterIsInstance<DraggableItem.GroupHeader>()
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<DraggableItem>.divideGroups(movingItem: DraggableItem): List<DraggableItem> {
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)
}
}
}
}