Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-20 12:47:25 +03:00
commit c50840894f
739 changed files with 27664 additions and 4701 deletions

View file

@ -24,6 +24,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.ui.TokenDetails
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.TokenDetailsScreenLegacy
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.ChooseAddressBottomSheetComponent
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.CloreMigrationBottomSheetComponent
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet.DynamicAddressesBottomSheetComponent
import com.tangem.features.markets.token.block.TokenMarketBlockComponent
import com.tangem.features.tokendetails.TokenDetailsComponent
import com.tangem.features.tokenreceive.TokenReceiveComponent
@ -96,7 +97,6 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor(
TokenDetailsScreen(
tokenDetailsUM = tokenDetailsUM,
tokenMarketBlockComponent = tokenMarketBlockComponent,
modifier = modifier,
)
} else {
@ -148,6 +148,10 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor(
cloreMigrationModel = model.cloreMigrationModel,
onDismiss = model.bottomSheetNavigation::dismiss,
)
is TokenDetailsBottomSheetConfig.DynamicAddresses -> DynamicAddressesBottomSheetComponent(
dynamicAddressesDelegate = model.dynamicAddressesDelegate,
onDismiss = model.bottomSheetNavigation::dismiss,
)
}
@AssistedFactory

View file

@ -9,8 +9,8 @@ import com.tangem.common.routing.deeplink.DeeplinkConst.TRANSACTION_ID_KEY
import com.tangem.common.routing.deeplink.DeeplinkConst.TYPE_KEY
import com.tangem.common.routing.deeplink.DeeplinkConst.WALLET_ID_KEY
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher
import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
@ -18,7 +18,6 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isLocked
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.notifications.models.NotificationType
import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher
import com.tangem.domain.tokens.wallet.WalletBalanceFetcher
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
@ -27,12 +26,12 @@ import com.tangem.features.pushnotifications.api.analytics.PushNotificationAnaly
import com.tangem.features.tangempay.TangemPayFeatureToggles
import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler
import com.tangem.features.wallet.deeplink.WalletDeepLinkActionTrigger
import com.tangem.utils.logging.TangemLogger
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import com.tangem.utils.logging.TangemLogger
@Suppress("LongParameterList")
internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor(
@ -128,9 +127,6 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor(
private suspend fun fetchCurrency(userWallet: UserWallet, cryptoCurrency: CryptoCurrency) {
val isMultiCurrency = userWallet.isMultiCurrency
// single-currency wallet with token (NODL)
userWallet is UserWallet.Cold &&
userWallet.scanResponse.cardTypesResolver.isSingleWalletWithToken()
when {
isMultiCurrency -> cryptoCurrencyBalanceFetcher(
userWalletId = userWallet.walletId,
@ -150,7 +146,7 @@ internal class DefaultTokenDetailsDeepLinkHandler @AssistedInject constructor(
val derivationPath = queryParams[DERIVATION_PATH_KEY]
getCryptoCurrencies(userWalletId = userWallet.walletId)?.firstOrNull { currency ->
val isNetwork = currency.network.backendId.equals(networkId, ignoreCase = true)
val isNetwork = currency.network.rawId.equals(networkId, ignoreCase = true)
val isCurrency = currency.id.rawCurrencyId?.value?.equals(tokenId, ignoreCase = true) == true
val isDefaultDerivation = currency.network.derivationPath is Network.DerivationPath.Card

View file

@ -0,0 +1,362 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.model
import com.tangem.common.core.TangemSdkError
import com.tangem.core.decompose.di.GlobalUiMessageSender
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.res.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.common.ui.amountScreen.utils.getFiatString
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.dynamicaddresses.CreateConsolidationTransactionUseCase
import com.tangem.domain.dynamicaddresses.DisableDynamicAddressesUseCase
import com.tangem.domain.dynamicaddresses.EnableDynamicAddressesError
import com.tangem.domain.dynamicaddresses.EnableDynamicAddressesUseCase
import com.tangem.domain.dynamicaddresses.GetDerivedXpubUseCase
import com.tangem.domain.dynamicaddresses.model.DynamicAddressesStatus
import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.utils.Provider
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.domain.transaction.usecase.GetFeeUseCase
import com.tangem.domain.transaction.usecase.SendTransactionUseCase
import com.tangem.domain.wallets.usecase.GetExtendedPublicKeyForCurrencyUseCase
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses.DynamicAddressesBottomSheetConfig
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.logging.TangemLogger
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@Suppress("LongParameterList")
internal class DynamicAddressesDelegate @AssistedInject constructor(
private val enableDynamicAddressesUseCase: EnableDynamicAddressesUseCase,
private val disableDynamicAddressesUseCase: DisableDynamicAddressesUseCase,
private val createConsolidationTransactionUseCase: CreateConsolidationTransactionUseCase,
private val getFeeUseCase: GetFeeUseCase,
private val sendTransactionUseCase: SendTransactionUseCase,
private val getDerivedXpubUseCase: GetDerivedXpubUseCase,
private val dynamicAddressesRepository: DynamicAddressesRepository,
private val getExtendedPublicKeyUseCase: GetExtendedPublicKeyForCurrencyUseCase,
@GlobalUiMessageSender private val uiMessageSender: UiMessageSender,
private val dispatchers: CoroutineDispatcherProvider,
@Assisted private val userWallet: UserWallet,
@Assisted private val cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus?>,
@Assisted private val appCurrencyProvider: Provider<AppCurrency>,
@Assisted private val coroutineScope: CoroutineScope,
@Assisted("showBottomSheet") private val showBottomSheet: () -> Unit,
@Assisted("dismissBottomSheet") private val dismissBottomSheet: () -> Unit,
@Assisted("onDynamicAddressesStateChanged") private val onDynamicAddressesStateChanged: () -> Unit,
) {
private val userWalletId get() = userWallet.walletId
private val _bottomSheetConfig = MutableStateFlow<DynamicAddressesBottomSheetConfig>(
DynamicAddressesBottomSheetConfig.Enable(
isCardScanRequired = false,
onEnableClick = {},
),
)
val bottomSheetConfig: StateFlow<DynamicAddressesBottomSheetConfig> = _bottomSheetConfig.asStateFlow()
// region Entry point
fun onDynamicAddressesClick() {
val network = cryptoCurrencyStatusProvider()?.currency?.network ?: return
coroutineScope.launch(dispatchers.main) {
val status = dynamicAddressesRepository.getStatus(userWalletId, network).first()
when (status) {
DynamicAddressesStatus.ENABLED,
DynamicAddressesStatus.ENABLED_REQUIRES_SETUP,
-> onDisableFlow(network)
DynamicAddressesStatus.DISABLED -> onEnableFlow(network)
}
}
}
// endregion
// region Enable flow
private suspend fun onEnableFlow(network: Network) {
val hasConflicts = dynamicAddressesRepository.hasConflictingCustomTokens(userWalletId, network)
if (hasConflicts) {
_bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ConflictingCustomTokens(
onDismissClick = dismissBottomSheet,
)
showBottomSheet()
return
}
val isCardScanRequired = !isXpubAlreadyDerived(network)
_bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.Enable(
isCardScanRequired = isCardScanRequired,
onEnableClick = ::onEnableClick,
)
showBottomSheet()
}
private fun onEnableClick() {
val network = cryptoCurrencyStatusProvider()?.currency?.network ?: return
coroutineScope.launch(dispatchers.main) {
_bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.Enable(
isCardScanRequired = false,
isLoading = true,
onEnableClick = {},
)
val xpub = getExtendedPublicKeyUseCase(userWalletId, network).fold(
ifLeft = { error ->
if (isUserCancellation(error)) {
dismissBottomSheet()
} else {
TangemLogger.e("Failed to get XPUB: ${error.message}")
_bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ServiceUnavailable(
onDismissClick = dismissBottomSheet,
)
}
return@launch
},
ifRight = { it },
)
enableDynamicAddressesUseCase(userWalletId, network, xpub).fold(
ifLeft = { error ->
when (error) {
is EnableDynamicAddressesError.ConflictingCustomTokens -> {
_bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ConflictingCustomTokens(
onDismissClick = dismissBottomSheet,
)
}
is EnableDynamicAddressesError.ServiceError -> {
TangemLogger.e("Failed to enable dynamic addresses: ${error.cause.message}")
_bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ServiceUnavailable(
onDismissClick = dismissBottomSheet,
)
}
}
},
ifRight = {
dismissBottomSheet()
onDynamicAddressesStateChanged()
uiMessageSender.send(
SnackbarMessage(message = resourceReference(R.string.dynamic_addresses_enabled_toast_title)),
)
},
)
}
}
// endregion
// region Disable flow
private fun onDisableFlow(network: Network) {
coroutineScope.launch(dispatchers.main) {
disableDynamicAddressesUseCase(userWalletId, network).fold(
ifLeft = { error ->
TangemLogger.e("Failed to check disable: ${error.message}")
_bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ServiceUnavailable(
onDismissClick = dismissBottomSheet,
)
showBottomSheet()
},
ifRight = { isConsolidationRequired ->
if (!isConsolidationRequired) {
showSimpleDisableSheet()
} else {
showDisableSheetAndLoadFee()
}
},
)
}
}
private fun showSimpleDisableSheet() {
_bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.DisableWithoutConsolidation(
onDisableClick = ::onSimpleDisableClick,
onReadMoreClick = ::onReadMoreClick,
)
showBottomSheet()
}
private fun onSimpleDisableClick() {
val network = cryptoCurrencyStatusProvider()?.currency?.network ?: return
coroutineScope.launch(dispatchers.main) {
runSuspendCatching { dynamicAddressesRepository.disable(userWalletId, network) }
.onSuccess {
dismissBottomSheet()
onDynamicAddressesStateChanged()
uiMessageSender.send(
SnackbarMessage(message = resourceReference(R.string.dynamic_addresses_disabled_popup_title)),
)
}
.onFailure { e ->
TangemLogger.e("Failed to disable dynamic addresses: ${e.message}")
_bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ServiceUnavailable(
onDismissClick = dismissBottomSheet,
)
}
}
}
private fun showDisableSheetAndLoadFee() {
_bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.DisableWithConsolidation(
feeState = DynamicAddressesBottomSheetConfig.DisableFeeState.Loading,
onDisableClick = ::onDisableClick,
onRefreshFee = ::loadDisableFee,
onReadMoreClick = ::onReadMoreClick,
)
showBottomSheet()
loadDisableFee()
}
private fun loadDisableFee() {
coroutineScope.launch(dispatchers.main) {
_bottomSheetConfig.value = disableWithConsolidationConfig().copy(
feeState = DynamicAddressesBottomSheetConfig.DisableFeeState.Loading,
)
val status = cryptoCurrencyStatusProvider()
val currency = status?.currency
val balance = status?.value?.amount
val address = status?.value?.networkAddress?.defaultAddress?.value
if (currency == null || balance == null || address == null) {
_bottomSheetConfig.value = disableWithConsolidationConfig().copy(
feeState = DynamicAddressesBottomSheetConfig.DisableFeeState.Error,
)
return@launch
}
getFeeUseCase(
amount = balance,
destination = address,
userWallet = userWallet,
cryptoCurrency = currency,
).fold(
ifLeft = {
_bottomSheetConfig.value = disableWithConsolidationConfig().copy(
feeState = DynamicAddressesBottomSheetConfig.DisableFeeState.Error,
)
},
ifRight = { txFee ->
val fee = txFee.normal
val fiatFormatted = getFiatString(
value = fee.amount.value,
rate = status.value.fiatRate,
appCurrency = appCurrencyProvider(),
approximate = true,
)
_bottomSheetConfig.value = disableWithConsolidationConfig().copy(
feeState = DynamicAddressesBottomSheetConfig.DisableFeeState.Content(
feeSymbol = currency.symbol,
fiatFormatted = fiatFormatted,
),
)
},
)
}
}
private fun disableWithConsolidationConfig(): DynamicAddressesBottomSheetConfig.DisableWithConsolidation {
return _bottomSheetConfig.value as? DynamicAddressesBottomSheetConfig.DisableWithConsolidation
?: DynamicAddressesBottomSheetConfig.DisableWithConsolidation(
onDisableClick = ::onDisableClick,
onRefreshFee = ::loadDisableFee,
onReadMoreClick = ::onReadMoreClick,
)
}
private fun onReadMoreClick() {
// TODO: Replace with actual URL
}
private fun onDisableClick() {
val network = cryptoCurrencyStatusProvider()?.currency?.network ?: return
coroutineScope.launch(dispatchers.main) {
_bottomSheetConfig.value = disableWithConsolidationConfig().copy(
isSending = true,
)
val txData = createConsolidationTransactionUseCase(userWalletId, network).fold(
ifLeft = { error ->
TangemLogger.e("Failed to create consolidation tx: ${error.message}")
_bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ServiceUnavailable(
onDismissClick = dismissBottomSheet,
)
return@launch
},
ifRight = { it },
)
sendTransactionUseCase(
txData = txData,
userWallet = userWallet,
network = network,
).fold(
ifLeft = { error ->
if (error is SendTransactionError.UserCancelledError) {
dismissBottomSheet()
} else {
TangemLogger.e("Failed to send consolidation tx: $error")
_bottomSheetConfig.value = DynamicAddressesBottomSheetConfig.ServiceUnavailable(
onDismissClick = dismissBottomSheet,
)
}
},
ifRight = {
try {
dynamicAddressesRepository.disable(userWalletId, network)
} catch (e: Exception) {
TangemLogger.e("Failed to disable dynamic addresses after consolidation: ${e.message}")
}
dismissBottomSheet()
onDynamicAddressesStateChanged()
uiMessageSender.send(
SnackbarMessage(
message = resourceReference(R.string.dynamic_addresses_disabled_popup_title),
),
)
},
)
}
}
// endregion
// region Common
private suspend fun isXpubAlreadyDerived(network: Network): Boolean {
return getDerivedXpubUseCase(userWalletId, network) != null
}
private fun isUserCancellation(error: Throwable): Boolean {
return error is TangemSdkError.UserCancelled || error.cause is TangemSdkError.UserCancelled
}
// endregion
@AssistedFactory
interface Factory {
@Suppress("LongParameterList")
fun create(
userWallet: UserWallet,
cryptoCurrencyStatusProvider: Provider<CryptoCurrencyStatus?>,
appCurrencyProvider: Provider<AppCurrency>,
coroutineScope: CoroutineScope,
@Assisted("showBottomSheet") showBottomSheet: () -> Unit,
@Assisted("dismissBottomSheet") dismissBottomSheet: () -> Unit,
@Assisted("onDynamicAddressesStateChanged") onDynamicAddressesStateChanged: () -> Unit,
): DynamicAddressesDelegate
}
}

View file

@ -48,6 +48,8 @@ interface TokenDetailsClickIntents {
fun onGenerateExtendedKey()
fun onDynamicAddressesClick()
fun onCopyAddress(): TextReference?
fun onAssociateClick()
@ -125,6 +127,8 @@ internal class EmptyTokenDetailsClickIntents : TokenDetailsClickIntents {
override fun onGenerateExtendedKey() { /* no op */ }
override fun onDynamicAddressesClick() { /* no op */ }
override fun onSellClick(unavailabilityReason: ScenarioUnavailabilityReason) { /* no op */ }
override fun onSwapClick(unavailabilityReason: ScenarioUnavailabilityReason) { /* no op */ }

View file

@ -0,0 +1,73 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.model
import com.tangem.core.decompose.di.GlobalUiMessageSender
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.tokendetails.impl.R
import javax.inject.Inject
@ModelScoped
internal class TokenDetailsDialogFactory @Inject constructor(
@GlobalUiMessageSender private val uiMessageSender: UiMessageSender,
) {
fun showConfirmHideToken(currency: CryptoCurrency, onConfirm: () -> Unit) {
uiMessageSender.send(
DialogMessage(
title = resourceReference(
id = R.string.token_details_hide_alert_title,
formatArgs = wrappedList(currency.name),
),
message = resourceReference(R.string.token_details_hide_alert_message),
firstActionBuilder = {
EventMessageAction(
title = resourceReference(R.string.token_details_hide_alert_hide),
isWarning = true,
onClick = onConfirm,
)
},
secondActionBuilder = { cancelAction() },
),
)
}
fun showLinkedTokens(currency: CryptoCurrency) {
uiMessageSender.send(
DialogMessage(
title = resourceReference(
id = R.string.token_details_unable_hide_alert_title,
formatArgs = wrappedList(currency.symbol),
),
message = resourceReference(
id = R.string.token_details_unable_hide_alert_message,
formatArgs = wrappedList(currency.name, currency.symbol, currency.network.name),
),
),
)
}
fun showDismissIncompleteTransactionConfirm(onConfirm: () -> Unit) {
uiMessageSender.send(
DialogMessage(
message = resourceReference(R.string.warning_kaspa_unfinished_token_transaction_discard_message),
firstActionBuilder = {
EventMessageAction(
title = resourceReference(R.string.common_yes),
onClick = onConfirm,
)
},
secondActionBuilder = { cancelAction() },
),
)
}
fun showError(text: TextReference) {
uiMessageSender.send(DialogMessage(message = text))
}
}

View file

@ -2,13 +2,17 @@ package com.tangem.feature.tokendetails.presentation.tokendetails.model
import androidx.compose.runtime.Stable
import arrow.core.getOrElse
import arrow.core.merge
import arrow.core.right
import com.tangem.utils.logging.TangemLogger
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.blockchain.common.address.AddressType
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.domain.dynamicaddresses.DynamicAddressesDerivationChecker
import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles
import com.tangem.domain.dynamicaddresses.DynamicAddressesSupportedBlockchains
import com.tangem.domain.dynamicaddresses.IsXpubSupportedUseCase
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.bottomsheet.receive.AddressModel
@ -16,21 +20,21 @@ import com.tangem.common.ui.bottomsheet.receive.mapToAddressModels
import com.tangem.common.ui.expressStatus.ExpressStatusBottomSheetConfig
import com.tangem.common.ui.expressStatus.state.ExpressTransactionStateUM
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.core.analytics.models.event.OfframpAnalyticsEvent
import com.tangem.core.decompose.di.GlobalUiMessageSender
import com.tangem.common.ui.userwallet.converter.WalletIconUMConverter
import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.common.wallets.UserWalletsListRepository
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.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.common.ui.tokens.getUnavailabilityReasonText
import com.tangem.core.ui.DesignFeatureToggles
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.ds.image.DeviceIconUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
@ -41,6 +45,7 @@ import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.account.status.usecase.IsCryptoCurrencyCouldHideUseCase
import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase
import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher
@ -53,6 +58,7 @@ import com.tangem.domain.models.TokenReceiveNotification
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
@ -81,6 +87,7 @@ import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.domain.transaction.usecase.*
import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
import com.tangem.domain.wallets.usecase.GetExploreUrlUseCase
import com.tangem.domain.wallets.usecase.GetWalletIconUseCase
import com.tangem.domain.wallets.usecase.GetExtendedPublicKeyForCurrencyUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.domain.wallets.usecase.NetworkHasDerivationUseCase
@ -93,13 +100,17 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.analytics.Token
import com.tangem.feature.tokendetails.presentation.tokendetails.analytics.TokenDetailsNotificationsAnalyticsSender
import com.tangem.feature.tokendetails.presentation.tokendetails.route.TokenDetailsBottomSheetConfig
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceSegmentedButtonConfig
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsStateController
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsStateFactory
import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.express.TokenDetailsExpressStatusFactory
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.InitializeWithCryptoCurrencyTransformer
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.SetBalanceLoadingTransformer
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.SetBalanceTransformer
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.SetTopBarTitleTransformer
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.ToggleBalanceTypeTransformer
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateTopBarMenuTransformer
import com.tangem.features.tokendetails.TokenDetailsComponent
import com.tangem.features.tokendetails.impl.R
import com.tangem.features.txhistory.entity.TxHistoryContentUpdateEmitter
@ -109,7 +120,6 @@ import com.tangem.utils.Provider
import com.tangem.utils.coroutines.*
import com.tangem.utils.extensions.isZero
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
@ -154,7 +164,6 @@ internal class TokenDetailsModel @Inject constructor(
private val appRouter: AppRouter,
private val router: InnerTokenDetailsRouter,
private val tokenDetailsDeepLinkActionListener: TokenDetailsDeepLinkActionListener,
private val analyticsExceptionHandler: AnalyticsExceptionHandler,
private val receiveAddressesFactory: ReceiveAddressesFactory,
private val saveViewedYieldSupplyWarningUseCase: SaveViewedYieldSupplyWarningUseCase,
private val saveViewedTokenReceiveWarningUseCase: SaveViewedTokenReceiveWarningUseCase,
@ -163,6 +172,17 @@ internal class TokenDetailsModel @Inject constructor(
private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase,
private val yieldSupplyGetRewardsBalanceUseCase: YieldSupplyGetRewardsBalanceUseCase,
private val signCloreMessageUseCase: SignCloreMessageUseCase,
private val isXpubSupportedUseCase: IsXpubSupportedUseCase,
private val dynamicAddressesFeatureToggles: DynamicAddressesFeatureToggles,
private val dynamicAddressesDelegateFactory: DynamicAddressesDelegate.Factory,
private val dialogFactory: TokenDetailsDialogFactory,
private val userWalletsListRepository: UserWalletsListRepository,
private val singleAccountListSupplier: SingleAccountListSupplier,
private val getWalletIconUseCase: GetWalletIconUseCase,
private val walletIconUMConverter: WalletIconUMConverter,
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
private val designFeatureToggles: DesignFeatureToggles,
private val redesignStateController: TokenDetailsStateController,
) : Model(),
TokenDetailsClickIntents,
ExpressTransactionsClickIntents,
@ -183,6 +203,7 @@ internal class TokenDetailsModel @Inject constructor(
private val stakingJobHolder = JobHolder()
private val yieldSupplyBalanceJobHolder = JobHolder()
private val selectedAppCurrencyFlow: StateFlow<AppCurrency> = createSelectedAppCurrencyFlow()
private val redesignBalanceJobHolder = JobHolder()
private var cryptoCurrencyStatus: CryptoCurrencyStatus? = null
private var account: Account.CryptoPortfolio? = null
@ -204,11 +225,10 @@ internal class TokenDetailsModel @Inject constructor(
userWalletId = userWalletId,
)
private val internalUiState = MutableStateFlow(stateFactory.getInitialState(cryptoCurrency))
val uiState: StateFlow<TokenDetailsState> = internalUiState
val uiState: StateFlow<TokenDetailsState>
field = MutableStateFlow(stateFactory.getInitialState(cryptoCurrency))
private val internalRedesignUiState = MutableStateFlow(createInitialRedesignState())
val redesignUiState: StateFlow<TokenDetailsUM> = internalRedesignUiState
val redesignUiState: StateFlow<TokenDetailsUM> get() = redesignStateController.uiState
// region Clore migration
// TODO: Remove after Clore migration ends ([REDACTED_TASK_KEY])
@ -226,6 +246,22 @@ internal class TokenDetailsModel @Inject constructor(
}
// endregion
// region Dynamic Addresses
val dynamicAddressesDelegate by lazy(mode = LazyThreadSafetyMode.NONE) {
dynamicAddressesDelegateFactory.create(
userWallet = userWallet,
cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus },
appCurrencyProvider = Provider { selectedAppCurrencyFlow.value },
coroutineScope = modelScope,
showBottomSheet = {
bottomSheetNavigation.activate(TokenDetailsBottomSheetConfig.DynamicAddresses)
},
dismissBottomSheet = bottomSheetNavigation::dismiss,
onDynamicAddressesStateChanged = ::onDynamicAddressesStateChanged,
)
}
// endregion Dynamic Addresses
private val expressStatusFactory by lazy(mode = LazyThreadSafetyMode.NONE) {
tokenDetailsExpressStatusFactory.create(
clickIntents = this,
@ -249,6 +285,7 @@ internal class TokenDetailsModel @Inject constructor(
}
init {
initRedesign()
updateTopBarMenu()
initButtons()
updateContent()
@ -299,7 +336,7 @@ internal class TokenDetailsModel @Inject constructor(
private fun handleBalanceHiding() {
getBalanceHidingSettingsUseCase()
.onEach { settings ->
internalUiState.value = stateFactory.getStateWithUpdatedHidden(
uiState.value = stateFactory.getStateWithUpdatedHidden(
isBalanceHidden = settings.isBalanceHidden,
)
}
@ -315,7 +352,7 @@ internal class TokenDetailsModel @Inject constructor(
.distinctUntilChanged()
.onEach { state ->
sendButtonsEvents(state.states)
internalUiState.value = stateFactory.getManageButtonsState(actions = state.states)
uiState.value = stateFactory.getManageButtonsState(actions = state.states)
}
.flowOn(dispatchers.main)
.launchIn(modelScope)
@ -347,8 +384,8 @@ internal class TokenDetailsModel @Inject constructor(
.distinctUntilChanged()
.onEach { warnings ->
val updatedState = stateFactory.getStateWithNotifications(warnings)
notificationsAnalyticsSender.send(internalUiState.value, updatedState.notifications)
internalUiState.value = updatedState
notificationsAnalyticsSender.send(uiState.value, updatedState.notifications)
uiState.value = updatedState
}
.launchIn(modelScope)
.saveIn(warningsJobHolder)
@ -361,7 +398,7 @@ internal class TokenDetailsModel @Inject constructor(
.map { it.status.right() }
.distinctUntilChanged()
.onEach { maybeCurrencyStatus ->
internalUiState.value = stateFactory.getCurrencyLoadedBalanceState(maybeCurrencyStatus)
uiState.value = stateFactory.getCurrencyLoadedBalanceState(maybeCurrencyStatus)
maybeCurrencyStatus.onRight { status ->
sendOneTimeBalanceLoadedAnalyticsEvent(status)
cryptoCurrencyStatus = status
@ -383,7 +420,7 @@ internal class TokenDetailsModel @Inject constructor(
.distinctUntilChanged()
.onEach { waitForFirstExpressStatusEmmit.value = true }
.onEach { expressTxs ->
internalUiState.value = expressStatusFactory.getStateWithUpdatedExpressTxs(
uiState.value = expressStatusFactory.getStateWithUpdatedExpressTxs(
expressTxs = expressTxs,
updateBalance = ::updateNetworkToSwapBalance,
)
@ -394,11 +431,11 @@ internal class TokenDetailsModel @Inject constructor(
delay = EXPRESS_STATUS_UPDATE_DELAY,
task = {
runSuspendCatching {
expressStatusFactory.getUpdatedExpressStatuses(internalUiState.value.expressTxs)
expressStatusFactory.getUpdatedExpressStatuses(uiState.value.expressTxs)
}
},
onSuccess = { updatedTxs ->
internalUiState.value = expressStatusFactory.getStateWithUpdatedExpressTxs(
uiState.value = expressStatusFactory.getStateWithUpdatedExpressTxs(
updatedTxs,
::updateNetworkToSwapBalance,
)
@ -419,14 +456,14 @@ internal class TokenDetailsModel @Inject constructor(
}
yieldSupplyGetRewardsBalanceUseCase(status = status, appCurrency = selectedAppCurrencyFlow.value)
.onEach { formatted ->
internalUiState.value = stateFactory.getStateWithUpdatedYieldSupplyDisplayBalance(formatted)
uiState.value = stateFactory.getStateWithUpdatedYieldSupplyDisplayBalance(formatted)
}
.flowOn(dispatchers.main)
.launchIn(modelScope)
.saveIn(yieldSupplyBalanceJobHolder)
} else {
yieldSupplyBalanceJobHolder.cancel()
internalUiState.value = stateFactory.getStateWithUpdatedYieldSupplyDisplayBalance(
uiState.value = stateFactory.getStateWithUpdatedYieldSupplyDisplayBalance(
YieldSupplyRewardBalance.empty(),
)
}
@ -463,7 +500,7 @@ internal class TokenDetailsModel @Inject constructor(
null
}
internalUiState.update { state ->
uiState.update { state ->
stateFactory.getStakingInfoState(
state = state,
stakingEntryInfo = stakingEntryInfo,
@ -485,39 +522,41 @@ internal class TokenDetailsModel @Inject constructor(
).getOrElse { false }
val isSupported = isXPUBSupported()
val isDynamicAddressesAvailable = isSupported && isDynamicAddressesAvailable()
internalUiState.value = stateFactory.getStateWithUpdatedMenu(
uiState.value = stateFactory.getStateWithUpdatedMenu(
userWallet = userWallet,
hasDerivations = hasDerivations,
isSupported = isSupported,
isDynamicAddressesAvailable = isDynamicAddressesAvailable,
)
}
}
private fun isDynamicAddressesAvailable(): Boolean {
if (!dynamicAddressesFeatureToggles.isDynamicAddressesEnabled) return false
if (cryptoCurrency !is CryptoCurrency.Coin) return false
val networkId = cryptoCurrency.network.rawId
if (!DynamicAddressesSupportedBlockchains.isSupportedByNetworkId(networkId)) return false
return isDefaultBaseDerivation(cryptoCurrency.network.derivationPath, networkId)
}
private fun isDefaultBaseDerivation(derivationPath: Network.DerivationPath, networkId: String): Boolean {
val pathValue = derivationPath.value ?: return false
val nodes = runCatching { DerivationPath(pathValue).nodes }.getOrNull() ?: return false
if (nodes.size < BASE_DERIVATION_NODE_COUNT) return false
val purposeNode = nodes.first()
val allowedPurpose = DynamicAddressesSupportedBlockchains.getAllowedPurpose(networkId) ?: return false
if (purposeNode.getIndex(includeHardened = false) != allowedPurpose) return false
return DynamicAddressesDerivationChecker.isBaseDerivation(pathValue)
}
private suspend fun isXPUBSupported(): Boolean {
return getExtendedPublicKeyForCurrencyUseCase.isSupported(
userWalletId = userWalletId,
network = cryptoCurrency.network,
)
.mapLeft { throwable ->
analyticsExceptionHandler.sendException(
event = ExceptionAnalyticsEvent(
exception = throwable,
params = mapOf(
"blockchainId" to cryptoCurrency.network.id.rawId.value,
"networkId" to cryptoCurrency.network.backendId,
),
),
)
TangemLogger.e(
"Unable to get wallet manager for user wallet $userWalletId and network ${cryptoCurrency.network}",
throwable,
)
false
}
.merge()
return isXpubSupportedUseCase(userWalletId = userWalletId, network = cryptoCurrency.network)
}
private fun createSelectedAppCurrencyFlow(): StateFlow<AppCurrency> {
@ -657,6 +696,15 @@ internal class TokenDetailsModel @Inject constructor(
openStaking()
}
override fun onDynamicAddressesClick() = dynamicAddressesDelegate.onDynamicAddressesClick()
private fun onDynamicAddressesStateChanged() {
updateTopBarMenu()
modelScope.launch(dispatchers.main) {
cryptoCurrencyBalanceFetcher.invokeAndAwait(userWalletId = userWalletId, currency = cryptoCurrency)
}
}
override fun onGenerateExtendedKey() {
modelScope.launch(dispatchers.main) {
val extendedKey = getExtendedPublicKeyForCurrencyUseCase(
@ -845,7 +893,12 @@ internal class TokenDetailsModel @Inject constructor(
}
override fun onRefreshSwipe(isRefreshing: Boolean) {
internalUiState.value = stateFactory.getRefreshingState()
uiState.value = stateFactory.getRefreshingState()
redesignStateController.update(
SetBalanceLoadingTransformer(
currencyIconState = redesignStateController.value.balanceBlockUM.currencyIconState,
),
)
modelScope.launch(dispatchers.main) {
listOf(
@ -857,29 +910,29 @@ internal class TokenDetailsModel @Inject constructor(
subscribeOnExpressTransactionsUpdates()
},
).awaitAll()
internalUiState.value = stateFactory.getRefreshedState()
uiState.value = stateFactory.getRefreshedState()
}.saveIn(refreshStateJobHolder)
}
override fun onDismissBottomSheet() {
when (val bsContent = internalUiState.value.bottomSheetConfig?.content) {
when (val bsContent = uiState.value.bottomSheetConfig?.content) {
is ExpressStatusBottomSheetConfig -> {
modelScope.launch(dispatchers.main) {
expressStatusFactory.removeTransactionOnBottomSheetClosed(bsContent.value)
}
}
}
internalUiState.value = stateFactory.getStateWithClosedBottomSheet()
uiState.value = stateFactory.getStateWithClosedBottomSheet()
}
override fun onCloseRentInfoNotification() {
internalUiState.value = stateFactory.getStateWithRemovedRentNotification()
uiState.value = stateFactory.getStateWithRemovedRentNotification()
}
override fun onExpressTransactionClick(txId: String) {
val expressTxState = internalUiState.value.expressTxsToDisplay.firstOrNull { it.info.txId == txId }
val expressTxState = uiState.value.expressTxsToDisplay.firstOrNull { it.info.txId == txId }
?: return
internalUiState.value = expressStatusFactory.getStateWithExpressStatusBottomSheet(expressTxState)
uiState.value = expressStatusFactory.getStateWithExpressStatusBottomSheet(expressTxState)
}
override fun onGoToProviderClick(url: String) {
@ -973,7 +1026,7 @@ internal class TokenDetailsModel @Inject constructor(
}
},
ifRight = {
internalUiState.value = stateFactory.getStateWithRemovedKaspaIncompleteTransactionNotification()
uiState.value = stateFactory.getStateWithRemovedKaspaIncompleteTransactionNotification()
},
)
}
@ -1015,7 +1068,7 @@ internal class TokenDetailsModel @Inject constructor(
showErrorDialog(message)
}
},
ifRight = { internalUiState.value = stateFactory.getStateWithRemovedRequiredTrustlineNotification() },
ifRight = { uiState.value = stateFactory.getStateWithRemovedRequiredTrustlineNotification() },
)
}
}
@ -1041,7 +1094,7 @@ internal class TokenDetailsModel @Inject constructor(
TangemLogger.e("Error: $e")
},
ifRight = {
internalUiState.value = stateFactory.getStateWithRemovedKaspaIncompleteTransactionNotification()
uiState.value = stateFactory.getStateWithRemovedKaspaIncompleteTransactionNotification()
},
)
}
@ -1075,13 +1128,13 @@ internal class TokenDetailsModel @Inject constructor(
}
}
},
ifRight = { internalUiState.value = stateFactory.getStateWithRemovedHederaAssociateNotification() },
ifRight = { uiState.value = stateFactory.getStateWithRemovedHederaAssociateNotification() },
)
}
}
override fun onBalanceSelect(config: TokenBalanceSegmentedButtonConfig) {
internalUiState.value = stateFactory.getStateWithUpdatedBalanceSegmentedButtonConfig(config)
uiState.value = stateFactory.getStateWithUpdatedBalanceSegmentedButtonConfig(config)
}
override fun onConfirmDisposeExpressStatus() {
@ -1089,7 +1142,7 @@ internal class TokenDetailsModel @Inject constructor(
}
override fun onDisposeExpressStatus() {
val bottomSheetState = internalUiState.value.bottomSheetConfig?.content
val bottomSheetState = uiState.value.bottomSheetConfig?.content
if (bottomSheetState is ExpressStatusBottomSheetConfig) {
modelScope.launch {
expressStatusFactory.removeTransactionOnBottomSheetClosed(
@ -1098,7 +1151,7 @@ internal class TokenDetailsModel @Inject constructor(
)
}
}
internalUiState.value = stateFactory.getStateWithClosedBottomSheet()
uiState.value = stateFactory.getStateWithClosedBottomSheet()
}
override fun onYieldInfoClick() {
@ -1149,52 +1202,16 @@ internal class TokenDetailsModel @Inject constructor(
}
private fun showConfirmHideTokenDialog(currency: CryptoCurrency) {
uiMessageSender.send(
DialogMessage(
title = resourceReference(
id = R.string.token_details_hide_alert_title,
formatArgs = wrappedList(currency.name),
),
message = resourceReference(R.string.token_details_hide_alert_message),
firstActionBuilder = {
EventMessageAction(
title = resourceReference(R.string.token_details_hide_alert_hide),
isWarning = true,
onClick = ::onHideConfirmed,
)
},
secondActionBuilder = { cancelAction() },
),
)
dialogFactory.showConfirmHideToken(currency = currency, onConfirm = ::onHideConfirmed)
}
private fun showLinkedTokensDialog(currency: CryptoCurrency) {
uiMessageSender.send(
DialogMessage(
title = resourceReference(
id = R.string.token_details_unable_hide_alert_title,
formatArgs = wrappedList(currency.symbol),
),
message = resourceReference(
id = R.string.token_details_unable_hide_alert_message,
formatArgs = wrappedList(currency.name, currency.symbol, currency.network.name),
),
),
)
dialogFactory.showLinkedTokens(currency = currency)
}
private fun showDismissIncompleteTransactionConfirmDialog() {
uiMessageSender.send(
DialogMessage(
message = resourceReference(R.string.warning_kaspa_unfinished_token_transaction_discard_message),
firstActionBuilder = {
EventMessageAction(
title = resourceReference(R.string.common_yes),
onClick = ::onConfirmDismissIncompleteTransactionClick,
)
},
secondActionBuilder = { cancelAction() },
),
dialogFactory.showDismissIncompleteTransactionConfirm(
onConfirm = ::onConfirmDismissIncompleteTransactionClick,
)
}
@ -1217,7 +1234,7 @@ internal class TokenDetailsModel @Inject constructor(
}
private fun showErrorDialog(text: TextReference) {
uiMessageSender.send(DialogMessage(message = text))
dialogFactory.showError(text = text)
}
private fun checkForActionUpdates() {
@ -1357,30 +1374,112 @@ internal class TokenDetailsModel @Inject constructor(
// endregion Clore migration
private fun createInitialRedesignState(): TokenDetailsUM {
return TokenDetailsUM(
topAppBarUM = TokenDetailsTopAppBarUM(
title = stringReference(cryptoCurrency.name),
subtitle = stringReference(cryptoCurrency.symbol),
menuItems = persistentListOf(),
private fun observeRedesignBalance() {
getAccountCryptoCurrencyStatusUseCase(userWalletId, cryptoCurrency)
.map { it.status }
.distinctUntilChanged()
.combine(selectedAppCurrencyFlow) { status, appCurrency -> status to appCurrency }
.onEach { (status, appCurrency) ->
redesignStateController.update(
SetBalanceTransformer(
status = status,
appCurrency = appCurrency,
onToggleBalanceType = ::toggleRedesignBalanceType,
),
)
}
.flowOn(dispatchers.default)
.launchIn(modelScope)
.saveIn(redesignBalanceJobHolder)
}
private fun toggleRedesignBalanceType() {
redesignStateController.update(ToggleBalanceTypeTransformer())
}
private fun updateRedesignTopBarMenu() {
modelScope.launch(dispatchers.main) {
val hasDerivations = networkHasDerivationUseCase(
userWallet = userWallet,
network = cryptoCurrency.network,
).getOrElse { false }
val isSupported = isXPUBSupported()
redesignStateController.update(
UpdateTopBarMenuTransformer(
userWallet = userWallet,
hasDerivations = hasDerivations,
isXPubSupported = isSupported,
onGenerateExtendedKey = ::onGenerateExtendedKey,
onHideClick = ::onHideClick,
),
)
}
}
private fun initRedesign() {
if (!designFeatureToggles.isRedesignEnabled) return
initRedesignState()
observeRedesignBalance()
updateRedesignTopBarMenu()
observeRedesignTopBarTitle()
}
private fun initRedesignState() {
redesignStateController.update(
InitializeWithCryptoCurrencyTransformer(
cryptoCurrency = cryptoCurrency,
onBackClick = ::onBackClick,
),
balanceBlockUM = TokenDetailsBalanceBlockUM.Loading(
actionButtons = persistentListOf(),
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
),
marketPriceBlockState = MarketPriceBlockState.Loading(currencySymbol = cryptoCurrency.symbol),
stakingBlocksState = null,
pullToRefreshConfig = PullToRefreshConfig(
isRefreshing = false,
onRefresh = {},
),
isBalanceHidden = false,
isMarketPriceAvailable = false,
)
}
private fun observeRedesignTopBarTitle() {
combine(
flow = userWalletsListRepository.userWallets.filterNotNull(),
flow2 = isAccountsModeEnabledUseCase.invoke(),
flow3 = singleAccountListSupplier(userWalletId),
flow4 = getAccountCryptoCurrencyStatusUseCase(userWalletId, cryptoCurrency)
.map { status -> status.account }
.distinctUntilChanged(),
) { wallets, accountsModeEnabled, accountList, currentAccount ->
val currentWallet = wallets.firstOrNull { it.walletId == userWalletId } ?: userWallet
TopBarTitleInputs(
hasMultipleWallets = wallets.size > 1,
hasMultipleAccounts = accountsModeEnabled && accountList.accounts.size > 1,
walletName = currentWallet.name,
deviceIconUM = walletIconUMConverter.convert(getWalletIconUseCase(currentWallet)),
account = currentAccount,
)
}
.distinctUntilChanged()
.onEach { inputs ->
redesignStateController.update(
SetTopBarTitleTransformer(
cryptoCurrency = cryptoCurrency,
hasMultipleWallets = inputs.hasMultipleWallets,
hasMultipleAccounts = inputs.hasMultipleAccounts,
walletName = inputs.walletName,
deviceIconUM = inputs.deviceIconUM,
account = inputs.account,
),
)
}
.flowOn(dispatchers.default)
.launchIn(modelScope)
}
private data class TopBarTitleInputs(
val hasMultipleWallets: Boolean,
val hasMultipleAccounts: Boolean,
val walletName: String,
val deviceIconUM: DeviceIconUM,
val account: Account.CryptoPortfolio?,
)
private companion object {
const val EXPRESS_STATUS_UPDATE_DELAY = 10_000L
const val BASE_DERIVATION_NODE_COUNT = 5
}
}

View file

@ -27,4 +27,7 @@ sealed class TokenDetailsBottomSheetConfig : Route {
@Serializable
data object CloreMigration : TokenDetailsBottomSheetConfig()
@Serializable
data object DynamicAddresses : TokenDetailsBottomSheetConfig()
}

View file

@ -4,6 +4,8 @@ import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.tokendetails.impl.R
import kotlinx.collections.immutable.ImmutableList
@Immutable
@ -23,10 +25,25 @@ internal sealed class TokenDetailsBalanceBlockUM {
override val actionButtons: ImmutableList<TangemButtonUM>,
override val tokenBalanceTypeUM: TokenBalanceTypeUM,
override val currencyIconState: CurrencyIconState,
val displayCryptoBalance: TextReference,
val displayFiatBalance: TextReference,
val displayCryptoBalanceAll: TextReference,
val displayFiatBalanceAll: TextReference,
val displayCryptoBalanceAvailable: TextReference?,
val displayFiatBalanceAvailable: TextReference?,
val isBalanceFlickering: Boolean,
) : TokenDetailsBalanceBlockUM()
) : TokenDetailsBalanceBlockUM() {
val displayCryptoBalance: TextReference
get() = when (tokenBalanceTypeUM.type) {
TokenBalanceTypeUM.Type.ALL -> displayCryptoBalanceAll
TokenBalanceTypeUM.Type.AVAILABLE -> displayCryptoBalanceAvailable ?: displayCryptoBalanceAll
}
val displayFiatBalance: TextReference
get() = when (tokenBalanceTypeUM.type) {
TokenBalanceTypeUM.Type.ALL -> displayFiatBalanceAll
TokenBalanceTypeUM.Type.AVAILABLE -> displayFiatBalanceAvailable ?: displayFiatBalanceAll
}
}
data class Error(
override val actionButtons: ImmutableList<TangemButtonUM>,
@ -34,11 +51,11 @@ internal sealed class TokenDetailsBalanceBlockUM {
override val currencyIconState: CurrencyIconState,
) : TokenDetailsBalanceBlockUM()
fun copyActionButtons(buttons: ImmutableList<TangemButtonUM>): TokenDetailsBalanceBlockUM {
fun copyCurrencyIconState(iconState: CurrencyIconState): TokenDetailsBalanceBlockUM {
return when (this) {
is Content -> this.copy(actionButtons = buttons)
is Error -> this.copy(actionButtons = buttons)
is Loading -> this.copy(actionButtons = buttons)
is Content -> this.copy(currencyIconState = iconState)
is Error -> this.copy(currencyIconState = iconState)
is Loading -> this.copy(currencyIconState = iconState)
}
}
}
@ -54,11 +71,11 @@ internal sealed class TokenBalanceTypeUM {
data class Multiple(
override val type: Type,
val availableTypes: ImmutableList<Type>,
val onSelect: (Type) -> Unit,
val onSelect: () -> Unit,
) : TokenBalanceTypeUM()
enum class Type {
ALL,
AVAILABLE,
enum class Type(val text: TextReference) {
ALL(resourceReference(R.string.token_details_balance_total)),
AVAILABLE(resourceReference(R.string.token_details_balance_available)),
}
}

View file

@ -0,0 +1,74 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
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.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.tokendetails.impl.R
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
@ModelScoped
internal class TokenDetailsStateController @Inject constructor() {
val uiState: StateFlow<TokenDetailsUM>
field = MutableStateFlow(value = getInitialState())
val value: TokenDetailsUM get() = uiState.value
fun update(function: (TokenDetailsUM) -> TokenDetailsUM) {
uiState.update(function = function)
}
fun update(transformer: Transformer<TokenDetailsUM>) {
uiState.update(function = transformer::transform)
}
private fun getInitialState(): TokenDetailsUM {
return TokenDetailsUM(
topAppBarUM = TokenDetailsTopAppBarUM(
titleState = TokenDetailsTopAppBarUM.TitleState.Simple(tokenName = ""),
subtitle = TextReference.EMPTY,
onBackClick = {},
menuItems = persistentListOf(),
),
balanceBlockUM = TokenDetailsBalanceBlockUM.Loading(
actionButtons = persistentListOf(
TangemButtonUM(
text = resourceReference(R.string.tangempay_card_details_add_funds),
tangemIconUM = TangemIconUM.Icon(iconRes = R.drawable.ic_arrow_down_24),
onClick = { },
isEnabled = true,
type = TangemButtonType.Secondary,
),
TangemButtonUM(
text = resourceReference(R.string.common_transfer),
tangemIconUM = TangemIconUM.Icon(iconRes = R.drawable.ic_arrow_up_24),
onClick = { },
isEnabled = true,
type = TangemButtonType.Secondary,
),
),
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
),
marketPriceBlockState = MarketPriceBlockState.Loading(currencySymbol = ""),
stakingBlocksState = null,
pullToRefreshConfig = PullToRefreshConfig(
isRefreshing = false,
onRefresh = {},
),
isBalanceHidden = false,
isMarketPriceAvailable = false,
)
}
}

View file

@ -1,12 +1,15 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state
import androidx.compose.runtime.Stable
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.ds.image.DeviceIconUM
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
@Stable
@Immutable
internal data class TokenDetailsUM(
val topAppBarUM: TokenDetailsTopAppBarUM,
val balanceBlockUM: TokenDetailsBalanceBlockUM,
@ -17,8 +20,31 @@ internal data class TokenDetailsUM(
val isMarketPriceAvailable: Boolean,
)
@Immutable
internal data class TokenDetailsTopAppBarUM(
val title: TextReference,
val titleState: TitleState,
val subtitle: TextReference,
val menuItems: ImmutableList<TextReference>,
)
val onBackClick: () -> Unit,
val menuItems: ImmutableList<TangemDropdownMenuItem>,
) {
@Immutable
sealed interface TitleState {
val tokenName: String
data class Simple(
override val tokenName: String,
) : TitleState
data class WithWallet(
override val tokenName: String,
val walletName: String,
val deviceIconUM: DeviceIconUM,
) : TitleState
data class WithAccount(
override val tokenName: String,
val accountName: TextReference,
val accountIconUM: AccountIconUM.CryptoPortfolio,
) : TitleState
}
}

View file

@ -168,6 +168,6 @@ internal class TokenDetailsNotificationConverter(
// workaround for networks that users have misunderstanding
private fun CryptoCurrency.shouldMergeFeeNetworkName(): Boolean {
return Blockchain.fromNetworkId(this.network.backendId) == Blockchain.Arbitrum
return Blockchain.fromNetworkId(this.network.rawId) == Blockchain.Arbitrum
}
}

View file

@ -167,12 +167,18 @@ internal class TokenDetailsStateFactory(
userWallet: UserWallet,
hasDerivations: Boolean,
isSupported: Boolean,
isDynamicAddressesAvailable: Boolean = false,
): TokenDetailsState {
return with(currentStateProvider()) {
copy(
topAppBarConfig = topAppBarConfig.copy(
tokenDetailsAppBarMenuConfig = topAppBarConfig.tokenDetailsAppBarMenuConfig
?.updateMenu(userWallet, hasDerivations, isSupported),
?.updateMenu(
userWallet = userWallet,
hasDerivations = hasDerivations,
isSupported = isSupported,
isDynamicAddressesAvailable = isDynamicAddressesAvailable,
),
),
)
}
@ -206,6 +212,7 @@ internal class TokenDetailsStateFactory(
userWallet: UserWallet,
hasDerivations: Boolean,
isSupported: Boolean,
isDynamicAddressesAvailable: Boolean,
): TokenDetailsAppBarMenuConfig? {
if (userWallet is UserWallet.Cold &&
userWallet.scanResponse.cardTypesResolver.isSingleWalletWithToken()
@ -215,6 +222,13 @@ internal class TokenDetailsStateFactory(
return copy(
items = buildList {
if (isDynamicAddressesAvailable) {
TangemDropdownMenuItem(
title = resourceReference(R.string.dynamic_addresses),
textColor = themedColor { TangemTheme.colors.text.primary1 },
onClick = tokenDetailsClickIntents::onDynamicAddressesClick,
).let(::add)
}
if (isSupported && hasDerivations) {
TangemDropdownMenuItem(
title = resourceReference(R.string.token_details_generate_xpub),

View file

@ -0,0 +1,28 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.utils.transformer.Transformer
internal class InitializeWithCryptoCurrencyTransformer(
private val cryptoCurrency: CryptoCurrency,
private val onBackClick: () -> Unit,
) : Transformer<TokenDetailsUM> {
override fun transform(prevState: TokenDetailsUM): TokenDetailsUM {
val iconState = CryptoCurrencyToIconStateConverter().convert(cryptoCurrency)
return prevState.copy(
topAppBarUM = prevState.topAppBarUM.copy(
titleState = TokenDetailsTopAppBarUM.TitleState.Simple(tokenName = cryptoCurrency.name),
subtitle = stringReference(cryptoCurrency.symbol),
onBackClick = onBackClick,
),
balanceBlockUM = prevState.balanceBlockUM.copyCurrencyIconState(iconState),
marketPriceBlockState = MarketPriceBlockState.Loading(currencySymbol = cryptoCurrency.symbol),
)
}
}

View file

@ -0,0 +1,23 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.utils.transformer.Transformer
internal class SetBalanceLoadingTransformer(
private val currencyIconState: CurrencyIconState,
) : Transformer<TokenDetailsUM> {
override fun transform(prevState: TokenDetailsUM): TokenDetailsUM {
val prevBalance = prevState.balanceBlockUM
return prevState.copy(
balanceBlockUM = TokenDetailsBalanceBlockUM.Loading(
actionButtons = prevBalance.actionButtons,
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = currencyIconState,
),
)
}
}

View file

@ -0,0 +1,131 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import androidx.compose.ui.text.SpanStyle
import com.tangem.common.getTotalWithRewardsStakingBalance
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.defaultAmount
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.formatStyled
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.utils.StringsSigns.DASH_SIGN
import com.tangem.utils.isNullOrZero
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
/**
* Maps [CryptoCurrencyStatus] into [TokenDetailsBalanceBlockUM] and sets it on the state.
*
* Produces [TokenDetailsBalanceBlockUM.Content] for loaded states,
* [TokenDetailsBalanceBlockUM.Loading] for loading,
* [TokenDetailsBalanceBlockUM.Error] for unreachable/no-amount/missed-derivation.
*/
internal class SetBalanceTransformer(
private val status: CryptoCurrencyStatus,
private val appCurrency: AppCurrency,
private val onToggleBalanceType: () -> Unit,
) : Transformer<TokenDetailsUM> {
override fun transform(prevState: TokenDetailsUM): TokenDetailsUM {
val prev = prevState.balanceBlockUM
val balanceBlockUM = when (status.value) {
is CryptoCurrencyStatus.Loading -> TokenDetailsBalanceBlockUM.Loading(
actionButtons = prev.actionButtons,
tokenBalanceTypeUM = prev.tokenBalanceTypeUM,
currencyIconState = prev.currencyIconState,
)
is CryptoCurrencyStatus.Loaded,
is CryptoCurrencyStatus.NoQuote,
is CryptoCurrencyStatus.NoAccount,
is CryptoCurrencyStatus.Custom,
-> buildLoadedContent(prev)
is CryptoCurrencyStatus.MissedDerivation,
is CryptoCurrencyStatus.Unreachable,
is CryptoCurrencyStatus.NoAmount,
-> TokenDetailsBalanceBlockUM.Error(
actionButtons = prev.actionButtons,
tokenBalanceTypeUM = prev.tokenBalanceTypeUM,
currencyIconState = prev.currencyIconState,
)
}
return prevState.copy(balanceBlockUM = balanceBlockUM)
}
private fun buildLoadedContent(prev: TokenDetailsBalanceBlockUM): TokenDetailsBalanceBlockUM.Content {
val stakingCryptoAmount =
(status.value.stakingBalance as? StakingBalance.Data)?.getTotalWithRewardsStakingBalance(
status.currency.network.rawId,
)
val stakingFiatAmount = stakingCryptoAmount?.let { status.value.fiatRate?.multiply(it) }
val hasStaking = !stakingCryptoAmount.isNullOrZero()
val prevType = prev.tokenBalanceTypeUM
val tokenBalanceTypeUM = if (hasStaking) {
TokenBalanceTypeUM.Multiple(
type = (prevType as? TokenBalanceTypeUM.Multiple)?.type ?: TokenBalanceTypeUM.Type.ALL,
availableTypes = persistentListOf(TokenBalanceTypeUM.Type.ALL, TokenBalanceTypeUM.Type.AVAILABLE),
onSelect = onToggleBalanceType,
)
} else {
TokenBalanceTypeUM.Single
}
return TokenDetailsBalanceBlockUM.Content(
actionButtons = prev.actionButtons,
currencyIconState = prev.currencyIconState,
tokenBalanceTypeUM = tokenBalanceTypeUM,
displayFiatBalanceAll = formatFiatStyled(
fiatAmount = computeTotal(status.value.fiatAmount, stakingFiatAmount),
),
displayCryptoBalanceAll = formatCrypto(
amount = computeTotal(status.value.amount, stakingCryptoAmount),
),
displayFiatBalanceAvailable = if (hasStaking) {
formatFiatStyled(fiatAmount = status.value.fiatAmount)
} else {
null
},
displayCryptoBalanceAvailable = if (hasStaking) {
formatCrypto(amount = status.value.amount)
} else {
null
},
isBalanceFlickering = status.value.sources.total == StatusSource.CACHE,
)
}
private fun computeTotal(base: BigDecimal?, staking: BigDecimal?): BigDecimal? {
if (base == null) return null
return if (staking != null) base + staking else base
}
private fun formatFiatStyled(fiatAmount: BigDecimal?): TextReference {
if (fiatAmount == null) return stringReference(DASH_SIGN)
return fiatAmount.formatStyled {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.neutral.secondary) },
).defaultAmount(
spanStyleReference = { SpanStyle(color = TangemTheme.colors2.text.neutral.secondary) },
)
}
}
private fun formatCrypto(amount: BigDecimal?): TextReference {
if (amount == null) return stringReference(DASH_SIGN)
return stringReference(
amount.format { crypto(status.currency).defaultAmount() },
)
}
}

View file

@ -0,0 +1,78 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.common.ui.account.toUM
import com.tangem.core.ui.ds.image.DeviceIconUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.utils.transformer.Transformer
import com.tangem.core.res.R as CoreResR
internal class SetTopBarTitleTransformer(
private val cryptoCurrency: CryptoCurrency,
private val hasMultipleWallets: Boolean,
private val hasMultipleAccounts: Boolean,
private val walletName: String,
private val deviceIconUM: DeviceIconUM,
private val account: Account.CryptoPortfolio?,
) : Transformer<TokenDetailsUM> {
override fun transform(prevState: TokenDetailsUM): TokenDetailsUM = prevState.copy(
topAppBarUM = prevState.topAppBarUM.copy(
titleState = createTitleState(),
subtitle = createSubtitle(),
),
)
private fun createTitleState(): TitleState {
val tokenName = cryptoCurrency.name
return when {
hasMultipleAccounts && account != null -> {
val accountNameUM = account.accountName.toUM()
TitleState.WithAccount(
tokenName = tokenName,
accountName = accountNameUM.value,
accountIconUM = AccountIconUM.CryptoPortfolio(
value = account.icon.value,
color = account.icon.color,
),
)
}
hasMultipleWallets -> TitleState.WithWallet(
tokenName = tokenName,
walletName = walletName,
deviceIconUM = deviceIconUM,
)
else -> TitleState.Simple(tokenName = tokenName)
}
}
private fun createSubtitle(): TextReference {
val networkName = cryptoCurrency.network.name
return when (cryptoCurrency) {
is CryptoCurrency.Token -> {
val standardName = cryptoCurrency.network.standardType
.takeIf { it !is Network.StandardType.Unspecified }
?.name
if (standardName != null) {
resourceReference(
CoreResR.string.token_details_toolbar_subtitle_standard,
wrappedList(standardName, networkName),
)
} else {
resourceReference(CoreResR.string.token_details_toolbar_subtitle_network, wrappedList(networkName))
}
}
is CryptoCurrency.Coin -> {
resourceReference(CoreResR.string.token_details_toolbar_subtitle_network, wrappedList(networkName))
}
}
}
}

View file

@ -0,0 +1,31 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.utils.transformer.Transformer
/**
* Toggles the balance type between [TokenBalanceTypeUM.Type.ALL] and [TokenBalanceTypeUM.Type.AVAILABLE].
*
* No-op if the current balance state is not [TokenDetailsBalanceBlockUM.Content] or its
* [TokenDetailsBalanceBlockUM.Content.tokenBalanceTypeUM] is not [TokenBalanceTypeUM.Multiple].
*/
internal class ToggleBalanceTypeTransformer : Transformer<TokenDetailsUM> {
override fun transform(prevState: TokenDetailsUM): TokenDetailsUM {
val content = prevState.balanceBlockUM as? TokenDetailsBalanceBlockUM.Content ?: return prevState
val multiple = content.tokenBalanceTypeUM as? TokenBalanceTypeUM.Multiple ?: return prevState
val nextType = when (multiple.type) {
TokenBalanceTypeUM.Type.ALL -> TokenBalanceTypeUM.Type.AVAILABLE
TokenBalanceTypeUM.Type.AVAILABLE -> TokenBalanceTypeUM.Type.ALL
}
return prevState.copy(
balanceBlockUM = content.copy(
tokenBalanceTypeUM = multiple.copy(type = nextType),
),
)
}
}

View file

@ -0,0 +1,51 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.themedColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.features.tokendetails.impl.R
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
internal class UpdateTopBarMenuTransformer(
private val userWallet: UserWallet,
private val hasDerivations: Boolean,
private val isXPubSupported: Boolean,
private val onGenerateExtendedKey: () -> Unit,
private val onHideClick: () -> Unit,
) : Transformer<TokenDetailsUM> {
override fun transform(prevState: TokenDetailsUM): TokenDetailsUM = prevState.copy(
topAppBarUM = prevState.topAppBarUM.copy(menuItems = createMenuItems()),
)
private fun createMenuItems() = if (userWallet is UserWallet.Cold &&
userWallet.cardTypesResolver.isSingleWalletWithToken()
) {
persistentListOf()
} else {
buildList {
if (isXPubSupported && hasDerivations) {
add(
TangemDropdownMenuItem(
title = resourceReference(R.string.token_details_generate_xpub),
textColor = themedColor { TangemTheme.colors.text.primary1 },
onClick = onGenerateExtendedKey,
),
)
}
add(
TangemDropdownMenuItem(
title = resourceReference(R.string.token_details_hide_token),
textColor = themedColor { TangemTheme.colors.text.warning },
onClick = onHideClick,
),
)
}.toImmutableList()
}
}

View file

@ -1,30 +1,157 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.ui
import android.content.res.Configuration
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.features.markets.token.block.TokenMarketBlockComponent
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem
import com.tangem.core.ui.components.haze.hazeEffectTangem
import com.tangem.core.ui.components.haze.hazeSourceTangem
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.ds.topbar.collapsing.TangemCollapsingTopBar
import com.tangem.core.ui.ds.topbar.collapsing.rememberTangemExitUntilCollapsedScrollBehavior
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.themedColor
import com.tangem.core.ui.res.LocalRootBackgroundColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenDetailsBalanceBlock
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.TokenDetailsBalanceBlockHeight
import dev.chrisbanes.haze.HazeProgressive
import dev.chrisbanes.haze.HazeTint
import kotlinx.collections.immutable.persistentListOf
@Suppress("UnusedParameter")
@Composable
internal fun TokenDetailsScreen(
tokenDetailsUM: TokenDetailsUM,
tokenMarketBlockComponent: TokenMarketBlockComponent?,
modifier: Modifier = Modifier,
) {
internal fun TokenDetailsScreen(tokenDetailsUM: TokenDetailsUM, modifier: Modifier = Modifier) {
val topAppBarUM = tokenDetailsUM.topAppBarUM
val statusBarHeight = with(LocalDensity.current) { WindowInsets.systemBars.getTop(this).toDp() }
val topBarHeight = 64.dp
val partialCollapsedHeight = topBarHeight + statusBarHeight
val expandedHeight = TokenDetailsBalanceBlockHeight + partialCollapsedHeight
val behavior = rememberTangemExitUntilCollapsedScrollBehavior(
expandedHeight = expandedHeight,
partialCollapsedHeight = partialCollapsedHeight,
)
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(
text = "Token Details Redesign",
style = TangemTheme.typography.h2,
color = TangemTheme.colors.text.primary1,
Box(
modifier = Modifier
.fillMaxSize()
.hazeSourceTangem(zIndex = -2f),
) {
TangemCollapsingTopBar(
state = behavior.state,
collapsingPart = {
TokenDetailsBalanceBlock(
balanceBlockUM = tokenDetailsUM.balanceBlockUM,
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(top = topBarHeight),
)
},
body = {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.nestedScroll(behavior.nestedScrollConnection),
) {
// TODO [REDACTED_TASK_KEY] Token Details Make Transaction History
}
},
)
}
val rootBackground by LocalRootBackgroundColor.current
val hazeIntensity by animateFloatAsState(
targetValue = (behavior.state.collapsedFraction * 2f).coerceIn(0f, 1f),
label = "TopBarHazeIntensity",
)
Box(
modifier = Modifier.hazeEffectTangem {
fallbackTint = HazeTint(rootBackground.copy(alpha = hazeIntensity / 2f))
progressive = HazeProgressive.verticalGradient(
startIntensity = hazeIntensity,
endIntensity = 0f,
preferPerformance = true,
)
},
) {
TokenDetailsTopBar(topAppBarUM = topAppBarUM)
}
}
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun TokenDetailsScreen_Preview() {
TangemThemePreviewRedesign {
TokenDetailsScreen(
tokenDetailsUM = TokenDetailsUM(
topAppBarUM = TokenDetailsTopAppBarUM(
titleState = TitleState.WithAccount(
tokenName = "Tether",
accountName = stringReference("Portfolio"),
accountIconUM = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Star,
color = CryptoPortfolioIcon.Color.Azure,
),
),
subtitle = stringReference("ERC-20 in Ethereum network"),
onBackClick = {},
menuItems = persistentListOf(
TangemDropdownMenuItem(
title = stringReference("Hide Token"),
textColor = themedColor { TangemTheme.colors.text.warning },
onClick = {},
),
),
),
balanceBlockUM = TokenDetailsBalanceBlockUM.Loading(
actionButtons = persistentListOf(),
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
),
marketPriceBlockState = MarketPriceBlockState.Loading(currencySymbol = "USDT"),
stakingBlocksState = null,
pullToRefreshConfig = PullToRefreshConfig(
isRefreshing = false,
onRefresh = {},
),
isBalanceHidden = false,
isMarketPriceAvailable = true,
),
)
}
}
}
// endregion Preview

View file

@ -0,0 +1,478 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
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.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.util.fastForEach
import com.tangem.common.ui.account.AccountIcon
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.core.ui.components.account.AccountIconSize
import com.tangem.core.ui.components.dropdownmenu.TangemDropdownItem
import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenu
import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem
import com.tangem.core.ui.ds.image.DeviceIconUM
import com.tangem.core.ui.ds.image.TangemDeviceIcon
import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds.topbar.TangemTopBarActionContent
import com.tangem.core.ui.ds.topbar.TangemTopBarActionUM
import com.tangem.core.ui.extensions.resolveAnnotatedReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.extensions.themedColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState
import kotlinx.collections.immutable.persistentListOf
import com.tangem.core.ui.R as CoreUiR
@Composable
internal fun TokenDetailsTopBar(topAppBarUM: TokenDetailsTopAppBarUM, modifier: Modifier = Modifier) {
TangemTopBar(
modifier = modifier.statusBarsPadding(),
startContent = {
TangemTopBarActionContent(
actionUM = TangemTopBarActionUM(
iconRes = CoreUiR.drawable.ic_back_24,
onClick = topAppBarUM.onBackClick,
ghostModeProgress = 1f,
),
)
},
endContent = if (topAppBarUM.menuItems.isNotEmpty()) {
{
var isDropdownMenuShown by rememberSaveable { mutableStateOf(false) }
Box {
TangemTopBarActionContent(
actionUM = TangemTopBarActionUM(
iconRes = CoreUiR.drawable.ic_more_default_24,
onClick = { isDropdownMenuShown = true },
ghostModeProgress = 1f,
),
)
TangemDropdownMenu(
expanded = isDropdownMenuShown,
modifier = Modifier.background(TangemTheme.colors.background.primary),
onDismissRequest = { isDropdownMenuShown = false },
content = {
topAppBarUM.menuItems.fastForEach { menuItem ->
TangemDropdownItem(
item = menuItem,
dismissParent = { isDropdownMenuShown = false },
)
}
},
)
}
}
} else {
null
},
content = {
Column(
modifier = Modifier
.weight(1f)
.padding(horizontal = TangemTheme.dimens2.x1),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x0_5),
) {
TokenDetailsTitle(titleState = topAppBarUM.titleState)
Text(
text = topAppBarUM.subtitle.resolveAnnotatedReference(),
color = TangemTheme.colors2.text.neutral.secondary,
style = TangemTheme.typography2.captionMedium12,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
},
)
}
@Composable
private fun TokenDetailsTitle(titleState: TitleState) {
val appearance = TitleAppearance(
style = TangemTheme.typography2.bodySemibold16,
iconSize = TangemTheme.dimens2.x5,
spacing = TangemTheme.dimens2.x1,
)
when (titleState) {
is TitleState.Simple -> {
Text(
text = titleState.tokenName,
color = TangemTheme.colors2.text.neutral.primary,
style = appearance.style,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
autoSize = TextAutoSize.StepBased(
minFontSize = MIN_TITLE_FONT_SIZE,
maxFontSize = MAX_TITLE_FONT_SIZE,
),
)
}
is TitleState.WithWallet -> {
AdaptiveTokenWithSecondaryRow(
tokenName = titleState.tokenName,
secondaryName = titleState.walletName,
template = stringResourceSafe(
id = CoreUiR.string.token_details_toolbar_title_token_in_wallet,
titleState.tokenName,
titleState.walletName,
),
appearance = appearance,
icon = {
TangemDeviceIcon(
state = titleState.deviceIconUM,
modifier = Modifier.size(appearance.iconSize),
)
},
)
}
is TitleState.WithAccount -> {
val accountNameStr = titleState.accountName.resolveAnnotatedReference().toString()
AdaptiveTokenWithSecondaryRow(
tokenName = titleState.tokenName,
secondaryName = accountNameStr,
template = stringResourceSafe(
id = CoreUiR.string.token_details_toolbar_title_token_in_account,
titleState.tokenName,
accountNameStr,
),
appearance = appearance,
icon = {
AccountIcon(
name = titleState.accountName,
icon = titleState.accountIconUM,
size = AccountIconSize.ExtraSmall,
)
},
)
}
}
}
/**
* Adaptive title for [TitleState.WithAccount] / [TitleState.WithWallet].
*
* Phrase template carries the [IMAGE_PLACEHOLDER] marker translator decides where
* the icon sits (e.g. "Tether in [⭐] Portfolio" or "Tether in My Wallet [⭐]").
* RTL is handled by BiDi inside the single [Text].
*
* Width-driven cascade:
* 12. Full phrase as single [Text] with inline icon; [TextOverflow.Ellipsis]
* trims the secondary name tail when needed.
* 3. No meaningful tail left fall back to `[tokenName] [icon]` in a [Row];
* icon is a sibling so ellipsis trims only tokenName, never the icon.
* 45. tokenName itself doesn't fit [TextAutoSize] shrinks to
* [MIN_TITLE_FONT_SIZE], then [TextOverflow.Ellipsis] tails.
*
* #1/#2 vs #3 is decided here via [rememberTextMeasurer]; #4/#5 are delegated
* to [TextAutoSize] + [TextOverflow.Ellipsis] in the fallback branch.
*/
@Composable
private fun AdaptiveTokenWithSecondaryRow(
tokenName: String,
secondaryName: String,
template: String,
appearance: TitleAppearance,
icon: @Composable () -> Unit,
) {
val (beforeIcon, afterIcon) = remember(template) { splitTemplate(template) }
val inlineContent = rememberIconInlineContent(appearance.iconSize, icon)
BoxWithConstraints(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
val isFullTextShown = rememberShouldShowFullText(
beforeIcon = beforeIcon,
afterIcon = afterIcon,
secondaryName = secondaryName,
appearance = appearance,
maxWidthPx = constraints.maxWidth,
)
if (isFullTextShown) {
FullPhraseTitle(
beforeIcon = beforeIcon,
afterIcon = afterIcon,
style = appearance.style,
inlineContent = inlineContent,
)
} else {
FallbackTokenWithIconTitle(
tokenName = tokenName,
appearance = appearance,
icon = icon,
)
}
}
}
private fun splitTemplate(template: String): Pair<String, String> {
val parts = template.split(IMAGE_PLACEHOLDER, limit = 2)
return if (parts.size == 2) parts[0] to parts[1] else template to ""
}
@Composable
private fun rememberIconInlineContent(iconSize: Dp, icon: @Composable () -> Unit): Map<String, InlineTextContent> {
val iconSizeSp = with(LocalDensity.current) { iconSize.toSp() }
val currentIcon by rememberUpdatedState(icon)
return remember(iconSizeSp) {
mapOf(
ICON_INLINE_ID to InlineTextContent(
placeholder = Placeholder(
width = iconSizeSp,
height = iconSizeSp,
placeholderVerticalAlign = PlaceholderVerticalAlign.Center,
),
children = {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
currentIcon()
}
},
),
)
}
}
@Composable
private fun rememberShouldShowFullText(
beforeIcon: String,
afterIcon: String,
secondaryName: String,
appearance: TitleAppearance,
maxWidthPx: Int,
): Boolean {
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
return remember(beforeIcon, afterIcon, secondaryName, maxWidthPx, appearance, density) {
if (maxWidthPx <= 0) return@remember true
val fullTextWidthPx = measurer
.measure(text = beforeIcon + afterIcon, style = appearance.style, softWrap = false)
.size.width
val secondaryWidthPx = measurer
.measure(text = secondaryName, style = appearance.style, softWrap = false)
.size.width
val staticWidthPx = (fullTextWidthPx - secondaryWidthPx).coerceAtLeast(0)
val iconReservePx = with(density) {
(appearance.iconSize + appearance.spacing * 2).toPx()
}.toInt()
val minSecondaryPx = with(density) { MIN_SECONDARY_NAME_WIDTH.toPx() }.toInt()
staticWidthPx + iconReservePx + minSecondaryPx <= maxWidthPx
}
}
@Composable
private fun FullPhraseTitle(
beforeIcon: String,
afterIcon: String,
style: TextStyle,
inlineContent: Map<String, InlineTextContent>,
) {
val fullText = remember(beforeIcon, afterIcon) {
buildAnnotatedString {
append(beforeIcon)
appendInlineContent(ICON_INLINE_ID, IMAGE_PLACEHOLDER)
append(afterIcon)
}
}
Text(
text = fullText,
inlineContent = inlineContent,
color = TangemTheme.colors2.text.neutral.primary,
style = style,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
@Composable
private fun FallbackTokenWithIconTitle(tokenName: String, appearance: TitleAppearance, icon: @Composable () -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(appearance.spacing, Alignment.CenterHorizontally),
) {
Text(
text = tokenName,
color = TangemTheme.colors2.text.neutral.primary,
style = appearance.style,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
autoSize = TextAutoSize.StepBased(
minFontSize = MIN_TITLE_FONT_SIZE,
maxFontSize = MAX_TITLE_FONT_SIZE,
),
modifier = Modifier.weight(weight = 1f, fill = false),
)
icon()
}
}
@Immutable
private data class TitleAppearance(
val style: TextStyle,
val iconSize: Dp,
val spacing: Dp,
)
private const val ICON_INLINE_ID = "account_icon"
private const val IMAGE_PLACEHOLDER = "%image%"
private val MIN_TITLE_FONT_SIZE = 12.sp
private val MAX_TITLE_FONT_SIZE = 16.sp
private val MIN_SECONDARY_NAME_WIDTH = 48.dp
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun TokenDetailsTopBar_Preview(
@PreviewParameter(TokenDetailsTopBarPreviewProvider::class) titleState: TitleState,
) {
TangemThemePreviewRedesign {
TokenDetailsTopBar(
topAppBarUM = TokenDetailsTopAppBarUM(
titleState = titleState,
subtitle = stringReference("ERC-20 in Ethereum network"),
onBackClick = {},
menuItems = persistentListOf(
TangemDropdownMenuItem(
title = stringReference("Hide Token"),
textColor = themedColor { TangemTheme.colors.text.warning },
onClick = {},
),
),
),
)
}
}
private class TokenDetailsTopBarPreviewProvider : PreviewParameterProvider<TitleState> {
override val values: Sequence<TitleState>
get() = sequenceOf(
// === Base title states ===
// Simple — 1 wallet, 1 account
TitleState.Simple(tokenName = "Tether"),
// WithWallet — N wallets, 1 account
TitleState.WithWallet(
tokenName = "Tether",
walletName = "Tangem wallet",
deviceIconUM = DeviceIconUM.Card(
mainColor = Color.DarkGray,
secondColor = null,
),
),
// WithAccount — 1 wallet, N accounts
TitleState.WithAccount(
tokenName = "Tether",
accountName = stringReference("Portfolio"),
accountIconUM = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Star,
color = CryptoPortfolioIcon.Color.Azure,
),
),
// === AdaptiveTokenTitleRow cascade cases ===
// Cascade #1 — full text fits as is (short token + short wallet)
TitleState.WithWallet(
tokenName = "BTC",
walletName = "Main",
deviceIconUM = DeviceIconUM.Card(
mainColor = Color.DarkGray,
secondColor = null,
),
),
// Cascade #2 — full text doesn't fit, ellipsized tail still meaningful
TitleState.WithWallet(
tokenName = "Tether",
walletName = "My Long Tangem Hardware Wallet",
deviceIconUM = DeviceIconUM.Card(
mainColor = Color.DarkGray,
secondColor = null,
),
),
// Cascade #3 — secondary part too small to be meaningful, drop to token-only + icon
TitleState.WithWallet(
tokenName = "USDCoinWrapped",
walletName = "Super Extra Long Wallet Name That Definitely Wont Fit",
deviceIconUM = DeviceIconUM.Card(
mainColor = Color.DarkGray,
secondColor = null,
),
),
// Cascade #4 — even tokenName alone doesn't fit at full font size: TextAutoSize shrinks it
TitleState.WithAccount(
tokenName = "VeryLongTokenNameThatOverflowsVeryLongTokenNameThatOverflows",
accountName = stringReference("Portfolio"),
accountIconUM = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Star,
color = CryptoPortfolioIcon.Color.Azure,
),
),
// Cascade #5 — even at MIN_TITLE_FONT_SIZE doesn't fit: TextOverflow.Ellipsis tails it
TitleState.Simple(
tokenName = "ExtremelyLongTokenNameThatCannotPossiblyFitEvenAtMinFontSize",
),
// Account variant — long account name triggers ellipsized tail (#2)
TitleState.WithAccount(
tokenName = "Tether",
accountName = stringReference("My Personal Long Account Name"),
accountIconUM = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Star,
color = CryptoPortfolioIcon.Color.Azure,
),
),
// Account variant — drop secondary, icon-only fallback (#3)
TitleState.WithAccount(
tokenName = "USDCoinWrapped",
accountName = stringReference("Super Extra Long Account Name That Wont Fit Anywhere"),
accountIconUM = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Star,
color = CryptoPortfolioIcon.Color.Azure,
),
),
)
}
// endregion Preview

View file

@ -0,0 +1,34 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.ui.bottomsheet
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.feature.tokendetails.presentation.tokendetails.model.DynamicAddressesDelegate
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses.DynamicAddressesBottomSheet
internal class DynamicAddressesBottomSheetComponent(
private val dynamicAddressesDelegate: DynamicAddressesDelegate,
private val onDismiss: () -> Unit,
) : ComposableBottomSheetComponent {
override fun dismiss() {
onDismiss()
}
@Composable
override fun BottomSheet() {
val content by dynamicAddressesDelegate.bottomSheetConfig.collectAsStateWithLifecycle()
val config = remember(content) {
TangemBottomSheetConfig(
isShown = true,
onDismissRequest = ::dismiss,
content = content,
)
}
DynamicAddressesBottomSheet(config = config)
}
}

View file

@ -0,0 +1,239 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components
import android.content.res.Configuration
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.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.dp
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
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.button.action.ActionButtons
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.extensions.resolveAnnotatedReference
import com.tangem.core.ui.extensions.resolveReference
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.TangemThemePreviewRedesign
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.features.tokendetails.impl.R
import kotlinx.collections.immutable.persistentListOf
private val CurrencyIconSize: Dp = 70.dp
private val NetworkBadgeSize: Dp = 24.dp
internal val TokenDetailsBalanceBlockHeight: Dp = 404.dp
@Composable
internal fun TokenDetailsBalanceBlock(balanceBlockUM: TokenDetailsBalanceBlockUM, modifier: Modifier = Modifier) {
val rootBackground by LocalRootBackgroundColor.current
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.fillMaxWidth()
.padding(vertical = TangemTheme.dimens2.x10),
) {
CurrencyIcon(
state = balanceBlockUM.currencyIconState,
shouldDisplayNetwork = true,
iconSize = CurrencyIconSize,
networkBadgeSize = NetworkBadgeSize,
networkBadgeBackground = rootBackground,
)
SpacerH(TangemTheme.dimens2.x3)
when (balanceBlockUM) {
is TokenDetailsBalanceBlockUM.Content -> ContentBody(state = balanceBlockUM)
is TokenDetailsBalanceBlockUM.Loading -> LoadingBody()
is TokenDetailsBalanceBlockUM.Error -> ErrorBody()
}
SpacerH(TangemTheme.dimens2.x10)
ActionButtons(buttons = balanceBlockUM.actionButtons)
}
}
@Composable
private fun ContentBody(state: TokenDetailsBalanceBlockUM.Content) {
AnimatedContent(
targetState = state.tokenBalanceTypeUM.type,
label = "Token balance type",
) { currentType ->
val tokenBalanceTypeUM = state.tokenBalanceTypeUM
when (tokenBalanceTypeUM) {
is TokenBalanceTypeUM.Multiple -> Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1),
modifier = Modifier.clickable(onClick = tokenBalanceTypeUM.onSelect),
) {
Text(
text = currentType.text.resolveReference(),
style = TangemTheme.typography2.calloutSemibold15,
color = TangemTheme.colors2.text.neutral.secondary,
)
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_sort_24),
contentDescription = null,
tint = TangemTheme.colors2.graphic.neutral.secondary,
modifier = Modifier.size(TangemTheme.dimens2.x4),
)
}
TokenBalanceTypeUM.Single -> Text(
text = currentType.text.resolveReference(),
style = TangemTheme.typography2.calloutSemibold15,
color = TangemTheme.colors2.text.neutral.secondary,
)
}
}
SpacerH(TangemTheme.dimens2.x2)
Text(
text = state.displayFiatBalance.resolveAnnotatedReference(),
style = TangemTheme.typography2.titleRegular44,
color = TangemTheme.colors2.text.neutral.primary,
)
SpacerH(TangemTheme.dimens2.x2_5)
Text(
text = state.displayCryptoBalance.resolveAnnotatedReference(),
style = TangemTheme.typography2.bodySemibold16,
color = TangemTheme.colors2.text.neutral.secondary,
)
}
@Composable
private fun LoadingBody() {
Text(
text = TokenBalanceTypeUM.Type.ALL.text.resolveReference(),
style = TangemTheme.typography2.calloutSemibold15,
color = TangemTheme.colors2.text.neutral.secondary,
)
SpacerH(TangemTheme.dimens2.x2)
TextShimmer(
style = TangemTheme.typography2.titleRegular44,
text = "$1234567890",
radius = TangemTheme.dimens2.x6,
)
SpacerH(TangemTheme.dimens2.x2)
TextShimmer(
style = TangemTheme.typography2.bodySemibold16,
text = "12345.67",
radius = TangemTheme.dimens2.x4,
)
}
@Composable
private fun ErrorBody() {
Text(
text = "",
style = TangemTheme.typography2.titleRegular44,
color = TangemTheme.colors2.text.neutral.primary,
)
SpacerH(TangemTheme.dimens2.x2_5)
Text(
text = "",
style = TangemTheme.typography2.bodySemibold16,
color = TangemTheme.colors2.text.neutral.secondary,
)
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun TokenDetailsBalanceBlock_Preview(
@PreviewParameter(PreviewProvider::class) params: TokenDetailsBalanceBlockUM,
) {
TangemThemePreviewRedesign {
TokenDetailsBalanceBlock(
balanceBlockUM = params,
modifier = Modifier.background(TangemTheme.colors2.surface.level2),
)
}
}
private class PreviewProvider : PreviewParameterProvider<TokenDetailsBalanceBlockUM> {
private val previewActionButtons = persistentListOf(
TangemButtonUM(
text = stringReference("Add funds"),
tangemIconUM = TangemIconUM.Icon(
iconRes = R.drawable.ic_arrow_down_24,
tintReference = { TangemTheme.colors2.graphic.neutral.primary },
),
onClick = { },
isEnabled = true,
type = TangemButtonType.Secondary,
),
TangemButtonUM(
text = stringReference("Transfer"),
tangemIconUM = TangemIconUM.Icon(
iconRes = R.drawable.ic_arrow_up_24,
tintReference = { TangemTheme.colors2.graphic.neutral.primary },
),
onClick = { },
isEnabled = true,
type = TangemButtonType.Secondary,
),
)
override val values: Sequence<TokenDetailsBalanceBlockUM>
get() = sequenceOf(
TokenDetailsBalanceBlockUM.Content(
actionButtons = previewActionButtons,
tokenBalanceTypeUM = TokenBalanceTypeUM.Multiple(
type = TokenBalanceTypeUM.Type.ALL,
availableTypes = persistentListOf(
TokenBalanceTypeUM.Type.ALL,
TokenBalanceTypeUM.Type.AVAILABLE,
),
onSelect = { },
),
currencyIconState = CurrencyIconState.Loading,
displayCryptoBalanceAll = stringReference("0.0613884 BTC"),
displayFiatBalanceAll = stringReference("$12,380.94"),
displayCryptoBalanceAvailable = stringReference("0.05 BTC"),
displayFiatBalanceAvailable = stringReference("$10,000.00"),
isBalanceFlickering = false,
),
TokenDetailsBalanceBlockUM.Content(
actionButtons = previewActionButtons,
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
displayCryptoBalanceAll = stringReference("123.456 USDT"),
displayFiatBalanceAll = stringReference("$123.45"),
displayCryptoBalanceAvailable = null,
displayFiatBalanceAvailable = null,
isBalanceFlickering = false,
),
TokenDetailsBalanceBlockUM.Loading(
actionButtons = previewActionButtons,
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
),
TokenDetailsBalanceBlockUM.Error(
actionButtons = previewActionButtons,
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
),
)
}
// endregion

View file

@ -0,0 +1,38 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses
import androidx.compose.runtime.Composable
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.R as CoreR
@Composable
internal fun DynamicAddressesBottomSheet(config: TangemBottomSheetConfig) {
TangemModalBottomSheet<DynamicAddressesBottomSheetConfig>(
config = config,
containerColor = TangemTheme.colors.background.tertiary,
title = {
TangemModalBottomSheetTitle(
endIconRes = CoreR.drawable.ic_close_24,
onEndClick = config.onDismissRequest,
)
},
) { content ->
when (content) {
is DynamicAddressesBottomSheetConfig.Enable -> DynamicAddressesEnableContent(content = content)
is DynamicAddressesBottomSheetConfig.DisableWithoutConsolidation -> {
DynamicAddressesDisableWithoutConsolidationContent(content = content)
}
is DynamicAddressesBottomSheetConfig.DisableWithConsolidation -> {
DynamicAddressesDisableWithConsolidationContent(content = content)
}
is DynamicAddressesBottomSheetConfig.ConflictingCustomTokens -> {
DynamicAddressesConflictingCustomTokensContent(content = content)
}
is DynamicAddressesBottomSheetConfig.ServiceUnavailable -> {
DynamicAddressesServiceUnavailableContent(content = content)
}
}
}
}

View file

@ -0,0 +1,44 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
@Immutable
internal sealed class DynamicAddressesBottomSheetConfig : TangemBottomSheetConfigContent {
data class Enable(
val isCardScanRequired: Boolean,
val isLoading: Boolean = false,
val onEnableClick: () -> Unit,
) : DynamicAddressesBottomSheetConfig()
data class DisableWithoutConsolidation(
val onDisableClick: () -> Unit,
val onReadMoreClick: () -> Unit,
) : DynamicAddressesBottomSheetConfig()
data class DisableWithConsolidation(
val feeState: DisableFeeState = DisableFeeState.Loading,
val isSending: Boolean = false,
val onDisableClick: () -> Unit,
val onRefreshFee: () -> Unit,
val onReadMoreClick: () -> Unit,
) : DynamicAddressesBottomSheetConfig()
sealed interface DisableFeeState {
data object Loading : DisableFeeState
data class Content(
val feeSymbol: String,
val fiatFormatted: String,
) : DisableFeeState
data object Error : DisableFeeState
}
data class ConflictingCustomTokens(
val onDismissClick: () -> Unit,
) : DynamicAddressesBottomSheetConfig()
data class ServiceUnavailable(
val onDismissClick: () -> Unit,
) : DynamicAddressesBottomSheetConfig()
}

View file

@ -0,0 +1,529 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withLink
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.components.PrimaryButton
import com.tangem.core.ui.components.PrimaryButtonIconEnd
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.audits.AuditLabel
import com.tangem.core.ui.components.audits.AuditLabelUM
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.res.R
import com.tangem.core.ui.R as CoreR
import com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.dynamicaddresses.DynamicAddressesBottomSheetConfig.DisableFeeState
@Composable
internal fun DynamicAddressesEnableContent(content: DynamicAddressesBottomSheetConfig.Enable) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens.spacing16),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
painter = painterResource(id = CoreR.drawable.ic_dynamic_addresses_bottomsheet_enable_top),
contentDescription = null,
modifier = Modifier.size(TangemTheme.dimens.size44),
tint = TangemTheme.colors.icon.accent,
)
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing12))
Text(
text = stringResourceSafe(id = R.string.dynamic_addresses),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing8))
Text(
text = stringResourceSafe(id = R.string.dynamic_addresses_enter_subtitle),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing24))
FeatureItem(
iconRes = CoreR.drawable.ic_dynamic_addresses_bottomsheet_flash_24,
title = stringResourceSafe(id = R.string.dynamic_addresses_enter_features_receving_title),
description = stringResourceSafe(id = R.string.dynamic_addresses_enter_features_receving_description),
)
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing16))
FeatureItem(
iconRes = CoreR.drawable.ic_dynamic_addresses_bottomsheet_check_24,
title = stringResourceSafe(id = R.string.dynamic_addresses_enter_features_privacy_title),
description = stringResourceSafe(id = R.string.dynamic_addresses_enter_features_privacy_description),
)
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing24))
PrimaryButtonIconEnd(
text = stringResourceSafe(id = R.string.dynamic_addresses_enter_main_button_title),
iconResId = if (content.isCardScanRequired) CoreR.drawable.ic_tangem_24 else null,
onClick = content.onEnableClick,
modifier = Modifier.fillMaxWidth(),
showProgress = content.isLoading,
enabled = !content.isLoading,
)
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing16))
}
}
@Composable
internal fun DynamicAddressesDisableWithoutConsolidationContent(
content: DynamicAddressesBottomSheetConfig.DisableWithoutConsolidation,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens.spacing16),
horizontalAlignment = Alignment.CenterHorizontally,
) {
DisableHeader()
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing24))
PrimaryButtonIconEnd(
text = stringResourceSafe(id = R.string.dynamic_addresses_disable_main_button_title),
iconResId = null,
onClick = content.onDisableClick,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing16))
}
}
@Composable
internal fun DynamicAddressesDisableWithConsolidationContent(
content: DynamicAddressesBottomSheetConfig.DisableWithConsolidation,
) {
val isConfirmEnabled = content.feeState is DisableFeeState.Content && !content.isSending
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens.spacing16),
horizontalAlignment = Alignment.CenterHorizontally,
) {
DisableHeader()
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing16))
DisableFeeBlock(
feeState = content.feeState,
onReadMoreClick = content.onReadMoreClick,
)
if (content.feeState is DisableFeeState.Error) {
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing12))
Notification(
config = NotificationConfig(
title = resourceReference(R.string.send_fee_unreachable_error_title),
subtitle = resourceReference(R.string.send_fee_unreachable_error_text),
iconResId = CoreR.drawable.ic_alert_24,
iconTint = NotificationConfig.IconTint.Warning,
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
text = resourceReference(R.string.warning_button_refresh),
onClick = content.onRefreshFee,
),
),
containerColor = TangemTheme.colors.background.action,
)
}
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing24))
PrimaryButtonIconEnd(
text = stringResourceSafe(id = R.string.dynamic_addresses_disable_main_button_title),
iconResId = CoreR.drawable.ic_tangem_24,
onClick = content.onDisableClick,
modifier = Modifier.fillMaxWidth(),
showProgress = content.isSending,
enabled = isConfirmEnabled,
)
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing16))
}
}
@Composable
private fun DisableHeader() {
Icon(
painter = painterResource(id = CoreR.drawable.ic_dynamic_addresses_bottomsheet_enable_unavailable),
contentDescription = null,
modifier = Modifier.size(TangemTheme.dimens.size44),
tint = TangemTheme.colors.icon.attention,
)
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing12))
Text(
text = stringResourceSafe(id = R.string.dynamic_addresses_disable_title),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing8))
Text(
text = stringResourceSafe(id = R.string.dynamic_addresses_disable_description),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
)
}
@Composable
private fun DisableFeeBlock(feeState: DisableFeeState, onReadMoreClick: () -> Unit) {
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(
color = TangemTheme.colors.background.action,
shape = TangemTheme.shapes.roundedCornersXMedium,
)
.padding(TangemTheme.dimens.spacing12),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResourceSafe(id = R.string.common_network_fee_title),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary1,
)
when (feeState) {
is DisableFeeState.Loading -> TextShimmer(
style = TangemTheme.typography.body2,
modifier = Modifier.size(
width = TangemTheme.dimens.size80,
height = TangemTheme.dimens.spacing16,
),
)
is DisableFeeState.Content -> Row(
verticalAlignment = Alignment.CenterVertically,
) {
AuditLabel(
state = AuditLabelUM(
text = stringReference(feeState.feeSymbol),
type = AuditLabelUM.Type.General,
),
)
Text(
text = feeState.fiatFormatted,
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier.padding(start = TangemTheme.dimens.spacing4),
)
}
is DisableFeeState.Error -> Text(
text = "\u2014",
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary1,
)
}
}
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing8))
DisableFeeDescription(onReadMoreClick = onReadMoreClick)
}
}
@Composable
private fun DisableFeeDescription(onReadMoreClick: () -> Unit) {
val readMoreText = stringResourceSafe(id = R.string.common_read_more)
val fullText = stringResourceSafe(id = R.string.dynamic_addresses_disable_fee_description)
val annotatedString = buildAnnotatedString {
withStyle(SpanStyle(color = TangemTheme.colors.text.tertiary)) {
append(fullText)
append(" ")
}
withLink(
link = LinkAnnotation.Clickable(
tag = "read_more",
linkInteractionListener = { onReadMoreClick() },
),
) {
withStyle(SpanStyle(color = TangemTheme.colors.text.accent)) {
append(readMoreText)
}
}
}
Text(
text = annotatedString,
style = TangemTheme.typography.caption2,
)
}
@Composable
internal fun DynamicAddressesConflictingCustomTokensContent(
content: DynamicAddressesBottomSheetConfig.ConflictingCustomTokens,
) {
ErrorContent(
titleRes = R.string.dynamic_addresses_error_has_custom_token_title,
descriptionRes = R.string.dynamic_addresses_error_has_custom_token_description,
buttonTextRes = R.string.common_got_it,
onButtonClick = content.onDismissClick,
)
}
@Composable
internal fun DynamicAddressesServiceUnavailableContent(content: DynamicAddressesBottomSheetConfig.ServiceUnavailable) {
ErrorContent(
titleRes = R.string.dynamic_addresses_error_service_unavailable_title,
descriptionRes = R.string.dynamic_addresses_error_service_unavailable_description,
buttonTextRes = R.string.common_got_it,
onButtonClick = content.onDismissClick,
)
}
@Composable
private fun ErrorContent(titleRes: Int, descriptionRes: Int, buttonTextRes: Int, onButtonClick: () -> Unit) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens.spacing16),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
painter = painterResource(id = CoreR.drawable.ic_dynamic_addresses_bottomsheet_enable_unavailable),
contentDescription = null,
modifier = Modifier.size(TangemTheme.dimens.size44),
tint = TangemTheme.colors.icon.attention,
)
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing12))
Text(
text = stringResourceSafe(id = titleRes),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing8))
Text(
text = stringResourceSafe(id = descriptionRes),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing24))
PrimaryButton(
text = stringResourceSafe(id = buttonTextRes),
onClick = onButtonClick,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing16))
}
}
@Composable
private fun FeatureItem(iconRes: Int, title: String, description: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12),
) {
Icon(
painter = painterResource(id = iconRes),
contentDescription = null,
modifier = Modifier.size(TangemTheme.dimens.size24),
tint = TangemTheme.colors.icon.accent,
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
)
Spacer(modifier = Modifier.height(TangemTheme.dimens.spacing4))
Text(
text = description,
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.secondary,
)
}
}
}
// region Previews
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_Enable() {
TangemThemePreview {
DynamicAddressesEnableContent(
content = DynamicAddressesBottomSheetConfig.Enable(
isCardScanRequired = false,
onEnableClick = {},
),
)
}
}
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_EnableWithCardScan() {
TangemThemePreview {
DynamicAddressesEnableContent(
content = DynamicAddressesBottomSheetConfig.Enable(
isCardScanRequired = true,
onEnableClick = {},
),
)
}
}
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_DisableWithoutConsolidation() {
TangemThemePreview {
DynamicAddressesDisableWithoutConsolidationContent(
content = DynamicAddressesBottomSheetConfig.DisableWithoutConsolidation(
onDisableClick = {},
onReadMoreClick = {},
),
)
}
}
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_DisableFeeLoading() {
TangemThemePreview {
DynamicAddressesDisableWithConsolidationContent(
content = DynamicAddressesBottomSheetConfig.DisableWithConsolidation(
feeState = DisableFeeState.Loading,
onDisableClick = {},
onRefreshFee = {},
onReadMoreClick = {},
),
)
}
}
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_DisableFeeLoaded() {
TangemThemePreview {
DynamicAddressesDisableWithConsolidationContent(
content = DynamicAddressesBottomSheetConfig.DisableWithConsolidation(
feeState = DisableFeeState.Content(
feeSymbol = "BTC",
fiatFormatted = "~$0.12",
),
onDisableClick = {},
onRefreshFee = {},
onReadMoreClick = {},
),
)
}
}
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_DisableFeeError() {
TangemThemePreview {
DynamicAddressesDisableWithConsolidationContent(
content = DynamicAddressesBottomSheetConfig.DisableWithConsolidation(
feeState = DisableFeeState.Error,
onDisableClick = {},
onRefreshFee = {},
onReadMoreClick = {},
),
)
}
}
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_DisableSending() {
TangemThemePreview {
DynamicAddressesDisableWithConsolidationContent(
content = DynamicAddressesBottomSheetConfig.DisableWithConsolidation(
feeState = DisableFeeState.Content(
feeSymbol = "BTC",
fiatFormatted = "~$0.12",
),
isSending = true,
onDisableClick = {},
onRefreshFee = {},
onReadMoreClick = {},
),
)
}
}
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_ConflictingCustomTokens() {
TangemThemePreview {
DynamicAddressesConflictingCustomTokensContent(
content = DynamicAddressesBottomSheetConfig.ConflictingCustomTokens(onDismissClick = {}),
)
}
}
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_ServiceUnavailable() {
TangemThemePreview {
DynamicAddressesServiceUnavailableContent(
content = DynamicAddressesBottomSheetConfig.ServiceUnavailable(onDismissClick = {}),
)
}
}
// endregion

