Updated on 2026-08-14

This commit is contained in:
Tangem 2026-03-06 21:07:20 +05:00
parent 37a31042c0
commit 5a3e53cb3b
28 changed files with 1763 additions and 13 deletions

View file

@ -39,22 +39,18 @@ fun BottomFade(modifier: Modifier = Modifier, backgroundColor: Color = TangemThe
}
/**
* A composable that draws a fade effect at the right end of the screen. Same as [BottomFade]
* but with a horizontal gradient.
* A composable that draws a fade effect at the bottom of the screen. Used on screens with a list of repeating
* elements and floating button at the bottom of the screen.
*/
@Composable
fun HorizontalFade(modifier: Modifier = Modifier, backgroundColor: Color = TangemTheme.colors.background.secondary) {
fun BottomFade(gradientBrush: Brush, modifier: Modifier = Modifier) {
val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() }
Box(
modifier = modifier
.fillMaxHeight()
.background(
brush = Brush.horizontalGradient(
colors = listOf(
Color.Transparent,
backgroundColor,
),
),
),
.fillMaxWidth()
.height(TangemTheme.dimens.size100 + bottomBarHeight)
.background(gradientBrush),
)
}

View file

@ -15,6 +15,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.haze.hazeEffectTangem
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
@ -83,6 +84,7 @@ fun SecondaryTangemButton(
onClick = onClick,
modifier = modifier
.clip(shape.toShape(size))
.hazeEffectTangem()
.then(backgroundModifier),
text = text,
contentColor = contentColor,

View file

@ -0,0 +1,43 @@
package com.tangem.feature.wallet.child.organizetokens
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.feature.wallet.child.organizetokens.model.OrganizeTokensModel
import com.tangem.feature.wallet.child.organizetokens.ui.OrganizeTokensContent
internal class OrganizeTokensComponent(
appComponentContext: AppComponentContext,
private val params: Params,
) : ComposableBottomSheetComponent, AppComponentContext by appComponentContext {
private val model: OrganizeTokensModel = getOrCreateModel(params)
override fun dismiss() {
params.callback.onDismiss()
}
@Composable
override fun BottomSheet() {
val uiState by model.uiState.collectAsStateWithLifecycle()
OrganizeTokensContent(
organizeTokensUM = uiState,
dragAndDropIntents = model.dragAndDropAdapter,
onDismiss = ::dismiss,
)
}
interface Callback {
fun onDismiss()
}
data class Params(
val userWalletId: UserWalletId,
val callback: Callback,
)
}

View file

@ -2,6 +2,7 @@ package com.tangem.feature.wallet.child.organizetokens.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.feature.wallet.child.organizetokens.model.OrganizeTokensModel
import com.tangem.feature.wallet.child.organizetokens.model.OrganizeTokensModelLegacy
import dagger.Binds
import dagger.Module
@ -17,4 +18,9 @@ internal interface OrganizeTokensModule {
@IntoMap
@ClassKey(OrganizeTokensModelLegacy::class)
fun bindOrganizeTokensModelLegacy(model: OrganizeTokensModelLegacy): Model
@Binds
@IntoMap
@ClassKey(OrganizeTokensModel::class)
fun bindOrganizeTokensModel(model: OrganizeTokensModel): Model
}

View file

@ -0,0 +1,116 @@
package com.tangem.feature.wallet.child.organizetokens.entity
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.ds.row.header.TangemHeaderRowUM
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
/**
* Helper class for the DND list items
*
* @property id ID of the item
* @property roundingModeUM item [RoundingModeUM]
* @property isShowShadow if true then item should be elevated
* */
@Immutable
internal sealed class OrganizeRowItemUM {
abstract val id: String
abstract val roundingModeUM: RoundingModeUM
abstract val isShowShadow: Boolean
/**
* Item for token.
*
* @property id ID of the token
* @property groupId ID of the network group which contains this token
* @property accountId ID of account which contains this token
* @property tokenRowUM state of the token item
* @property roundingModeUM item [RoundingModeUM]
* @property isShowShadow if true then item should be elevated
* */
data class Token(
override val isShowShadow: Boolean = false,
override val roundingModeUM: RoundingModeUM = RoundingModeUM.None,
val tokenRowUM: TangemTokenRowUM,
val groupId: String,
val accountId: String = "",
) : OrganizeRowItemUM() {
override val id: String = tokenRowUM.id
}
/**
* Item for network group header.
*
* @property id ID of the network group
* @property accountId ID of account which contains this network group
* @property headerRowUM state of the network group header item
* @property roundingModeUM item [RoundingModeUM]
* @property isShowShadow if true then item should be elevated
* */
data class Network(
override val roundingModeUM: RoundingModeUM = RoundingModeUM.None,
override val isShowShadow: Boolean = false,
val headerRowUM: TangemHeaderRowUM,
val accountId: String = "",
) : OrganizeRowItemUM() {
override val id: String = headerRowUM.id
}
/**
* Item for portfolio.
*
* @property id ID of the portfolio
* @property headerRowUM state of the portfolio item
* @property roundingModeUM item [RoundingModeUM]
* @property isShowShadow if true then item should be elevated
* */
data class Portfolio(
override val roundingModeUM: RoundingModeUM = RoundingModeUM.None,
val headerRowUM: TangemHeaderRowUM,
) : OrganizeRowItemUM() {
override val id: String = headerRowUM.id
override val isShowShadow: Boolean = false
}
/**
* Helper item used to detect possible positions where a draggable item can be placed.
*
* @property id ID of the placeholder for corresponding ID of the group
* @property accountId ID of account which contains this placeholder
* */
data class Placeholder(
override val id: String,
val accountId: String = "",
) : OrganizeRowItemUM() {
override val isShowShadow: Boolean = false
override val roundingModeUM: RoundingModeUM = RoundingModeUM.None
}
/**
* Update item [RoundingModeUM]
*
* @param mode new [RoundingModeUM]
*
* @return updated [DraggableItem]
* */
fun updateRoundingMode(mode: RoundingModeUM): OrganizeRowItemUM = when (this) {
is Placeholder -> this
is Portfolio -> this.copy(roundingModeUM = mode)
is Network -> this.copy(roundingModeUM = mode)
is Token -> this.copy(roundingModeUM = mode)
}
/**
* Update item shadow visibility
*
* @param show if true then item should be elevated
*
* @return updated [DraggableItem]
* */
fun updateShadowVisibility(show: Boolean): OrganizeRowItemUM = when (this) {
is Portfolio,
is Placeholder,
-> this
is Network -> this.copy(isShowShadow = show)
is Token -> this.copy(isShowShadow = show)
}
}

View file

@ -0,0 +1,27 @@
package com.tangem.feature.wallet.child.organizetokens.entity
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.event.StateEvent
import kotlinx.collections.immutable.PersistentList
@Immutable
internal data class OrganizeTokensUM(
val tokenList: PersistentList<OrganizeRowItemUM>,
val organizeMenuUM: OrganizeMenuUM,
val isGrouped: Boolean,
val isAccountsMode: Boolean,
val scrollListToTop: StateEvent<Unit>,
val cancelButton: TangemButtonUM,
val applyButton: TangemButtonUM,
val isBalanceHidden: Boolean,
) {
data class OrganizeMenuUM(
val isEnabled: Boolean = false,
val isSortedByBalance: Boolean = false,
val isGrouped: Boolean = false,
val onSortClick: () -> Unit,
val onGroupClick: () -> Unit,
)
}

View file

@ -5,6 +5,7 @@ import com.tangem.domain.account.status.model.AccountCryptoCurrencies
import com.tangem.domain.models.account.filterCryptoPortfolio
import com.tangem.domain.models.tokenlist.TokenList
import com.tangem.feature.wallet.child.organizetokens.entity.DraggableItem
import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeRowItemUM
import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeTokensListUM
internal class CryptoCurrenciesIdsResolver {
@ -35,4 +36,23 @@ internal class CryptoCurrenciesIdsResolver {
.toList()
}
}
fun resolve(tokenList: List<OrganizeRowItemUM>, accountStatusList: AccountStatusList?): AccountCryptoCurrencies {
if (accountStatusList == null) return emptyMap()
val draggableTokens = tokenList.filterIsInstance<OrganizeRowItemUM.Token>()
return accountStatusList.accountStatuses
.filterCryptoPortfolio()
.filter { it.tokenList != TokenList.Empty }
.associate { accountStatus ->
val currenciesById = accountStatus.flattenCurrencies().associateBy { it.currency.id.value }
accountStatus.account to draggableTokens
.asSequence()
.filter { it.accountId == accountStatus.account.accountId.value }
.mapNotNull { token -> currenciesById[token.id]?.currency }
.toList()
}
}
}

