Updated on 2026-08-14

This commit is contained in:
Tangem 2026-03-20 15:31:57 +03:00
commit 47dc1c38df
762 changed files with 23960 additions and 10924 deletions

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,22 @@
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 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(
isEnabled = !isSortingInProgress,
),
applyButton = prevState.applyButton.copy(
isLoading = isSortingInProgress,
),
)
}
}

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,362 @@
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.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.SpacerH
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
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.ds.topbar.TangemTopBarType
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,
),
containerColor = TangemTheme.colors2.surface.level2,
title = {
TangemTopBar(
title = resourceReference(R.string.organize_tokens_title),
type = TangemTopBarType.BottomSheet,
endContent = {
TangemTopBarActionContent(
actionUM = TangemTopBarActionUM(
iconRes = R.drawable.ic_exchange_mini_24,
isActionable = true,
onClick = { isShowDropdownMenu = true },
ghostModeProgress = 0f,
),
type = TangemTopBarType.BottomSheet,
)
OrganizeDropDownMenu(
organizeMenuUM = organizeTokensUM.organizeMenuUM,
showDropdownMenu = isShowDropdownMenu,
onDropdownDismiss = { isShowDropdownMenu = false },
modifier = Modifier.hazeEffectTangem(hazeState),
)
},
)
},
footer = {
BottomButtons(organizeTokensUM = organizeTokensUM)
},
content = {
TokenList(
organizeTokensUM = organizeTokensUM,
dragAndDropIntents = dragAndDropIntents,
modifier = Modifier.hazeSourceTangem(hazeState, zIndex = -1f),
)
},
)
}
@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.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,
)
}
item {
SpacerH(TangemTheme.dimens2.x20)
}
}
}
}
@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