View file

@ -104,7 +104,6 @@ class ExpressStatusBottomSheetStateProvider : PreviewParameterProvider<ExpressSt
name = "Network One",
isTestnet = false,
standardType = Network.StandardType.ERC20,
backendId = "network1",
currencySymbol = "ETH",
derivationPath = Network.DerivationPath.None,
hasFiatFeeRate = true,

View file

@ -0,0 +1,491 @@
package com.tangem.feature.tokendetails.deeplink
import arrow.core.Either
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.common.routing.deeplink.DeeplinkConst.DERIVATION_PATH_KEY
import com.tangem.common.routing.deeplink.DeeplinkConst.NETWORK_ID_KEY
import com.tangem.common.routing.deeplink.DeeplinkConst.TOKEN_ID_KEY
import com.tangem.common.routing.deeplink.DeeplinkConst.TRANSACTION_ID_KEY
import com.tangem.common.routing.deeplink.DeeplinkConst.TYPE_KEY
import com.tangem.common.routing.deeplink.DeeplinkConst.WALLET_ID_KEY
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.status.utils.CryptoCurrencyBalanceFetcher
import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.common.wallets.error.SelectWalletError
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isLocked
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.tokens.wallet.WalletBalanceFetcher
import com.tangem.domain.wallets.models.GetUserWalletError
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.domain.wallets.usecase.SelectWalletUseCase
import com.tangem.features.tangempay.TangemPayFeatureToggles
import com.tangem.features.wallet.deeplink.WalletDeepLinkActionTrigger
import com.tangem.utils.logging.TangemLogger
import io.mockk.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ValueSource
@OptIn(ExperimentalCoroutinesApi::class)
class DefaultTokenDetailsDeepLinkHandlerTest {
private val appRouter: AppRouter = mockk()
private val selectWalletUseCase: SelectWalletUseCase = mockk()
private val getSelectedWalletSync: GetSelectedWalletSyncUseCase = mockk()
private val cryptoCurrencyBalanceFetcher: CryptoCurrencyBalanceFetcher = mockk()
private val tokenDetailsDeepLinkActionTrigger: TokenDetailsDeepLinkActionTrigger = mockk()
private val walletDeepLinkActionTrigger: WalletDeepLinkActionTrigger = mockk()
private val analyticsEventHandler: AnalyticsEventHandler = mockk()
private val getUserWalletUseCase: GetUserWalletUseCase = mockk()
private val walletBalanceFetcher: WalletBalanceFetcher = mockk()
private val tangemPayFeatureToggles: TangemPayFeatureToggles = mockk()
private val singleAccountListSupplier: SingleAccountListSupplier = mockk()
@BeforeEach
fun setUp() {
MockKAnnotations.init(this)
mockkObject(TangemLogger)
every { analyticsEventHandler.send(any()) } just Runs
every { appRouter.push(any(), any()) } just Runs
val userWallet: UserWallet = mockk()
every { userWallet.walletId } returns mockk()
every { getSelectedWalletSync() } returns Either.Right(
value = userWallet
)
}
@Test
fun `GIVEN error instead of user wallet WHEN handle deeplink THEN get error`() = runTest {
val queryParams = mapOf(WALLET_ID_KEY to "011")
every {
getUserWalletUseCase.invoke(
userWalletId = UserWalletId(
"011"
)
)
} returns Either.Left(
value = GetUserWalletError.UserWalletNotFound
)
every { TangemLogger.e("Error on getting user wallet") } just Runs
createHandler(scope = this, queryParams)
advanceUntilIdle()
verify { TangemLogger.e("Error on getting user wallet") }
}
@Test
fun `GIVEN locked user wallet WHEN handle deeplink THEN get error`() = runTest {
val queryParams = mapOf(WALLET_ID_KEY to "011")
every {
getUserWalletUseCase.invoke(
userWalletId = UserWalletId(
"011"
)
)
} returns Either.Right(
value = mockk { every { isLocked } returns true }
)
every { TangemLogger.e("Error on getting user wallet") } just Runs
createHandler(scope = this, queryParams)
advanceUntilIdle()
verify { TangemLogger.e("Error on getting user wallet") }
}
@Test
fun `GIVEN error instead select wallet WHEN handle deeplink THEN get error`() = runTest {
val queryParams = mapOf(WALLET_ID_KEY to "011")
val userWalletId = UserWalletId("011")
every { getUserWalletUseCase.invoke(userWalletId) } returns Either.Right(
value = mockk { every { isLocked } returns false }
)
coEvery { selectWalletUseCase.invoke(userWalletId) } returns Either.Left(
value = SelectWalletError.UnableToSelectUserWallet
)
every { TangemLogger.e("Error on selecting user wallet") } just Runs
createHandler(scope = this, queryParams)
advanceUntilIdle()
verify { TangemLogger.e("Error on selecting user wallet") }
}
@Test
fun `GIVEN no crypto by wallet WHEN handle deeplink THEN get error`() = runTest {
val queryParams = mapOf(
WALLET_ID_KEY to "011",
NETWORK_ID_KEY to "123",
TOKEN_ID_KEY to "321",
)
val userWalletId = UserWalletId("011")
every { getUserWalletUseCase.invoke(userWalletId) } returns Either.Right(
value = mockk {
every { isMultiCurrency } returns false
every { walletId } returns userWalletId
every { isLocked } returns false
}
)
coEvery { selectWalletUseCase.invoke(userWalletId) } returns Either.Right(
value = mockk {
every { walletId } returns userWalletId
}
)
coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns null
val expectedErrorText = """
Could not get crypto currency for
|- $NETWORK_ID_KEY: 123
|- $TOKEN_ID_KEY: 321
""".trimIndent()
every { TangemLogger.e(messageString = expectedErrorText) } just Runs
createHandler(scope = this, queryParams)
advanceUntilIdle()
verify { TangemLogger.e(messageString = expectedErrorText) }
}
@Test
fun `GIVEN multicurrency wallet WHEN handle deeplink THEN push new route`() = runTest {
val queryParams = mapOf(
WALLET_ID_KEY to "011",
NETWORK_ID_KEY to "123",
TOKEN_ID_KEY to "321",
DERIVATION_PATH_KEY to "777"
)
val userWalletId = UserWalletId("011")
val expectedCryptoCurrency = mockk<CryptoCurrency> {
every { network } returns mockk {
every { rawId } returns "123"
every { derivationPath } returns Network.DerivationPath.Card(value = "777")
}
every { id } returns CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkIdWithDerivationPath(
rawId = "321",
derivationPath = "777"
),
suffix = CryptoCurrency.ID.Suffix.RawID("321")
)
}
val expectedRoute = AppRoute.CurrencyDetails(
userWalletId = userWalletId,
currency = expectedCryptoCurrency,
)
every { getUserWalletUseCase.invoke(userWalletId) } returns Either.Right(
value = mockk {
every { isMultiCurrency } returns true
every { walletId } returns userWalletId
every { isLocked } returns false
}
)
coEvery { selectWalletUseCase.invoke(userWalletId) } returns Either.Right(
value = mockk {
every { walletId } returns userWalletId
}
)
coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns AccountList.empty(
userWalletId = userWalletId,
cryptoCurrencies = listOf(expectedCryptoCurrency),
)
createHandler(scope = this, queryParams)
advanceUntilIdle()
verify {
appRouter.push(
route = expectedRoute,
onComplete = any(),
)
}
}
@Test
fun `GIVEN single currency wallet WHEN handle deeplink THEN push new route`() = runTest {
val queryParams = mapOf(
WALLET_ID_KEY to "011",
NETWORK_ID_KEY to "123",
TOKEN_ID_KEY to "321",
DERIVATION_PATH_KEY to "777"
)
val userWalletId = UserWalletId("011")
val expectedCryptoCurrency = mockk<CryptoCurrency> {
every { network } returns mockk {
every { rawId } returns "123"
every { derivationPath } returns Network.DerivationPath.Card(value = "777")
}
every { id } returns CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkIdWithDerivationPath(
rawId = "321",
derivationPath = "777"
),
suffix = CryptoCurrency.ID.Suffix.RawID("321")
)
}
every { getUserWalletUseCase.invoke(userWalletId) } returns Either.Right(
value = mockk {
every { isMultiCurrency } returns false
every { walletId } returns userWalletId
every { isLocked } returns false
}
)
coEvery { selectWalletUseCase.invoke(userWalletId) } returns Either.Right(
value = mockk {
every { walletId } returns userWalletId
}
)
coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns AccountList.empty(
userWalletId = userWalletId,
cryptoCurrencies = listOf(expectedCryptoCurrency),
)
every { walletDeepLinkActionTrigger.selectWallet(userWalletId) } just Runs
createHandler(scope = this, queryParams)
advanceUntilIdle()
verify {
walletDeepLinkActionTrigger.selectWallet(userWalletId)
}
}
@ParameterizedTest
@ValueSource(strings = ["swap_status_update", "onramp_status_update"])
fun `GIVEN type WHEN handle deeplink THEN token details deeplink triggered`(type: String) = runTest {
val queryParams = mapOf(
WALLET_ID_KEY to "011",
NETWORK_ID_KEY to "123",
TOKEN_ID_KEY to "321",
DERIVATION_PATH_KEY to "777",
TRANSACTION_ID_KEY to "000",
TYPE_KEY to type,
)
val userWalletId = UserWalletId("011")
val expectedCryptoCurrency = mockk<CryptoCurrency> {
every { network } returns mockk {
every { rawId } returns "123"
every { derivationPath } returns Network.DerivationPath.Card(value = "777")
}
every { id } returns CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkIdWithDerivationPath(
rawId = "321",
derivationPath = "777"
),
suffix = CryptoCurrency.ID.Suffix.RawID("321")
)
}
every { getUserWalletUseCase.invoke(userWalletId) } returns Either.Right(
value = mockk {
every { isMultiCurrency } returns false
every { walletId } returns userWalletId
every { isLocked } returns false
}
)
coEvery { selectWalletUseCase.invoke(userWalletId) } returns Either.Right(
value = mockk {
every { walletId } returns userWalletId
}
)
coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns AccountList.empty(
userWalletId = userWalletId,
cryptoCurrencies = listOf(expectedCryptoCurrency),
)
every { walletDeepLinkActionTrigger.selectWallet(userWalletId) } just Runs
coEvery { tokenDetailsDeepLinkActionTrigger.trigger("000") } just Runs
createHandler(scope = this, queryParams)
advanceUntilIdle()
coVerify {
tokenDetailsDeepLinkActionTrigger.trigger("000")
}
}
@ParameterizedTest
@ValueSource(strings = ["income_transaction", "promo", "unknown"])
fun `GIVEN type WHEN handle deeplink THEN token details deeplink not triggered`(type: String) = runTest {
val queryParams = mapOf(
WALLET_ID_KEY to "011",
NETWORK_ID_KEY to "123",
TOKEN_ID_KEY to "321",
DERIVATION_PATH_KEY to "777",
TRANSACTION_ID_KEY to "000",
TYPE_KEY to type,
)
val userWalletId = UserWalletId("011")
val expectedCryptoCurrency = mockk<CryptoCurrency> {
every { network } returns mockk {
every { rawId } returns "123"
every { derivationPath } returns Network.DerivationPath.Card(value = "777")
}
every { id } returns CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkIdWithDerivationPath(
rawId = "321",
derivationPath = "777"
),
suffix = CryptoCurrency.ID.Suffix.RawID("321")
)
}
every { getUserWalletUseCase.invoke(userWalletId) } returns Either.Right(
value = mockk {
every { isMultiCurrency } returns false
every { walletId } returns userWalletId
every { isLocked } returns false
}
)
coEvery { selectWalletUseCase.invoke(userWalletId) } returns Either.Right(
value = mockk {
every { walletId } returns userWalletId
}
)
coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns AccountList.empty(
userWalletId = userWalletId,
cryptoCurrencies = listOf(expectedCryptoCurrency),
)
every { walletDeepLinkActionTrigger.selectWallet(userWalletId) } just Runs
coEvery { tokenDetailsDeepLinkActionTrigger.trigger("000") } just Runs
createHandler(scope = this, queryParams)
advanceUntilIdle()
coVerify(exactly = 0) {
tokenDetailsDeepLinkActionTrigger.trigger("000")
}
}
@Test
fun `GIVEN multicurrency wallet AND isFromOnNewIntent WHEN handle deeplink THEN fetch currency`() = runTest {
val queryParams = mapOf(
WALLET_ID_KEY to "011",
NETWORK_ID_KEY to "123",
TOKEN_ID_KEY to "321",
DERIVATION_PATH_KEY to "777"
)
val userWalletId = UserWalletId("011")
val expectedCryptoCurrency = mockk<CryptoCurrency> {
every { network } returns mockk {
every { rawId } returns "123"
every { derivationPath } returns Network.DerivationPath.Card(value = "777")
}
every { id } returns CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkIdWithDerivationPath(
rawId = "321",
derivationPath = "777"
),
suffix = CryptoCurrency.ID.Suffix.RawID("321")
)
}
every { getUserWalletUseCase.invoke(userWalletId) } returns Either.Right(
value = mockk {
every { isMultiCurrency } returns true
every { walletId } returns userWalletId
every { isLocked } returns false
}
)
coEvery { selectWalletUseCase.invoke(userWalletId) } returns Either.Right(
value = mockk {
every { walletId } returns userWalletId
}
)
coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns AccountList.empty(
userWalletId = userWalletId,
cryptoCurrencies = listOf(expectedCryptoCurrency),
)
every {
cryptoCurrencyBalanceFetcher.invoke(userWalletId = userWalletId, currency = expectedCryptoCurrency)
} just Runs
createHandler(scope = this, queryParams, isFromOnNewIntent = true)
advanceUntilIdle()
verify { cryptoCurrencyBalanceFetcher.invoke(userWalletId = userWalletId, currency = expectedCryptoCurrency) }
}
@Test
fun `GIVEN single currency wallet AND isFromOnNewIntent WHEN handle deeplink THEN fetch currency`() = runTest {
val queryParams = mapOf(
WALLET_ID_KEY to "011",
NETWORK_ID_KEY to "123",
TOKEN_ID_KEY to "321",
DERIVATION_PATH_KEY to "777"
)
val userWalletId = UserWalletId("011")
val expectedCryptoCurrency = mockk<CryptoCurrency> {
every { network } returns mockk {
every { rawId } returns "123"
every { derivationPath } returns Network.DerivationPath.Card(value = "777")
}
every { id } returns CryptoCurrency.ID(
prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX,
body = CryptoCurrency.ID.Body.NetworkIdWithDerivationPath(
rawId = "321",
derivationPath = "777"
),
suffix = CryptoCurrency.ID.Suffix.RawID("321")
)
}
every { getUserWalletUseCase.invoke(userWalletId) } returns Either.Right(
value = mockk {
every { isMultiCurrency } returns false
every { walletId } returns userWalletId
every { isLocked } returns false
}
)
coEvery { selectWalletUseCase.invoke(userWalletId) } returns Either.Right(
value = mockk {
every { walletId } returns userWalletId
}
)
coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId) } returns AccountList.empty(
userWalletId = userWalletId,
cryptoCurrencies = listOf(expectedCryptoCurrency),
)
every { tangemPayFeatureToggles.isTangemPayAccountsRefactorEnabled } returns true
coEvery {
walletBalanceFetcher.invoke(
WalletBalanceFetcher.Params(
userWalletId = userWalletId,
isPaymentAccountRefactorEnabled = true
)
)
} returns mockk()
every { walletDeepLinkActionTrigger.selectWallet(userWalletId) } just Runs
createHandler(scope = this, queryParams, isFromOnNewIntent = true)
advanceUntilIdle()
coEvery {
walletBalanceFetcher.invoke(
WalletBalanceFetcher.Params(
userWalletId = userWalletId,
isPaymentAccountRefactorEnabled = true
)
)
}
}
private fun createHandler(
scope: CoroutineScope,
queryParams: Map<String, String>,
isFromOnNewIntent: Boolean = false,
) {
DefaultTokenDetailsDeepLinkHandler(
scope = scope,
queryParams = queryParams,
isFromOnNewIntent = isFromOnNewIntent,
appRouter = appRouter,
selectWalletUseCase = selectWalletUseCase,
cryptoCurrencyBalanceFetcher = cryptoCurrencyBalanceFetcher,
tokenDetailsDeepLinkActionTrigger = tokenDetailsDeepLinkActionTrigger,
walletDeepLinkActionTrigger = walletDeepLinkActionTrigger,
analyticsEventHandler = analyticsEventHandler,
getUserWalletUseCase = getUserWalletUseCase,
walletBalanceFetcher = walletBalanceFetcher,
tangemPayFeatureToggles = tangemPayFeatureToggles,
singleAccountListSupplier = singleAccountListSupplier,
getSelectedWalletSyncUseCase = getSelectedWalletSync,
)
}
}