View file

@ -2,6 +2,7 @@ package com.tangem.feature.wallet.child.organizetokens.model
import androidx.compose.runtime.Stable
import com.tangem.feature.wallet.child.organizetokens.entity.DraggableItem
import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeRowItemUM
import org.burnoutcrew.reorderable.ItemPosition
internal interface OrganizeTokensIntents {
@ -25,6 +26,7 @@ internal interface DragAndDropIntents {
fun canDragItemOver(dragOver: ItemPosition, dragging: ItemPosition): Boolean
fun onItemDraggingStartLegacy(item: DraggableItem)
fun onItemDraggingStart(item: OrganizeRowItemUM)
fun onItemDraggingEnd()
}

View file

@ -0,0 +1,283 @@
package com.tangem.feature.wallet.child.organizetokens.model
import androidx.compose.runtime.Stable
import arrow.core.getOrElse
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.ds.button.TangemButtonType
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.account.status.usecase.ApplyTokenListSortingUseCase
import com.tangem.domain.account.status.usecase.ToggleTokenListGroupingUseCase
import com.tangem.domain.account.status.usecase.ToggleTokenListSortingUseCase
import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.models.TokensSortType
import com.tangem.feature.wallet.child.organizetokens.OrganizeTokensComponent
import com.tangem.feature.wallet.child.organizetokens.analytics.PortfolioOrganizeTokensAnalyticsEvent
import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeTokensUM
import com.tangem.feature.wallet.child.organizetokens.model.dnd.DragAndDropAdapter
import com.tangem.feature.wallet.child.organizetokens.model.transformer.OrganizeContentStateTransformer
import com.tangem.feature.wallet.child.organizetokens.model.transformer.OrganizeDisableBalanceSortingTransformer
import com.tangem.feature.wallet.child.organizetokens.model.transformer.OrganizeSortingProgressStateTransformer
import com.tangem.feature.wallet.impl.R
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.transformer.update
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@Suppress("LongParameterList")
@Stable
@ModelScoped
internal class OrganizeTokensModel @Inject constructor(
paramsContainer: ParamsContainer,
getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
override val dispatchers: CoroutineDispatcherProvider,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val analyticsEventsHandler: AnalyticsEventHandler,
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
private val toggleTokenListGroupingUseCase: ToggleTokenListGroupingUseCase,
private val toggleTokenListSortingUseCase: ToggleTokenListSortingUseCase,
private val applyTokenListSortingUseCase: ApplyTokenListSortingUseCase,
) : Model(), OrganizeTokensIntents {
private val params: OrganizeTokensComponent.Params = paramsContainer.require()
private val selectedAppCurrencyFlow = createSelectedAppCurrencyFlow()
private val userWalletId = params.userWalletId
private var cachedAccountStatusList: AccountStatusList? = null
private var isAccountsModeEnabled: Boolean = false
val uiState: StateFlow<OrganizeTokensUM>
field = MutableStateFlow(getInitialState())
val dragAndDropAdapter by lazy(LazyThreadSafetyMode.NONE) {
DragAndDropAdapter(uiState)
}
init {
analyticsEventsHandler.send(PortfolioOrganizeTokensAnalyticsEvent.ScreenOpened())
getBalanceHidingSettingsUseCase()
.onEach { balanceSettings ->
uiState.update {
it.copy(
isBalanceHidden = balanceSettings.isBalanceHidden,
)
}
}
.launchIn(modelScope)
bootstrapTokenList()
bootstrapDragAndDropUpdates()
}
override fun onBackClick() {
params.callback.onDismiss()
}
override fun onSortClick() {
val list = cachedAccountStatusList ?: return
if (list.sortType == TokensSortType.BALANCE) return
analyticsEventsHandler.send(PortfolioOrganizeTokensAnalyticsEvent.ByBalance())
modelScope.launch {
toggleTokenListSortingUseCase(list).fold(
ifLeft = {
uiState.update {
it.copy(
organizeMenuUM = it.organizeMenuUM.copy(isEnabled = false),
)
}
},
ifRight = { accountStatusList ->
uiState.update(
OrganizeContentStateTransformer(
accountStatusList = accountStatusList,
isAccountsMode = isAccountsModeEnabled,
appCurrency = selectedAppCurrencyFlow.value,
),
)
cachedAccountStatusList = accountStatusList
},
)
}
}
override fun onGroupClick() {
val list = cachedAccountStatusList ?: return
analyticsEventsHandler.send(PortfolioOrganizeTokensAnalyticsEvent.Group())
modelScope.launch {
toggleTokenListGroupingUseCase(list).fold(
ifLeft = {
uiState.update {
it.copy(
organizeMenuUM = it.organizeMenuUM.copy(isEnabled = false),
)
}
},
ifRight = { accountStatusList ->
uiState.update(
OrganizeContentStateTransformer(
accountStatusList = accountStatusList,
isAccountsMode = isAccountsModeEnabled,
appCurrency = selectedAppCurrencyFlow.value,
),
)
cachedAccountStatusList = accountStatusList
},
)
}
}
override fun onApplyClick() {
modelScope.launch {
uiState.update(OrganizeSortingProgressStateTransformer(true))
val resolver = CryptoCurrenciesIdsResolver()
val isSortedByBalance = uiState.value.organizeMenuUM.isSortedByBalance
val isGroupedByNetwork = uiState.value.isGrouped
val tokensListUM = uiState.value.tokenList
sendAnalyticsEvent(
isGroupedByNetwork = isGroupedByNetwork,
isSortedByBalance = isSortedByBalance,
)
val result = applyTokenListSortingUseCase(
sortedTokensIdsByAccount = resolver.resolve(tokensListUM, cachedAccountStatusList),
isGroupedByNetwork = isGroupedByNetwork,
isSortedByBalance = isSortedByBalance,
)
result.fold(
ifLeft = {
uiState.update {
it.copy(
organizeMenuUM = it.organizeMenuUM.copy(isEnabled = false),
)
}
},
ifRight = {
onBackClick()
uiState.update(OrganizeSortingProgressStateTransformer(false))
},
)
}
}
override fun onCancelClick() {
analyticsEventsHandler.send(PortfolioOrganizeTokensAnalyticsEvent.Cancel())
onBackClick()
}
private fun bootstrapTokenList() {
modelScope.launch {
val accountList = singleAccountStatusListSupplier.getSyncOrNull(
SingleAccountStatusListProducer.Params(userWalletId),
) ?: return@launch
isAccountsModeEnabled = isAccountsModeEnabledUseCase.invokeSync()
uiState.update(
transformer = OrganizeContentStateTransformer(
accountStatusList = accountList,
isAccountsMode = isAccountsModeEnabled,
appCurrency = selectedAppCurrencyFlow.value,
),
)
cachedAccountStatusList = accountList
}
}
private fun bootstrapDragAndDropUpdates() {
dragAndDropAdapter.dragAndDropUpdates
.filterNotNull()
.distinctUntilChanged()
.onEach { (type, updatedTokenList) ->
disableSortingByBalanceIfListChanged(type)
uiState.update { it.copy(tokenList = updatedTokenList) }
}
.launchIn(modelScope)
}
private fun disableSortingByBalanceIfListChanged(dragOperationType: DragAndDropAdapter.DragOperation.Type) {
if (dragOperationType !is DragAndDropAdapter.DragOperation.Type.End) return
if (uiState.value.organizeMenuUM.isSortedByBalance && dragOperationType.isItemsOrderChanged) {
cachedAccountStatusList = cachedAccountStatusList?.copy(sortType = TokensSortType.NONE)
uiState.update(OrganizeDisableBalanceSortingTransformer)
}
}
private fun createSelectedAppCurrencyFlow(): StateFlow<AppCurrency> {
return getSelectedAppCurrencyUseCase()
.map { maybeAppCurrency ->
maybeAppCurrency.getOrElse { AppCurrency.Default }
}
.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = AppCurrency.Default,
)
}
private fun sendAnalyticsEvent(isGroupedByNetwork: Boolean, isSortedByBalance: Boolean) {
analyticsEventsHandler.send(
PortfolioOrganizeTokensAnalyticsEvent.Apply(
grouping = if (isGroupedByNetwork) {
AnalyticsParam.OnOffState.On
} else {
AnalyticsParam.OnOffState.Off
},
organizeSortType = if (isSortedByBalance) {
AnalyticsParam.OrganizeSortType.ByBalance
} else {
AnalyticsParam.OrganizeSortType.Manually
},
),
)
}
private fun getInitialState(): OrganizeTokensUM {
return OrganizeTokensUM(
tokenList = persistentListOf(),
organizeMenuUM = OrganizeTokensUM.OrganizeMenuUM(
onSortClick = ::onSortClick,
onGroupClick = ::onGroupClick,
),
cancelButton = TangemButtonUM(
text = resourceReference(R.string.common_cancel),
onClick = ::onCancelClick,
type = TangemButtonType.Secondary,
),
applyButton = TangemButtonUM(
text = resourceReference(R.string.common_apply),
onClick = ::onApplyClick,
type = TangemButtonType.Primary,
),
scrollListToTop = consumedEvent(),
isBalanceHidden = true,
isGrouped = false,
isAccountsMode = false,
)
}
}