@ -16,11 +16,14 @@ import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.DesignFeatureToggles
import com.tangem.features.promobanners.api.NewPromoBannersFeatureToggles
import com.tangem.features.promobanners.api.PromoBannersBlockComponent
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
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
@ -29,10 +32,12 @@ import com.tangem.feature.wallet.presentation.wallet.ui.WalletScreen
import com.tangem.feature.wallet.presentation.wallet.ui.WalletScreen2
import com.tangem.feature.wallet.presentation.wallet.ui.components.visa.KycRejectedComponent
import com.tangem.feature.walletsettings.component.RenameWalletComponent
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.features.biometry.AskBiometryComponent
import com.tangem.features.feed.entry.components.FeedEntryComponent
import com.tangem.features.pushnotifications.api.PushNotificationsBottomSheetComponent
import com.tangem.features.pushnotifications.api.PushNotificationsParams
import com.tangem.features.send.v2.api.NetworkSelectionComponent
import com.tangem.features.tokenreceive.TokenReceiveComponent
import com.tangem.features.yield.supply.api.YieldSupplyDepositedWarningComponent
import dagger.assisted.Assisted
@ -51,6 +56,9 @@ internal class WalletComponent @AssistedInject constructor(
private val pushNotificationsBottomSheetComponent: PushNotificationsBottomSheetComponent.Factory,
private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory,
private val yieldSupplyDepositedWarningComponent: YieldSupplyDepositedWarningComponent.Factory,
private val promoBannersBlockComponentFactory: PromoBannersBlockComponent.Factory,
private val newPromoBannersFeatureToggles: NewPromoBannersFeatureToggles,
private val networkSelectionComponentFactory: NetworkSelectionComponent.Factory,
private val designFeatureToggles: DesignFeatureToggles,
) : ComposableContentComponent, AppComponentContext by appComponentContext {
@ -63,6 +71,16 @@ internal class WalletComponent @AssistedInject constructor(
)
}
private val promoBannersBlockComponent: PromoBannersBlockComponent? by lazy {
if (!newPromoBannersFeatureToggles.isNewPromoBannersEnabled) return@lazy null
promoBannersBlockComponentFactory.create(
context = child("promoBannersBlockComponent"),
params = PromoBannersBlockComponent.Params(
placeholder = PromoBannersBlockComponent.Placeholder.MAIN,
),
)
}
init {
lifecycle.subscribe(model.screenLifecycleProvider)
doOnResume { model.onResume() }
@ -146,6 +164,51 @@ internal class WalletComponent @AssistedInject constructor(
),
)
}
is WalletDialogConfig.OrganizeTokens -> {
OrganizeTokensComponent(
appComponentContext = childByContext(componentContext),
params = OrganizeTokensComponent.Params(
userWalletId = dialogConfig.userWalletId,
callback = model.innerWalletRouter.organizeCallbacks,
),
)
}
is WalletDialogConfig.NetworkSelection -> {
networkSelectionComponentFactory.create(
context = childByContext(componentContext),
params = NetworkSelectionComponent.Params(
address = dialogConfig.address,
amount = dialogConfig.amount,
memo = dialogConfig.memo,
walletGroups = dialogConfig.walletGroups.map { walletGroup ->
NetworkSelectionComponent.Params.WalletGroup(
userWalletId = walletGroup.userWalletId,
walletName = walletGroup.walletName,
accounts = walletGroup.accounts.map { accountGroup ->
NetworkSelectionComponent.Params.AccountGroup(
accountId = accountGroup.accountId,
accountName = accountGroup.accountName,
currencies = accountGroup.currencies,
hiddenTokensCount = accountGroup.hiddenTokensCount,
)
},
)
},
onTokenSelected = { userWalletId, currency ->
model.innerWalletRouter.dialogNavigation.dismiss()
model.innerWalletRouter.openSend(
userWalletId = userWalletId,
currency = currency,
address = dialogConfig.address,
amount = dialogConfig.amount?.parseBigDecimal(currency.decimals),
tag = dialogConfig.memo,
entryType = AppRoute.Send.EntryType.Manual,
)
},
onDismiss = model.innerWalletRouter.dialogNavigation::dismiss,
),
)
}
}
},
)
@ -172,6 +235,7 @@ internal class WalletComponent @AssistedInject constructor(
} else {
WalletScreen(
state = model.uiState.collectAsStateWithLifecycle().value,
promoBannersBlockComponent = promoBannersBlockComponent,
bottomSheetContent = {
BottomSheetContent(
bottomSheetState = bottomSheetState,

View file

@ -4,12 +4,15 @@ import androidx.compose.runtime.Stable
import arrow.core.getOrElse
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.common.routing.AppRoute
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.event.MainScreenAnalyticsEvent
import com.tangem.core.analytics.utils.TrackingContextProxy
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase
@ -22,8 +25,16 @@ import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.*
import com.tangem.domain.notifications.GetIsHuaweiDeviceWithoutGoogleServicesUseCase
import com.tangem.domain.notifications.repository.NotificationsRepository
import com.tangem.domain.qrscanning.models.QrResultSource
import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
import com.tangem.domain.qrscanning.models.QrSendTarget
import com.tangem.domain.walletconnect.WcPairService
import com.tangem.domain.walletconnect.model.WcPairRequest
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.pay.usecase.TangemPayMainScreenCustomerInfoUseCase
import com.tangem.domain.qrscanning.usecases.ResolveQrSendTargetsUseCase
import com.tangem.domain.settings.*
import com.tangem.domain.tokens.RefreshMultiCurrencyWalletQuotesUseCase
import com.tangem.domain.wallets.usecase.*
@ -39,6 +50,7 @@ import com.tangem.feature.wallet.presentation.wallet.domain.WalletImageResolver
import com.tangem.feature.wallet.presentation.wallet.domain.WalletNameMigrationUseCase
import com.tangem.feature.wallet.presentation.wallet.loaders.WalletScreenContentLoader
import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletAlertUM
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletDialogConfig
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletEvent
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletEvent.DemonstrateWalletsScrollPreview.Direction
@ -50,6 +62,7 @@ import com.tangem.feature.wallet.presentation.wallet.utils.ScreenLifecycleProvid
import com.tangem.features.biometry.AskBiometryComponent
import com.tangem.features.pushnotifications.api.PushNotificationsModelCallbacks
import com.tangem.features.wallet.deeplink.WalletDeepLinkActionListener
import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.*
import kotlinx.coroutines.*
@ -102,6 +115,11 @@ internal class WalletModel @Inject constructor(
private val appsFlyerStore: AppsFlyerStore,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getWalletIconUseCase: GetWalletIconUseCase,
private val walletFeatureToggles: WalletFeatureToggles,
private val listenToQrScanningUseCase: ListenToQrScanningUseCase,
private val wcPairService: WcPairService,
private val resolveQrSendTargetsUseCase: ResolveQrSendTargetsUseCase,
private val uiMessageSender: UiMessageSender,
val screenLifecycleProvider: ScreenLifecycleProvider,
val innerWalletRouter: InnerWalletRouter,
) : Model() {
@ -132,6 +150,7 @@ internal class WalletModel @Inject constructor(
subscribeToScreenBackgroundState()
subscribeOnPushNotificationsPermission()
subscribeTangemPayOnWalletState()
subscribeToMainScreenQrScanning()
enableNotificationsIfNeeded()
clickIntents.initialize(innerWalletRouter, modelScope)
@ -522,6 +541,7 @@ internal class WalletModel @Inject constructor(
wallets = action.wallets,
clickIntents = clickIntents,
walletImageResolver = walletImageResolver,
isMainScreenQrScanningEnabled = walletFeatureToggles.isMainScreenQrScanningEnabled,
getWalletIconUseCase = getWalletIconUseCase,
),
)
@ -727,6 +747,67 @@ internal class WalletModel @Inject constructor(
}
}
private fun subscribeToMainScreenQrScanning() {
listenToQrScanningUseCase.listen(SourceType.MAIN_SCREEN)
.getOrElse { emptyFlow() }
.onEach { rawResult -> handleQrResult(rawResult.qrCode, rawResult.resultSource) }
.launchIn(modelScope)
}
private suspend fun handleQrResult(qrCode: String, resultSource: QrResultSource) {
when (val target = resolveQrSendTargetsUseCase(qrCode)) {
is QrSendTarget.WalletConnect -> {
val source = when (resultSource) {
QrResultSource.CLIPBOARD -> WcPairRequest.Source.CLIPBOARD
QrResultSource.CAMERA,
QrResultSource.GALLERY,
-> WcPairRequest.Source.QR
}
wcPairService.pair(
WcPairRequest(
userWalletId = stateHolder.getSelectedWalletId(),
uri = target.uri,
source = source,
screen = WcPairRequest.Screen.MAIN,
),
)
}
is QrSendTarget.Single -> {
innerWalletRouter.openSend(
userWalletId = target.userWalletId,
currency = target.currency,
address = target.address,
amount = target.amount?.parseBigDecimal(target.currency.decimals),
tag = target.memo,
entryType = AppRoute.Send.EntryType.QR,
)
}
is QrSendTarget.Multiple -> {
innerWalletRouter.openNetworkSelectionBottomSheet(target)
}
is QrSendTarget.Error -> handleQrError(target.error)
}
}
private fun handleQrError(error: ClassifiedQrContent.Error) {
when (error) {
is ClassifiedQrContent.Error.Unrecognized -> {
analyticsEventsHandler.send(
WalletScreenAnalyticsEvent.MainScreen.NoticeUnrecognizedQr(),
)
uiMessageSender.send(WalletAlertUM.qrCodeUnrecognized())
}
is ClassifiedQrContent.Error.UnsupportedNetwork -> {
analyticsEventsHandler.send(
WalletScreenAnalyticsEvent.MainScreen.NoticeNoAvailableTokens(
blockchain = error.blockchain,
),
)
uiMessageSender.send(WalletAlertUM.qrCodeUnsupportedNetwork())
}
}
}
private fun enableNotificationsIfNeeded() {
modelScope.launch {
val isUserAllowToEnableNotifications = notificationsRepository.isUserAllowToSubscribeOnPushNotifications()

View file

@ -22,6 +22,7 @@ import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayDetailsConfig
import com.tangem.domain.pay.TangemPayEligibilityManager
import com.tangem.domain.pay.model.TangemPayEntryPoint
import com.tangem.domain.pay.repository.OnboardingRepository
import com.tangem.domain.pay.usecase.ProduceTangemPayInitialDataUseCase
import com.tangem.domain.pay.usecase.TangemPayMainScreenCustomerInfoUseCase
@ -190,8 +191,8 @@ internal class TangemPayClickIntentsImplementor @Inject constructor(
val issuingBottomSheet = bottomSheetMessage {
infoBlock {
icon(com.tangem.core.ui.R.drawable.ic_clock_24) {
type = MessageBottomSheetUMV2.Icon.Type.Informative
backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.Informative
type = MessageBottomSheetUM.Icon.Type.Informative
backgroundType = MessageBottomSheetUM.Icon.BackgroundType.Informative
}
title = resourceReference(R.string.tangempay_issuing_your_card)
body = resourceReference(R.string.tangempay_issuing_your_card_description)
@ -209,8 +210,8 @@ internal class TangemPayClickIntentsImplementor @Inject constructor(
val issuingBottomSheet = bottomSheetMessage {
infoBlock {
icon(com.tangem.core.ui.R.drawable.ic_alert_24) {
type = MessageBottomSheetUMV2.Icon.Type.Warning
backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.Warning
type = MessageBottomSheetUM.Icon.Type.Warning
backgroundType = MessageBottomSheetUM.Icon.BackgroundType.Warning
}
title = resourceReference(R.string.tangempay_failed_to_issue_card)
body = resourceReference(R.string.tangempay_failed_to_issue_card_support_description)
@ -246,7 +247,7 @@ internal class TangemPayClickIntentsImplementor @Inject constructor(
override fun onOnboardingBannerClick(userWalletId: UserWalletId) {
modelScope.launch {
analyticsEventHandler.send(TangemPayAnalyticsEvents.MainVisaPermanentBannerClicked())
val isEligible = tangemPayEligibilityManager.getTangemPayAvailability()
val isEligible = tangemPayEligibilityManager.getTangemPayAvailability(TangemPayEntryPoint.BANNER)
if (isEligible) {
router.openTangemPayOnboarding(mode = AppRoute.TangemPayOnboarding.Mode.FromBannerOnMain)
} else {

View file

@ -1,6 +1,7 @@
package com.tangem.feature.wallet.child.wallet.model.intents
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.ui.DesignFeatureToggles
import com.tangem.domain.exchange.RampStateManager
import com.tangem.domain.models.wallet.isLocked
import com.tangem.domain.onramp.FetchHotCryptoUseCase
@ -15,6 +16,7 @@ import com.tangem.feature.wallet.presentation.wallet.domain.unwrap
import com.tangem.feature.wallet.presentation.wallet.loaders.WalletScreenContentLoader
import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletUM
import com.tangem.feature.wallet.presentation.wallet.state.transformers.SetRefreshStateTransformer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
@ -41,6 +43,7 @@ internal class WalletClickIntents @Inject constructor(
private val onrampStatusFactory: OnrampStatusFactory,
private val tangemPayIntents: TangemPayClickIntentsImplementor,
private val yieldSupplyApyUpdateUseCase: YieldSupplyApyUpdateUseCase,
private val designFeatureToggles: DesignFeatureToggles,
) : BaseWalletClickIntents(),
WalletCardClickIntents by walletCardClickIntentsImplementor,
WalletWarningsClickIntents by warningsClickIntentsImplementer,
@ -86,17 +89,24 @@ internal class WalletClickIntents @Inject constructor(
}
fun onRefreshSwipe(showRefreshState: Boolean) {
when (stateController.getSelectedWallet()) {
is WalletState.MultiCurrency.Content -> {
refreshMultiCurrencyContent(showRefreshState)
if (designFeatureToggles.isRedesignEnabled) {
when (stateController.getSelectedWalletUM()) {
is WalletUM.Content -> refreshMultiCurrencyContent(showRefreshState)
is WalletUM.Locked -> Unit
}
is WalletState.SingleCurrency.Content,
-> {
refreshSingleCurrencyContent(showRefreshState)
} else {
when (stateController.getSelectedWallet()) {
is WalletState.MultiCurrency.Content -> {
refreshMultiCurrencyContent(showRefreshState)
}
is WalletState.SingleCurrency.Content,
-> {
refreshSingleCurrencyContent(showRefreshState)
}
is WalletState.MultiCurrency.Locked,
is WalletState.SingleCurrency.Locked,
-> Unit
}
is WalletState.MultiCurrency.Locked,
is WalletState.SingleCurrency.Locked,
-> Unit
}
}

View file

@ -8,6 +8,7 @@ import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.event.MainScreenAnalyticsEvent
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.currency.CryptoCurrency
@ -18,7 +19,6 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.nft.analytics.NFTAnalyticsEvent
import com.tangem.domain.settings.ShouldShowMarketsTooltipUseCase
import com.tangem.domain.tokens.GetCryptoCurrencyActionsUseCase
import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase
import com.tangem.domain.tokens.model.details.NavigationAction
import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
@ -43,6 +43,7 @@ import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@Suppress("TooManyFunctions")
internal interface WalletContentClickIntents {
fun onDetailsClick()
@ -69,6 +70,8 @@ internal interface WalletContentClickIntents {
fun onManageTokensClick(accountId: AccountId)
fun onManageTokensClick(userWalletId: UserWalletId)
fun onTransactionClick(txHash: String)
fun onDissmissBottomSheet()
@ -82,16 +85,18 @@ internal interface WalletContentClickIntents {
fun onDisposeExpressStatus()
fun onNFTClick(userWallet: UserWallet)
fun onScanQrClick()
}
@Suppress("LongParameterList", "LargeClass")
@Suppress("LongParameterList", "LargeClass", "TooManyFunctions")
@ModelScoped
internal class WalletContentClickIntentsImplementor @Inject constructor(
private val stateHolder: WalletStateController,
private val currencyActionsClickIntents: WalletCurrencyActionsClickIntentsImplementor,
private val onrampStatusFactory: OnrampStatusFactory,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
private val getCryptoCurrencyActionsUseCase: GetCryptoCurrencyActionsUseCase,
private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase,
private val shouldShowMarketsTooltipUseCase: ShouldShowMarketsTooltipUseCase,
@ -225,6 +230,12 @@ internal class WalletContentClickIntentsImplementor @Inject constructor(
router.openManageTokensScreen(accountId)
}
override fun onManageTokensClick(userWalletId: UserWalletId) {
router.openManageTokensScreen(
AccountId.forMainCryptoPortfolio(userWalletId),
)
}
private fun openYieldSupply(userWalletId: UserWalletId, cryptoCurrencyStatus: CryptoCurrencyStatus, apy: String) {
router.openYieldSupplyEntryScreen(
userWalletId = userWalletId,
@ -266,7 +277,7 @@ internal class WalletContentClickIntentsImplementor @Inject constructor(
override fun onTransactionClick(txHash: String) {
modelScope.launch(dispatchers.main) {
val currency = getSingleCryptoCurrencyStatusUseCase.unwrap(
val currency = singleAccountStatusListSupplier.unwrap(
userWalletId = stateHolder.getSelectedWalletId(),
)
?.currency
@ -369,4 +380,9 @@ internal class WalletContentClickIntentsImplementor @Inject constructor(
router.openNFT(userWallet)
}
override fun onScanQrClick() {
analyticsEventHandler.send(MainScreenAnalyticsEvent.ButtonQrScan())
router.openQrScanner()
}
}

View file

@ -23,6 +23,7 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.haptic.TangemHapticEffect
import com.tangem.core.ui.haptic.VibratorHapticManager
import com.tangem.core.ui.message.DialogMessage
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.extenstions.unwrap
@ -43,7 +44,10 @@ import com.tangem.domain.onramp.model.OnrampSource
import com.tangem.domain.promo.GetStoryContentUseCase
import com.tangem.domain.promo.models.StoryContentIds
import com.tangem.domain.staking.model.StakingOption
import com.tangem.domain.tokens.*
import com.tangem.domain.account.status.usecase.IsCryptoCurrencyCouldHideUseCase
import com.tangem.domain.tokens.NeedShowYieldSupplyDepositedWarningUseCase
import com.tangem.domain.tokens.SaveViewedTokenReceiveWarningUseCase
import com.tangem.domain.tokens.SaveViewedYieldSupplyWarningUseCase
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.domain.tokens.model.analytics.TokenReceiveCopyActionSource
import com.tangem.domain.tokens.model.analytics.TokenReceiveNewAnalyticsEvent
@ -125,7 +129,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
private val walletManagersFacade: WalletManagersFacade,
private val isDemoCardUseCase: IsDemoCardUseCase,
private val getSingleCryptoCurrencyStatusUseCase: GetSingleCryptoCurrencyStatusUseCase,
private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier,
private val getExploreUrlUseCase: GetExploreUrlUseCase,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getStoryContentUseCase: GetStoryContentUseCase,
@ -141,7 +145,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
private val receiveAddressesFactory: ReceiveAddressesFactory,
private val needShowYieldSupplyDepositedWarningUseCase: NeedShowYieldSupplyDepositedWarningUseCase,
private val saveViewedYieldSupplyWarningUseCase: SaveViewedYieldSupplyWarningUseCase,
private val isCryptoCurrencyCoinCouldHide: IsCryptoCurrencyCoinCouldHideUseCase,
private val isCryptoCurrencyCouldHideUseCase: IsCryptoCurrencyCouldHideUseCase,
private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase,
private val uiMessageSender: UiMessageSender,
) : BaseWalletClickIntents(), WalletCurrencyActionsClickIntents {
@ -264,17 +268,19 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
modelScope.launch(dispatchers.main) {
val currency = cryptoCurrencyStatus.currency
val isCryptoCurrencyCoinCouldHide = currency is CryptoCurrency.Coin &&
!isCryptoCurrencyCoinCouldHide(userWalletId = accountId.userWalletId, cryptoCurrencyCoin = currency)
val canHide = isCryptoCurrencyCouldHideUseCase(
userWalletId = accountId.userWalletId,
cryptoCurrency = currency,
)
if (isCryptoCurrencyCoinCouldHide) {
uiMessageSender.send(WalletAlertUM.unableHideToken(cryptoCurrency = cryptoCurrencyStatus.currency))
} else {
if (canHide) {
uiMessageSender.send(
WalletAlertUM.hideTokenConfirm(cryptoCurrency = cryptoCurrencyStatus.currency) {
onPerformHideToken(accountId, cryptoCurrencyStatus)
},
)
} else {
uiMessageSender.send(WalletAlertUM.unableHideToken(cryptoCurrency = cryptoCurrencyStatus.currency))
}
}
}
@ -486,7 +492,7 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
val userWalletId = stateHolder.getSelectedWalletId()
modelScope.launch(dispatchers.main) {
val currencyStatus = getSingleCryptoCurrencyStatusUseCase.unwrap(userWalletId) ?: return@launch
val currencyStatus = singleAccountStatusListSupplier.unwrap(userWalletId) ?: return@launch
when (val addresses = currencyStatus.value.networkAddress) {
is NetworkAddress.Selectable -> {

View file

@ -1,5 +1,6 @@
package com.tangem.feature.wallet.featuretoggles
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles
import javax.inject.Inject
@ -9,5 +10,8 @@ internal class DefaultWalletFeatureToggles @Inject constructor(
) : WalletFeatureToggles {
override val isWalletReorderFeatureEnabled: Boolean
get() = featureToggles.isFeatureEnabled("WALLET_REORDER_FEATURE_ENABLED")
get() = featureToggles.isFeatureEnabled(FeatureToggles.WALLET_REORDER_FEATURE_ENABLED)
override val isMainScreenQrScanningEnabled: Boolean
get() = featureToggles.isFeatureEnabled(FeatureToggles.MAIN_SCREEN_QR_SCANNING_ENABLED)
}

View file

@ -11,7 +11,7 @@ import kotlinx.collections.immutable.persistentListOf
@Suppress("LargeClass")
internal object WalletPreviewDataLegacy {
val topBarConfig by lazy { WalletTopBarConfig(onDetailsClick = {}) }
val topBarConfig by lazy { WalletTopBarConfig() }
val walletCardContentState by lazy {
WalletCardState.Content(

View file

@ -2,6 +2,8 @@ package com.tangem.feature.wallet.presentation.common.preview
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.ds.button.TangemButtonShape
import com.tangem.core.ui.ds.button.TangemButtonSize
import com.tangem.core.ui.ds.button.TangemButtonType
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.ds.image.TangemIconUM
@ -40,6 +42,27 @@ internal object WalletScreenPreviewData {
onItemLongClick = {},
)
private val accountRowDefault = TangemTokenRowUM.Content(
id = "1",
headIconUM = TangemIconUM.Currency(CurrencyIconState.Loading),
titleUM = TangemTokenRowUM.TitleUM.Content(
text = stringReference("Main Account"),
),
subtitleUM = TangemTokenRowUM.SubtitleUM.Content(
text = stringReference("2 tokens"),
),
topEndContentUM = TangemTokenRowUM.EndContentUM.Content(
text = stringReference("1 234,56 \$"),
),
bottomEndContentUM = TangemTokenRowUM.EndContentUM.Content(
text = stringReference("0,12345678 BTC"),
),
promoBannerUM = TangemTokenRowUM.PromoBannerUM.Empty,
tailUM = TangemRowTailUM.Empty,
onItemClick = {},
onItemLongClick = {},
)
private val tokenListDefault = WalletTokensListUM.Content(
tokenList = persistentListOf(
TokensListItemUM2.Token(tokenRowDefault.copy(id = "0")),
@ -50,14 +73,79 @@ internal object WalletScreenPreviewData {
text = resourceReference(R.string.organize_tokens_title),
type = TangemButtonType.Secondary,
onClick = {},
iconRes = R.drawable.ic_filter_default_24,
size = TangemButtonSize.X9,
shape = TangemButtonShape.Rounded,
),
)
private val tokenListAccounts = WalletTokensListUM.Content(
tokenList = persistentListOf(
TokensListItemUM2.Portfolio(
tokenRowUM = accountRowDefault.copy(id = "0"),
tokenList = persistentListOf(
TokensListItemUM2.Token(tokenRowDefault.copy(id = "1")),
TokensListItemUM2.Token(tokenRowDefault.copy(id = "2")),
),
isExpanded = true,
isCollapsable = true,
onEmptyClick = {},
),
TokensListItemUM2.Portfolio(
tokenRowUM = accountRowDefault.copy(id = "3"),
tokenList = persistentListOf(
TokensListItemUM2.Token(tokenRowDefault.copy(id = "4")),
TokensListItemUM2.Token(tokenRowDefault.copy(id = "5")),
),
isExpanded = true,
isCollapsable = true,
onEmptyClick = {},
),
),
organizeButtonUM = TangemButtonUM(
text = resourceReference(R.string.organize_tokens_title),
type = TangemButtonType.Secondary,
onClick = {},
iconRes = R.drawable.ic_filter_default_24,
size = TangemButtonSize.X9,
shape = TangemButtonShape.Rounded,
),
)
private val tokenListAccountsWithEmpty = WalletTokensListUM.Content(
tokenList = persistentListOf(
TokensListItemUM2.Portfolio(
tokenRowUM = accountRowDefault.copy(id = "0"),
tokenList = persistentListOf(),
isExpanded = true,
isCollapsable = true,
onEmptyClick = {},
),
TokensListItemUM2.Portfolio(
tokenRowUM = accountRowDefault.copy(id = "3"),
tokenList = persistentListOf(),
isExpanded = false,
isCollapsable = true,
onEmptyClick = {},
),
),
organizeButtonUM = TangemButtonUM(
text = resourceReference(R.string.organize_tokens_title),
type = TangemButtonType.Secondary,
onClick = {},
iconRes = R.drawable.ic_filter_default_24,
size = TangemButtonSize.X9,
shape = TangemButtonShape.Rounded,
),
)
private val walletLocked = WalletUM.Locked(
walletsBalanceUM = WalletBalancePreview.content,
buttons = WalletPreviewData.actionButtons,
walletsBalanceUM = WalletBalancePreview.empty,
buttons = WalletPreviewData.disabledActionButtons,
type = WalletType.Cold,
notifications = persistentListOf(),
notifications = persistentListOf(
WalletNotificationUM.UnlockWallets({}),
),
)
private val walletDefault = WalletUM.Content(
@ -82,13 +170,40 @@ internal object WalletScreenPreviewData {
tangemPayState = TangemPayState.Loading,
)
internal val defaultState = WalletScreenState(
private val walletEmpty = WalletUM.Content(
walletsBalanceUM = WalletBalancePreview.empty,
buttons = WalletPreviewData.disabledActionButtons,
type = WalletType.Cold,
pullToRefreshConfig = PullToRefreshConfig(
isRefreshing = false,
onRefresh = {},
),
notifications = persistentListOf(),
notificationsCarousel = persistentListOf(),
tokensListUM = WalletTokensListUM.Empty(onEmptyClick = {}),
nftState = WalletNFTItemUM.Hidden,
tangemPayState = TangemPayState.Empty,
)
private val walletAccountDefault = walletDefault.copy(
tokensListUM = tokenListAccounts,
)
private val walletAccountEmpty = walletEmpty.copy(
walletsBalanceUM = WalletBalancePreview.content,
tokensListUM = tokenListAccountsWithEmpty,
)
val defaultState = WalletScreenState(
topBarConfig = topBarConfig,
selectedWalletIndex = 0,
wallets = persistentListOf(),
wallets2 = persistentListOf(
walletLocked,
walletDefault,
walletEmpty,
walletLocked,
walletAccountDefault,
walletAccountEmpty,
),
onWalletChange = { _, _ -> },
event = consumedEvent(),
@ -96,4 +211,20 @@ internal object WalletScreenPreviewData {
showMarketsOnboarding = false,
onDismissMarketsTooltip = {},
)
val defaultAccountState = defaultState.copy(
selectedWalletIndex = 3,
)
val emptyState = defaultState.copy(
selectedWalletIndex = 1,
)
val emptyAccountState = defaultState.copy(
selectedWalletIndex = 4,
)
val lockedState = defaultState.copy(
selectedWalletIndex = 2,
)
}

View file

@ -3,6 +3,7 @@ package com.tangem.feature.wallet.presentation.preview
import androidx.compose.ui.text.SpanStyle
import com.tangem.core.ui.ds.image.DeviceIconUM
import com.tangem.core.ui.extensions.combinedReference
import com.tangem.core.ui.extensions.orMaskWithStars
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.styledStringReference
import com.tangem.core.ui.res.TangemTheme
@ -37,6 +38,8 @@ internal object WalletBalancePreview {
isZeroBalance = false,
)
val hiddenBalanceContent = content.copy(balance = content.balance.orMaskWithStars(true))
val loading: WalletBalanceUM.Loading = WalletBalanceUM.Loading(
id = UserWalletId("1"),
name = "My Wallet",
@ -48,4 +51,10 @@ internal object WalletBalancePreview {
name = "My Wallet",
deviceIcon = DeviceIconUM.Stub(cardsCount = 3),
)
val empty: WalletBalanceUM.Empty = WalletBalanceUM.Empty(
id = UserWalletId("2"),
name = "My Wallet",
deviceIcon = DeviceIconUM.Stub(cardsCount = 3),
)
}

View file

@ -15,6 +15,12 @@ internal object WalletPreviewData {
}
val actionButtons = persistentListOf(
WalletActionButtons.Buy({}, true).buttonUM,
WalletActionButtons.Swap({}, true).buttonUM,
WalletActionButtons.Sell({}, true).buttonUM,
)
val disabledActionButtons = persistentListOf(
WalletActionButtons.Buy({}, false).buttonUM,
WalletActionButtons.Swap({}, false).buttonUM,
WalletActionButtons.Sell({}, false).buttonUM,

View file

@ -2,15 +2,18 @@ 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
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.qrscanning.models.QrSendTarget
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.TangemPayDetailsConfig
@ -18,6 +21,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 +36,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 +46,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) {
@ -161,4 +175,58 @@ internal class DefaultWalletRouter @Inject constructor(
),
)
}
override fun openQrScanner() {
router.push(AppRoute.QrScanning(source = AppRoute.QrScanning.Source.MainScreen))
}
override fun openSend(
userWalletId: UserWalletId,
currency: CryptoCurrency,
address: String,
amount: String?,
tag: String?,
entryType: AppRoute.Send.EntryType,
) {
router.push(
AppRoute.Send(
userWalletId = userWalletId,
currency = currency,
destinationAddress = address,
amount = amount,
tag = tag,
entryType = entryType,
),
)
}
override fun openNetworkSelectionBottomSheet(target: QrSendTarget.Multiple) {
dialogNavigation.activate(
configuration = WalletDialogConfig.NetworkSelection(
address = target.address,
amount = target.amount,
memo = target.memo,
walletGroups = target.walletGroups.map { walletGroup ->
WalletDialogConfig.NetworkSelection.WalletGroupData(
userWalletId = walletGroup.userWalletId,
walletName = walletGroup.walletName,
accounts = walletGroup.accounts.map { accountGroup ->
WalletDialogConfig.NetworkSelection.AccountGroupData(
accountId = accountGroup.accountId,
accountName = accountGroup.accountName,
currencies = accountGroup.currencies,
hiddenTokensCount = accountGroup.hiddenTokensCount,
)
},
)
},
),
)
}
inner class OrganizeCallbacks : OrganizeTokensComponent.Callback {
override fun onDismiss() {
dialogNavigation.dismiss()
}
}
}

View file

@ -8,11 +8,13 @@ import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.qrscanning.models.QrSendTarget
import com.tangem.domain.models.wallet.UserWallet
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 +36,8 @@ internal interface InnerWalletRouter {
val navigateToFlow: SharedFlow<WalletRoute>
val organizeCallbacks: OrganizeTokensComponent.Callback
/** Open organize tokens screen */
fun openOrganizeTokensScreen(userWalletId: UserWalletId)
@ -86,4 +90,20 @@ internal interface InnerWalletRouter {
/** Open token action sheet */
fun openTokenActionSheet(userWallet: UserWallet, tokenActionList: ImmutableList<TokenActionButtonUM>)
/** Open QR scanner screen */
fun openQrScanner()
/** Open send screen with prefilled destination */
fun openSend(
userWalletId: UserWalletId,
currency: CryptoCurrency,
address: String,
amount: String?,
tag: String?,
entryType: AppRoute.Send.EntryType = AppRoute.Send.EntryType.Manual,
)
/** Open network selection bottom sheet for multiple QR matches */
fun openNetworkSelectionBottomSheet(target: QrSendTarget.Multiple)
}

View file

@ -167,6 +167,17 @@ sealed class WalletScreenAnalyticsEvent {
class NoticeSeedPhraseSupportButtonDeclined : MainScreen(event = "Button - Support Declined")
class NoticeUnrecognizedQr : MainScreen(
event = "Notice - Unrecognized QR",
)
class NoticeNoAvailableTokens(blockchain: String?) : MainScreen(
event = "Notice - No Available Tokens",
params = buildMap {
if (blockchain != null) put("Blockchain", blockchain)
},
)
// region Referral Promo
class ReferralPromo : MainScreen(event = "Referral Banner")
class ReferralPromoButtonParticipate : MainScreen(event = "Button - Referral Participate")

View file

@ -122,6 +122,7 @@ internal class WalletWarningsAnalyticsSender @Inject constructor(
is WalletNotification.Warning.TangemPayRefreshNeeded -> null
is WalletNotification.Warning.TangemPayUnreachable -> null
is WalletNotification.UpgradeHotWalletPromo -> null
is WalletNotification.TokenSyncCompleted -> null
}
}

View file

@ -114,8 +114,8 @@ internal class WalletWarningsSingleEventSender @Inject constructor(
val message = bottomSheetMessage {
infoBlock {
icon(R.drawable.img_knight_shield_32) {
type = MessageBottomSheetUMV2.Icon.Type.Warning
backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.SameAsTint
type = MessageBottomSheetUM.Icon.Type.Warning
backgroundType = MessageBottomSheetUM.Icon.BackgroundType.SameAsTint
}
title = resourceReference(R.string.hw_activation_need_title)
body = resourceReference(R.string.hw_activation_need_description)

View file

@ -1,13 +1,11 @@
package com.tangem.feature.wallet.presentation.wallet.domain
import arrow.core.Either
import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase
import com.tangem.domain.tokens.error.CurrencyStatusError
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import kotlinx.coroutines.flow.*
import timber.log.Timber
internal fun GetSelectedWalletSyncUseCase.unwrap(): UserWallet? {
@ -20,29 +18,9 @@ internal fun GetSelectedWalletSyncUseCase.unwrap(): UserWallet? {
)
}
internal suspend fun GetSingleCryptoCurrencyStatusUseCase.unwrap(userWalletId: UserWalletId): CryptoCurrencyStatus? {
return invokeSingleWallet(userWalletId)
.conflate()
.distinctUntilChanged()
.filter(Either<CurrencyStatusError, CryptoCurrencyStatus>::isRight)
.firstOrNull()
?.fold(
ifLeft = {
Timber.e("Impossible to get primary currency status $it")
null
},
ifRight = { it },
)
}
internal suspend fun GetSingleCryptoCurrencyStatusUseCase.collectLatest(
userWalletId: UserWalletId,
onRight: suspend (CryptoCurrencyStatus) -> Unit,
) {
invokeSingleWallet(userWalletId = userWalletId)
.conflate()
.distinctUntilChanged()
.collectLatest { maybeStatus ->
maybeStatus.onRight { onRight(it) }
}
internal suspend fun SingleAccountStatusListSupplier.unwrap(userWalletId: UserWalletId): CryptoCurrencyStatus? {
return getSyncOrNull(params = SingleAccountStatusListProducer.Params(userWalletId))
?.mainAccount
?.flattenCurrencies()
?.firstOrNull()
}

View file

@ -0,0 +1,23 @@
package com.tangem.feature.wallet.presentation.wallet.qr
import com.tangem.domain.models.currency.CryptoCurrency
import java.math.BigDecimal
internal sealed class ClassifiedQrContent {
data class WalletConnect(val uri: String) : ClassifiedQrContent()
data class PaymentUri(
val currency: CryptoCurrency,
val address: String,
val amount: BigDecimal?,
val memo: String?,
) : ClassifiedQrContent()
data class PlainAddress(
val address: String,
val matchingCurrencies: List<CryptoCurrency>,
) : ClassifiedQrContent()
data class Unknown(val raw: String) : ClassifiedQrContent()
}

View file

@ -0,0 +1,120 @@
package com.tangem.feature.wallet.presentation.wallet.qr
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import java.net.URLDecoder
internal class QrContentClassifier(
private val blockchainDataProvider: BlockchainDataProvider,
) {
fun classify(qrCode: String, userCurrencies: List<CryptoCurrency>): ClassifiedQrContent {
if (qrCode.startsWith(WC_PREFIX)) {
return ClassifiedQrContent.WalletConnect(qrCode)
}
if (isDAppWcUrl(qrCode)) {
return ClassifiedQrContent.WalletConnect(qrCode)
}
val coins = userCurrencies.filterIsInstance<CryptoCurrency.Coin>()
val uniqueCoins = coins.distinctBy { it.network.id }
val paymentUri = tryParsePaymentUri(qrCode, uniqueCoins)
if (paymentUri != null) return paymentUri
val matchingCurrencies = uniqueCoins.filter { coin ->
blockchainDataProvider.validateAddress(coin.network, qrCode)
}
if (matchingCurrencies.isNotEmpty()) {
return ClassifiedQrContent.PlainAddress(
address = qrCode,
matchingCurrencies = matchingCurrencies,
)
}
return ClassifiedQrContent.Unknown(qrCode)
}
private fun tryParsePaymentUri(qrCode: String, coins: List<CryptoCurrency.Coin>): ClassifiedQrContent.PaymentUri? {
return coins.firstNotNullOfOrNull { coin ->
val matchedScheme = blockchainDataProvider.getShareSchemes(coin.network)
.sortedByDescending { it.length }
.firstOrNull { qrCode.startsWith(it) }
?: return@firstNotNullOfOrNull null
val withoutScheme = qrCode.removePrefix(matchedScheme)
val address = withoutScheme.takeWhile {
it != CHAIN_DELIMITER && it != FUNCTION_DELIMITER && it != PARAM_DELIMITER
}
val params = extractParameters(withoutScheme)
if (address.isBlank()) return@firstNotNullOfOrNull null
val amount = params[PARAM_AMOUNT]?.toBigDecimalOrNull()
val memo = (params[PARAM_MEMO] ?: params[PARAM_MESSAGE])?.let {
runCatching { URLDecoder.decode(it, CHARSET_UTF8) }.getOrDefault(it)
}
ClassifiedQrContent.PaymentUri(
currency = coin,
address = address,
amount = amount,
memo = memo,
)
}
}
private fun isDAppWcUrl(qrCode: String): Boolean {
if (!qrCode.startsWith(HTTP_PREFIX) && !qrCode.startsWith(HTTPS_PREFIX)) return false
val uriParam = extractParameters(qrCode)[PARAM_URI] ?: return false
val decodedUri = runCatching { URLDecoder.decode(uriParam, CHARSET_UTF8) }.getOrDefault(uriParam)
return decodedUri.startsWith(WC_PREFIX)
}
private fun extractParameters(from: String): Map<String, String> {
val paramsBlock = from.substringAfter(PARAM_DELIMITER, missingDelimiterValue = "")
if (paramsBlock.isBlank()) return emptyMap()
return paramsBlock.split(PARAMS_DELIMITER)
.mapNotNull { param ->
val parts = param.split(PARAM_VALUE_DELIMITER, limit = 2)
if (parts.size == 2) parts[0].lowercase() to parts[1] else null
}
.toMap()
}
internal interface BlockchainDataProvider {
fun getShareSchemes(network: Network): List<String>
fun validateAddress(network: Network, address: String): Boolean
}
internal class DefaultBlockchainDataProvider : BlockchainDataProvider {
override fun getShareSchemes(network: Network): List<String> {
return runCatching { network.toBlockchain().getShareScheme() }.getOrDefault(emptyList())
}
override fun validateAddress(network: Network, address: String): Boolean {
return runCatching { network.toBlockchain().validateAddress(address) }.getOrDefault(false)
}
}
private companion object {
const val HTTP_PREFIX = "http://"
const val HTTPS_PREFIX = "https://"
const val PARAM_URI = "uri"
const val WC_PREFIX = "wc:"
const val CHAIN_DELIMITER = '@'
const val FUNCTION_DELIMITER = '/'
const val PARAM_DELIMITER = '?'
const val PARAMS_DELIMITER = '&'
const val PARAM_VALUE_DELIMITER = '='
const val PARAM_AMOUNT = "amount"
const val PARAM_MEMO = "memo"
const val PARAM_MESSAGE = "message"
const val CHARSET_UTF8 = "UTF-8"
}
}

View file

@ -130,7 +130,7 @@ internal class WalletStateController @Inject constructor(
private fun getInitialState(): WalletScreenState {
return WalletScreenState(
topBarConfig = WalletTopBarConfig(onDetailsClick = {}),
topBarConfig = WalletTopBarConfig(),
selectedWalletIndex = NOT_INITIALIZED_WALLET_INDEX,
wallets = persistentListOf(),
wallets2 = persistentListOf(),

View file

@ -3,7 +3,6 @@ package com.tangem.feature.wallet.presentation.wallet.state.model
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.ds.button.TangemButtonShape
import com.tangem.core.ui.ds.button.TangemButtonState
import com.tangem.core.ui.ds.button.TangemButtonType
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.extensions.TextReference
@ -31,11 +30,6 @@ internal sealed class WalletActionButtons(
shape = TangemButtonShape.Rounded,
onClick = onClick,
isEnabled = isEnabled,
state = if (isEnabled) {
TangemButtonState.Default
} else {
TangemButtonState.Disabled
},
)
data class Buy(

View file

@ -8,4 +8,5 @@ data class WalletAdditionalInfo(
val hideable: Boolean,
val content: TextReference,
val isHotBackedUp: Boolean = false,
val shouldShowProgress: Boolean = false,
)

View file

@ -80,6 +80,20 @@ internal object WalletAlertUM {
)
}
fun qrCodeUnrecognized(): DialogMessage {
return DialogMessage(
title = resourceReference(R.string.qr_scanner_error_unrecognized_title),
message = resourceReference(R.string.qr_scanner_error_unrecognized_message),
)
}
fun qrCodeUnsupportedNetwork(): DialogMessage {
return DialogMessage(
title = resourceReference(R.string.qr_scanner_error_token_not_added_title),
message = resourceReference(R.string.qr_scanner_error_token_not_added_message),
)
}
fun confirmExpressStatusHide(onConfirmClick: () -> Unit): DialogMessage {
return DialogMessage(
title = resourceReference(R.string.express_status_hide_dialog_title),

View file

@ -69,11 +69,24 @@ internal sealed interface WalletBalanceUM {
override val deviceIcon: DeviceIconUM,
) : WalletBalanceUM
/**
* Wallet card loading state
*
* @property id wallet id
* @property name wallet name
*/
data class Empty(
override val id: UserWalletId,
override val name: String,
override val deviceIcon: DeviceIconUM,
) : WalletBalanceUM
fun copySealed(name: String): WalletBalanceUM {
return when (this) {
is Content -> copy(name = name)
is Error -> copy(name = name)
is Loading -> copy(name = name)
is Empty -> copy(name = name)
}
}
}

View file

@ -1,11 +1,15 @@
package com.tangem.feature.wallet.presentation.wallet.state.model
import com.tangem.domain.models.TokenReceiveConfig
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.serialization.BigDecimalSerializer
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.tokens.model.details.TokenAction
import kotlinx.collections.immutable.ImmutableList
import kotlinx.serialization.Serializable
import java.math.BigDecimal
/**
* Wallet dialog config. Used to show Decompose dialogs
@ -41,4 +45,31 @@ internal sealed interface WalletDialogConfig {
@Serializable
data class KycRejected(val walletId: UserWalletId, val customerId: String) : WalletDialogConfig
@Serializable
data class OrganizeTokens(val userWalletId: UserWalletId) : WalletDialogConfig
@Serializable
data class NetworkSelection(
val address: String,
val amount: @Serializable(BigDecimalSerializer::class) BigDecimal?,
val memo: String?,
val walletGroups: List<WalletGroupData>,
) : WalletDialogConfig {
@Serializable
data class WalletGroupData(
val userWalletId: UserWalletId,
val walletName: String,
val accounts: List<AccountGroupData>,
)
@Serializable
data class AccountGroupData(
val accountId: AccountId,
val accountName: AccountName,
val currencies: List<CryptoCurrency>,
val hiddenTokensCount: Int = 0,
)
}
}

View file

@ -463,4 +463,21 @@ sealed class WalletNotification(val config: NotificationConfig) {
iconSize = 72.dp,
),
)
data class TokenSyncCompleted(
val onCloseClick: () -> Unit,
val onManageTokensClick: () -> Unit,
) : WalletNotification(
config = NotificationConfig(
title = resourceReference(R.string.initial_wallet_sync_banner_title),
subtitle = resourceReference(R.string.initial_wallet_sync_banner_description),
iconResId = R.drawable.ic_check_circle_24,
iconTint = IconTint.Accent,
onCloseClick = onCloseClick,
buttonsState = ButtonsState.SecondaryButtonConfig(
text = resourceReference(R.string.main_manage_tokens),
onClick = onManageTokensClick,
),
),
)
}

View file

@ -1,6 +1,7 @@
package com.tangem.feature.wallet.presentation.wallet.state.model
import androidx.annotation.DrawableRes
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.ds.button.TangemButtonType
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.message.TangemMessageButtonUM
@ -196,7 +197,6 @@ internal sealed class WalletNotificationUM(val messageUM: TangemMessageUM, val t
onClick = onConfirmClick,
),
),
),
type = WalletNotificationType.Critical,
)
@ -343,7 +343,13 @@ internal sealed class WalletNotificationUM(val messageUM: TangemMessageUM, val t
resourceReference(R.string.common_biometrics),
),
),
onClick = onClick,
buttonsUM = persistentListOf(
TangemMessageButtonUM(
text = resourceReference(R.string.common_unlock),
onClick = onClick,
type = TangemButtonType.Primary,
),
),
messageEffect = TangemMessageEffect.Card,
isCentered = true,
),
@ -379,8 +385,14 @@ internal sealed class WalletNotificationUM(val messageUM: TangemMessageUM, val t
title = resourceReference(R.string.notification_one_plus_one_title),
subtitle = resourceReference(R.string.notification_one_plus_one_text),
messageEffect = TangemMessageEffect.Magic,
onCloseClick = onCloseClick,
iconUM = TangemIconUM.Image(R.drawable.img_one_plus_one_promo),
iconSize = 54.dp,
buttonsUM = persistentListOf(
TangemMessageButtonUM(
text = resourceReference(R.string.common_later),
type = TangemButtonType.PrimaryInverse,
onClick = onCloseClick,
),
TangemMessageButtonUM(
text = resourceReference(R.string.notification_one_plus_one_button),
type = TangemButtonType.Primary,
@ -452,7 +464,8 @@ internal sealed class WalletNotificationUM(val messageUM: TangemMessageUM, val t
id = "PushNotificationsNotification",
title = resourceReference(R.string.user_push_notification_banner_title),
subtitle = resourceReference(R.string.user_push_notification_banner_subtitle),
onCloseClick = onCloseClick,
iconUM = TangemIconUM.Image(R.drawable.img_push_reminder),
iconSize = 54.dp,
messageEffect = TangemMessageEffect.Magic,
buttonsUM = persistentListOf(
TangemMessageButtonUM(

View file

@ -20,11 +20,33 @@ internal sealed class WalletTokensListUM {
abstract val tokenList: ImmutableList<TokensListItemUM2>
abstract val organizeButtonUM: TangemButtonUM?
data object Empty : WalletTokensListUM() {
data class Empty(
val onEmptyClick: () -> Unit,
) : WalletTokensListUM() {
override val tokenList: ImmutableList<TokensListItemUM2.Portfolio> = persistentListOf()
override val organizeButtonUM: TangemButtonUM? = null
}
data object Locked : WalletTokensListUM() {
override val tokenList: ImmutableList<TokensListItemUM2.Portfolio> = persistentListOf(
TokensListItemUM2.Portfolio(
tokenRowUM = TangemTokenRowUM.Empty(id = "0"),
tokenList = persistentListOf(),
isExpanded = false,
isCollapsable = true,
onEmptyClick = {},
),
TokensListItemUM2.Portfolio(
tokenRowUM = TangemTokenRowUM.Empty(id = "1"),
tokenList = persistentListOf(),
isExpanded = false,
isCollapsable = true,
onEmptyClick = {},
),
)
override val organizeButtonUM: TangemButtonUM? = null
}
data object Loading : WalletTokensListUM() {
override val tokenList: ImmutableList<TokensListItemUM2> = persistentListOf(
TokensListItemUM2.Portfolio(
@ -32,18 +54,21 @@ internal sealed class WalletTokensListUM {
tokenList = persistentListOf(),
isExpanded = false,
isCollapsable = true,
onEmptyClick = {},
),
TokensListItemUM2.Portfolio(
tokenRowUM = TangemTokenRowUM.Loading(id = "1"),
tokenList = persistentListOf(),
isExpanded = false,
isCollapsable = true,
onEmptyClick = {},
),
TokensListItemUM2.Portfolio(
tokenRowUM = TangemTokenRowUM.Loading(id = "2"),
tokenList = persistentListOf(),
isExpanded = false,
isCollapsable = true,
onEmptyClick = {},
),
)
override val organizeButtonUM: TangemButtonUM? = null
@ -72,6 +97,7 @@ internal sealed interface TokensListItemUM2 {
data class Portfolio(
override val tokenRowUM: TangemTokenRowUM,
val onEmptyClick: () -> Unit,
val tokenList: ImmutableList<TokensListItemUM2>,
val isExpanded: Boolean,
val isCollapsable: Boolean,

View file

@ -1,8 +1,16 @@
package com.tangem.feature.wallet.presentation.wallet.state.model
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.ds.topbar.TangemTopBarActionUM
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
/**
* Wallet screen top bar config
*
* @property onDetailsClick lambda be invoked when details button is clicked
* @property endActions list of top bar end action buttons (e.g. QR scan, More)
*/
internal data class WalletTopBarConfig(val onDetailsClick: () -> Unit)
@Immutable
internal data class WalletTopBarConfig(
val endActions: ImmutableList<TangemTopBarActionUM> = persistentListOf(),
)

View file

@ -45,7 +45,7 @@ internal sealed interface WalletUM {
) : WalletUM {
override val notificationsCarousel: ImmutableList<WalletNotificationUM> = persistentListOf()
override val pullToRefreshConfig = PullToRefreshConfig(false, {})
override val tokensListUM: WalletTokensListUM = WalletTokensListUM.Empty // todo redesign main locked state
override val tokensListUM: WalletTokensListUM = WalletTokensListUM.Locked
override val nftState: WalletNFTItemUM = WalletNFTItemUM.Hidden
override val tangemPayState: TangemPayState = TangemPayState.Empty
}

View file

@ -2,6 +2,7 @@ package com.tangem.feature.wallet.presentation.wallet.state.transformers
import com.tangem.common.ui.userwallet.converter.WalletIconUMConverter
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.ds.topbar.TangemTopBarActionUM
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.isLocked
@ -18,6 +19,7 @@ import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
import com.tangem.core.ui.R as CoreUiR
internal class InitializeWalletsTransformer(
private val selectedWalletIndex: Int,
@ -25,6 +27,7 @@ internal class InitializeWalletsTransformer(
private val clickIntents: WalletClickIntents,
private val walletImageResolver: WalletImageResolver,
private val getWalletIconUseCase: GetWalletIconUseCase,
private val isMainScreenQrScanningEnabled: Boolean = false,
) : WalletScreenStateTransformer {
private val walletLoadingStateFactory by lazy {
@ -60,7 +63,20 @@ internal class InitializeWalletsTransformer(
private fun createTopBarConfig(): WalletTopBarConfig {
return WalletTopBarConfig(
onDetailsClick = clickIntents::onDetailsClick,
endActions = listOfNotNull(
if (isMainScreenQrScanningEnabled) {
TangemTopBarActionUM(
iconRes = CoreUiR.drawable.ic_qrcode_scaner_24,
onClick = clickIntents::onScanQrClick,
)
} else {
null
},
TangemTopBarActionUM(
iconRes = CoreUiR.drawable.ic_more_default_24,
onClick = clickIntents::onDetailsClick,
),
).toPersistentList(),
)
}
@ -112,7 +128,7 @@ internal class InitializeWalletsTransformer(
private fun UserWallet.toLockedWalletUM(): WalletUM.Locked {
return WalletUM.Locked(
walletsBalanceUM = WalletBalanceUM.Loading(
walletsBalanceUM = WalletBalanceUM.Empty(
id = walletId,
name = name,
deviceIcon = getWalletIconUseCase.invoke(userWallet = this)

View file

@ -4,7 +4,6 @@ import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfi
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.feature.wallet.presentation.wallet.state.model.*
import com.tangem.feature.wallet.presentation.wallet.state.utils.enableButtons
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletUM
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.mutate

View file

@ -9,6 +9,7 @@ import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.card.common.util.getCardsCount
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.tokens.error.TokenListError
import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
import com.tangem.feature.wallet.presentation.wallet.domain.WalletAdditionalInfoFactory
import com.tangem.feature.wallet.presentation.wallet.state.model.*
import com.tangem.feature.wallet.presentation.wallet.state.utils.disableButtons
@ -19,6 +20,7 @@ internal class SetTokenListErrorTransformer(
private val selectedWallet: UserWallet,
private val error: TokenListError,
private val appCurrency: AppCurrency,
private val clickIntents: WalletClickIntents,
) : WalletStateTransformer(selectedWallet.walletId) {
override fun transform(prevState: WalletState): WalletState {
@ -57,7 +59,11 @@ internal class SetTokenListErrorTransformer(
is WalletUM.Content -> {
walletUM.copy(
walletsBalanceUM = walletUM.walletsBalanceUM.toLoadedState(),
tokensListUM = WalletTokensListUM.Empty,
tokensListUM = WalletTokensListUM.Empty(
onEmptyClick = {
clickIntents.onManageTokensClick(walletUM.walletsBalanceUM.id)
},
),
buttons = walletUM.disableButtons(),
)
}

View file

@ -7,7 +7,6 @@ import com.tangem.domain.staking.model.StakingAvailability
import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
import com.tangem.feature.wallet.presentation.wallet.state.model.*
import com.tangem.feature.wallet.presentation.wallet.state.transformers.converter.MultiWalletBalanceUMTransformer
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletUM
import com.tangem.feature.wallet.presentation.wallet.state.transformers.converter.MultiWalletCardStateConverter
import com.tangem.feature.wallet.presentation.wallet.state.transformers.converter.TokenListStateConverter
import com.tangem.feature.wallet.presentation.wallet.state.transformers.converter.WalletTokensListUMConverter
@ -99,7 +98,13 @@ internal class SetTokenListTransformer(
}
private fun toLoadedState(): WalletTokensListUM {
if (params !is TokenConverterParams.Account) return WalletTokensListUM.Empty
if (params !is TokenConverterParams.Account) {
return WalletTokensListUM.Empty(
onEmptyClick = {
clickIntents.onManageTokensClick(userWallet.walletId)
},
)
}
return WalletTokensListUMConverter(
selectedWallet = userWallet,

View file

@ -0,0 +1,52 @@
package com.tangem.feature.wallet.presentation.wallet.state.transformers
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.feature.wallet.impl.R
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletAdditionalInfo
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletCardState
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletUM
internal class SetTokenSyncProgressTransformer(
userWalletId: UserWalletId,
private val progressPercent: Int,
) : WalletStateTransformer(userWalletId) {
override fun transform(prevState: WalletState): WalletState {
return when (prevState) {
is WalletState.MultiCurrency.Content -> {
val updatedCardState = updateCardState(prevState.walletCardState)
prevState.copy(walletCardState = updatedCardState)
}
else -> {
prevState
}
}
}
override fun transform(walletUM: WalletUM): WalletUM {
return walletUM
}
private fun updateCardState(cardState: WalletCardState): WalletCardState {
val additionalInfo = WalletAdditionalInfo(
hideable = false,
content = resourceReference(
id = R.string.initial_wallet_sync_restore_progress,
formatArgs = wrappedList(progressPercent),
),
shouldShowProgress = true,
)
return when (cardState) {
is WalletCardState.Loading -> {
cardState.copy(additionalInfo = additionalInfo)
}
is WalletCardState.Content -> {
cardState.copy(additionalInfo = additionalInfo)
}
else -> cardState
}
}
}

View file

@ -46,38 +46,38 @@ internal class WalletTokenCurrencyItemConverter(
stakingApyMap = stakingAvailabilityMap,
)
override fun convert(currencyStatus: CryptoCurrencyStatus): TangemTokenRowUM {
val earnApyInfo = earnApyConverter.convert(currencyStatus)
override fun convert(value: CryptoCurrencyStatus): TangemTokenRowUM {
val earnApyInfo = earnApyConverter.convert(value)
return TangemTokenRowUM.Content(
id = currencyStatus.currency.id.value,
id = value.currency.id.value,
headIconUM = TangemIconUM.Currency(
currencyIconState = currencyToIconStateConverter.convert(currencyStatus),
currencyIconState = currencyToIconStateConverter.convert(value),
),
titleUM = toCurrencyRowTitle(currencyStatus, earnApyInfo),
subtitleUM = toCurrencyRowSubtitle(currencyStatus),
topEndContentUM = toCurrencyRowTopEnd(currencyStatus),
bottomEndContentUM = toCurrencyRowBottomEnd(currencyStatus),
titleUM = toCurrencyRowTitle(value, earnApyInfo),
subtitleUM = toCurrencyRowSubtitle(value),
topEndContentUM = toCurrencyRowTopEnd(value),
bottomEndContentUM = toCurrencyRowBottomEnd(value),
promoBannerUM = toPromoBannerUM(
accountId,
currencyStatus,
value,
earnApyInfo.takeIf { shouldShowPromo },
),
onItemClick = when (currencyStatus.value) {
onItemClick = when (value.value) {
CryptoCurrencyStatus.Loading,
is CryptoCurrencyStatus.MissedDerivation,
-> null
else -> {
{
clickIntents.onTokenItemClick(accountId, currencyStatus)
clickIntents.onTokenItemClick(accountId, value)
}
}
},
onItemLongClick = when (currencyStatus.value) {
onItemLongClick = when (value.value) {
CryptoCurrencyStatus.Loading -> null
else -> {
{
clickIntents.onTokenItemLongClick(accountId, currencyStatus)
clickIntents.onTokenItemLongClick(accountId, value)
}
}
},
@ -280,6 +280,8 @@ internal class WalletTokenCurrencyItemConverter(
R.string.yield_module_main_screen_promo_banner_message,
wrappedList(earnApyInfo.apy),
),
iconRes = R.drawable.ic_yield_mode_mini_12,
type = TangemTokenRowUM.PromoBannerUM.Content.Type.Yield,
onPromoBannerClick = {
clickIntents.onYieldPromoClicked(currency)
clickIntents.onApyLabelClick(

View file

@ -71,8 +71,10 @@ internal class WalletTokensListUMConverter(
override fun convert(value: AccountStatusList): WalletTokensListUM {
val promoCryptoCurrency = yieldSupplyPromoBannerConverter.convert2(value = value)
return if (value.accountStatuses.isEmpty()) {
WalletTokensListUM.Empty
return if (value.flattenCurrencies().isEmpty()) {
WalletTokensListUM.Empty(
onEmptyClick = { clickIntents.onManageTokensClick(value.mainAccount.accountId) },
)
} else {
val isCollapsable = value.accountStatuses.count {
it is AccountStatus.CryptoPortfolio && it.account.tokensCount > 0
@ -89,6 +91,7 @@ internal class WalletTokensListUMConverter(
tokenRowUM = accountRowConverter.convert(accountStatus),
isExpanded = isExpanded || !isCollapsable,
isCollapsable = isCollapsable,
onEmptyClick = { clickIntents.onManageTokensClick(accountStatus.account.accountId) },
tokenList = getTokenListItems(
accountStatus,
promoCryptoCurrency,
@ -163,7 +166,7 @@ internal class WalletTokensListUMConverter(
return if (accountList.flattenCurrencies().size > 1 && !selectedWallet.isSingleWalletWithToken()) {
TangemButtonUM(
text = resourceReference(R.string.organize_tokens_title),
isEnabled = accountList.totalFiatBalance !is TotalFiatBalance.Loading,
isEnabled = accountList.totalFiatBalance is TotalFiatBalance.Loading,
size = TangemButtonSize.X9,
shape = TangemButtonShape.Rounded,
type = TangemButtonType.PrimaryInverse,

View file

@ -132,6 +132,7 @@ internal abstract class BasicAccountListSubscriber : BasicWalletSubscriber() {
selectedWallet = userWallet,
error = e,
appCurrency = appCurrency,
clickIntents = clickIntents,
),
)
return

View file

@ -10,6 +10,9 @@ import com.tangem.core.res.getStringSafe
import com.tangem.core.ui.event.EventEffect
import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.core.ui.res.LocalTopSnackbarHostState
import com.tangem.core.ui.utils.requestPermission
import com.tangem.feature.wallet.impl.R
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletEvent
@ -69,18 +72,17 @@ internal fun WalletEventEffectLegacy(
@Composable
internal fun WalletEventEffect(
walletsPagerState: PagerState,
snackbarHostState: SnackbarHostState,
event: StateEvent<WalletEvent>,
onCollapseBalance: () -> Unit,
) {
val resources = LocalContext.current.resources
var showPermissionRequest by remember { mutableStateOf<Pair<() -> Unit, () -> Unit>?>(null) }
HandlePermissionRequest(
permissionRequestParams = showPermissionRequest,
onPermissionRequestResult = { showPermissionRequest = null },
)
val tangemTopSnackbarHostState = LocalTopSnackbarHostState.current
EventEffect(
event = event,
onTrigger = { value ->
@ -92,12 +94,14 @@ internal fun WalletEventEffect(
walletsPagerState.scrollToPage(page = value.newIndex)
}
is WalletEvent.ShowError -> {
snackbarHostState.showSnackbar(message = value.text.resolveReference(resources))
tangemTopSnackbarHostState.showSnackbar(SnackbarMessage(message = value.text))
}
is WalletEvent.CopyAddress -> {
snackbarHostState.showSnackbar(
message = resources.getStringSafe(R.string.wallet_notification_address_copied),
duration = SnackbarDuration.Short,
tangemTopSnackbarHostState.showSnackbar(
SnackbarMessage(
startIconId = R.drawable.ic_check_24,
message = resourceReference(R.string.wallet_notification_address_copied),
),
)
}
is WalletEvent.DemonstrateWalletsScrollPreview -> {

View file

@ -11,6 +11,7 @@ import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
@ -20,16 +21,17 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.*
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
@ -40,19 +42,17 @@ import androidx.compose.ui.text.style.TextAlign
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 androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.*
import androidx.paging.compose.collectAsLazyPagingItems
import com.tangem.common.ui.bottomsheet.chooseaddress.ChooseAddressBottomSheet
import com.tangem.common.ui.bottomsheet.chooseaddress.ChooseAddressBottomSheetConfig
import com.tangem.common.ui.expressStatus.ExpressStatusBottomSheet
import com.tangem.common.ui.expressStatus.ExpressStatusBottomSheetConfig
import com.tangem.common.ui.expressStatus.expressTransactionsItems
import com.tangem.core.ui.components.atoms.Hand
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.components.atoms.handComposableComponentHeight
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheetDraggableHeaderLegacy
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.components.containers.pullToRefresh.TangemPullToRefreshContainer
import com.tangem.core.ui.components.rememberIsKeyboardVisible
@ -61,6 +61,7 @@ import com.tangem.core.ui.components.snackbar.CopiedTextSnackbar
import com.tangem.core.ui.components.snackbar.TangemSnackbar
import com.tangem.core.ui.components.transactions.state.TxHistoryState
import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.extensions.softLayerShadow
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.LocalWindowSize
@ -91,6 +92,7 @@ import kotlin.math.roundToInt
@Composable
internal fun WalletScreen(
state: WalletScreenState,
promoBannersBlockComponent: ComposableContentComponent? = null,
bottomSheetContent: @Composable (() -> Unit),
bottomSheetHeaderHeightProvider: () -> Dp,
onBottomSheetStateChange: (BottomSheetState) -> Unit,
@ -108,6 +110,7 @@ internal fun WalletScreen(
snackbarHostState = snackbarHostState,
isAutoScroll = isAutoScroll,
onAutoScrollReset = { isAutoScroll.value = false },
promoBannersBlockComponent = promoBannersBlockComponent,
bottomSheetContent = bottomSheetContent,
bottomSheetHeaderHeightProvider = bottomSheetHeaderHeightProvider,
onBottomSheetStateChange = onBottomSheetStateChange,
@ -129,6 +132,7 @@ private fun WalletContent(
snackbarHostState: SnackbarHostState,
isAutoScroll: State<Boolean>,
onAutoScrollReset: () -> Unit,
promoBannersBlockComponent: ComposableContentComponent? = null,
bottomSheetHeaderHeightProvider: () -> Dp,
onBottomSheetStateChange: (BottomSheetState) -> Unit,
bottomSheetContent: @Composable (() -> Unit),
@ -210,6 +214,12 @@ private fun WalletContent(
notifications(configs = selectedWallet.warnings, modifier = itemModifier)
promoBannersBlockComponent?.let { component ->
item(key = "PromoBannersBlock") {
component.Content(modifier = itemModifier)
}
}
if (selectedWallet is WalletState.MultiCurrency) {
item(
key = "TangemPayMainScreenBlock",
@ -290,15 +300,8 @@ private inline fun BaseScaffoldWithMarkets(
crossinline bottomSheetContent: @Composable () -> Unit,
crossinline content: @Composable (PaddingValues) -> Unit,
) {
val bottomSheetState = rememberTangemStandardBottomSheetState()
val isKeyboardVisible by rememberIsKeyboardVisible()
val scaffoldState = rememberTangemBottomSheetScaffoldState(
bottomSheetState = bottomSheetState,
snackbarHostState = snackbarHostState,
)
val density = LocalDensity.current
val bottomBarHeight = with(density) { WindowInsets.systemBars.getBottom(density = this).toDp() }
val statusBarHeight = with(density) { WindowInsets.statusBars.getTop(density = this).toDp() }
@ -308,6 +311,12 @@ private inline fun BaseScaffoldWithMarkets(
val coroutineScope = rememberCoroutineScope()
val background = TangemTheme.colors.background.tertiary
val bottomSheetState = rememberTangemStandardBottomSheetState()
val scaffoldState = rememberTangemBottomSheetScaffoldState(
bottomSheetState = bottomSheetState,
snackbarHostState = snackbarHostState,
)
val showMarketsHint by remember {
derivedStateOf {
// Show hint only when there are items in the list
@ -321,7 +330,7 @@ private inline fun BaseScaffoldWithMarkets(
CompositionLocalProvider(
LocalMainBottomSheetColor provides remember(background) { mutableStateOf(background) },
) {
val backgroundColor = LocalMainBottomSheetColor.current
val backgroundColor by LocalMainBottomSheetColor.current
var isSearchFieldFocused by remember { mutableStateOf(false) }
val isNavBarVisible = remember { mutableStateOf(true) }
@ -343,43 +352,61 @@ private inline fun BaseScaffoldWithMarkets(
.navigationBarsPadding(),
)
},
containerColor = TangemTheme.colors.background.secondary,
sheetContainerColor = backgroundColor.value,
scaffoldState = scaffoldState,
sheetPeekHeight = peekHeight,
sheetShape = TangemTheme.shapes.bottomSheetLarge,
sheetContent = {
// hide bottom sheet when back pressed
BackHandler(
isKeyboardVisible.not() &&
bottomSheetState.currentValue == TangemSheetValue.Expanded,
) {
coroutineScope.launch { bottomSheetState.partialExpand() }
}
Column(
containerColor = TangemTheme.colors.background.secondary,
scaffoldState = scaffoldState,
bottomSheet = {
CustomBottomSheet(
state = scaffoldState.bottomSheetState,
peekHeight = peekHeight,
modifier = Modifier
// expand bottom sheet when clicked on the header
.clickable(
enabled = bottomSheetState.currentValue == TangemSheetValue.PartiallyExpanded,
indication = null,
interactionSource = null,
.softLayerShadow(
radius = 8.dp,
color = Color.Black.copy(
alpha = if (isSystemInDarkTheme()) .16f else .08f,
),
shape = TangemTheme.shapes.bottomSheetLarge,
offset = DpOffset(x = 0.dp, y = (-4).dp),
isAlphaContentClip = true,
)
.clip(TangemTheme.shapes.bottomSheetLarge)
.background(backgroundColor),
content = {
// hide bottom sheet when back pressed
BackHandler(
isKeyboardVisible.not() &&
bottomSheetState.currentValue == TangemSheetValue.Expanded,
) {
coroutineScope.launch { bottomSheetState.expand() }
coroutineScope.launch { bottomSheetState.partialExpand() }
}
.sizeIn(maxHeight = maxHeight - statusBarHeight),
) {
Hand(Modifier.drawBehind { drawRect(backgroundColor.value) })
Box(
modifier = Modifier
.onFocusChanged {
isSearchFieldFocused = it.isFocused
},
) {
bottomSheetContent()
}
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
// expand bottom sheet when clicked on the header
.clickable(
enabled = bottomSheetState.currentValue == TangemSheetValue.PartiallyExpanded,
indication = null,
interactionSource = null,
) {
coroutineScope.launch { bottomSheetState.expand() }
}
.sizeIn(maxHeight = maxHeight - statusBarHeight),
) {
TangemBottomSheetDraggableHeaderLegacy(backgroundColor)
Box(
modifier = Modifier
.onFocusChanged {
isSearchFieldFocused = it.isFocused
},
) {
bottomSheetContent()
}
}
},
)
},
content = { paddingValues ->
Box {
@ -428,7 +455,7 @@ private inline fun BaseScaffoldWithMarkets(
Box(
Modifier
.align(Alignment.BottomCenter)
.background(backgroundColor.value)
.background(backgroundColor)
.height(bottomBarHeight)
.fillMaxWidth(),
)

View file

@ -17,18 +17,21 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusState
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
@ -40,25 +43,23 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.arkivanov.decompose.ExperimentalDecomposeApi
import com.tangem.core.ui.components.atoms.Hand
import com.tangem.core.ui.components.BottomFade
import com.tangem.core.ui.components.atoms.handComposableComponentHeight
import com.tangem.core.ui.components.background.northernlights.NorthernLightsBackground
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheetDraggableHeader
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.components.containers.pullToRefresh.TangemPullToRefreshSlidingContainer
import com.tangem.core.ui.components.haze.hazeSourceTangem
import com.tangem.core.ui.components.rememberIsKeyboardVisible
import com.tangem.core.ui.components.sheetscaffold.*
import com.tangem.core.ui.components.snackbar.CopiedTextSnackbar
import com.tangem.core.ui.components.snackbar.TangemSnackbar
import com.tangem.core.ui.ds.topbar.collapsing.TangemCollapsingAppBarBehavior
import com.tangem.core.ui.ds.topbar.collapsing.TangemCollapsingTopBar
import com.tangem.core.ui.ds.topbar.collapsing.rememberTangemExitUntilCollapsedScrollBehavior
import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.res.*
import com.tangem.feature.wallet.presentation.common.preview.WalletScreenPreviewData
import com.tangem.feature.wallet.presentation.wallet.state.model.NOT_INITIALIZED_WALLET_INDEX
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletBalanceUM
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletEvent
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletScreenState
import com.tangem.feature.wallet.presentation.wallet.ui.components.MarketsHint
import com.tangem.feature.wallet.presentation.wallet.ui.components.MarketsTooltip
@ -85,7 +86,6 @@ internal fun WalletScreen2(
val statusBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getTop(this).toDp() }
val snackbarHostState = remember(::SnackbarHostState)
val walletsPagerState = rememberPagerState(
initialPage = state.selectedWalletIndex,
pageCount = { state.wallets2.size },
@ -104,7 +104,6 @@ internal fun WalletScreen2(
WalletContent2(
state = state,
walletsPagerState = walletsPagerState,
snackbarHostState = snackbarHostState,
behavior = behavior,
bottomSheetContent = bottomSheetContent,
bottomSheetHeaderHeightProvider = bottomSheetHeaderHeightProvider,
@ -113,7 +112,6 @@ internal fun WalletScreen2(
WalletEventEffect(
walletsPagerState = walletsPagerState,
snackbarHostState = snackbarHostState,
event = state.event,
onCollapseBalance = {
if (behavior.state.collapsedFraction < 1f) {
@ -131,7 +129,6 @@ private fun WalletContent2(
state: WalletScreenState,
walletsPagerState: PagerState,
behavior: TangemCollapsingAppBarBehavior,
snackbarHostState: SnackbarHostState,
bottomSheetHeaderHeightProvider: () -> Dp,
onBottomSheetStateChange: (BottomSheetState) -> Unit,
bottomSheetContent: @Composable (() -> Unit),
@ -140,10 +137,14 @@ private fun WalletContent2(
val bottomBarHeight = with(density) { WindowInsets.systemBars.getBottom(this).toDp() }
var walletBalance by remember { mutableStateOf<TextReference?>(TextReference.EMPTY) }
var pullToRefreshConfig by remember {
mutableStateOf(
state.wallets2.getOrNull(state.selectedWalletIndex)?.pullToRefreshConfig,
)
}
BaseScaffoldWithMarkets(
state = state,
snackbarHostState = snackbarHostState,
bottomSheetHeaderHeightProvider = bottomSheetHeaderHeightProvider,
onBottomSheetStateChange = onBottomSheetStateChange,
bottomSheetContent = bottomSheetContent,
@ -175,6 +176,8 @@ private fun WalletContent2(
val canPagerScroll by remember { derivedStateOf { behavior.state.heightOffset == 0f } }
val pullToRefreshState = rememberPullToRefreshState()
Box(
modifier = Modifier
.fillMaxSize()
@ -186,11 +189,15 @@ private fun WalletContent2(
} else {
TangemTheme.colors2.surface.level2
},
modifier = Modifier.matchParentSize(),
modifier = Modifier
.graphicsLayer { alpha = 1 - behavior.state.collapsedFraction * 2 }
.matchParentSize(),
)
WalletPagerIndicator(
pagerState = walletsPagerState,
pullToRefreshState = pullToRefreshState,
pullToRefreshConfig = pullToRefreshConfig,
behavior = behavior,
)
@ -210,6 +217,11 @@ private fun WalletContent2(
walletBalance = (currentWallet.walletsBalanceUM as? WalletBalanceUM.Content)?.balanceInAppBar
}
}
LaunchedEffect(walletsPagerState.currentPage, currentWallet.pullToRefreshConfig) {
if (walletsPagerState.currentPage == currentWalletIndex) {
pullToRefreshConfig = currentWallet.pullToRefreshConfig
}
}
val isShowMarketsHint by remember {
derivedStateOf {
@ -222,8 +234,13 @@ private fun WalletContent2(
val pageSlideAlpha by rememberPageAlpha(walletsPagerState, currentWalletIndex)
Box(
TangemPullToRefreshSlidingContainer(
state = pullToRefreshState,
config = currentWallet.pullToRefreshConfig,
modifier = Modifier.alpha(pageSlideAlpha),
indicatorOffset = with(LocalDensity.current) {
behavior.state.partialHeightLimit.toDp()
},
) {
TangemCollapsingTopBar(
state = behavior.state,
@ -278,7 +295,6 @@ private fun WalletContent2(
@Composable
private inline fun BaseScaffoldWithMarkets(
state: WalletScreenState,
snackbarHostState: SnackbarHostState,
bottomSheetHeaderHeightProvider: () -> Dp,
modifier: Modifier = Modifier,
noinline onBottomSheetStateChange: (BottomSheetState) -> Unit,
@ -286,28 +302,20 @@ private inline fun BaseScaffoldWithMarkets(
crossinline bottomSheetContent: @Composable () -> Unit,
crossinline content: @Composable (PaddingValues, TangemSheetState) -> Unit,
) {
val bottomSheetState = rememberTangemStandardBottomSheetState()
val isKeyboardVisible by rememberIsKeyboardVisible()
val scaffoldState = rememberTangemBottomSheetScaffoldState(
bottomSheetState = bottomSheetState,
snackbarHostState = snackbarHostState,
)
val density = LocalDensity.current
val bottomBarHeight = with(density) { WindowInsets.systemBars.getBottom(density = this).toDp() }
val statusBarHeight = with(density) { WindowInsets.statusBars.getTop(density = this).toDp() }
val peekHeight = bottomSheetHeaderHeightProvider() + handComposableComponentHeight + bottomBarHeight
val maxHeight = LocalWindowSize.current.height
val peekHeight = bottomSheetHeaderHeightProvider() + TangemTheme.dimens2.x3 + bottomBarHeight
val coroutineScope = rememberCoroutineScope()
val background = TangemTheme.colors2.surface.level3
val background = TangemTheme.colors2.surface.level2
val bottomSheetState = rememberTangemStandardBottomSheetState()
val scaffoldState = rememberTangemBottomSheetScaffoldState(bottomSheetState = bottomSheetState)
CompositionLocalProvider(
LocalMainBottomSheetColor provides remember(background) { mutableStateOf(background) },
) {
val backgroundColor = LocalMainBottomSheetColor.current
val backgroundColor by LocalMainBottomSheetColor.current
var isSearchFieldFocused by remember { mutableStateOf(false) }
val isNavBarVisible = remember { mutableStateOf(true) }
@ -320,51 +328,19 @@ private inline fun BaseScaffoldWithMarkets(
Box(modifier = modifier) {
TangemBottomSheetScaffold(
snackbarHost = { snackbarHostState ->
WalletSnackbarHost(
snackbarHostState = snackbarHostState,
event = state.event,
modifier = Modifier
.padding(bottom = TangemTheme.dimens2.x1)
.navigationBarsPadding(),
)
},
containerColor = Color.Unspecified,
sheetContainerColor = backgroundColor.value,
scaffoldState = scaffoldState,
sheetPeekHeight = peekHeight,
sheetShape = TangemTheme.shapes.bottomSheetLarge,
sheetContent = {
// hide bottom sheet when back pressed
BackHandler(
isKeyboardVisible.not() &&
bottomSheetState.currentValue == TangemSheetValue.Expanded,
bottomSheet = {
BottomSheet(
bottomSheetState = bottomSheetState,
backgroundColor = backgroundColor,
peekHeight = peekHeight,
onFocusChange = { focusState ->
isSearchFieldFocused = focusState.isFocused
},
) {
coroutineScope.launch { bottomSheetState.partialExpand() }
}
Column(
modifier = Modifier
// expand bottom sheet when clicked on the header
.clickable(
enabled = bottomSheetState.currentValue == TangemSheetValue.PartiallyExpanded,
indication = null,
interactionSource = null,
) {
coroutineScope.launch { bottomSheetState.expand() }
}
.sizeIn(maxHeight = maxHeight - statusBarHeight),
) {
Hand(Modifier.drawBehind { drawRect(backgroundColor.value) })
Box(
modifier = Modifier
.onFocusChanged {
isSearchFieldFocused = it.isFocused
},
) {
bottomSheetContent()
}
bottomSheetContent()
}
},
content = { paddingValues ->
@ -388,7 +364,7 @@ private inline fun BaseScaffoldWithMarkets(
Box(
Modifier
.align(Alignment.BottomCenter)
.background(backgroundColor.value)
.background(backgroundColor)
.height(bottomBarHeight)
.fillMaxWidth(),
)
@ -403,6 +379,81 @@ private inline fun BaseScaffoldWithMarkets(
}
}
@Composable
private fun BottomSheet(
bottomSheetState: TangemSheetState,
backgroundColor: Color,
peekHeight: Dp,
onFocusChange: (FocusState) -> Unit,
bottomSheetContent: @Composable () -> Unit,
) {
val isKeyboardVisible by rememberIsKeyboardVisible()
val coroutineScope = rememberCoroutineScope()
val density = LocalDensity.current
val statusBarHeight = with(density) { WindowInsets.statusBars.getTop(density = this).toDp() }
val maxHeight = LocalWindowSize.current.height
val shape = RoundedCornerShape(
topStart = TangemTheme.dimens2.x8,
topEnd = TangemTheme.dimens2.x8,
)
CustomBottomSheet(
state = bottomSheetState,
peekHeight = peekHeight,
content = {
// hide bottom sheet when back pressed
BackHandler(
isKeyboardVisible.not() &&
bottomSheetState.currentValue == TangemSheetValue.Expanded,
) {
coroutineScope.launch { bottomSheetState.partialExpand() }
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
// expand bottom sheet when clicked on the header
.clickable(
enabled = bottomSheetState.currentValue == TangemSheetValue.PartiallyExpanded,
indication = null,
interactionSource = null,
) {
coroutineScope.launch { bottomSheetState.expand() }
}
.sizeIn(maxHeight = maxHeight - statusBarHeight),
) {
Box(modifier = Modifier.fillMaxWidth()) {
BottomFade(
gradientBrush = Brush.verticalGradient(
colors = listOf(
TangemTheme.colors2.shadow.min,
TangemTheme.colors2.shadow.max,
),
),
modifier = Modifier
.offset(y = TangemTheme.dimens2.x5.unaryMinus()),
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
TangemBottomSheetDraggableHeader()
Box(
modifier = Modifier
.fillMaxWidth()
.clip(shape)
.background(backgroundColor)
.onFocusChanged(onFocusChange),
) {
bottomSheetContent()
}
}
}
}
},
)
}
@Composable
private fun BottomSheetScrim(color: Color, visible: Boolean, onDismissRequest: () -> Unit) {
val alpha by animateFloatAsState(
@ -482,21 +533,6 @@ private fun BottomSheetStateEffects(
}
}
@Composable
private fun WalletSnackbarHost(
snackbarHostState: SnackbarHostState,
event: StateEvent<WalletEvent>,
modifier: Modifier = Modifier,
) {
SnackbarHost(hostState = snackbarHostState, modifier = modifier) { data ->
if (event is StateEvent.Triggered && event.data is WalletEvent.CopyAddress) {
CopiedTextSnackbar(data)
} else {
TangemSnackbar(data)
}
}
}
@Composable
private fun rememberPageAlpha(pagerState: PagerState, currentPageIndex: Int): State<Float> {
return remember {
@ -542,7 +578,10 @@ private class WalletScreen2PreviewProvider : PreviewParameterProvider<WalletScre
override val values: Sequence<WalletScreenState>
get() = sequenceOf(
WalletScreenPreviewData.defaultState,
WalletScreenPreviewData.defaultState.copy(selectedWalletIndex = 1),
WalletScreenPreviewData.emptyState,
WalletScreenPreviewData.defaultAccountState,
WalletScreenPreviewData.emptyAccountState,
WalletScreenPreviewData.lockedState,
)
}
// endregion

View file

@ -5,9 +5,10 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -15,17 +16,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.feature.wallet.impl.R
private const val STARS_INLINE_CONTENT_ID = "stars"
@Composable
internal fun MarketsHint(isVisible: Boolean, modifier: Modifier = Modifier) {
AnimatedVisibility(
@ -36,41 +33,28 @@ internal fun MarketsHint(isVisible: Boolean, modifier: Modifier = Modifier) {
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1),
) {
Text(
text = "Swipe up to explore the market", // todo redesign main lokalise
style = TangemTheme.typography2.bodyRegular14,
text = stringResourceSafe(R.string.markets_hint_part_one),
style = TangemTheme.typography2.bodyRegular15,
color = TangemTheme.colors2.text.neutral.primary,
textAlign = TextAlign.Center,
)
Text(
text = buildAnnotatedString {
append("Find new hidden gems ") // todo redesign main lokalise
appendInlineContent(
STARS_INLINE_CONTENT_ID,
alternateText = "\uDBC0\uDDBF",
)
},
inlineContent = mapOf(
STARS_INLINE_CONTENT_ID to InlineTextContent(
placeholder = Placeholder(
width = TangemTheme.typography2.bodyRegular14.fontSize,
height = TangemTheme.typography2.bodyRegular14.fontSize,
placeholderVerticalAlign = PlaceholderVerticalAlign.Center,
),
children = {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_magic_default_24),
tint = TangemTheme.colors2.text.neutral.tertiary,
contentDescription = null,
)
},
),
),
style = TangemTheme.typography2.bodyRegular14,
color = TangemTheme.colors2.text.neutral.tertiary,
textAlign = TextAlign.Center,
)
Row(horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1)) {
Text(
text = stringResourceSafe(R.string.markets_hint_part_two),
style = TangemTheme.typography2.bodyRegular15,
color = TangemTheme.colors2.text.neutral.tertiary,
textAlign = TextAlign.Center,
)
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_magic_default_24),
tint = TangemTheme.colors2.text.neutral.tertiary,
contentDescription = null,
modifier = Modifier.size(TangemTheme.dimens2.x5),
)
}
}
}
}

View file

@ -26,7 +26,7 @@ internal fun LazyListScope.organizeTokens2(state: WalletUM, itemModifier: Modifi
contentType = "OrganizeTokensButton",
) {
TangemButton(
organizeButton,
buttonUM = organizeButton,
modifier = itemModifier,
)
}

View file

@ -6,6 +6,7 @@ import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.material3.Text
@ -28,6 +29,7 @@ import com.tangem.core.ui.ds.button.SecondaryTangemButton
import com.tangem.core.ui.ds.button.TangemButtonShape
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.ds.image.TangemDeviceIcon
import com.tangem.core.ui.ds.placeholder.TextPlaceholder
import com.tangem.core.ui.ds.topbar.collapsing.TangemCollapsingAppBarBehavior
import com.tangem.core.ui.ds.topbar.collapsing.rememberTangemExitUntilCollapsedScrollBehavior
import com.tangem.core.ui.ds.topbar.collapsing.snapToExitUntilCollapsed
@ -75,7 +77,7 @@ internal fun WalletBalance(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
.padding(vertical = 58.dp),
) {
Balance(
walletBalanceUM = walletBalanceUM,
@ -112,44 +114,40 @@ private fun Balance(walletBalanceUM: WalletBalanceUM, isBalanceHidden: Boolean,
},
) { balanceUM ->
when (balanceUM) {
is WalletBalanceUM.Content -> {
Text(
text = balanceUM.balance.orMaskWithStars(isBalanceHidden).resolveAnnotatedReference(),
style = TangemTheme.typography2.titleRegular44.applyBladeBrush(
isEnabled = balanceUM.isBalanceFlickering,
textColor = TangemTheme.colors2.text.neutral.primary,
),
color = TangemTheme.colors2.text.neutral.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
autoSize = TextAutoSize.StepBased(
minFontSize = TangemTheme.typography2.bodySemibold15.fontSize,
maxFontSize = TangemTheme.typography2.titleRegular44.fontSize,
),
)
}
is WalletBalanceUM.Error -> {
Text(
text = StringsSigns.DASH_SIGN,
style = TangemTheme.typography2.titleRegular44,
color = TangemTheme.colors2.text.neutral.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
autoSize = TextAutoSize.StepBased(
minFontSize = TangemTheme.typography2.bodySemibold15.fontSize,
maxFontSize = TangemTheme.typography2.titleRegular44.fontSize,
),
)
}
is WalletBalanceUM.Loading,
-> {
TextShimmer(
text = "123456",
style = TangemTheme.typography2.titleRegular44,
radius = TangemTheme.dimens2.x25,
textSizeHeight = true,
)
}
is WalletBalanceUM.Content -> Text(
text = balanceUM.balance.orMaskWithStars(isBalanceHidden).resolveAnnotatedReference(),
style = TangemTheme.typography2.titleRegular44.applyBladeBrush(
isEnabled = balanceUM.isBalanceFlickering,
textColor = TangemTheme.colors2.text.neutral.primary,
),
color = TangemTheme.colors2.text.neutral.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
autoSize = TextAutoSize.StepBased(
minFontSize = TangemTheme.typography2.bodySemibold15.fontSize,
maxFontSize = TangemTheme.typography2.titleRegular44.fontSize,
),
)
is WalletBalanceUM.Error -> Text(
text = StringsSigns.DASH_SIGN,
style = TangemTheme.typography2.titleRegular44,
color = TangemTheme.colors2.text.neutral.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
autoSize = TextAutoSize.StepBased(
minFontSize = TangemTheme.typography2.bodySemibold15.fontSize,
maxFontSize = TangemTheme.typography2.titleRegular44.fontSize,
),
)
is WalletBalanceUM.Loading -> TextShimmer(
text = "123456",
style = TangemTheme.typography2.titleRegular44,
radius = TangemTheme.dimens2.x25,
)
is WalletBalanceUM.Empty -> TextPlaceholder(
textStyle = TangemTheme.typography2.titleRegular44,
width = 200.dp,
)
}
}
}
@ -162,6 +160,11 @@ private fun ActionButtons(buttons: ImmutableList<TangemButtonUM>) {
) {
buttons.fastForEach { button ->
key(button.text) {
val textColor = if (button.isEnabled) {
TangemTheme.colors2.text.neutral.primary
} else {
TangemTheme.colors2.text.status.disabled
}
Column(
modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x2_5),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
@ -170,12 +173,13 @@ private fun ActionButtons(buttons: ImmutableList<TangemButtonUM>) {
SecondaryTangemButton(
iconRes = button.iconRes,
onClick = button.onClick,
isEnabled = button.isEnabled,
shape = TangemButtonShape.Rounded,
)
Text(
text = button.text.orEmpty().resolveReference(),
style = TangemTheme.typography2.bodySemibold15,
color = TangemTheme.colors2.text.neutral.primary,
color = textColor,
)
}
}
@ -187,24 +191,34 @@ private fun ActionButtons(buttons: ImmutableList<TangemButtonUM>) {
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun WalletBalance_Preview(@PreviewParameter(WalletBalancePreviewProvider::class) params: WalletBalanceUM) {
private fun WalletBalance_Preview(
@PreviewParameter(WalletBalancePreviewProvider::class)
params: WalletBalancePreviewData,
) {
TangemThemePreviewRedesign {
WalletBalance(
walletBalanceUM = params,
walletBalanceUM = params.walletBalanceUM,
behavior = rememberTangemExitUntilCollapsedScrollBehavior(),
buttons = WalletPreviewData.actionButtons,
buttons = params.actionsList,
isBalanceHidden = false,
modifier = Modifier.background(TangemTheme.colors2.surface.level1),
)
}
}
private class WalletBalancePreviewProvider : PreviewParameterProvider<WalletBalanceUM> {
override val values: Sequence<WalletBalanceUM>
private data class WalletBalancePreviewData(
val walletBalanceUM: WalletBalanceUM,
val actionsList: ImmutableList<TangemButtonUM>,
)
private class WalletBalancePreviewProvider : PreviewParameterProvider<WalletBalancePreviewData> {
override val values: Sequence<WalletBalancePreviewData>
get() = sequenceOf(
WalletBalancePreview.content,
WalletBalancePreview.content.copy(isBalanceFlickering = true),
WalletBalancePreview.loading,
WalletBalancePreview.error,
WalletBalancePreviewData(WalletBalancePreview.content, WalletPreviewData.actionButtons),
WalletBalancePreviewData(WalletBalancePreview.hiddenBalanceContent, WalletPreviewData.actionButtons),
WalletBalancePreviewData(WalletBalancePreview.loading, WalletPreviewData.disabledActionButtons),
WalletBalancePreviewData(WalletBalancePreview.error, WalletPreviewData.disabledActionButtons),
WalletBalancePreviewData(WalletBalancePreview.empty, WalletPreviewData.disabledActionButtons),
)
}
// endregion

View file

@ -12,6 +12,7 @@ import androidx.compose.foundation.indication
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.material3.*
@ -164,6 +165,7 @@ private fun CardContainer(state: WalletCardState, isBalanceHidden: Boolean, item
}
AdditionalInfo(
text = additionalText,
showProgress = state.additionalInfo?.shouldShowProgress == true,
modifier = Modifier.conditional(
state.imageResId == null,
) { fillMaxWidth() },
@ -298,7 +300,7 @@ private fun Modifier.nonContentBalanceSize(dimens: TangemDimens): Modifier {
}
@Composable
private fun AdditionalInfo(text: TextReference?, modifier: Modifier = Modifier) {
private fun AdditionalInfo(text: TextReference?, showProgress: Boolean, modifier: Modifier = Modifier) {
AnimatedContent(
targetState = text,
label = "Update the additional text",
@ -309,7 +311,19 @@ private fun AdditionalInfo(text: TextReference?, modifier: Modifier = Modifier)
},
) { animatedText ->
if (animatedText != null) {
AdditionalInfoText(text = animatedText)
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
AdditionalInfoText(text = animatedText)
if (showProgress) {
CircularProgressIndicator(
modifier = Modifier
.size(TangemTheme.dimens.size16),
color = TangemTheme.colors.icon.accent,
strokeWidth = TangemTheme.dimens.size2,
)
}
}
} else {
RectangleShimmer(modifier = Modifier.nonContentAdditionalInfoSize(dimens = TangemTheme.dimens))
}

View file

@ -15,6 +15,7 @@ import com.tangem.common.ui.notifications.notificationsCarousel
import com.tangem.core.ui.components.transactions.state.TxHistoryState
import com.tangem.core.ui.components.transactions.txHistoryItems
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.TangemSharedTransitionLayout
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletState
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletUM
import com.tangem.feature.wallet.presentation.wallet.ui.components.multicurrency.tokensListItems
@ -37,39 +38,40 @@ internal fun WalletListContent(
val movableItemModifier = Modifier.padding(horizontal = TangemTheme.dimens2.x3)
val itemModifier = movableItemModifier.padding(top = TangemTheme.dimens2.x3)
LazyColumn(
modifier = modifier,
state = listState,
contentPadding = contentPadding,
horizontalAlignment = Alignment.CenterHorizontally,
overscrollEffect = rememberOverscrollEffect(),
) {
notifications(
notifications = currentWallet.notifications.map { it.messageUM }.toPersistentList(),
contentColor = containerColor,
modifier = movableItemModifier,
)
notificationsCarousel(
containerColor = containerColor,
modifier = movableItemModifier,
notifications = currentWallet.notificationsCarousel.map { it.messageUM }.toPersistentList(),
)
TangemSharedTransitionLayout(modifier) {
LazyColumn(
state = listState,
contentPadding = contentPadding,
horizontalAlignment = Alignment.CenterHorizontally,
overscrollEffect = rememberOverscrollEffect(),
) {
notifications(
notifications = currentWallet.notifications.map { it.messageUM }.toPersistentList(),
contentColor = containerColor,
modifier = movableItemModifier,
)
notificationsCarousel(
containerColor = containerColor,
modifier = movableItemModifier,
notifications = currentWallet.notificationsCarousel.map { it.messageUM }.toPersistentList(),
)
tangemPay(
walletUM = currentWallet,
isBalanceHiding = isBalanceHidden,
modifier = itemModifier,
)
tangemPay(
walletUM = currentWallet,
isBalanceHiding = isBalanceHidden,
modifier = itemModifier,
)
tokensListItems2(
walletTokensListUM = currentWallet.tokensListUM,
modifier = movableItemModifier,
isBalanceHidden = isBalanceHidden,
)
tokensListItems2(
walletTokensListUM = currentWallet.tokensListUM,
modifier = movableItemModifier,
isBalanceHidden = isBalanceHidden,
)
nftCollections2(state = currentWallet, itemModifier = itemModifier)
nftCollections2(state = currentWallet, itemModifier = itemModifier)
organizeTokens2(state = currentWallet, itemModifier = itemModifier)
organizeTokens2(state = currentWallet, itemModifier = itemModifier)
}
}
}

View file

@ -1,49 +1,71 @@
package com.tangem.feature.wallet.presentation.wallet.ui.components.common
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.PagerState
import androidx.compose.material3.pulltorefresh.PullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.containers.pullToRefresh.getPullToRefreshIndicatorOffset
import com.tangem.core.ui.ds.TangemPagerIndicator
import com.tangem.core.ui.ds.topbar.collapsing.TangemCollapsingAppBarBehavior
private const val MIN_SCALE = 0.75f
private const val MAX_SCALE = 1f
private const val WALLET_INDICATOR_OFFSET = 0.6f
@Composable
internal fun WalletPagerIndicator(pagerState: PagerState, behavior: TangemCollapsingAppBarBehavior) {
internal fun WalletPagerIndicator(
pagerState: PagerState,
behavior: TangemCollapsingAppBarBehavior,
pullToRefreshConfig: PullToRefreshConfig?,
pullToRefreshState: PullToRefreshState,
) {
val collapsedFraction = behavior.state.collapsedFraction
val alpha = MAX_SCALE - collapsedFraction
val scale = alpha.coerceIn(MIN_SCALE, MAX_SCALE)
val height = with(LocalDensity.current) {
behavior.state.heightOffsetLimit.toDp().unaryMinus()
}
Box(
modifier = Modifier
.graphicsLayer {
scaleY = scale
translationY = behavior.state.heightOffset
}
.fillMaxWidth()
.height(
with(LocalDensity.current) {
behavior.state.heightOffsetLimit.toDp().unaryMinus()
},
)
.alpha(alpha),
val contentOffset = getPullToRefreshIndicatorOffset(
pullToRefreshConfig = pullToRefreshConfig,
pullToRefreshState = pullToRefreshState,
)
val padding = height * WALLET_INDICATOR_OFFSET
AnimatedVisibility(
visible = pagerState.pageCount > 1,
enter = fadeIn(),
exit = fadeOut(),
) {
TangemPagerIndicator(
pagerState = pagerState,
Box(
modifier = Modifier
.padding(top = 248.dp)
.scale(scaleY = 1f, scaleX = scale)
.fillMaxWidth(),
)
.graphicsLayer {
scaleY = scale
translationY = behavior.state.heightOffset + contentOffset.toPx()
}
.fillMaxWidth()
.height(height)
.alpha(alpha),
) {
TangemPagerIndicator(
pagerState = pagerState,
modifier = Modifier
.padding(top = padding)
.scale(scaleY = 1f, scaleX = scale)
.fillMaxWidth(),
)
}
}
}

View file

@ -1,22 +1,33 @@
package com.tangem.feature.wallet.presentation.wallet.ui.components.common
import android.content.res.Configuration
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.haze.hazeEffectTangem
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.ds.topbar.collapsing.TangemCollapsingAppBarBehavior
import com.tangem.core.ui.ds.topbar.collapsing.rememberTangemExitUntilCollapsedScrollBehavior
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.LocalRootBackgroundColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.res.TangemThemePreviewRedesign
@ -25,6 +36,8 @@ import com.tangem.feature.wallet.impl.R
import com.tangem.feature.wallet.presentation.common.WalletPreviewDataLegacy
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletTopBarConfig
import dev.chrisbanes.haze.HazeProgressive
import dev.chrisbanes.haze.HazeTint
import kotlinx.collections.immutable.persistentListOf
private const val VISIBILITY_THRESHOLD = 0.5f
@ -44,26 +57,44 @@ internal fun WalletTopBar(
Surface(
color = Color.Unspecified,
contentColor = Color.Unspecified,
modifier = Modifier.hazeEffectTangem {
progressive = HazeProgressive.verticalGradient(startIntensity = 1f, endIntensity = 0f)
},
modifier = Modifier.hazeEffectTangemTopBar(behavior),
) {
val wrappedBalance = remember(behavior.state.collapsedFraction) {
if (behavior.state.collapsedFraction > VISIBILITY_THRESHOLD) walletBalance else null
val isWrappedBalanceShown by remember {
derivedStateOf { behavior.state.collapsedFraction > VISIBILITY_THRESHOLD }
}
val wrappedBalance = remember(walletBalance, isWrappedBalanceShown) {
walletBalance.takeIf { isWrappedBalanceShown }
}
TangemTopBar(
title = wrappedBalance,
startActionUM = TangemTopBarActionUM(
iconRes = R.drawable.ic_tangem_24,
isActionable = false,
),
endActionUM = TangemTopBarActionUM(
iconRes = R.drawable.ic_more_default_24,
isActionable = true,
onClick = topBarConfig.onDetailsClick,
ghostModeProgress = behavior.state.collapsedFraction,
),
startContent = {
TangemTopBarActionContent(
TangemTopBarActionUM(
iconRes = R.drawable.ic_tangem_24,
isActionable = false,
),
)
},
endContent = {
Row(
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x5),
modifier = Modifier
.clip(CircleShape)
.background(
lerp(
start = Color.Transparent,
stop = TangemTheme.colors2.button.backgroundSecondary,
fraction = behavior.state.collapsedFraction,
),
),
) {
topBarConfig.endActions.forEach { action ->
TangemTopBarActionContent(action)
}
}
},
modifier = Modifier
.statusBarsPadding()
.testTag(MainScreenTestTags.TOP_BAR),
@ -85,8 +116,16 @@ internal fun WalletTopBar(config: WalletTopBarConfig) {
Icon(painter = painterResource(id = R.drawable.img_tangem_logo_90_24), contentDescription = null)
},
actions = {
IconButton(onClick = config.onDetailsClick, modifier = Modifier.testTag(MainScreenTestTags.MORE_BUTTON)) {
Icon(painter = painterResource(id = R.drawable.ic_more_vertical_24), contentDescription = null)
config.endActions.forEach { action ->
action.onClick?.let { onClick ->
IconButton(onClick = onClick) {
Icon(
painter = painterResource(id = action.iconRes),
contentDescription = null,
modifier = Modifier.testTag(MainScreenTestTags.MORE_BUTTON),
)
}
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
@ -99,6 +138,21 @@ internal fun WalletTopBar(config: WalletTopBarConfig) {
)
}
@Composable
private fun Modifier.hazeEffectTangemTopBar(behavior: TangemCollapsingAppBarBehavior): Modifier {
val rootBackground by LocalRootBackgroundColor.current
val intensity by animateFloatAsState(targetValue = behavior.state.collapsedFraction * 2f)
return hazeEffectTangem {
fallbackTint = HazeTint(rootBackground.copy(alpha = intensity / 2))
progressive = HazeProgressive.verticalGradient(
startIntensity = intensity,
endIntensity = 0f,
preferPerformance = true,
)
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
@ -115,7 +169,31 @@ private fun Preview_WalletTopBar() {
private fun WalletTopBar_Preview() {
TangemThemePreviewRedesign {
WalletTopBar(
topBarConfig = WalletTopBarConfig(onDetailsClick = {}),
topBarConfig = WalletTopBarConfig(),
walletBalance = stringReference("$ 8923,05"),
behavior = rememberTangemExitUntilCollapsedScrollBehavior(),
)
}
}
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun WalletTopBar_WithQrButton_Preview() {
TangemThemePreviewRedesign {
WalletTopBar(
topBarConfig = WalletTopBarConfig(
endActions = persistentListOf(
TangemTopBarActionUM(
iconRes = R.drawable.ic_qrcode_scaner_24,
onClick = {},
),
TangemTopBarActionUM(
iconRes = R.drawable.ic_more_default_24,
onClick = {},
),
),
),
walletBalance = stringReference("$ 8923,05"),
behavior = rememberTangemExitUntilCollapsedScrollBehavior(),
)

View file

@ -2,6 +2,9 @@ package com.tangem.feature.wallet.presentation.wallet.ui.components.multicurrenc
import androidx.compose.animation.*
import androidx.compose.animation.core.FastOutLinearInEasing
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.animateIntAsState
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
@ -26,7 +29,6 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.test.MainScreenTestTags
import com.tangem.core.ui.utils.lazyListItemPosition
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.delay
internal fun LazyListScope.portfolioContentItems(
items: ImmutableList<TokensListItemUM.Portfolio>,
@ -155,18 +157,17 @@ private fun LazyListScope.portfolioItem(
key = "account-${portfolio.id}",
contentType = "account-content",
) {
var lastIndexProxy by remember { mutableIntStateOf(lastIndex) }
// When collapsing the portfolio, we delay updating lastIndexProxy to allow
// shrinking animation to complete before changing the shape.
LaunchedEffect(lastIndex) {
if (lastIndex != 0) {
lastIndexProxy = lastIndex
return@LaunchedEffect
}
delay(timeMillis = minOf(50 * tokens.size, 250).toLong())
lastIndexProxy = 0
}
// Snap immediately on expand; on collapse, hold until all child items finish
// their shrink animation, then snap to fully-rounded shape.
val effectiveLastIndex by animateIntAsState(
targetValue = lastIndex,
animationSpec = if (lastIndex != 0) {
snap()
} else {
snap(delayMillis = minOf(50 * tokens.lastIndex, 250) + 150)
},
label = "lastIndex",
)
PortfolioListItem(
state = portfolio,
@ -176,7 +177,7 @@ private fun LazyListScope.portfolioItem(
.roundedShapeItemDecoration(
currentIndex = 0,
radius = TangemTheme.dimens.radius14,
lastIndex = lastIndexProxy,
lastIndex = effectiveLastIndex,
backgroundColor = TangemTheme.colors.background.primary,
),
)
@ -199,18 +200,14 @@ internal fun SlideInItemVisibility(
AnimatedVisibility(
modifier = modifier,
visible = visible,
enter = fadeIn(
tween(100, delayMillis = delayEnter),
) + expandVertically(
tween(100, delayMillis = delayEnter, easing = FastOutLinearInEasing),
enter = expandVertically(
tween(200, delayMillis = delayEnter, easing = LinearOutSlowInEasing),
expandFrom = Alignment.Top,
),
exit = fadeOut(
tween(100, delayMillis = delayExit),
) + shrinkVertically(
tween(100, delayMillis = delayExit, easing = FastOutLinearInEasing),
) + fadeIn(tween(200, delayMillis = delayEnter, easing = LinearOutSlowInEasing)),
exit = shrinkVertically(
tween(150, delayMillis = delayExit, easing = FastOutLinearInEasing),
shrinkTowards = Alignment.Top,
),
) + fadeOut(tween(150, delayMillis = delayExit, easing = FastOutLinearInEasing)),
) {
content()
}

View file

@ -3,6 +3,8 @@ package com.tangem.feature.wallet.presentation.wallet.ui.components.multicurrenc
import androidx.compose.animation.*
import androidx.compose.animation.SharedTransitionScope.ResizeMode.Companion.scaleToBounds
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.animateIntAsState
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@ -15,6 +17,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
@ -23,12 +26,17 @@ import androidx.compose.ui.text.lerp
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEachIndexed
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.account.AccountIconSize
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.tokenlist.TokenListItem
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.ds.button.PrimaryInverseTangemButton
import com.tangem.core.ui.ds.button.TangemButtonShape
import com.tangem.core.ui.ds.button.TangemButtonSize
import com.tangem.core.ui.ds.image.TangemIcon
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.row.header.TangemHeaderRow
import com.tangem.core.ui.ds.row.header.TangemHeaderRowUM
import com.tangem.core.ui.ds.row.internal.TangemRowTailUM
@ -95,6 +103,7 @@ internal fun LazyListScope.tokensListItems2(
when (walletTokensListUM) {
is WalletTokensListUM.Loading,
is WalletTokensListUM.Content,
WalletTokensListUM.Locked,
-> {
walletTokensListUM.tokenList.fastForEachIndexed { index, listItem ->
when (listItem) {
@ -116,7 +125,10 @@ internal fun LazyListScope.tokensListItems2(
}
}
}
WalletTokensListUM.Empty -> nonContentItem(modifier = modifier)
is WalletTokensListUM.Empty -> nonContentItem2(
onEmptyClick = walletTokensListUM.onEmptyClick,
modifier = modifier,
)
}
}
@ -164,7 +176,11 @@ private fun LazyListScope.portfolioItem(
isBalanceHidden: Boolean,
modifier: Modifier,
) {
val lastIndex = listItem.tokenList.lastIndex + 1
val lastIndex = if (listItem.tokenList.isEmpty()) {
listItem.tokenList.lastIndex + 2
} else {
listItem.tokenList.lastIndex + 1
}
accountItem(
listItem = listItem,
@ -173,47 +189,57 @@ private fun LazyListScope.portfolioItem(
lastIndex = lastIndex,
isBalanceHidden = isBalanceHidden,
)
itemsIndexed(
items = listItem.tokenList,
key = { _, item -> item.tokenRowUM.id },
contentType = { _, item -> item::class.java },
itemContent = { tokenIndex, item ->
SlideInItemVisibility(
currentIndex = tokenIndex + 1,
lastIndex = lastIndex,
modifier = modifier
.animateItem(fadeInSpec = null, placementSpec = null, fadeOutSpec = null)
.roundedShapeItemDecoration(
radius = 18.dp,
currentIndex = tokenIndex + 1,
addDefaultPadding = false,
lastIndex = lastIndex,
backgroundColor = TangemTheme.colors2.surface.level3,
),
visible = listItem.isExpanded,
) {
val itemModifier = Modifier
.testTag(MainScreenTestTags.TOKEN_LIST_ITEM)
.semantics { lazyListItemPosition = tokenIndex + 1 }
if (listItem.tokenList.isEmpty()) {
nonContentAccountItem(
listItem = listItem,
index = index,
lastIndex = lastIndex,
modifier = modifier,
)
} else {
itemsIndexed(
items = listItem.tokenList,
key = { _, item -> item.tokenRowUM.id },
contentType = { _, item -> item::class.java },
itemContent = { tokenIndex, item ->
SlideInItemVisibility(
currentIndex = tokenIndex + 1,
lastIndex = lastIndex,
modifier = modifier
.animateItem(fadeInSpec = null, placementSpec = null, fadeOutSpec = null)
.roundedShapeItemDecoration(
radius = 18.dp,
currentIndex = tokenIndex + 1,
addDefaultPadding = false,
lastIndex = lastIndex,
backgroundColor = TangemTheme.colors2.surface.level3,
),
visible = listItem.isExpanded,
) {
val itemModifier = Modifier
.testTag(MainScreenTestTags.TOKEN_LIST_ITEM)
.semantics { lazyListItemPosition = tokenIndex + 1 }
when (val tokenRowUM = item.tokenRowUM) {
is TangemTokenRowUM -> TangemTokenRow(
tokenRowUM = tokenRowUM,
isBalanceHidden = isBalanceHidden,
reorderableState = null,
modifier = itemModifier,
)
is TangemHeaderRowUM -> TangemHeaderRow(
headerRowUM = tokenRowUM,
isBalanceHidden = isBalanceHidden,
modifier = itemModifier,
)
when (val tokenRowUM = item.tokenRowUM) {
is TangemTokenRowUM -> TangemTokenRow(
tokenRowUM = tokenRowUM,
isBalanceHidden = isBalanceHidden,
reorderableState = null,
modifier = itemModifier,
)
is TangemHeaderRowUM -> TangemHeaderRow(
headerRowUM = tokenRowUM,
isBalanceHidden = isBalanceHidden,
modifier = itemModifier,
)
}
}
}
},
)
},
)
}
}
@Suppress("MagicNumber")
private fun LazyListScope.accountItem(
listItem: TokensListItemUM2.Portfolio,
modifier: Modifier,
@ -225,6 +251,18 @@ private fun LazyListScope.accountItem(
key = listItem.tokenRowUM.id,
contentType = listItem.tokenRowUM::class.java,
) {
// Snap immediately on expand; on collapse, hold the current value until all
// child items finish their shrink animation, then snap to fully-rounded shape.
val effectiveLastIndex by animateIntAsState(
targetValue = if (listItem.isExpanded) lastIndex else 0,
animationSpec = if (listItem.isExpanded) {
snap()
} else {
snap(delayMillis = minOf(50 * maxOf(listItem.tokenList.lastIndex, 0), 250) + 150)
},
label = "lastIndex",
)
val portfolioModifier = modifier
.padding(top = if (index != 0) TangemTheme.dimens2.x2 else TangemTheme.dimens2.x3)
.testTag(MainScreenTestTags.TOKEN_LIST_ITEM)
@ -233,7 +271,7 @@ private fun LazyListScope.accountItem(
currentIndex = 0,
radius = 18.dp,
addDefaultPadding = false,
lastIndex = if (listItem.isExpanded) lastIndex else 0,
lastIndex = effectiveLastIndex,
backgroundColor = TangemTheme.colors2.surface.level3,
)
if (listItem.isCollapsable) {
@ -288,10 +326,9 @@ internal fun PortfolioRowItem(
isBalanceHidden: Boolean,
modifier: Modifier = Modifier,
) {
// TangemSharedTransitionLayout {
ProvideSharedTransitionScope(modifier) {
val iconSharedContentState = rememberSharedContentState(key = "icon")
val titleSharedContentState = rememberSharedContentState(key = "title")
val iconSharedContentState = rememberSharedContentState(key = "icon_${item.tokenRowUM.id}")
val titleSharedContentState = rememberSharedContentState(key = "title_${item.tokenRowUM.id}")
val boundsTransform = BoundsTransform { _, _ -> tween(250) }
AnimatedContent(
@ -307,22 +344,30 @@ internal fun PortfolioRowItem(
SharedTokenRowComposables(
icon = { modifier ->
val size = if (isExpandedWrapped) AccountIconSize.ExtraSmall else AccountIconSize.Default
val currencyIconState =
when (val currencyIconState = item.tokenRowUM.headIconUM.currencyIconState) {
is CurrencyIconState.CryptoPortfolio.Icon ->
currencyIconState.copy(size = size)
is CurrencyIconState.CryptoPortfolio.Letter ->
currencyIconState.copy(size = size)
else -> currencyIconState
}
val headIcon = item.tokenRowUM.headIconUM
val sizedHeadIcon = if (headIcon is TangemIconUM.Currency) {
headIcon.copy(
currencyIconState = when (val currencyIconState = headIcon.currencyIconState) {
is CurrencyIconState.CryptoPortfolio.Icon -> currencyIconState.copy(size = size)
is CurrencyIconState.CryptoPortfolio.Letter -> currencyIconState.copy(size = size)
else -> currencyIconState
},
)
} else {
headIcon
}
TangemIcon(
tangemIconUM = item.tokenRowUM.headIconUM.copy(currencyIconState = currencyIconState),
modifier = modifier.sharedBounds(
sharedContentState = iconSharedContentState,
animatedVisibilityScope = animatedContentScope,
boundsTransform = boundsTransform,
),
tangemIconUM = sizedHeadIcon,
modifier = modifier
.conditionalCompose(headIcon is TangemIconUM.Empty) {
size(TangemTheme.dimens2.x9)
}
.sharedBounds(
sharedContentState = iconSharedContentState,
animatedVisibilityScope = animatedContentScope,
boundsTransform = boundsTransform,
),
)
},
title = { modifier ->
@ -382,7 +427,6 @@ internal fun PortfolioRowItem(
)
}
}
// }
}
}
@ -405,6 +449,54 @@ private fun LazyListScope.nonContentItem(modifier: Modifier = Modifier) {
}
}
private fun LazyListScope.nonContentItem2(onEmptyClick: () -> Unit, modifier: Modifier = Modifier) {
item(
key = NON_CONTENT_TOKENS_LIST_KEY,
contentType = NON_CONTENT_TOKENS_LIST_KEY,
) {
NonContentItemContentV2(
onClick = onEmptyClick,
textColor = TangemTheme.colors2.text.neutral.tertiary,
modifier = modifier
.animateItem(fadeInSpec = null, fadeOutSpec = null)
.padding(top = 46.dp),
)
}
}
private fun LazyListScope.nonContentAccountItem(
listItem: TokensListItemUM2.Portfolio,
index: Int,
lastIndex: Int,
modifier: Modifier = Modifier,
) {
item(
key = "$NON_CONTENT_TOKENS_LIST_KEY account-${listItem.tokenRowUM.id}",
contentType = "$NON_CONTENT_TOKENS_LIST_KEY account-${listItem.tokenRowUM.id}",
) {
SlideInItemVisibility(
currentIndex = index + 1,
lastIndex = lastIndex,
modifier = modifier
.animateItem(fadeInSpec = null, placementSpec = null, fadeOutSpec = null)
.roundedShapeItemDecoration(
radius = 18.dp,
addDefaultPadding = false,
currentIndex = 1,
lastIndex = 1,
backgroundColor = TangemTheme.colors2.surface.level3,
),
visible = listItem.isExpanded,
) {
NonContentItemContentV2(
modifier = Modifier.padding(vertical = 36.dp),
textColor = TangemTheme.colors2.text.neutral.secondary,
onClick = listItem.onEmptyClick,
)
}
}
}
@Composable
internal fun NonContentItemContent(modifier: Modifier = Modifier) {
Column(
@ -427,4 +519,35 @@ internal fun NonContentItemContent(modifier: Modifier = Modifier) {
style = TangemTheme.typography.caption2,
)
}
}
@Composable
internal fun NonContentItemContentV2(textColor: Color, modifier: Modifier = Modifier, onClick: () -> Unit) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
painter = painterResource(id = R.drawable.ic_empty_64),
contentDescription = null,
modifier = Modifier.size(size = TangemTheme.dimens2.x16),
tint = TangemTheme.colors2.graphic.neutral.quaternary,
)
SpacerH(TangemTheme.dimens2.x5)
Text(
text = stringResourceSafe(id = R.string.main_empty_tokens_list_message),
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing48),
color = textColor,
textAlign = TextAlign.Center,
style = TangemTheme.typography2.bodyRegular14,
)
SpacerH(TangemTheme.dimens2.x2)
PrimaryInverseTangemButton(
text = resourceReference(id = R.string.common_add_tokens),
onClick = onClick,
size = TangemButtonSize.X8,
shape = TangemButtonShape.Rounded,
modifier = Modifier,
)
}
}

View file

@ -5,7 +5,7 @@ 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.components.bottomsheets.message.MessageBottomSheetV2
import com.tangem.core.ui.components.bottomsheets.message.MessageBottomSheet
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.domain.models.wallet.UserWalletId
import dagger.assisted.Assisted
@ -26,7 +26,7 @@ internal class KycRejectedComponent @AssistedInject constructor(
@Composable
override fun BottomSheet() {
val state by model.uiState.collectAsStateWithLifecycle()
MessageBottomSheetV2(state = state, onDismissRequest = ::dismiss)
MessageBottomSheet(state = state, onDismissRequest = ::dismiss)
}
data class Params(

View file

@ -27,15 +27,15 @@ internal class KycRejectedModel @Inject constructor(
private val params = paramsContainer.require<KycRejectedComponent.Params>()
val uiState: StateFlow<MessageBottomSheetUMV2>
val uiState: StateFlow<MessageBottomSheetUM>
field = MutableStateFlow(getInitialState())
private fun getInitialState(): MessageBottomSheetUMV2 {
private fun getInitialState(): MessageBottomSheetUM {
return bottomSheetMessage {
infoBlock {
icon(com.tangem.core.ui.R.drawable.ic_heart_broken_32) {
type = MessageBottomSheetUMV2.Icon.Type.Warning
backgroundType = MessageBottomSheetUMV2.Icon.BackgroundType.Warning
type = MessageBottomSheetUM.Icon.Type.Warning
backgroundType = MessageBottomSheetUM.Icon.BackgroundType.Warning
}
title = resourceReference(R.string.tangempay_kyc_rejected)
body = combinedReference(
@ -68,7 +68,7 @@ internal class KycRejectedModel @Inject constructor(
onDismiss()
}
}
}.messageBottomSheetUMV2
}.messageBottomSheetUM
}
fun onDismiss() {

View file

@ -6,7 +6,7 @@ import com.tangem.common.ui.userwallet.state.UserWalletItemUM
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.account.status.usecase.GetWalletTotalBalanceUseCaseV2
import com.tangem.domain.account.status.usecase.GetWalletTotalBalanceUseCase
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.error.SelectedAppCurrencyError
import com.tangem.domain.appcurrency.model.AppCurrency
@ -37,7 +37,7 @@ import kotlinx.coroutines.flow.*
@Suppress("LongParameterList")
internal class DefaultUserWalletsFetcher @AssistedInject constructor(
getWalletsUseCase: GetWalletsUseCase,
private val getWalletTotalBalanceUseCaseV2: GetWalletTotalBalanceUseCaseV2,
private val getWalletTotalBalanceUseCase: GetWalletTotalBalanceUseCase,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
@Assisted private val onWalletClick: (UserWalletId) -> Unit,
@ -99,7 +99,7 @@ internal class DefaultUserWalletsFetcher @AssistedInject constructor(
// We should not load balances in auth mode
flowOf(Lce.Loading(walletIds.associateWith { TotalFiatBalance.Loading }))
} else {
getWalletTotalBalanceUseCaseV2(userWalletIds = walletIds)
getWalletTotalBalanceUseCase(userWalletIds = walletIds)
}
}

View file

@ -0,0 +1,303 @@
package com.tangem.feature.wallet.presentation.wallet.qr
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import io.mockk.every
import io.mockk.mockk
import org.junit.Test
import java.math.BigDecimal
internal class QrContentClassifierTest {
private val blockchainDataProvider = mockk<QrContentClassifier.BlockchainDataProvider> {
every { getShareSchemes(any()) } returns emptyList()
every { validateAddress(any(), any()) } returns false
}
private val classifier = QrContentClassifier(blockchainDataProvider)
// region WalletConnect
@Test
fun `WalletConnect URI is classified correctly`() {
val uri = "wc:a4f86d5-72ac-46ad-a1aa-9e@1-f5c0c@2"
val result = classifier.classify(uri, listOf(bitcoinCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.WalletConnect::class.java)
assertThat((result as ClassifiedQrContent.WalletConnect).uri).isEqualTo(uri)
}
@Test
fun `WalletConnect URI takes priority over address matching`() {
val uri = "wc:something"
every { blockchainDataProvider.validateAddress(any(), uri) } returns true
val result = classifier.classify(uri, listOf(bitcoinCoin, ethereumCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.WalletConnect::class.java)
}
@Test
fun `dApp URL with wc uri query param is classified as WalletConnect`() {
val dAppUrl = "https://uniswap.org/app/wc?uri=wc:6ea45@2?relay-protocol=irn&symKey=f2de&expiryTimestamp=123"
val result = classifier.classify(dAppUrl, listOf(bitcoinCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.WalletConnect::class.java)
assertThat((result as ClassifiedQrContent.WalletConnect).uri).isEqualTo(dAppUrl)
}
@Test
fun `HTTP URL without wc uri param is not classified as WalletConnect`() {
val url = "https://example.com/page?foo=bar"
val result = classifier.classify(url, listOf(bitcoinCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java)
}
@Test
fun `HTTP URL with uri param not starting with wc is not WalletConnect`() {
val url = "https://example.com/page?uri=https://other.com"
val result = classifier.classify(url, listOf(bitcoinCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java)
}
// endregion
// region PaymentUri
@Test
fun `Bitcoin BIP-021 URI with amount is parsed`() {
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.5"
val result = classifier.classify(qr, listOf(bitcoinCoin, ethereumCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.PaymentUri::class.java)
val paymentUri = result as ClassifiedQrContent.PaymentUri
assertThat(paymentUri.currency).isEqualTo(bitcoinCoin)
assertThat(paymentUri.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa")
assertThat(paymentUri.amount).isEqualTo(BigDecimal("0.5"))
assertThat(paymentUri.memo).isNull()
}
@Test
fun `Bitcoin URI without params returns address only`() {
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"
val result = classifier.classify(qr, listOf(bitcoinCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.PaymentUri::class.java)
val paymentUri = result as ClassifiedQrContent.PaymentUri
assertThat(paymentUri.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa")
assertThat(paymentUri.amount).isNull()
assertThat(paymentUri.memo).isNull()
}
@Test
fun `Bitcoin URI with message param is parsed as memo`() {
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=1.0&message=test%20memo"
val result = classifier.classify(qr, listOf(bitcoinCoin))
val paymentUri = result as ClassifiedQrContent.PaymentUri
assertThat(paymentUri.amount).isEqualTo(BigDecimal("1.0"))
assertThat(paymentUri.memo).isEqualTo("test memo")
}
@Test
fun `URI with memo parameter is parsed`() {
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?memo=hello"
val result = classifier.classify(qr, listOf(bitcoinCoin))
val paymentUri = result as ClassifiedQrContent.PaymentUri
assertThat(paymentUri.memo).isEqualTo("hello")
}
@Test
fun `Ethereum ERC-681 URI with chain_id and function is parsed`() {
every { blockchainDataProvider.getShareSchemes(ethereumCoin.network) } returns listOf("ethereum:")
val qr = "ethereum:0x1234567890abcdef1234567890abcdef12345678@1/transfer?amount=1.5"
val result = classifier.classify(qr, listOf(ethereumCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.PaymentUri::class.java)
val paymentUri = result as ClassifiedQrContent.PaymentUri
assertThat(paymentUri.address).isEqualTo("0x1234567890abcdef1234567890abcdef12345678")
assertThat(paymentUri.amount).isEqualTo(BigDecimal("1.5"))
}
@Test
fun `URI scheme not matching user currencies falls through`() {
val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"
val result = classifier.classify(qr, listOf(ethereumCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java)
}
@Test
fun `Longest matching scheme is preferred`() {
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns
listOf("bitcoin:", "bitcoin://")
val qr = "bitcoin://1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"
val result = classifier.classify(qr, listOf(bitcoinCoin))
val paymentUri = result as ClassifiedQrContent.PaymentUri
assertThat(paymentUri.address).isEqualTo("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa")
}
// endregion
// region PlainAddress
@Test
fun `Plain address matches single currency`() {
val address = "0x1234567890abcdef1234567890abcdef12345678"
every { blockchainDataProvider.validateAddress(ethereumCoin.network, address) } returns true
val result = classifier.classify(address, listOf(ethereumCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.PlainAddress::class.java)
val plain = result as ClassifiedQrContent.PlainAddress
assertThat(plain.address).isEqualTo(address)
assertThat(plain.matchingCurrencies).containsExactly(ethereumCoin)
}
@Test
fun `Plain address matches multiple currencies`() {
val address = "0x1234567890abcdef1234567890abcdef12345678"
every { blockchainDataProvider.validateAddress(ethereumCoin.network, address) } returns true
every { blockchainDataProvider.validateAddress(bscCoin.network, address) } returns true
val result = classifier.classify(address, listOf(ethereumCoin, bscCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.PlainAddress::class.java)
val plain = result as ClassifiedQrContent.PlainAddress
assertThat(plain.matchingCurrencies).hasSize(2)
}
// endregion
// region Unknown
@Test
fun `Random string returns Unknown`() {
val result = classifier.classify("hello world", listOf(bitcoinCoin, ethereumCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java)
assertThat((result as ClassifiedQrContent.Unknown).raw).isEqualTo("hello world")
}
@Test
fun `Empty string returns Unknown`() {
val result = classifier.classify("", listOf(bitcoinCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java)
}
@Test
fun `Empty currencies list returns Unknown`() {
val result = classifier.classify("0x1234567890abcdef1234567890abcdef12345678", emptyList())
assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java)
}
// endregion
// region Edge cases
@Test
fun `Tokens are filtered out, only Coins are used`() {
val token = CryptoCurrency.Token(
id = CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkId("ethereum"),
suffix = CryptoCurrency.ID.Suffix.RawID("ethereum"),
),
network = buildNetwork("ethereum"),
name = "USDT",
symbol = "USDT",
decimals = 6,
iconUrl = null,
isCustom = false,
contractAddress = "0xdAC17F958D2ee523a2206206994597C13D831ec7",
)
val result = classifier.classify("0x1234", listOf(token))
assertThat(result).isInstanceOf(ClassifiedQrContent.Unknown::class.java)
}
@Test
fun `Duplicate coins with same network are deduplicated`() {
val address = "0x1234567890abcdef1234567890abcdef12345678"
every { blockchainDataProvider.validateAddress(ethereumCoin.network, address) } returns true
val result = classifier.classify(address, listOf(ethereumCoin, ethereumCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.PlainAddress::class.java)
val plain = result as ClassifiedQrContent.PlainAddress
assertThat(plain.matchingCurrencies).hasSize(1)
}
@Test
fun `Payment URI takes priority over plain address match`() {
every { blockchainDataProvider.getShareSchemes(bitcoinCoin.network) } returns listOf("bitcoin:")
every { blockchainDataProvider.validateAddress(bitcoinCoin.network, any()) } returns true
val qr = "bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.1"
val result = classifier.classify(qr, listOf(bitcoinCoin))
assertThat(result).isInstanceOf(ClassifiedQrContent.PaymentUri::class.java)
}
// endregion
// region Helpers
private val bitcoinCoin = buildCoin("bitcoin")
private val ethereumCoin = buildCoin("ethereum")
private val bscCoin = buildCoin("bsc")
private fun buildCoin(rawNetworkId: String): CryptoCurrency.Coin {
return CryptoCurrency.Coin(
id = CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId),
suffix = CryptoCurrency.ID.Suffix.RawID(rawNetworkId),
),
network = buildNetwork(rawNetworkId),
name = rawNetworkId,
symbol = rawNetworkId.take(3).uppercase(),
decimals = 8,
iconUrl = null,
isCustom = false,
)
}
private fun buildNetwork(rawNetworkId: String): Network {
return Network(
id = Network.ID(Network.RawID(rawNetworkId), Network.DerivationPath.None),
backendId = rawNetworkId,
name = rawNetworkId,
currencySymbol = rawNetworkId.take(3).uppercase(),
derivationPath = Network.DerivationPath.None,
isTestnet = false,
standardType = Network.StandardType.Unspecified("UNSPECIFIED"),
hasFiatFeeRate = false,
canHandleTokens = false,
transactionExtrasType = Network.TransactionExtrasType.NONE,
nameResolvingType = Network.NameResolvingType.NONE,
)
}
// endregion
}