View file

@ -0,0 +1,134 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.Test
class InitializeWithCryptoCurrencyTransformerTest {
private val cryptoCurrency: CryptoCurrency = mockk(relaxed = true) {
every { name } returns TOKEN_NAME
every { symbol } returns TOKEN_SYMBOL
}
private val onBackClick: () -> Unit = mockk(relaxed = true)
@Test
fun `GIVEN crypto currency WHEN transform THEN top bar title is Simple with token name`() {
// GIVEN
val transformer = InitializeWithCryptoCurrencyTransformer(
cryptoCurrency = cryptoCurrency,
onBackClick = onBackClick,
)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.topAppBarUM.titleState).isEqualTo(TitleState.Simple(tokenName = TOKEN_NAME))
}
@Test
fun `GIVEN crypto currency WHEN transform THEN subtitle is token symbol`() {
// GIVEN
val transformer = InitializeWithCryptoCurrencyTransformer(
cryptoCurrency = cryptoCurrency,
onBackClick = onBackClick,
)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.topAppBarUM.subtitle).isEqualTo(stringReference(TOKEN_SYMBOL))
}
@Test
fun `GIVEN onBackClick callback WHEN top bar onBackClick invoked THEN callback is dispatched`() {
// GIVEN
val transformer = InitializeWithCryptoCurrencyTransformer(
cryptoCurrency = cryptoCurrency,
onBackClick = onBackClick,
)
// WHEN
val result = transformer.transform(initialState())
result.topAppBarUM.onBackClick()
// THEN
verify(exactly = 1) { onBackClick.invoke() }
}
@Test
fun `GIVEN crypto currency WHEN transform THEN market price loading carries currency symbol`() {
// GIVEN
val transformer = InitializeWithCryptoCurrencyTransformer(
cryptoCurrency = cryptoCurrency,
onBackClick = onBackClick,
)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.marketPriceBlockState)
.isEqualTo(MarketPriceBlockState.Loading(currencySymbol = TOKEN_SYMBOL))
}
@Test
fun `GIVEN any state WHEN transform THEN unrelated fields are preserved`() {
// GIVEN
val state = initialState()
val transformer = InitializeWithCryptoCurrencyTransformer(
cryptoCurrency = cryptoCurrency,
onBackClick = onBackClick,
)
// WHEN
val result = transformer.transform(state)
// THEN — only top bar title/subtitle/onBackClick and marketPriceBlockState are touched
assertThat(result.topAppBarUM.menuItems).isEqualTo(state.topAppBarUM.menuItems)
assertThat(result.balanceBlockUM.actionButtons).isEqualTo(state.balanceBlockUM.actionButtons)
assertThat(result.balanceBlockUM.tokenBalanceTypeUM).isEqualTo(state.balanceBlockUM.tokenBalanceTypeUM)
assertThat(result.stakingBlocksState).isEqualTo(state.stakingBlocksState)
assertThat(result.pullToRefreshConfig).isSameInstanceAs(state.pullToRefreshConfig)
assertThat(result.isBalanceHidden).isEqualTo(state.isBalanceHidden)
assertThat(result.isMarketPriceAvailable).isEqualTo(state.isMarketPriceAvailable)
}
private fun initialState(): TokenDetailsUM = TokenDetailsUM(
topAppBarUM = TokenDetailsTopAppBarUM(
titleState = TitleState.Simple(tokenName = ""),
subtitle = stringReference(""),
onBackClick = {},
menuItems = persistentListOf(),
),
balanceBlockUM = TokenDetailsBalanceBlockUM.Loading(
actionButtons = persistentListOf(),
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = mockk(relaxed = true),
),
marketPriceBlockState = mockk<MarketPriceBlockState>(relaxed = true),
stakingBlocksState = null,
pullToRefreshConfig = mockk<PullToRefreshConfig>(relaxed = true),
isBalanceHidden = false,
isMarketPriceAvailable = false,
)
private companion object {
const val TOKEN_NAME = "Tether"
const val TOKEN_SYMBOL = "USDT"
}
}