View file

@ -1,10 +1,18 @@
package com.tangem.feature.wallet.child.organizetokens.model.common
import com.tangem.feature.wallet.child.organizetokens.entity.DraggableItem
import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeRowItemUM
internal fun getGroupPlaceholderLegacy(index: Int, accountId: String = ""): DraggableItem.Placeholder {
return DraggableItem.Placeholder(
id = "placeholder_${accountId}_${index.inc()}",
accountId = accountId,
)
}
internal fun getGroupPlaceholder(index: Int, accountId: String = ""): OrganizeRowItemUM.Placeholder {
return OrganizeRowItemUM.Placeholder(
id = "placeholder_${accountId}_${index.inc()}",
accountId = accountId,
)
}

View file

@ -1,6 +1,7 @@
package com.tangem.feature.wallet.child.organizetokens.model.common
import com.tangem.feature.wallet.child.organizetokens.entity.DraggableItem
import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeRowItemUM
import com.tangem.feature.wallet.child.organizetokens.entity.RoundingModeUM
internal fun List<DraggableItem>.uniteItemsLegacy(isAccountsMode: Boolean): List<DraggableItem> {
@ -45,6 +46,48 @@ internal fun List<DraggableItem>.uniteItemsLegacy(isAccountsMode: Boolean): List
}.toList()
}
internal fun List<OrganizeRowItemUM>.uniteItems(isAccountsMode: Boolean): List<OrganizeRowItemUM> {
val items = this
val lastItemIndex = items.lastIndex
return items
.asSequence()
.mapIndexed { index, item ->
val mode = when (index) {
0 -> if (item is OrganizeRowItemUM.Placeholder) {
RoundingModeUM.None
} else {
RoundingModeUM.Top()
}
lastItemIndex -> RoundingModeUM.Bottom()
1 -> if (items.first() is OrganizeRowItemUM.Placeholder) {
RoundingModeUM.Top()
} else {
RoundingModeUM.None
}
else -> when (item) {
is OrganizeRowItemUM.Placeholder -> RoundingModeUM.None
is OrganizeRowItemUM.Network -> if (isAccountsMode) {
RoundingModeUM.None
} else {
RoundingModeUM.Top(isShowGap = true)
}
is OrganizeRowItemUM.Token -> applyRoundingModeToToken(
isAccountsMode = isAccountsMode,
items = items,
index = index,
lastItemIndex = lastItemIndex,
)
is OrganizeRowItemUM.Portfolio -> RoundingModeUM.Top(isShowGap = true)
}
}
item
.updateRoundingMode(mode)
.updateShadowVisibility(show = false)
}.toList()
}
internal fun List<DraggableItem>.divideMovingItem(movingItem: DraggableItem): List<DraggableItem> {
val mutableList = this.toMutableList()
val listIterator = mutableList.listIterator()
@ -110,4 +153,51 @@ private fun applyRoundingModeToTokenLegacy(
RoundingModeUM.Bottom(isShowGap = true)
}
else -> RoundingModeUM.None
}
/**
* Applying rounding to tokens
*
* If is in accounts mode without grouping
* * PORTFOLIO
* * TOKEN
* * TOKEN <- add rounding
* * PORTFOLIO index + 1 is PORTFOLIO
*
* If is in accounts mode with grouping
* * PORTFOLIO
* * PLACEHOLDER
* * GROUPING
* * TOKEN
* * TOKEN <- add rounding
* * PLACEHOLDER index + 1 is PLACEHOLDER
* * PORTFOLIO index + 2 is PORTFOLIO
* * PLACEHOLDER
*
* If is not accounts mode without grouping
* * TOKEN
* * TOKEN <- add rounding
*
* If is not accounts mode with grouping
* * PLACEHOLDER
* * GROUPING
* * TOKEN
* * TOKEN <- add rounding
* * PLACEHOLDER index + 1 is PLACEHOLDER
*/
private fun applyRoundingModeToToken(
isAccountsMode: Boolean,
items: List<OrganizeRowItemUM>,
index: Int,
lastItemIndex: Int,
) = when {
isAccountsMode && index + 1 < lastItemIndex &&
(items[index + 1] is OrganizeRowItemUM.Portfolio ||
items[index + 1] is OrganizeRowItemUM.Placeholder && items[index + 2] is OrganizeRowItemUM.Portfolio) -> {
RoundingModeUM.Bottom(isShowGap = true)
}
(!isAccountsMode || index + 1 == lastItemIndex) && items[index + 1] is OrganizeRowItemUM.Placeholder -> {
RoundingModeUM.Bottom(isShowGap = true)
}
else -> RoundingModeUM.None
}

View file