View file

@ -0,0 +1,143 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.ds.button.TangemButtonType
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.extensions.stringReference
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import io.mockk.mockk
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.Test
class SetBalanceLoadingTransformerTest {
private val currencyIconState: CurrencyIconState = mockk(relaxed = true)
@Test
fun `GIVEN any state WHEN transform THEN balance block is Loading`() {
// GIVEN
val transformer = SetBalanceLoadingTransformer(currencyIconState = currencyIconState)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Loading::class.java)
}
@Test
fun `GIVEN currency icon state WHEN transform THEN Loading block carries that icon state`() {
// GIVEN
val transformer = SetBalanceLoadingTransformer(currencyIconState = currencyIconState)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.balanceBlockUM.currencyIconState).isSameInstanceAs(currencyIconState)
}
@Test
fun `GIVEN state with action buttons WHEN transform THEN action buttons are preserved`() {
// GIVEN
val buttons = persistentListOf(
TangemButtonUM(
text = stringReference("Test"),
onClick = {},
isEnabled = true,
type = TangemButtonType.Secondary,
),
)
val state = initialState(actionButtons = buttons)
val transformer = SetBalanceLoadingTransformer(currencyIconState = currencyIconState)
// WHEN
val result = transformer.transform(state)
// THEN
assertThat(result.balanceBlockUM.actionButtons).isEqualTo(buttons)
}
@Test
fun `GIVEN any state WHEN transform THEN balance type is Single`() {
// GIVEN
val transformer = SetBalanceLoadingTransformer(currencyIconState = currencyIconState)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.balanceBlockUM.tokenBalanceTypeUM).isEqualTo(TokenBalanceTypeUM.Single)
}
@Test
fun `GIVEN Content balance block WHEN transform THEN switches to Loading`() {
// GIVEN
val contentState = initialState().copy(
balanceBlockUM = TokenDetailsBalanceBlockUM.Content(
actionButtons = persistentListOf(),
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
displayCryptoBalanceAll = stringReference("1.0 BTC"),
displayFiatBalanceAll = stringReference("$50,000"),
displayCryptoBalanceAvailable = null,
displayFiatBalanceAvailable = null,
isBalanceFlickering = false,
),
)
val transformer = SetBalanceLoadingTransformer(currencyIconState = currencyIconState)
// WHEN
val result = transformer.transform(contentState)
// THEN
assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Loading::class.java)
}
@Test
fun `GIVEN any state WHEN transform THEN unrelated fields are preserved`() {
// GIVEN
val state = initialState()
val transformer = SetBalanceLoadingTransformer(currencyIconState = currencyIconState)
// WHEN
val result = transformer.transform(state)
// THEN
assertThat(result.topAppBarUM).isSameInstanceAs(state.topAppBarUM)
assertThat(result.marketPriceBlockState).isSameInstanceAs(state.marketPriceBlockState)
assertThat(result.stakingBlocksState).isEqualTo(state.stakingBlocksState)
assertThat(result.pullToRefreshConfig).isSameInstanceAs(state.pullToRefreshConfig)
assertThat(result.isBalanceHidden).isEqualTo(state.isBalanceHidden)
assertThat(result.isMarketPriceAvailable).isEqualTo(state.isMarketPriceAvailable)
}
private fun initialState(
actionButtons: ImmutableList<TangemButtonUM> = persistentListOf(),
): TokenDetailsUM = TokenDetailsUM(
topAppBarUM = TokenDetailsTopAppBarUM(
titleState = TitleState.Simple(tokenName = ""),
subtitle = stringReference(""),
onBackClick = {},
menuItems = persistentListOf(),
),
balanceBlockUM = TokenDetailsBalanceBlockUM.Loading(
actionButtons = actionButtons,
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
),
marketPriceBlockState = mockk<MarketPriceBlockState>(relaxed = true),
stakingBlocksState = null,
pullToRefreshConfig = mockk<PullToRefreshConfig>(relaxed = true),
isBalanceHidden = false,
isMarketPriceAvailable = false,
)
}