@ -0,0 +1,82 @@
package com.tangem.feature.wallet.child.organizetokens.model.converter
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatus
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.filterCryptoPortfolio
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.tokenlist.TokenList
import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeRowItemUM
import com.tangem.feature.wallet.child.organizetokens.model.common.getGroupPlaceholder
import com.tangem.feature.wallet.child.organizetokens.model.common.uniteItems
import com.tangem.feature.wallet.child.organizetokens.model.converter.items.OrganizeAccountItemConverter
import com.tangem.feature.wallet.child.organizetokens.model.converter.items.OrganizeNetworkItemConverter
import com.tangem.feature.wallet.child.organizetokens.model.converter.items.OrganizeTokenItemConverter
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.addIf
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.toPersistentList
internal class OrganizeTokensListConverter(
private val isAccountsMode: Boolean,
private val appCurrency: AppCurrency,
) : Converter<AccountStatusList, PersistentList<OrganizeRowItemUM>> {
private val accountItemConverter by lazy(LazyThreadSafetyMode.NONE) {
OrganizeAccountItemConverter(appCurrency)
}
private val tokenItemConverter by lazy(LazyThreadSafetyMode.NONE) {
OrganizeTokenItemConverter(appCurrency)
}
override fun convert(value: AccountStatusList): PersistentList<OrganizeRowItemUM> {
return value.accountStatuses
.asSequence()
.filterCryptoPortfolio()
.flatMap { accountStatus ->
buildList {
addIf(
condition = isAccountsMode,
create = { accountItemConverter.convert(accountStatus) },
)
when (val tokenList = accountStatus.tokenList) {
is TokenList.Ungrouped -> addAll(
elements = tokenItemConverter.convertList(
input = tokenList.currencies.mapToAccountCryptoCurrencyStatus(accountStatus),
),
)
is TokenList.GroupedByNetwork -> {
addIf(
condition = isAccountsMode,
create = { getGroupPlaceholder(-1, accountStatus.accountId.value) },
)
tokenList.groups.asSequence().forEachIndexed { index, (groupNetwork, currencies) ->
add(OrganizeNetworkItemConverter.convert(accountStatus.accountId to groupNetwork))
addAll(
elements = tokenItemConverter.convertList(
input = currencies.mapToAccountCryptoCurrencyStatus(accountStatus),
),
)
add(getGroupPlaceholder(index, accountStatus.accountId.value))
}
}
TokenList.Empty -> Unit
}
}
}.toList()
.uniteItems(isAccountsMode).toPersistentList()
}
private fun List<CryptoCurrencyStatus>.mapToAccountCryptoCurrencyStatus(
accountStatus: AccountStatus.CryptoPortfolio,
): List<AccountCryptoCurrencyStatus> {
return map { cryptoCurrencyStatus ->
AccountCryptoCurrencyStatus(
account = accountStatus.account,
status = cryptoCurrencyStatus,
)
}
}
}

View file

@ -0,0 +1,35 @@
package com.tangem.feature.wallet.child.organizetokens.model.converter.items
import com.tangem.common.ui.account.toUM
import com.tangem.core.ui.ds.row.header.TangemHeaderRowUM
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.account.AccountStatus
import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeRowItemUM
import com.tangem.utils.converter.Converter
internal class OrganizeAccountItemConverter(
private val appCurrency: AppCurrency,
) : Converter<AccountStatus.CryptoPortfolio, OrganizeRowItemUM.Portfolio> {
override fun convert(value: AccountStatus.CryptoPortfolio): OrganizeRowItemUM.Portfolio {
val accountBalance = value.tokenList.totalFiatBalance as? TotalFiatBalance.Loaded
return OrganizeRowItemUM.Portfolio(
headerRowUM = TangemHeaderRowUM(
id = value.accountId.value,
title = value.account.accountName.toUM().value,
subtitle = stringReference(
accountBalance?.amount.format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
)
},
),
),
)
}
}

View file

@ -0,0 +1,25 @@
package com.tangem.feature.wallet.child.organizetokens.model.converter.items
import com.tangem.core.ui.ds.row.header.TangemHeaderRowUM
import com.tangem.core.ui.ds.row.internal.TangemRowTailUM
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.network.Network
import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeRowItemUM
import com.tangem.feature.wallet.impl.R
import com.tangem.utils.converter.Converter
internal object OrganizeNetworkItemConverter : Converter<Pair<AccountId, Network>, OrganizeRowItemUM.Network> {
override fun convert(value: Pair<AccountId, Network>): OrganizeRowItemUM.Network {
val (accountId, groupNetwork) = value
return OrganizeRowItemUM.Network(
headerRowUM = TangemHeaderRowUM(
id = groupNetwork.id.toString(),
title = stringReference(groupNetwork.name),
tailUM = TangemRowTailUM.Draggable(R.drawable.ic_group_drop_24),
),
accountId = accountId.value,
)
}
}

View file

@ -0,0 +1,67 @@
package com.tangem.feature.wallet.child.organizetokens.model.converter.items
import androidx.compose.ui.text.SpanStyle
import com.tangem.common.getTotalCryptoAmount
import com.tangem.common.getTotalFiatAmount
import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.row.internal.TangemRowTailUM
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.cryptoStyled
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.formatStyled
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatus
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeRowItemUM
import com.tangem.feature.wallet.child.organizetokens.model.common.getTokenItemId
import com.tangem.feature.wallet.impl.R
import com.tangem.utils.converter.Converter
internal class OrganizeTokenItemConverter(
private val appCurrency: AppCurrency,
) : Converter<AccountCryptoCurrencyStatus, OrganizeRowItemUM.Token> {
private val iconStateConverter by lazy(LazyThreadSafetyMode.NONE) {
CryptoCurrencyToIconStateConverter()
}
override fun convert(value: AccountCryptoCurrencyStatus): OrganizeRowItemUM.Token {
val (account, currencyStatus) = value
val currency = currencyStatus.currency
return OrganizeRowItemUM.Token(
tokenRowUM = TangemTokenRowUM.Actionable(
id = getTokenItemId(currency.id),
headIconUM = TangemIconUM.Currency(iconStateConverter.convert(currencyStatus)),
titleUM = TangemTokenRowUM.TitleUM.Content(
text = stringReference(currency.name),
),
subtitleUM = TangemTokenRowUM.SubtitleUM.Empty,
topEndContentUM = TangemTokenRowUM.EndContentUM.Content(
text = currencyStatus.getTotalFiatAmount().formatStyled {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.neutral.secondary) },
)
},
),
bottomEndContentUM = TangemTokenRowUM.EndContentUM.Content(
text = currencyStatus.getTotalCryptoAmount().formatStyled {
cryptoStyled(
cryptoCurrency = currency,
spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.neutral.secondary) },
)
},
),
tailUM = TangemRowTailUM.Draggable(R.drawable.ic_drag_24),
onItemClick = null,
onItemLongClick = null,
),
groupId = currency.network.id.toString(),
accountId = account.accountId.value,
)
}
}

View file