View file

@ -0,0 +1,481 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.google.common.truth.Truth.assertThat
import com.tangem.common.getTotalWithRewardsStakingBalance
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.staking.StakingBalance
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.math.BigDecimal
class SetBalanceTransformerTest {
private val onToggleBalanceType: () -> Unit = mockk(relaxed = true)
private val appCurrency: AppCurrency = AppCurrency.Default
private val network: Network = mockk(relaxed = true) {
every { rawId } returns "ethereum"
}
private val currency: CryptoCurrency = mockk(relaxed = true) {
every { this@mockk.network } returns this@SetBalanceTransformerTest.network
every { symbol } returns "ETH"
}
@BeforeEach
fun setup() {
mockkStatic(StakingBalance.Data::getTotalWithRewardsStakingBalance)
}
@AfterEach
fun teardown() {
unmockkStatic(StakingBalance.Data::getTotalWithRewardsStakingBalance)
}
// region Status type → BalanceBlock type mapping
@Test
fun `GIVEN Loading status WHEN transform THEN balance block is Loading`() {
// GIVEN
val status = createStatus(CryptoCurrencyStatus.Loading)
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Loading::class.java)
}
@Test
fun `GIVEN Loaded status WHEN transform THEN balance block is Content`() {
// GIVEN
val status = createStatus(loadedValue())
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Content::class.java)
}
@Test
fun `GIVEN NoQuote status WHEN transform THEN balance block is Content`() {
// GIVEN
val status = createStatus(noQuoteValue())
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Content::class.java)
}
@Test
fun `GIVEN NoAccount status WHEN transform THEN balance block is Content`() {
// GIVEN
val status = createStatus(noAccountValue())
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Content::class.java)
}
@Test
fun `GIVEN Custom status WHEN transform THEN balance block is Content`() {
// GIVEN
val status = createStatus(customValue())
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Content::class.java)
}
@Test
fun `GIVEN Unreachable status WHEN transform THEN balance block is Error`() {
// GIVEN
val status = createStatus(
CryptoCurrencyStatus.Unreachable(priceChange = null, fiatRate = null, networkAddress = null),
)
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Error::class.java)
}
@Test
fun `GIVEN NoAmount status WHEN transform THEN balance block is Error`() {
// GIVEN
val status = createStatus(CryptoCurrencyStatus.NoAmount(priceChange = null, fiatRate = null))
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Error::class.java)
}
@Test
fun `GIVEN MissedDerivation status WHEN transform THEN balance block is Error`() {
// GIVEN
val status = createStatus(CryptoCurrencyStatus.MissedDerivation(priceChange = null, fiatRate = null))
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.balanceBlockUM).isInstanceOf(TokenDetailsBalanceBlockUM.Error::class.java)
}
// endregion
// region Action buttons & icon preservation
@Test
fun `GIVEN any loaded status WHEN transform THEN action buttons are preserved`() {
// GIVEN
val status = createStatus(loadedValue())
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.balanceBlockUM.actionButtons).isEqualTo(initialState().balanceBlockUM.actionButtons)
}
@Test
fun `GIVEN any loaded status WHEN transform THEN currency icon state is preserved`() {
// GIVEN
val status = createStatus(loadedValue())
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.balanceBlockUM.currencyIconState)
.isEqualTo(initialState().balanceBlockUM.currencyIconState)
}
// endregion
// region Staking / balance type
@Test
fun `GIVEN loaded status without staking WHEN transform THEN balance type is Single`() {
// GIVEN
val status = createStatus(loadedValue(stakingBalance = null))
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content
assertThat(content.tokenBalanceTypeUM).isEqualTo(TokenBalanceTypeUM.Single)
}
@Test
fun `GIVEN loaded status with staking WHEN transform THEN balance type is Multiple`() {
// GIVEN
val stakingBalance: StakingBalance.Data = mockk(relaxed = true)
every { stakingBalance.getTotalWithRewardsStakingBalance(any()) } returns BigDecimal("1.5")
val status = createStatus(loadedValue(stakingBalance = stakingBalance))
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content
assertThat(content.tokenBalanceTypeUM).isInstanceOf(TokenBalanceTypeUM.Multiple::class.java)
}
@Test
fun `GIVEN loaded status with staking WHEN transform THEN available balance types include ALL and AVAILABLE`() {
// GIVEN
val stakingBalance: StakingBalance.Data = mockk(relaxed = true)
every { stakingBalance.getTotalWithRewardsStakingBalance(any()) } returns BigDecimal("1.5")
val status = createStatus(loadedValue(stakingBalance = stakingBalance))
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
val multiple = (result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content)
.tokenBalanceTypeUM as TokenBalanceTypeUM.Multiple
assertThat(multiple.availableTypes).containsExactly(
TokenBalanceTypeUM.Type.ALL,
TokenBalanceTypeUM.Type.AVAILABLE,
)
}
@Test
fun `GIVEN staking balance WHEN Multiple onSelect invoked THEN onToggleBalanceType is called`() {
// GIVEN
val stakingBalance: StakingBalance.Data = mockk(relaxed = true)
every { stakingBalance.getTotalWithRewardsStakingBalance(any()) } returns BigDecimal("1.5")
val status = createStatus(loadedValue(stakingBalance = stakingBalance))
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
val multiple = (result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content)
.tokenBalanceTypeUM as TokenBalanceTypeUM.Multiple
multiple.onSelect()
// THEN
verify(exactly = 1) { onToggleBalanceType.invoke() }
}
@Test
fun `GIVEN staking and previous Multiple type AVAILABLE WHEN transform THEN selected type is preserved`() {
// GIVEN
val stakingBalance: StakingBalance.Data = mockk(relaxed = true)
every { stakingBalance.getTotalWithRewardsStakingBalance(any()) } returns BigDecimal("1.5")
val status = createStatus(loadedValue(stakingBalance = stakingBalance))
val transformer = createTransformer(status)
val prevContent = TokenDetailsBalanceBlockUM.Content(
actionButtons = persistentListOf(),
tokenBalanceTypeUM = TokenBalanceTypeUM.Multiple(
type = TokenBalanceTypeUM.Type.AVAILABLE,
availableTypes = persistentListOf(TokenBalanceTypeUM.Type.ALL, TokenBalanceTypeUM.Type.AVAILABLE),
onSelect = {},
),
currencyIconState = CurrencyIconState.Loading,
displayCryptoBalanceAll = stringReference(""),
displayFiatBalanceAll = stringReference(""),
displayCryptoBalanceAvailable = null,
displayFiatBalanceAvailable = null,
isBalanceFlickering = false,
)
val state = initialState().copy(balanceBlockUM = prevContent)
// WHEN
val result = transformer.transform(state)
// THEN
val multiple = (result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content)
.tokenBalanceTypeUM as TokenBalanceTypeUM.Multiple
assertThat(multiple.type).isEqualTo(TokenBalanceTypeUM.Type.AVAILABLE)
}
// endregion
// region Balance flickering
@Test
fun `GIVEN CACHE source WHEN transform THEN isBalanceFlickering is true`() {
// GIVEN
val sources = CryptoCurrencyStatus.Sources(
networkSource = StatusSource.CACHE,
quoteSource = StatusSource.CACHE,
stakingBalanceSource = StatusSource.CACHE,
)
val status = createStatus(loadedValue(sources = sources))
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content
assertThat(content.isBalanceFlickering).isTrue()
}
@Test
fun `GIVEN ACTUAL source WHEN transform THEN isBalanceFlickering is false`() {
// GIVEN
val sources = CryptoCurrencyStatus.Sources(
networkSource = StatusSource.ACTUAL,
quoteSource = StatusSource.ACTUAL,
stakingBalanceSource = StatusSource.ACTUAL,
)
val status = createStatus(loadedValue(sources = sources))
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content
assertThat(content.isBalanceFlickering).isFalse()
}
// endregion
// region No staking → available balances
@Test
fun `GIVEN loaded without staking WHEN transform THEN available balances are null`() {
// GIVEN
val status = createStatus(loadedValue(stakingBalance = null))
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content
assertThat(content.displayCryptoBalanceAvailable).isNull()
assertThat(content.displayFiatBalanceAvailable).isNull()
}
@Test
fun `GIVEN staking balance WHEN transform THEN available balances are not null`() {
// GIVEN
val stakingBalance: StakingBalance.Data = mockk(relaxed = true)
every { stakingBalance.getTotalWithRewardsStakingBalance(any()) } returns BigDecimal("1.5")
val status = createStatus(loadedValue(stakingBalance = stakingBalance))
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(initialState())
// THEN
val content = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content
assertThat(content.displayCryptoBalanceAvailable).isNotNull()
assertThat(content.displayFiatBalanceAvailable).isNotNull()
}
// endregion
// region Unrelated fields preserved
@Test
fun `GIVEN any status WHEN transform THEN unrelated fields are preserved`() {
// GIVEN
val state = initialState()
val status = createStatus(loadedValue())
val transformer = createTransformer(status)
// WHEN
val result = transformer.transform(state)
// THEN
assertThat(result.topAppBarUM).isSameInstanceAs(state.topAppBarUM)
assertThat(result.marketPriceBlockState).isSameInstanceAs(state.marketPriceBlockState)
assertThat(result.stakingBlocksState).isEqualTo(state.stakingBlocksState)
assertThat(result.pullToRefreshConfig).isSameInstanceAs(state.pullToRefreshConfig)
assertThat(result.isBalanceHidden).isEqualTo(state.isBalanceHidden)
assertThat(result.isMarketPriceAvailable).isEqualTo(state.isMarketPriceAvailable)
}
// endregion
private fun createTransformer(status: CryptoCurrencyStatus) = SetBalanceTransformer(
status = status,
appCurrency = appCurrency,
onToggleBalanceType = onToggleBalanceType,
)
private fun createStatus(value: CryptoCurrencyStatus.Value) = CryptoCurrencyStatus(
currency = currency,
value = value,
)
private fun loadedValue(
amount: BigDecimal = BigDecimal("10.5"),
fiatAmount: BigDecimal = BigDecimal("21000"),
fiatRate: BigDecimal = BigDecimal("2000"),
stakingBalance: StakingBalance? = null,
sources: CryptoCurrencyStatus.Sources = CryptoCurrencyStatus.Sources(),
): CryptoCurrencyStatus.Loaded = CryptoCurrencyStatus.Loaded(
amount = amount,
fiatAmount = fiatAmount,
fiatRate = fiatRate,
priceChange = BigDecimal("2.5"),
stakingBalance = stakingBalance,
yieldSupplyStatus = null,
hasCurrentNetworkTransactions = false,
pendingTransactions = emptySet(),
networkAddress = mockk(relaxed = true),
sources = sources,
)
private fun noQuoteValue(): CryptoCurrencyStatus.NoQuote = CryptoCurrencyStatus.NoQuote(
amount = BigDecimal("5.0"),
stakingBalance = null,
yieldSupplyStatus = null,
hasCurrentNetworkTransactions = false,
pendingTransactions = emptySet(),
networkAddress = mockk(relaxed = true),
sources = CryptoCurrencyStatus.Sources(),
)
private fun noAccountValue(): CryptoCurrencyStatus.NoAccount = CryptoCurrencyStatus.NoAccount(
amountToCreateAccount = BigDecimal("0.01"),
fiatAmount = BigDecimal.ZERO,
priceChange = null,
fiatRate = BigDecimal("2000"),
networkAddress = mockk(relaxed = true),
sources = CryptoCurrencyStatus.Sources(),
)
private fun customValue(): CryptoCurrencyStatus.Custom = CryptoCurrencyStatus.Custom(
amount = BigDecimal("100"),
fiatAmount = null,
fiatRate = null,
priceChange = null,
stakingBalance = null,
yieldSupplyStatus = null,
hasCurrentNetworkTransactions = false,
pendingTransactions = emptySet(),
networkAddress = mockk(relaxed = true),
sources = CryptoCurrencyStatus.Sources(),
)
private fun initialState(): TokenDetailsUM = TokenDetailsUM(
topAppBarUM = TokenDetailsTopAppBarUM(
titleState = TitleState.Simple(tokenName = ""),
subtitle = stringReference(""),
onBackClick = {},
menuItems = persistentListOf(),
),
balanceBlockUM = TokenDetailsBalanceBlockUM.Loading(
actionButtons = persistentListOf(),
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
),
marketPriceBlockState = mockk<MarketPriceBlockState>(relaxed = true),
stakingBlocksState = null,
pullToRefreshConfig = mockk<PullToRefreshConfig>(relaxed = true),
isBalanceHidden = false,
isMarketPriceAvailable = false,
)
}