@ -0,0 +1,217 @@
package com.tangem.feature.wallet.child.organizetokens.model.dnd
import com.tangem.feature.wallet.child.organizetokens.entity.DraggableItem
import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeRowItemUM
import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeTokensUM
import com.tangem.feature.wallet.child.organizetokens.entity.RoundingModeUM
import com.tangem.feature.wallet.child.organizetokens.model.DragAndDropIntents
import com.tangem.feature.wallet.child.organizetokens.model.common.uniteItems
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.mutate
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.burnoutcrew.reorderable.ItemPosition
internal class DragAndDropAdapter(
private val organizeTokensUMFlow: StateFlow<OrganizeTokensUM>,
) : DragAndDropIntents {
private var draggingItem: OrganizeRowItemUM? = null
private var draggingListState: OrganizeTokensUM? = null
private val tokensUM: OrganizeTokensUM
get() = organizeTokensUMFlow.value
private val draggableGroupsOperations by lazy(LazyThreadSafetyMode.NONE) {
DraggableGroupsOperations()
}
val dragAndDropUpdates: StateFlow<DragOperation?>
field = MutableStateFlow(value = null)
override fun canDragItemOver(dragOver: ItemPosition, dragging: ItemPosition): Boolean {
val tokensListUM = tokensUM.tokenList
val (dragOverItem, draggingItem) = findItemsToMove(
items = tokensListUM,
moveOverItemKey = dragOver.key,
movedItemKey = dragging.key,
)
if (dragOverItem == null || draggingItem == null) {
return false
}
val canDrag = when (draggingItem) {
is OrganizeRowItemUM.Network -> checkCanMoveHeaderOver(
item = draggingItem,
moveOverItem = dragOverItem,
)
is OrganizeRowItemUM.Token -> checkCanMoveTokenOver(
item = draggingItem,
moveOverItem = dragOverItem,
isAccountsMode = tokensUM.isAccountsMode,
isGrouped = tokensUM.isGrouped,
)
is OrganizeRowItemUM.Placeholder,
is OrganizeRowItemUM.Portfolio,
-> false
}
return canDrag
}
override fun onItemDraggingStartLegacy(item: DraggableItem) {
/* no-op */
}
override fun onItemDraggingStart(item: OrganizeRowItemUM) {
if (draggingItem != null) return
draggingItem = item
dragAndDropUpdates.value = DragOperation(
type = DragOperation.Type.Start,
tokenList = when (item) {
is OrganizeRowItemUM.Placeholder,
is OrganizeRowItemUM.Portfolio,
-> tokensUM.tokenList
is OrganizeRowItemUM.Network -> draggableGroupsOperations
.collapseGroup(tokensUM.tokenList, item)
.divideMovingItem(item)
is OrganizeRowItemUM.Token -> tokensUM.tokenList.divideMovingItem(item)
}.toPersistentList(),
)
draggingListState = tokensUM
}
override fun onItemDraggingEnd() {
val draggingItem = draggingItem ?: return
dragAndDropUpdates.value = DragOperation(
type = DragOperation.Type.End(isItemsOrderChanged = checkIsItemsOrderChanged(tokensUM)),
tokenList = when (draggingItem) {
is OrganizeRowItemUM.Network -> draggableGroupsOperations
.expandGroups(tokensUM.tokenList, tokensUM.isAccountsMode)
.uniteItems(tokensUM.isAccountsMode)
is OrganizeRowItemUM.Token -> tokensUM.tokenList.uniteItems(tokensUM.isAccountsMode)
is OrganizeRowItemUM.Placeholder,
is OrganizeRowItemUM.Portfolio,
-> tokensUM.tokenList
}.toPersistentList(),
)
this.draggingItem = null
}
override fun onItemDragged(from: ItemPosition, to: ItemPosition) {
dragAndDropUpdates.value = DragOperation(
type = DragOperation.Type.Dragged,
tokenList = tokensUM.tokenList.mutate {
it.add(to.index, it.removeAt(from.index))
}.toPersistentList(),
)
}
private fun findItemsToMove(
items: List<OrganizeRowItemUM>,
moveOverItemKey: Any?,
movedItemKey: Any?,
): Pair<OrganizeRowItemUM?, OrganizeRowItemUM?> {
var moveOverItem: OrganizeRowItemUM? = null
var movedItem: OrganizeRowItemUM? = 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(item: OrganizeRowItemUM.Network, moveOverItem: OrganizeRowItemUM) =
when (moveOverItem) {
// Header can be moved only in its account
is OrganizeRowItemUM.Placeholder -> item.accountId == moveOverItem.accountId
else -> false
}
private fun checkCanMoveTokenOver(
item: OrganizeRowItemUM.Token,
moveOverItem: OrganizeRowItemUM,
isGrouped: Boolean,
isAccountsMode: Boolean,
): Boolean {
return when (moveOverItem) {
is OrganizeRowItemUM.Network -> false // Token item can not be moved to group item
is OrganizeRowItemUM.Token -> when {
// Token item can be moved only in its group
isGrouped -> item.groupId == moveOverItem.groupId
// Token item can be moved only in its account
isAccountsMode -> item.accountId == moveOverItem.accountId
// If ungrouped and not accounts mode then item can be moved anywhere
else -> true
}
is OrganizeRowItemUM.Portfolio,
is OrganizeRowItemUM.Placeholder,
-> false // Token item can not be moved to portfolio or placeholder
}
}
private fun checkIsItemsOrderChanged(tokensUM: OrganizeTokensUM): Boolean {
fun OrganizeTokensUM?.getItemsIds(): List<Any>? = this?.tokenList?.mapNotNull { item ->
if (item is OrganizeRowItemUM.Placeholder) {
null
} else {
item.id
}
}
return tokensUM.getItemsIds() != draggingListState.getItemsIds()
}
private fun List<OrganizeRowItemUM>.divideMovingItem(movingItem: OrganizeRowItemUM): List<OrganizeRowItemUM> {
val mutableList = this.toMutableList()
val listIterator = mutableList.listIterator()
while (listIterator.hasNext()) {
val item = listIterator.next()
if (item.id == movingItem.id) {
val dividedItem = movingItem
.updateRoundingMode(RoundingModeUM.All())
.updateShadowVisibility(show = true)
listIterator.set(dividedItem)
break
}
}
return mutableList
}
data class DragOperation(
val type: Type,
val tokenList: PersistentList<OrganizeRowItemUM>,
) {
sealed class Type {
data object Start : Type()
data object Dragged : Type()
data class End(val isItemsOrderChanged: Boolean) : Type()
}
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.feature.wallet.child.organizetokens.model.dnd
import com.tangem.feature.wallet.child.organizetokens.entity.DraggableItem
import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeRowItemUM
import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeTokensListUM
import com.tangem.feature.wallet.child.organizetokens.model.DragAndDropIntents
import com.tangem.feature.wallet.child.organizetokens.model.common.divideMovingItem
@ -90,6 +91,8 @@ internal class DragAndDropAdapterLegacy(
draggingListState = tokenListUM
}
override fun onItemDraggingStart(item: OrganizeRowItemUM) { /* no-op */ }
override fun onItemDraggingEnd() {
val draggingItem = draggingItem ?: return

View file

@ -1,11 +1,14 @@
package com.tangem.feature.wallet.child.organizetokens.model.dnd
import com.tangem.feature.wallet.child.organizetokens.entity.DraggableItem
import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeRowItemUM
import com.tangem.feature.wallet.child.organizetokens.model.common.divideMovingItem
import com.tangem.feature.wallet.child.organizetokens.model.common.getGroupPlaceholder
import com.tangem.feature.wallet.child.organizetokens.model.common.getGroupPlaceholderLegacy
internal class DraggableGroupsOperations {
private var groupIdToTokens: Map<String, List<OrganizeRowItemUM.Token>>? = null
private var groupIdToTokensLegacy: Map<Int, List<DraggableItem.Token>>? = null
fun collapseGroupLegacy(items: List<DraggableItem>, movingGroup: DraggableItem.GroupHeader): List<DraggableItem> {
@ -67,4 +70,67 @@ internal class DraggableGroupsOperations {
return expandedGroups
}
fun expandGroups(items: List<OrganizeRowItemUM>, isAccountsMode: Boolean): List<OrganizeRowItemUM> {
if (groupIdToTokens.isNullOrEmpty()) return items
val accountList = items.filterIsInstance<OrganizeRowItemUM.Portfolio>()
val currentGroups = items.filterIsInstance<OrganizeRowItemUM.Network>()
val expandedGroups = if (isAccountsMode) {
accountList
.asSequence()
.flatMap { account ->
buildList {
add(account)
currentGroups
.asSequence()
.filter { it.accountId == account.id }
.forEachIndexed { index, group ->
if (index == 0) {
add(getGroupPlaceholder(accountId = group.accountId, index = -1))
}
add(group)
addAll(groupIdToTokens?.get(group.id).orEmpty())
add(getGroupPlaceholder(accountId = group.accountId, index = index))
}
}
}
} else {
currentGroups
.asSequence()
.flatMapIndexed { index, group ->
buildList {
if (index == 0) {
add(getGroupPlaceholder(accountId = group.accountId, index = -1))
}
add(group)
addAll(groupIdToTokens?.get(group.id).orEmpty())
add(getGroupPlaceholder(accountId = group.accountId, index = index))
}
}
}.toList()
groupIdToTokens = null
return expandedGroups
}
fun collapseGroup(
items: List<OrganizeRowItemUM>,
movingGroup: OrganizeRowItemUM.Network,
): List<OrganizeRowItemUM> {
if (!groupIdToTokens.isNullOrEmpty()) return items
groupIdToTokens = items
.asSequence()
.filterIsInstance<OrganizeRowItemUM.Token>()
.groupBy { it.groupId }
val itemsWithoutGroupTokens = items.filterNot {
it is OrganizeRowItemUM.Token && it.groupId == movingGroup.id
}
return itemsWithoutGroupTokens
}
}

View file

@ -0,0 +1,39 @@
package com.tangem.feature.wallet.child.organizetokens.model.transformer
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.TokensGroupType
import com.tangem.domain.models.TokensSortType
import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeTokensUM
import com.tangem.feature.wallet.child.organizetokens.model.converter.OrganizeTokensListConverter
import com.tangem.utils.transformer.Transformer
internal class OrganizeContentStateTransformer(
private val accountStatusList: AccountStatusList,
private val isAccountsMode: Boolean,
private val appCurrency: AppCurrency,
) : Transformer<OrganizeTokensUM> {
private val tokenListConverter by lazy(LazyThreadSafetyMode.NONE) {
OrganizeTokensListConverter(
isAccountsMode = isAccountsMode,
appCurrency = appCurrency,
)
}
override fun transform(prevState: OrganizeTokensUM): OrganizeTokensUM {
val isGrouping = accountStatusList.groupType == TokensGroupType.NETWORK
val isSortedByBalance = accountStatusList.sortType == TokensSortType.BALANCE
return prevState.copy(
isGrouped = isGrouping,
isAccountsMode = isAccountsMode,
tokenList = tokenListConverter.convert(value = accountStatusList),
organizeMenuUM = prevState.organizeMenuUM.copy(
isEnabled = prevState.tokenList.isNotEmpty(),
isSortedByBalance = isSortedByBalance,
isGrouped = isGrouping,
),
)
}
}

View file

@ -0,0 +1,14 @@
package com.tangem.feature.wallet.child.organizetokens.model.transformer
import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeTokensUM
import com.tangem.utils.transformer.Transformer
internal object OrganizeDisableBalanceSortingTransformer : Transformer<OrganizeTokensUM> {
override fun transform(prevState: OrganizeTokensUM): OrganizeTokensUM {
return prevState.copy(
organizeMenuUM = prevState.organizeMenuUM.copy(
isSortedByBalance = false,
),
)
}
}

View file

@ -0,0 +1,31 @@
package com.tangem.feature.wallet.child.organizetokens.model.transformer
import com.tangem.core.ui.ds.button.TangemButtonState
import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeTokensUM
import com.tangem.utils.transformer.Transformer
internal class OrganizeSortingProgressStateTransformer(
private val isSortingInProgress: Boolean,
) : Transformer<OrganizeTokensUM> {
override fun transform(prevState: OrganizeTokensUM): OrganizeTokensUM {
return prevState.copy(
organizeMenuUM = prevState.organizeMenuUM.copy(
isEnabled = !isSortingInProgress,
),
cancelButton = prevState.cancelButton.copy(
state = if (isSortingInProgress) {
TangemButtonState.Disabled
} else {
TangemButtonState.Default
},
),
applyButton = prevState.applyButton.copy(
state = if (isSortingInProgress) {
TangemButtonState.Loading
} else {
TangemButtonState.Default
},
),
)
}
}

View file

@ -0,0 +1,43 @@
package com.tangem.feature.wallet.child.organizetokens.ui
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.ds.contextmenu.TangemContextMenu
import com.tangem.core.ui.ds.contextmenu.TangemContextMenuCheckboxItem
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeTokensUM
import com.tangem.feature.wallet.impl.R
@Composable
internal fun OrganizeDropDownMenu(
organizeMenuUM: OrganizeTokensUM.OrganizeMenuUM,
showDropdownMenu: Boolean,
onDropdownDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
TangemContextMenu(
expanded = showDropdownMenu,
onDismissRequest = onDropdownDismiss,
offset = DpOffset.Zero,
modifier = modifier,
) {
TangemContextMenuCheckboxItem(
title = TextReference.Res(R.string.organize_tokens_sort_by_balance),
isChecked = organizeMenuUM.isSortedByBalance,
onClick = organizeMenuUM.onSortClick,
)
HorizontalDivider(
thickness = 0.5.dp,
color = TangemTheme.colors2.border.neutral.quaternary,
)
TangemContextMenuCheckboxItem(
title = TextReference.Res(R.string.organize_tokens_group),
isChecked = organizeMenuUM.isGrouped,
onClick = organizeMenuUM.onGroupClick,
)
}
}

View file

@ -0,0 +1,371 @@
package com.tangem.feature.wallet.child.organizetokens.ui
import android.content.res.Configuration
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
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.graphics.Shape
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.Dp
import com.tangem.core.ui.components.BottomFade
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
import com.tangem.core.ui.components.haze.hazeEffectTangem
import com.tangem.core.ui.components.haze.hazeSourceTangem
import com.tangem.core.ui.ds.button.TangemButton
import com.tangem.core.ui.ds.row.header.TangemHeaderRow
import com.tangem.core.ui.ds.row.token.TangemTokenRow
import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds.topbar.TangemTopBarActionContent
import com.tangem.core.ui.ds.topbar.TangemTopBarActionUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.reordarable.ReorderableItem
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.test.OrganizeTokensScreenTestTags
import com.tangem.core.ui.utils.lazyListItemPosition
import com.tangem.feature.wallet.child.organizetokens.entity.DraggableItem
import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeRowItemUM
import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeTokensUM
import com.tangem.feature.wallet.child.organizetokens.entity.RoundingModeUM
import com.tangem.feature.wallet.child.organizetokens.model.DragAndDropIntents
import com.tangem.feature.wallet.child.organizetokens.ui.preview.OrganizeTokensPreview
import com.tangem.feature.wallet.impl.R
import dev.chrisbanes.haze.rememberHazeState
import org.burnoutcrew.reorderable.ItemPosition
import org.burnoutcrew.reorderable.ReorderableLazyListState
import org.burnoutcrew.reorderable.rememberReorderableLazyListState
import org.burnoutcrew.reorderable.reorderable
@Composable
internal fun OrganizeTokensContent(
organizeTokensUM: OrganizeTokensUM,
dragAndDropIntents: DragAndDropIntents,
onDismiss: () -> Unit,
) {
var isShowDropdownMenu by rememberSaveable { mutableStateOf(false) }
val hazeState = rememberHazeState()
TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
config = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = onDismiss,
content = TangemBottomSheetConfigContent.Empty,
),
addBottomInsets = false,
containerColor = TangemTheme.colors2.surface.level2,
title = {
TangemTopBar(
title = resourceReference(R.string.organize_tokens_title),
endContent = {
TangemTopBarActionContent(
actionUM = TangemTopBarActionUM(
iconRes = R.drawable.ic_exchange_mini_24,
isActionable = true,
onClick = { isShowDropdownMenu = true },
ghostModeProgress = 0f,
),
iconSize = TangemTheme.dimens2.x7,
)
OrganizeDropDownMenu(
organizeMenuUM = organizeTokensUM.organizeMenuUM,
showDropdownMenu = isShowDropdownMenu,
onDropdownDismiss = { isShowDropdownMenu = false },
modifier = Modifier.hazeEffectTangem(hazeState),
)
},
)
},
content = {
TokenList(
organizeTokensUM = organizeTokensUM,
dragAndDropIntents = dragAndDropIntents,
modifier = Modifier.hazeSourceTangem(hazeState),
)
},
)
}
@Suppress("MagicNumber")
@Composable
private fun TokenList(
organizeTokensUM: OrganizeTokensUM,
dragAndDropIntents: DragAndDropIntents,
modifier: Modifier = Modifier,
) {
val tokensListState = rememberLazyListState()
val hapticFeedback = LocalHapticFeedback.current
val tokenList = organizeTokensUM.tokenList
Box(
modifier = modifier
.fillMaxSize()
.background(TangemTheme.colors2.surface.level2),
) {
val onDragEnd: (Int, Int) -> Unit = remember {
{ _, _ ->
dragAndDropIntents.onItemDraggingEnd()
}
}
val reorderableListState = rememberReorderableLazyListState(
onMove = dragAndDropIntents::onItemDragged,
listState = tokensListState,
canDragOver = dragAndDropIntents::canDragItemOver,
onDragEnd = onDragEnd,
)
val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() }
val listContentPadding = PaddingValues(
top = TangemTheme.dimens2.x1,
bottom = TangemTheme.dimens2.x1 + bottomBarHeight,
start = TangemTheme.dimens2.x3,
end = TangemTheme.dimens2.x3,
)
LazyColumn(
modifier = Modifier
.align(Alignment.TopCenter)
.reorderable(reorderableListState)
.testTag(OrganizeTokensScreenTestTags.TOKENS_LAZY_LIST)
.hazeSourceTangem(zIndex = 1f),
state = reorderableListState.listState,
contentPadding = listContentPadding,
) {
itemsIndexed(
items = tokenList,
key = { _, item -> item.id },
) { index, item ->
val onDragStart = remember(item) {
{
dragAndDropIntents.onItemDraggingStart(item)
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
}
DraggableItem(
index = index,
item = item,
reorderableState = reorderableListState,
onDragStart = onDragStart,
isBalanceHidden = organizeTokensUM.isBalanceHidden,
)
}
}
BottomFade(
gradientBrush = Brush.verticalGradient(
colors = listOf(
Color.Transparent,
TangemTheme.colors2.surface.level2.copy(0.9f),
TangemTheme.colors2.surface.level2,
),
),
modifier = Modifier.align(Alignment.BottomCenter),
)
BottomButtons(organizeTokensUM = organizeTokensUM)
}
}
@Composable
private fun LazyItemScope.DraggableItem(
index: Int,
item: OrganizeRowItemUM,
reorderableState: ReorderableLazyListState,
onDragStart: () -> Unit,
isBalanceHidden: Boolean,
) {
var isDragging by remember {
mutableStateOf(value = false)
}
val itemModifier = Modifier.applyShapeAndShadow(item.roundingModeUM, item.isShowShadow)
ReorderableItem(
reorderableState = reorderableState,
index = index,
key = item.id,
) { isItemDragging ->
isDragging = isItemDragging
val modifierWithBackground = itemModifier
.background(color = TangemTheme.colors.background.primary)
.semantics { lazyListItemPosition = index }
when (item) {
is OrganizeRowItemUM.Network -> TangemHeaderRow(
modifier = modifierWithBackground,
reorderableState = reorderableState,
headerRowUM = item.headerRowUM,
)
is OrganizeRowItemUM.Portfolio -> TangemHeaderRow(
modifier = modifierWithBackground,
headerRowUM = item.headerRowUM,
isBalanceHidden = isBalanceHidden,
)
is OrganizeRowItemUM.Token -> TangemTokenRow(
modifier = modifierWithBackground,
tokenRowUM = item.tokenRowUM,
reorderableState = reorderableState,
isBalanceHidden = isBalanceHidden,
)
// Should be presented in the list but remain invisible
is OrganizeRowItemUM.Placeholder -> Box(modifier = Modifier.fillMaxWidth())
}
}
DisposableEffect(isDragging) {
onDispose {
if (isDragging) {
onDragStart()
}
}
}
}
@Composable
private fun BoxScope.BottomButtons(organizeTokensUM: OrganizeTokensUM) {
val bottomBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getBottom(this).toDp() }
Row(
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(horizontal = TangemTheme.dimens2.x4)
.padding(bottom = bottomBarHeight + TangemTheme.dimens2.x4),
) {
TangemButton(
buttonUM = organizeTokensUM.cancelButton,
modifier = Modifier.weight(1f),
)
TangemButton(
buttonUM = organizeTokensUM.applyButton,
modifier = Modifier.weight(1f),
)
}
}
private fun Modifier.applyShapeAndShadow(roundingMode: RoundingModeUM, showShadow: Boolean): Modifier {
return composed {
val radius by animateDpAsState(
targetValue = when (roundingMode) {
is RoundingModeUM.None -> TangemTheme.dimens2.x0
is RoundingModeUM.All -> TangemTheme.dimens2.x3
is RoundingModeUM.Bottom,
is RoundingModeUM.Top,
-> TangemTheme.dimens2.x4
},
label = "item_shape_radius",
)
val elevation by animateDpAsState(
targetValue = if (showShadow) {
TangemTheme.dimens2.x2
} else {
TangemTheme.dimens2.x0
},
label = "item_elevation",
)
this
.padding(paddingValues = getItemGap(roundingMode))
.shadow(
elevation = elevation,
shape = getItemShape(roundingMode, radius),
clip = true,
)
}
}
@Composable
@ReadOnlyComposable
private fun getItemGap(roundingMode: RoundingModeUM): PaddingValues {
val paddingValue = TangemTheme.dimens2.x1
return if (roundingMode.isShowGap) {
when (roundingMode) {
is RoundingModeUM.None -> PaddingValues(all = TangemTheme.dimens2.x0)
is RoundingModeUM.All -> PaddingValues(vertical = paddingValue)
is RoundingModeUM.Top -> PaddingValues(top = paddingValue)
is RoundingModeUM.Bottom -> PaddingValues(bottom = paddingValue)
}
} else {
PaddingValues(all = TangemTheme.dimens2.x0)
}
}
@Stable
private fun getItemShape(roundingMode: RoundingModeUM, radius: Dp): Shape {
return when (roundingMode) {
is RoundingModeUM.None -> RectangleShape
is RoundingModeUM.Top -> RoundedCornerShape(
topStart = radius,
topEnd = radius,
)
is RoundingModeUM.Bottom -> RoundedCornerShape(
bottomStart = radius,
bottomEnd = radius,
)
is RoundingModeUM.All -> RoundedCornerShape(
size = radius,
)
}
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun OrganizeTokensContent_Preview(
@PreviewParameter(OrganizeTokensContentPreviewProvider::class) params: OrganizeTokensUM,
) {
TangemThemePreviewRedesign {
OrganizeTokensContent(
organizeTokensUM = params,
dragAndDropIntents = object : DragAndDropIntents {
override fun onItemDragged(from: ItemPosition, to: ItemPosition) {}
override fun canDragItemOver(dragOver: ItemPosition, dragging: ItemPosition): Boolean = false
override fun onItemDraggingStartLegacy(item: DraggableItem) {}
override fun onItemDraggingStart(item: OrganizeRowItemUM) {}
override fun onItemDraggingEnd() {}
},
onDismiss = {},
)
}
}
private class OrganizeTokensContentPreviewProvider : PreviewParameterProvider<OrganizeTokensUM> {
override val values: Sequence<OrganizeTokensUM>
get() = sequenceOf(
OrganizeTokensPreview.defaultState,
OrganizeTokensPreview.defaultState.copy(isGrouped = false),
)
}
// endregion

View file

@ -0,0 +1,129 @@
package com.tangem.feature.wallet.child.organizetokens.ui.preview
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.ds.button.TangemButtonType
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.row.header.TangemHeaderRowUM
import com.tangem.core.ui.ds.row.internal.TangemRowTailUM
import com.tangem.core.ui.ds.row.token.TangemTokenRowUM
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemColorPalette
import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeRowItemUM
import com.tangem.feature.wallet.child.organizetokens.entity.OrganizeTokensUM
import com.tangem.feature.wallet.child.organizetokens.entity.RoundingModeUM
import com.tangem.feature.wallet.impl.R
import kotlinx.collections.immutable.toPersistentList
import java.util.UUID
internal object OrganizeTokensPreview {
private const val networksSize = 10
private const val tokensSize = 3
private val draggableToken = TangemTokenRowUM.Actionable(
id = UUID.randomUUID().toString(),
headIconUM = TangemIconUM.Currency(
CurrencyIconState.TokenIcon(
url = null,
topBadgeIconResId = R.drawable.img_polygon_22,
fallbackTint = TangemColorPalette.Black,
fallbackBackground = TangemColorPalette.Meadow,
isGrayscale = false,
shouldShowCustomBadge = false,
),
),
titleUM = TangemTokenRowUM.TitleUM.Content(stringReference(value = "Polygon")),
subtitleUM = TangemTokenRowUM.SubtitleUM.Empty,
topEndContentUM = TangemTokenRowUM.EndContentUM.Content(
text = stringReference("$ 42,900.13"),
),
bottomEndContentUM = TangemTokenRowUM.EndContentUM.Content(
text = stringReference("733,71097 POL"),
),
tailUM = TangemRowTailUM.Draggable(R.drawable.ic_group_drop_24),
onItemClick = null,
onItemLongClick = null,
)
private val tokenList = List(networksSize) { it }
.flatMap { index ->
val lastNetworkIndex = networksSize - 1
val lastTokenIndex = tokensSize - 1
val networkNumber = index + 1
val group = OrganizeRowItemUM.Network(
headerRowUM = TangemHeaderRowUM(
id = networkNumber.toString(),
title = stringReference(value = "$networkNumber"),
),
roundingModeUM = when (index) {
0 -> RoundingModeUM.Top()
lastNetworkIndex -> RoundingModeUM.Bottom()
else -> RoundingModeUM.None
},
accountId = "account_$networkNumber",
)
val tokens: MutableList<OrganizeRowItemUM.Token> = mutableListOf()
repeat(times = tokensSize) { i ->
val tokenNumber = i + 1
tokens.add(
OrganizeRowItemUM.Token(
tokenRowUM = draggableToken.copy(
id = "${group.id}_token_$tokenNumber",
titleUM = TangemTokenRowUM.TitleUM.Content(
text = stringReference(value = "Token $tokenNumber from $networkNumber network"),
),
),
groupId = group.id,
accountId = "account_$networkNumber",
roundingModeUM = when {
i == lastTokenIndex && index == lastNetworkIndex -> RoundingModeUM.Bottom()
else -> RoundingModeUM.None
},
),
)
}
val divider = OrganizeRowItemUM.Placeholder(
id = "divider_$networkNumber",
accountId = "account_$networkNumber",
)
buildList {
add(group)
addAll(tokens)
if (index != lastNetworkIndex) {
add(divider)
}
}
}
.toPersistentList()
val defaultState by lazy {
OrganizeTokensUM(
tokenList = tokenList,
organizeMenuUM = OrganizeTokensUM.OrganizeMenuUM(
onSortClick = {},
onGroupClick = {},
),
cancelButton = TangemButtonUM(
text = resourceReference(R.string.common_cancel),
onClick = {},
type = TangemButtonType.Secondary,
),
applyButton = TangemButtonUM(
text = resourceReference(R.string.common_apply),
onClick = {},
type = TangemButtonType.Primary,
),
scrollListToTop = consumedEvent(),
isAccountsMode = true,
isBalanceHidden = true,
isGrouped = false,
)
}
}

View file

@ -21,6 +21,7 @@ import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.decompose.ComposableDialogComponent
import com.tangem.domain.tokens.model.details.TokenAction
import com.tangem.feature.wallet.child.organizetokens.OrganizeTokensComponent
import com.tangem.feature.wallet.child.tokenActions.TokenActionsComponent
import com.tangem.feature.wallet.child.wallet.model.WalletModel
import com.tangem.feature.wallet.navigation.WalletRoute
@ -146,6 +147,15 @@ internal class WalletComponent @AssistedInject constructor(
),
)
}
is WalletDialogConfig.OrganizeTokens -> {
OrganizeTokensComponent(
appComponentContext = childByContext(componentContext),
params = OrganizeTokensComponent.Params(
userWalletId = dialogConfig.userWalletId,
callback = model.innerWalletRouter.organizeCallbacks,
),
)
}
}
},
)