View file

@ -0,0 +1,204 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import androidx.compose.ui.graphics.Color
import com.google.common.truth.Truth.assertThat
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.ds.image.DeviceIconUM
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountName
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import io.mockk.every
import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.Test
class SetTopBarTitleTransformerTest {
private val tokenName = "Tether"
private val walletName = "My Wallet"
private val deviceIconUM: DeviceIconUM = DeviceIconUM.Card(
mainColor = Color.DarkGray,
secondColor = null,
)
private val cryptoCurrency: CryptoCurrency.Coin = mockk(relaxed = true) {
every { name } returns tokenName
}
@Test
fun `GIVEN single wallet single account WHEN transform THEN Simple title`() {
// GIVEN
val transformer = createTransformer(
hasMultipleWallets = false,
hasMultipleAccounts = false,
account = null,
)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.topAppBarUM.titleState).isEqualTo(TitleState.Simple(tokenName = tokenName))
}
@Test
fun `GIVEN multiple wallets and single account WHEN transform THEN WithWallet title`() {
// GIVEN
val transformer = createTransformer(
hasMultipleWallets = true,
hasMultipleAccounts = false,
account = null,
)
// WHEN
val result = transformer.transform(initialState())
// THEN
val expected = TitleState.WithWallet(
tokenName = tokenName,
walletName = walletName,
deviceIconUM = deviceIconUM,
)
assertThat(result.topAppBarUM.titleState).isEqualTo(expected)
}
@Test
fun `GIVEN single wallet and multiple accounts WHEN transform THEN WithAccount title`() {
// GIVEN
val transformer = createTransformer(
hasMultipleWallets = false,
hasMultipleAccounts = true,
account = stubAccount(),
)
// WHEN
val result = transformer.transform(initialState())
// THEN
val title = result.topAppBarUM.titleState
assertThat(title).isInstanceOf(TitleState.WithAccount::class.java)
assertThat((title as TitleState.WithAccount).tokenName).isEqualTo(tokenName)
}
@Test
fun `GIVEN multiple wallets AND multiple accounts WHEN transform THEN WithAccount wins`() {
// GIVEN — design priority: account branch wins over wallet branch
val transformer = createTransformer(
hasMultipleWallets = true,
hasMultipleAccounts = true,
account = stubAccount(),
)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.topAppBarUM.titleState).isInstanceOf(TitleState.WithAccount::class.java)
}
@Test
fun `GIVEN multiple accounts but null account WHEN transform THEN falls back to wallet branch`() {
// GIVEN — race protection: hasMultipleAccounts=true but account not loaded yet
val transformer = createTransformer(
hasMultipleWallets = true,
hasMultipleAccounts = true,
account = null,
)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.topAppBarUM.titleState).isInstanceOf(TitleState.WithWallet::class.java)
}
@Test
fun `GIVEN multiple accounts but null account AND single wallet WHEN transform THEN falls back to Simple`() {
// GIVEN
val transformer = createTransformer(
hasMultipleWallets = false,
hasMultipleAccounts = true,
account = null,
)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.topAppBarUM.titleState).isEqualTo(TitleState.Simple(tokenName = tokenName))
}
@Test
fun `GIVEN account with custom icon WHEN transform THEN icon is propagated`() {
// GIVEN
val account = stubAccount(
iconValue = CryptoPortfolioIcon.Icon.Star,
iconColor = CryptoPortfolioIcon.Color.Azure,
)
val transformer = createTransformer(
hasMultipleWallets = false,
hasMultipleAccounts = true,
account = account,
)
// WHEN
val result = transformer.transform(initialState())
// THEN
val title = result.topAppBarUM.titleState as TitleState.WithAccount
val expectedIcon = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Star,
color = CryptoPortfolioIcon.Color.Azure,
)
assertThat(title.accountIconUM).isEqualTo(expectedIcon)
}
private fun createTransformer(
hasMultipleWallets: Boolean,
hasMultipleAccounts: Boolean,
account: Account.CryptoPortfolio?,
) = SetTopBarTitleTransformer(
cryptoCurrency = cryptoCurrency,
hasMultipleWallets = hasMultipleWallets,
hasMultipleAccounts = hasMultipleAccounts,
walletName = walletName,
deviceIconUM = deviceIconUM,
account = account,
)
private fun stubAccount(
iconValue: CryptoPortfolioIcon.Icon = CryptoPortfolioIcon.Icon.Star,
iconColor: CryptoPortfolioIcon.Color = CryptoPortfolioIcon.Color.Azure,
): Account.CryptoPortfolio {
val icon: CryptoPortfolioIcon = mockk {
every { value } returns iconValue
every { color } returns iconColor
}
return mockk {
every { accountName } returns AccountName.DefaultMain
every { this@mockk.icon } returns icon
}
}
private fun initialState(): TokenDetailsUM = TokenDetailsUM(
topAppBarUM = TokenDetailsTopAppBarUM(
titleState = TitleState.Simple(tokenName = ""),
subtitle = stringReference(""),
onBackClick = {},
menuItems = persistentListOf(),
),
balanceBlockUM = mockk<TokenDetailsBalanceBlockUM>(relaxed = true),
marketPriceBlockState = mockk<MarketPriceBlockState>(relaxed = true),
stakingBlocksState = null,
pullToRefreshConfig = mockk<PullToRefreshConfig>(relaxed = true),
isBalanceHidden = false,
isMarketPriceAvailable = false,
)
}

View file

@ -0,0 +1,203 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.extensions.stringReference
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceTypeUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.Test
class ToggleBalanceTypeTransformerTest {
private val transformer = ToggleBalanceTypeTransformer()
// region Toggle logic
@Test
fun `GIVEN Multiple with ALL WHEN transform THEN type switches to AVAILABLE`() {
// GIVEN
val state = stateWithContent(type = TokenBalanceTypeUM.Type.ALL)
// WHEN
val result = transformer.transform(state)
// THEN
val multiple = (result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content)
.tokenBalanceTypeUM as TokenBalanceTypeUM.Multiple
assertThat(multiple.type).isEqualTo(TokenBalanceTypeUM.Type.AVAILABLE)
}
@Test
fun `GIVEN Multiple with AVAILABLE WHEN transform THEN type switches to ALL`() {
// GIVEN
val state = stateWithContent(type = TokenBalanceTypeUM.Type.AVAILABLE)
// WHEN
val result = transformer.transform(state)
// THEN
val multiple = (result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content)
.tokenBalanceTypeUM as TokenBalanceTypeUM.Multiple
assertThat(multiple.type).isEqualTo(TokenBalanceTypeUM.Type.ALL)
}
@Test
fun `GIVEN Multiple with ALL WHEN transform twice THEN type returns to ALL`() {
// GIVEN
val state = stateWithContent(type = TokenBalanceTypeUM.Type.ALL)
// WHEN
val result = transformer.transform(transformer.transform(state))
// THEN
val multiple = (result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content)
.tokenBalanceTypeUM as TokenBalanceTypeUM.Multiple
assertThat(multiple.type).isEqualTo(TokenBalanceTypeUM.Type.ALL)
}
// endregion
// region No-op cases
@Test
fun `GIVEN Loading balance block WHEN transform THEN state is unchanged`() {
// GIVEN
val state = initialState()
// WHEN
val result = transformer.transform(state)
// THEN
assertThat(result).isSameInstanceAs(state)
}
@Test
fun `GIVEN Error balance block WHEN transform THEN state is unchanged`() {
// GIVEN
val state = initialState().copy(
balanceBlockUM = TokenDetailsBalanceBlockUM.Error(
actionButtons = persistentListOf(),
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
),
)
// WHEN
val result = transformer.transform(state)
// THEN
assertThat(result).isSameInstanceAs(state)
}
@Test
fun `GIVEN Content with Single balance type WHEN transform THEN state is unchanged`() {
// GIVEN
val state = initialState().copy(
balanceBlockUM = TokenDetailsBalanceBlockUM.Content(
actionButtons = persistentListOf(),
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
displayCryptoBalanceAll = stringReference("1.0 ETH"),
displayFiatBalanceAll = stringReference("$2,000"),
displayCryptoBalanceAvailable = null,
displayFiatBalanceAvailable = null,
isBalanceFlickering = false,
),
)
// WHEN
val result = transformer.transform(state)
// THEN
assertThat(result).isSameInstanceAs(state)
}
// endregion
// region Unrelated fields preserved
@Test
fun `GIVEN any togglable state WHEN transform THEN unrelated fields are preserved`() {
// GIVEN
val state = stateWithContent(type = TokenBalanceTypeUM.Type.ALL)
// WHEN
val result = transformer.transform(state)
// THEN
assertThat(result.topAppBarUM).isSameInstanceAs(state.topAppBarUM)
assertThat(result.marketPriceBlockState).isSameInstanceAs(state.marketPriceBlockState)
assertThat(result.stakingBlocksState).isEqualTo(state.stakingBlocksState)
assertThat(result.pullToRefreshConfig).isSameInstanceAs(state.pullToRefreshConfig)
assertThat(result.isBalanceHidden).isEqualTo(state.isBalanceHidden)
assertThat(result.isMarketPriceAvailable).isEqualTo(state.isMarketPriceAvailable)
}
@Test
fun `GIVEN togglable state WHEN transform THEN balance content fields besides type are preserved`() {
// GIVEN
val state = stateWithContent(type = TokenBalanceTypeUM.Type.ALL)
val originalContent = state.balanceBlockUM as TokenDetailsBalanceBlockUM.Content
// WHEN
val result = transformer.transform(state)
// THEN
val resultContent = result.balanceBlockUM as TokenDetailsBalanceBlockUM.Content
assertThat(resultContent.actionButtons).isEqualTo(originalContent.actionButtons)
assertThat(resultContent.currencyIconState).isEqualTo(originalContent.currencyIconState)
assertThat(resultContent.displayCryptoBalanceAll).isEqualTo(originalContent.displayCryptoBalanceAll)
assertThat(resultContent.displayFiatBalanceAll).isEqualTo(originalContent.displayFiatBalanceAll)
assertThat(resultContent.isBalanceFlickering).isEqualTo(originalContent.isBalanceFlickering)
}
// endregion
private fun stateWithContent(type: TokenBalanceTypeUM.Type): TokenDetailsUM {
return initialState().copy(
balanceBlockUM = TokenDetailsBalanceBlockUM.Content(
actionButtons = persistentListOf(),
tokenBalanceTypeUM = TokenBalanceTypeUM.Multiple(
type = type,
availableTypes = persistentListOf(
TokenBalanceTypeUM.Type.ALL,
TokenBalanceTypeUM.Type.AVAILABLE,
),
onSelect = {},
),
currencyIconState = CurrencyIconState.Loading,
displayCryptoBalanceAll = stringReference("10.5 ETH"),
displayFiatBalanceAll = stringReference("$21,000"),
displayCryptoBalanceAvailable = stringReference("9.0 ETH"),
displayFiatBalanceAvailable = stringReference("$18,000"),
isBalanceFlickering = false,
),
)
}
private fun initialState(): TokenDetailsUM = TokenDetailsUM(
topAppBarUM = TokenDetailsTopAppBarUM(
titleState = TitleState.Simple(tokenName = ""),
subtitle = stringReference(""),
onBackClick = {},
menuItems = persistentListOf(),
),
balanceBlockUM = TokenDetailsBalanceBlockUM.Loading(
actionButtons = persistentListOf(),
tokenBalanceTypeUM = TokenBalanceTypeUM.Single,
currencyIconState = CurrencyIconState.Loading,
),
marketPriceBlockState = mockk<MarketPriceBlockState>(relaxed = true),
stakingBlocksState = null,
pullToRefreshConfig = mockk<PullToRefreshConfig>(relaxed = true),
isBalanceHidden = false,
isMarketPriceAvailable = false,
)
}