View file

@ -2,10 +2,12 @@ package com.tangem.feature.wallet.presentation.router
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.DesignFeatureToggles
import com.tangem.domain.models.TokenReceiveConfig
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.currency.CryptoCurrency
@ -18,6 +20,7 @@ import com.tangem.domain.redux.ReduxStateHolder
import com.tangem.domain.redux.StateDialog
import com.tangem.domain.tokens.model.details.NavigationAction
import com.tangem.domain.tokens.model.details.TokenAction
import com.tangem.feature.wallet.child.organizetokens.OrganizeTokensComponent
import com.tangem.feature.wallet.navigation.WalletRoute
import com.tangem.feature.wallet.presentation.wallet.state.model.TokenActionButtonUM
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletDialogConfig
@ -32,6 +35,7 @@ internal class DefaultWalletRouter @Inject constructor(
private val router: AppRouter,
private val urlOpener: UrlOpener,
private val reduxStateHolder: ReduxStateHolder,
private val designFeatureToggles: DesignFeatureToggles,
) : InnerWalletRouter {
override val dialogNavigation: SlotNavigation<WalletDialogConfig> = SlotNavigation()
@ -41,8 +45,17 @@ internal class DefaultWalletRouter @Inject constructor(
onBufferOverflow = BufferOverflow.DROP_LATEST,
)
override val organizeCallbacks: OrganizeTokensComponent.Callback
get() = OrganizeCallbacks()
override fun openOrganizeTokensScreen(userWalletId: UserWalletId) {
navigateToFlow.tryEmit(WalletRoute.OrganizeTokens(userWalletId))
if (designFeatureToggles.isRedesignEnabled) {
dialogNavigation.activate(
configuration = WalletDialogConfig.OrganizeTokens(userWalletId),
)
} else {
navigateToFlow.tryEmit(WalletRoute.OrganizeTokens(userWalletId))
}
}
override fun openDetailsScreen(selectedWalletId: UserWalletId) {
@ -165,4 +178,10 @@ internal class DefaultWalletRouter @Inject constructor(
override fun openQrScanner() {
router.push(AppRoute.QrScanning(source = AppRoute.QrScanning.Source.MainScreen))
}
inner class OrganizeCallbacks : OrganizeTokensComponent.Callback {
override fun onDismiss() {
dialogNavigation.dismiss()
}
}
}

View file

@ -13,6 +13,7 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayDetailsConfig
import com.tangem.domain.tokens.model.details.NavigationAction
import com.tangem.domain.tokens.model.details.TokenAction
import com.tangem.feature.wallet.child.organizetokens.OrganizeTokensComponent
import com.tangem.feature.wallet.navigation.WalletRoute
import com.tangem.feature.wallet.presentation.wallet.state.model.TokenActionButtonUM
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletDialogConfig
@ -34,6 +35,8 @@ internal interface InnerWalletRouter {
val navigateToFlow: SharedFlow<WalletRoute>
val organizeCallbacks: OrganizeTokensComponent.Callback
/** Open organize tokens screen */
fun openOrganizeTokensScreen(userWalletId: UserWalletId)

View file

@ -41,4 +41,7 @@ internal sealed interface WalletDialogConfig {
@Serializable
data class KycRejected(val walletId: UserWalletId, val customerId: String) : WalletDialogConfig
@Serializable
data class OrganizeTokens(val userWalletId: UserWalletId) : WalletDialogConfig
}