View file

@ -0,0 +1,214 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.card.CardTypesResolver
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM.TitleState
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class UpdateTopBarMenuTransformerTest {
private val coldWallet: UserWallet.Cold = mockk(relaxed = true)
private val hotWallet: UserWallet.Hot = mockk(relaxed = true)
private val cardTypesResolver: CardTypesResolver = mockk(relaxed = true)
private val onGenerateExtendedKey: () -> Unit = mockk(relaxed = true)
private val onHideClick: () -> Unit = mockk(relaxed = true)
@BeforeEach
fun setUp() {
mockkStatic(UserWallet.Cold::cardTypesResolver)
every { coldWallet.cardTypesResolver } returns cardTypesResolver
}
@AfterEach
fun tearDown() {
unmockkStatic(UserWallet.Cold::cardTypesResolver)
}
@Test
fun `GIVEN cold wallet AND single wallet with token WHEN transform THEN menu is empty`() {
// GIVEN
every { cardTypesResolver.isSingleWalletWithToken() } returns true
val transformer = createTransformer(
userWallet = coldWallet,
hasDerivations = true,
isXPubSupported = true,
)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.topAppBarUM.menuItems).isEmpty()
}
@Test
fun `GIVEN cold wallet AND multi-wallet WHEN transform THEN Hide item is the only one`() {
// GIVEN
every { cardTypesResolver.isSingleWalletWithToken() } returns false
val transformer = createTransformer(
userWallet = coldWallet,
hasDerivations = false,
isXPubSupported = false,
)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.topAppBarUM.menuItems).hasSize(1)
result.topAppBarUM.menuItems.single().onClick()
verify(exactly = 1) { onHideClick.invoke() }
verify(exactly = 0) { onGenerateExtendedKey.invoke() }
}
@Test
fun `GIVEN hot wallet WHEN transform THEN Hide item is shown regardless of single-wallet flag`() {
// GIVEN — flag is read only for cold wallets, so no stub needed for hotWallet
val transformer = createTransformer(
userWallet = hotWallet,
hasDerivations = false,
isXPubSupported = false,
)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.topAppBarUM.menuItems).hasSize(1)
}
@Test
fun `GIVEN xPub supported AND derivations exist WHEN transform THEN both items are shown`() {
// GIVEN
every { cardTypesResolver.isSingleWalletWithToken() } returns false
val transformer = createTransformer(
userWallet = coldWallet,
hasDerivations = true,
isXPubSupported = true,
)
// WHEN
val result = transformer.transform(initialState())
// THEN — Generate xPub first, Hide token second
assertThat(result.topAppBarUM.menuItems).hasSize(2)
}
@Test
fun `GIVEN xPub supported but no derivations WHEN transform THEN xPub item is hidden`() {
// GIVEN
every { cardTypesResolver.isSingleWalletWithToken() } returns false
val transformer = createTransformer(
userWallet = coldWallet,
hasDerivations = false,
isXPubSupported = true,
)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.topAppBarUM.menuItems).hasSize(1)
}
@Test
fun `GIVEN derivations exist but xPub unsupported WHEN transform THEN xPub item is hidden`() {
// GIVEN
every { cardTypesResolver.isSingleWalletWithToken() } returns false
val transformer = createTransformer(
userWallet = coldWallet,
hasDerivations = true,
isXPubSupported = false,
)
// WHEN
val result = transformer.transform(initialState())
// THEN
assertThat(result.topAppBarUM.menuItems).hasSize(1)
}
@Test
fun `GIVEN any state WHEN transform THEN unrelated state fields are preserved`() {
// GIVEN
every { cardTypesResolver.isSingleWalletWithToken() } returns false
val state = initialState()
val transformer = createTransformer(
userWallet = coldWallet,
hasDerivations = false,
isXPubSupported = false,
)
// WHEN
val result = transformer.transform(state)
// THEN — only menuItems is touched
assertThat(result.topAppBarUM.titleState).isEqualTo(state.topAppBarUM.titleState)
assertThat(result.topAppBarUM.subtitle).isEqualTo(state.topAppBarUM.subtitle)
assertThat(result.balanceBlockUM).isSameInstanceAs(state.balanceBlockUM)
assertThat(result.marketPriceBlockState).isSameInstanceAs(state.marketPriceBlockState)
}
@Test
fun `GIVEN callbacks WHEN menu items invoked THEN callbacks are dispatched`() {
// GIVEN
every { cardTypesResolver.isSingleWalletWithToken() } returns false
val transformer = createTransformer(
userWallet = coldWallet,
hasDerivations = true,
isXPubSupported = true,
)
// WHEN
val result = transformer.transform(initialState())
result.topAppBarUM.menuItems.forEach { it.onClick() }
// THEN
verify(exactly = 1) { onGenerateExtendedKey.invoke() }
verify(exactly = 1) { onHideClick.invoke() }
}
private fun createTransformer(
userWallet: UserWallet,
hasDerivations: Boolean,
isXPubSupported: Boolean,
) = UpdateTopBarMenuTransformer(
userWallet = userWallet,
hasDerivations = hasDerivations,
isXPubSupported = isXPubSupported,
onGenerateExtendedKey = onGenerateExtendedKey,
onHideClick = onHideClick,
)
private fun initialState(): TokenDetailsUM = TokenDetailsUM(
topAppBarUM = TokenDetailsTopAppBarUM(
titleState = TitleState.Simple(tokenName = "Tether"),
subtitle = stringReference("ERC-20 in Ethereum network"),
onBackClick = {},
menuItems = persistentListOf(),
),
balanceBlockUM = mockk<TokenDetailsBalanceBlockUM>(relaxed = true),
marketPriceBlockState = mockk<MarketPriceBlockState>(relaxed = true),
stakingBlocksState = null,
pullToRefreshConfig = mockk<PullToRefreshConfig>(relaxed = true),
isBalanceHidden = false,
isMarketPriceAvailable = false,
)
}