Updated on 2026-08-14

This commit is contained in:
Tangem 2023-11-28 17:03:17 +01:00
parent 51c396a5d7
commit 6c82e6e484
17 changed files with 1001 additions and 14 deletions

View file

@ -16,6 +16,18 @@ import dagger.hilt.android.scopes.ViewModelScoped
@Suppress("TooManyFunctions") @Suppress("TooManyFunctions")
internal object TokensDomainModule { internal object TokensDomainModule {
@Provides
@ViewModelScoped
fun provideAddCryptoCurrenciesUseCase(
currenciesRepository: CurrenciesRepository,
networksRepository: NetworksRepository,
): AddCryptoCurrenciesUseCase {
return AddCryptoCurrenciesUseCase(
currenciesRepository = currenciesRepository,
networksRepository = networksRepository,
)
}
@Provides @Provides
@ViewModelScoped @ViewModelScoped
fun provideFetchTokenListUseCase( fun provideFetchTokenListUseCase(

View file

@ -2,11 +2,15 @@ package com.tangem.managetokens
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.hilt.navigation.compose.hiltViewModel
import com.tangem.core.ui.components.SystemBarsEffect import com.tangem.core.ui.components.SystemBarsEffect
import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.screen.ComposeFragment import com.tangem.core.ui.screen.ComposeFragment
import com.tangem.core.ui.theme.AppThemeModeHolder import com.tangem.core.ui.theme.AppThemeModeHolder
import com.tangem.features.managetokens.navigation.ManageTokensRouter import com.tangem.features.managetokens.navigation.ManageTokensRouter
import com.tangem.managetokens.presentation.managetokens.ui.ManageTokensScreen
import com.tangem.managetokens.presentation.managetokens.viewmodels.ManageTokensViewModel
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject import javax.inject.Inject
@ -26,16 +30,16 @@ internal class ManageTokensFragment : ComposeFragment() {
@Composable @Composable
override fun ScreenContent(modifier: Modifier) { override fun ScreenContent(modifier: Modifier) {
// val viewModel = hiltViewModel<ManageTokensViewModel>() val viewModel = hiltViewModel<ManageTokensViewModel>()
// viewModel.router = [REDACTED_EMAIL] // viewModel.router = [REDACTED_EMAIL]
// //
// LocalLifecycleOwner.current.lifecycle.addObserver(viewModel) LocalLifecycleOwner.current.lifecycle.addObserver(viewModel)
val systemBarsColor = TangemTheme.colors.background.secondary val systemBarsColor = TangemTheme.colors.background.secondary
SystemBarsEffect { SystemBarsEffect {
setSystemBarsColor(systemBarsColor) setSystemBarsColor(systemBarsColor)
} }
// ManageTokensScreen(state = viewModel.uiState) ManageTokensScreen(state = viewModel.uiState)
} }
} }

View file

@ -25,18 +25,21 @@ internal sealed class AlertState {
override val message: TextReference = resourceReference(R.string.manage_tokens_network_selector_non_native_info) override val message: TextReference = resourceReference(R.string.manage_tokens_network_selector_non_native_info)
} }
object TokensUnsupported : AlertState() { class TokensUnsupported(networkName: String) : AlertState() {
override val message: TextReference = resourceReference(R.string.alert_manage_tokens_unsupported_message) override val message: TextReference = resourceReference(
id = R.string.alert_manage_tokens_unsupported_message,
formatArgs = wrappedList(networkName),
)
} }
object TokensUnsupportedCurve : AlertState() { object TokensUnsupportedCurve : AlertState() {
override val message: TextReference = resourceReference(R.string.alert_manage_tokens_unsupported_curve_message) override val message: TextReference = resourceReference(R.string.alert_manage_tokens_unsupported_curve_message)
} }
class TokensUnsupportedBlockchainByCard(val token: String) : AlertState() { class TokensUnsupportedBlockchainByCard(networkName: String) : AlertState() {
override val message: TextReference = resourceReference( override val message: TextReference = resourceReference(
id = R.string.alert_manage_tokens_unsupported_blockchain_by_card_message, id = R.string.alert_manage_tokens_unsupported_blockchain_by_card_message,
formatArgs = wrappedList(token), formatArgs = wrappedList(networkName),
) )
} }
} }

View file

@ -33,7 +33,11 @@ internal fun ChooseWalletScreen(state: ChooseWalletState.Choose, modifier: Modif
LazyColumn( LazyColumn(
modifier = modifier modifier = modifier
.background(TangemTheme.colors.background.tertiary) .background(TangemTheme.colors.background.tertiary)
.padding(TangemTheme.dimens.spacing16), .padding(
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing16,
),
) { ) {
item { item {
Box( Box(
@ -101,13 +105,13 @@ private fun WalletItem(wallet: WalletState, selectedWallet: WalletState?, modifi
.build(), .build(),
loading = { loading = {
Image( Image(
painter = painterResource(R.drawable.card_placeholder_primary), painter = painterResource(R.drawable.card_placeholder_black),
contentDescription = null, contentDescription = null,
) )
}, },
error = { error = {
Image( Image(
painter = painterResource(R.drawable.card_placeholder_primary), painter = painterResource(R.drawable.card_placeholder_black),
contentDescription = null, contentDescription = null,
) )
}, },

View file

@ -14,7 +14,7 @@ internal fun Alert(state: AlertState, onDismiss: () -> Unit) {
is AlertState.DefaultAlert, is AlertState.DefaultAlert,
is AlertState.NonNative, is AlertState.NonNative,
AlertState.TokensUnsupportedCurve, AlertState.TokensUnsupportedCurve,
AlertState.TokensUnsupported, is AlertState.TokensUnsupported,
is AlertState.TokensUnsupportedBlockchainByCard, is AlertState.TokensUnsupportedBlockchainByCard,
-> DefaultAlert(state, onDismiss) -> DefaultAlert(state, onDismiss)
is AlertState.TokenUnavailable -> TokenUnavailableAlert(state, onDismiss) is AlertState.TokenUnavailable -> TokenUnavailableAlert(state, onDismiss)

View file

@ -0,0 +1,118 @@
package com.tangem.managetokens.presentation.managetokens.state.factory
import androidx.paging.PagingData
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.event.triggeredEvent
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.managetokens.presentation.common.state.ChooseWalletState
import com.tangem.managetokens.presentation.common.state.ChooseWalletWarning
import com.tangem.managetokens.presentation.common.state.Event
import com.tangem.managetokens.presentation.common.state.WalletState
import com.tangem.managetokens.presentation.managetokens.state.AddCustomTokenButton
import com.tangem.managetokens.presentation.managetokens.state.ManageTokensState
import com.tangem.managetokens.presentation.managetokens.state.SearchBarState
import com.tangem.managetokens.presentation.managetokens.state.TokenItemState
import com.tangem.managetokens.presentation.managetokens.viewmodels.ManageTokensClickIntents
import com.tangem.utils.Provider
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.Flow
internal class ManageTokensStateFactory(
private val currentStateProvider: Provider<ManageTokensState>,
private val clickIntents: ManageTokensClickIntents,
) {
fun getInitialState(tokens: Flow<PagingData<TokenItemState>>): ManageTokensState {
return ManageTokensState(
searchBarState = SearchBarState(
query = "",
onQueryChange = clickIntents::onSearchQueryChange,
active = false,
onActiveChange = clickIntents::onSearchActiveChange,
),
tokens = tokens,
addCustomTokenButton = AddCustomTokenButton(
isVisible = false,
onClick = clickIntents::onAddCustomTokensButtonClick,
),
derivationNotification = null,
isLoading = false,
event = consumedEvent(),
chooseWalletState = ChooseWalletState.NoSelection,
)
}
fun updateChooseWalletState(
wallets: List<UserWallet>,
userWallets: List<UserWallet>,
selectedWallet: UserWallet?,
): ManageTokensState {
val chooseWalletState = when {
wallets.size == 1 -> {
ChooseWalletState.NoSelection
}
wallets.isEmpty() && userWallets.all { !it.isMultiCurrency } -> {
ChooseWalletState.Warning(ChooseWalletWarning.SINGLE_CURRENCY)
}
else -> {
var selectedWalletState: WalletState? = null
ChooseWalletState.Choose(
wallets = wallets.map { wallet ->
val walletState = WalletState(
walletId = wallet.walletId.stringValue,
artworkUrl = wallet.artworkUrl,
onSelected = clickIntents::onWalletSelected,
walletName = wallet.name,
)
if (wallet.walletId.stringValue == selectedWallet?.walletId?.stringValue) {
selectedWalletState = walletState
}
walletState
}.toPersistentList(),
selectedWallet = selectedWalletState,
onChooseWalletClick = clickIntents::onChooseWalletClick,
onCloseChoosingWalletClick = clickIntents::onCloseChoosingWalletClick,
)
}
}
return currentStateProvider().copy(chooseWalletState = chooseWalletState)
}
fun showAddCustomTokensButton(show: Boolean): ManageTokensState {
return currentStateProvider().copy(
addCustomTokenButton = currentStateProvider().addCustomTokenButton.copy(isVisible = show),
)
}
fun updateSelectedWallet(selectedWalletId: String?): ManageTokensState {
val chooseWalletState = currentStateProvider().chooseWalletState
return currentStateProvider().copy(
showChooseWalletScreen = false,
chooseWalletState = if (chooseWalletState is ChooseWalletState.Choose) {
chooseWalletState.copy(
selectedWallet = chooseWalletState.wallets.find {
it.walletId == selectedWalletId
} ?: chooseWalletState.wallets.first(),
)
} else {
chooseWalletState
},
)
}
fun getStateAndTriggerEvent(
state: ManageTokensState,
event: Event,
setUiState: (ManageTokensState) -> Unit,
): ManageTokensState {
return state.copy(
event = triggeredEvent(
data = event,
onConsume = {
val currentState = currentStateProvider()
setUiState(currentState.copy(event = consumedEvent()))
},
),
)
}
}

View file

@ -0,0 +1,74 @@
package com.tangem.managetokens.presentation.managetokens.state.factory
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import com.tangem.blockchain.common.Blockchain
import com.tangem.core.ui.extensions.getActiveIconRes
import com.tangem.core.ui.extensions.getGreyedOutIconRes
import com.tangem.domain.common.extensions.fromNetworkId
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Token
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.managetokens.presentation.common.state.NetworkItemState
import com.tangem.managetokens.presentation.managetokens.state.TokenItemState
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
internal class NetworkToNetworkItemStateConverter(
private val addedCurrenciesByWalletProvider: Provider<MutableMap<UserWallet, MutableList<CryptoCurrency>>>,
private val selectedWalletProvider: Provider<UserWallet?>,
private val onNetworkToggleClick: (token: TokenItemState.Loaded, network: NetworkItemState.Toggleable) -> Unit,
) : Converter<Token.Network, NetworkItemState> {
override fun convert(value: Token.Network): NetworkItemState {
return createManageNetworkContent(value)
}
private fun createManageNetworkContent(network: Token.Network): NetworkItemState {
val addedCurrencies = addedCurrenciesByWalletProvider()[selectedWalletProvider()] ?: emptyList()
val blockchain = requireNotNull(Blockchain.fromNetworkId(network.networkId)) {
"Network ID must be valid"
}
val isAdded = isAdded(
address = network.address,
blockchain = blockchain,
currencies = addedCurrencies,
)
return NetworkItemState.Toggleable(
name = blockchain.fullName.uppercase(),
iconResId = mutableIntStateOf(
getNetworkIconResId(isAdded, blockchain.id),
),
isMainNetwork = isMainNetwork(network),
isAdded = mutableStateOf(isAdded),
id = network.networkId,
protocolName = network.standardType,
address = network.address,
decimals = network.decimalCount,
blockchain = blockchain,
onToggleClick = onNetworkToggleClick,
)
}
private fun getNetworkIconResId(isAdded: Boolean, blockchainId: String): Int {
return if (isAdded) {
getActiveIconRes(blockchainId)
} else {
getGreyedOutIconRes(blockchainId)
}
}
private fun isMainNetwork(network: Token.Network) = network.address == null
private fun isAdded(address: String?, blockchain: Blockchain, currencies: Collection<CryptoCurrency>): Boolean {
return if (address != null) {
currencies.any {
!it.isCustom && it is CryptoCurrency.Token && it.contractAddress == address
}
} else {
currencies.any {
!it.isCustom && it is CryptoCurrency.Coin && it.name == blockchain.fullName
}
}
}
}

View file

@ -0,0 +1,36 @@
package com.tangem.managetokens.presentation.managetokens.state.factory
import com.tangem.domain.tokens.model.Token
import com.tangem.managetokens.presentation.common.state.NetworkItemState
import com.tangem.managetokens.presentation.managetokens.state.ChooseNetworkState
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.toPersistentList
internal class NetworksToChooseNetworkStateConverter(
private val networkToNetworkItemStateConverter: NetworkToNetworkItemStateConverter,
private val onNonNativeNetworkHintClick: () -> Unit,
private val onCloseChooseNetworkScreen: () -> Unit,
) : Converter<List<Token.Network>, ChooseNetworkState> {
override fun convert(value: List<Token.Network>): ChooseNetworkState {
return createChooseNetworksState(value)
}
private fun createChooseNetworksState(networks: List<Token.Network>): ChooseNetworkState {
val nativeNetworks = mutableListOf<NetworkItemState>()
val nonNativeNetworks = mutableListOf<NetworkItemState>()
networks.map { networkToNetworkItemStateConverter.convert(it) }.forEach {
if (it is NetworkItemState.Toggleable && it.isMainNetwork) {
nativeNetworks.add(it)
} else {
nonNativeNetworks.add(it)
}
}
return ChooseNetworkState(
nativeNetworks = nativeNetworks.toPersistentList(),
nonNativeNetworks = nonNativeNetworks.toPersistentList(),
onNonNativeNetworkHintClick = onNonNativeNetworkHintClick,
onCloseChooseNetworkScreen = onCloseChooseNetworkScreen,
)
}
}

View file

@ -0,0 +1,34 @@
package com.tangem.managetokens.presentation.managetokens.state.factory
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.tokens.model.Quote
import com.tangem.managetokens.presentation.managetokens.state.PriceChangeType
import com.tangem.managetokens.presentation.managetokens.state.QuotesState
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.persistentListOf
import java.math.BigDecimal
@Suppress("MagicNumber") // TODO: remove when chart data is added (in [REDACTED_TASK_KEY] when endpoint is ready)
internal class QuotesToQuotesStateConverter : Converter<Quote, QuotesState> {
override fun convert(value: Quote): QuotesState {
val priceChange = value.priceChange
return QuotesState.Content(
priceChange = BigDecimalFormatter.formatPercent(
percent = priceChange.movePointLeft(2),
useAbsoluteValue = true,
maxFractionDigits = 1,
minFractionDigits = 1,
),
changeType = priceChange.getPriceChangeType(),
chartData = // TODO (in [REDACTED_TASK_KEY] when endpoint is ready)
when (priceChange.getPriceChangeType()) {
PriceChangeType.UP -> persistentListOf(0f, 5f, 10f, 30f)
PriceChangeType.DOWN -> persistentListOf(15f, 12f, 13f, 18f, 10f, 3f)
},
)
}
private fun BigDecimal.getPriceChangeType(): PriceChangeType {
return if (this >= BigDecimal.ZERO) PriceChangeType.UP else PriceChangeType.DOWN
}
}

View file

@ -0,0 +1,80 @@
package com.tangem.managetokens.presentation.managetokens.state.factory
import androidx.compose.runtime.mutableStateOf
import com.tangem.blockchain.common.Blockchain
import com.tangem.core.ui.extensions.ImageReference
import com.tangem.core.ui.extensions.getTintForTokenIcon
import com.tangem.core.ui.extensions.tryGetBackgroundForTokenIcon
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.common.extensions.fromNetworkId
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Token
import com.tangem.managetokens.presentation.managetokens.state.QuotesState
import com.tangem.managetokens.presentation.managetokens.state.TokenButtonType
import com.tangem.managetokens.presentation.managetokens.state.TokenIconState
import com.tangem.managetokens.presentation.managetokens.state.TokenItemState
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
internal class TokenConverter(
private val quotesStateConverter: QuotesToQuotesStateConverter,
private val networksToChooseNetworkStateConverter: NetworksToChooseNetworkStateConverter,
private val allAddedCurrencies: Provider<List<CryptoCurrency>>,
private val selectedAppCurrency: Provider<AppCurrency>,
private val onTokenItemButtonClick: (TokenItemState.Loaded) -> Unit,
) : Converter<Token, TokenItemState.Loaded> {
override fun convert(value: Token): TokenItemState.Loaded {
val isAnyAdded = value.networks.any { network ->
isAdded(
address = network.address,
blockchain = requireNotNull(Blockchain.fromNetworkId(network.networkId)),
currencies = allAddedCurrencies(),
)
}
val buttonType = when {
value.isAvailable && isAnyAdded -> TokenButtonType.EDIT
value.isAvailable && !isAnyAdded -> TokenButtonType.ADD
else -> TokenButtonType.NOT_AVAILABLE
}
val background = tryGetBackgroundForTokenIcon(value.networks.firstOrNull()?.address ?: "")
val tint = getTintForTokenIcon(background)
return TokenItemState.Loaded(
id = value.id,
name = value.name,
currencySymbol = value.symbol,
tokenId = value.id,
tokenIcon = TokenIconState(
ImageReference.Url(value.iconUrl),
placeholderBackground = background,
placeholderTint = tint,
),
quotes = value.quote?.let { quotesStateConverter.convert(it) } ?: QuotesState.Unknown,
rate = value.quote?.fiatRate?.let { rate ->
BigDecimalFormatter.formatFiatAmount(
fiatAmount = rate,
fiatCurrencyCode = selectedAppCurrency().code,
fiatCurrencySymbol = selectedAppCurrency().symbol,
)
},
availableAction = mutableStateOf(buttonType),
chooseNetworkState = networksToChooseNetworkStateConverter.convert(value.networks),
onButtonClick = onTokenItemButtonClick,
)
}
private fun isAdded(address: String?, blockchain: Blockchain, currencies: Collection<CryptoCurrency>): Boolean {
return if (address != null) {
currencies.any {
!it.isCustom && it is CryptoCurrency.Token && it.contractAddress == address
}
} else {
currencies.any {
!it.isCustom && it is CryptoCurrency.Coin && it.name == blockchain.fullName
}
}
}
}

View file

@ -0,0 +1,45 @@
package com.tangem.managetokens.presentation.managetokens.state.factory
import com.tangem.blockchain.common.Blockchain
import com.tangem.data.tokens.utils.CryptoCurrencyFactory
import com.tangem.domain.common.DerivationStyleProvider
import com.tangem.domain.common.extensions.fromNetworkId
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.managetokens.presentation.common.state.NetworkItemState
import com.tangem.managetokens.presentation.managetokens.state.TokenItemState
import com.tangem.utils.converter.Converter
internal class TokenToCryptoCurrencyConverter(
private val network: NetworkItemState,
private val derivationStyleProvider: DerivationStyleProvider,
) : Converter<TokenItemState.Loaded, CryptoCurrency?> {
override fun convert(value: TokenItemState.Loaded): CryptoCurrency? {
val blockchain = requireNotNull(Blockchain.fromNetworkId(network.id))
val sdkToken = if (network is NetworkItemState.Toggleable && network.address != null) {
com.tangem.blockchain.common.Token(
symbol = value.currencySymbol,
name = value.name,
contractAddress = network.address,
decimals = network.decimals!!,
id = value.tokenId,
)
} else {
null
}
return if (sdkToken != null) {
CryptoCurrencyFactory().createToken(
sdkToken = sdkToken,
blockchain = blockchain,
derivationStyleProvider = derivationStyleProvider,
extraDerivationPath = null,
)
} else {
CryptoCurrencyFactory().createCoin(
blockchain = blockchain,
derivationStyleProvider = derivationStyleProvider,
extraDerivationPath = null,
)
}
}
}

View file

@ -35,9 +35,13 @@ internal fun ChooseNetworkScreen(
) { ) {
val networkState = state.chooseNetworkState val networkState = state.chooseNetworkState
LazyColumn( LazyColumn(
contentPadding = PaddingValues(
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
bottom = TangemTheme.dimens.spacing16,
),
modifier = modifier modifier = modifier
.background(TangemTheme.colors.background.tertiary) .background(TangemTheme.colors.background.tertiary),
.padding(TangemTheme.dimens.spacing16),
) { ) {
item { item {
Text( Text(

View file

@ -40,7 +40,11 @@ internal fun TokensList(tokens: LazyPagingItems<TokenItemState>, addCustomTokenB
text = stringResource(id = R.string.manage_tokens_nothing_found), text = stringResource(id = R.string.manage_tokens_nothing_found),
style = TangemTheme.typography.caption2, style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary, color = TangemTheme.colors.text.tertiary,
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16), modifier = Modifier.padding(
top = TangemTheme.dimens.spacing8,
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
),
) )
} }
} }

View file

@ -0,0 +1,30 @@
package com.tangem.managetokens.presentation.managetokens.viewmodels
import com.tangem.managetokens.presentation.common.state.NetworkItemState
import com.tangem.managetokens.presentation.managetokens.state.TokenItemState
internal interface ManageTokensClickIntents {
fun onAddCustomTokensButtonClick()
fun onSearchQueryChange(query: String)
fun onSearchActiveChange(active: Boolean)
fun onTokenItemButtonClick(token: TokenItemState.Loaded)
fun onGenerateDerivationClick()
fun onBackClick()
fun onCloseChooseNetworkScreen()
fun onNetworkToggleClick(token: TokenItemState.Loaded, network: NetworkItemState.Toggleable)
fun onNonNativeNetworkHintClick()
fun onSelectWalletsClick()
fun onChooseWalletClick()
fun onCloseChoosingWalletClick()
fun onWalletSelected(walletId: String)
}

View file

@ -0,0 +1,393 @@
package com.tangem.managetokens.presentation.managetokens.viewmodels
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.map
import arrow.core.getOrElse
import com.tangem.blockchain.common.Blockchain
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.card.DerivePublicKeysUseCase
import com.tangem.domain.common.util.derivationStyleProvider
import com.tangem.domain.tokens.*
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.domain.wallets.usecase.SelectWalletUseCase
import com.tangem.domain.wallets.usecase.UpdateWalletUseCase
import com.tangem.managetokens.presentation.common.state.AlertState
import com.tangem.managetokens.presentation.common.state.Event
import com.tangem.managetokens.presentation.common.state.NetworkItemState
import com.tangem.managetokens.presentation.managetokens.state.DerivationNotificationState
import com.tangem.managetokens.presentation.managetokens.state.ManageTokensState
import com.tangem.managetokens.presentation.managetokens.state.TokenButtonType
import com.tangem.managetokens.presentation.managetokens.state.TokenItemState
import com.tangem.managetokens.presentation.managetokens.state.factory.*
import com.tangem.operations.derivation.ExtendedPublicKeysMap
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.Debouncer
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import kotlinx.coroutines.withContext
import javax.inject.Inject
import kotlin.collections.set
import kotlin.properties.Delegates
@Suppress("LargeClass", "LongParameterList", "TooManyFunctions")
@HiltViewModel
internal class ManageTokensViewModel @Inject constructor(
private val dispatchers: CoroutineDispatcherProvider,
private val getGlobalTokenListUseCase: GetGlobalTokenListUseCase,
private val getWalletsUseCase: GetWalletsUseCase,
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
private val removeCurrencyUseCase: RemoveCurrencyUseCase,
private val addCryptoCurrenciesUseCase: AddCryptoCurrenciesUseCase,
private val selectWalletUseCase: SelectWalletUseCase,
private val getCurrenciesUseCase: GetCryptoCurrenciesUseCase,
private val derivePublicKeysUseCase: DerivePublicKeysUseCase,
private val updateWalletUseCase: UpdateWalletUseCase,
private val fetchTokenListUseCase: FetchTokenListUseCase,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
) : ViewModel(), ManageTokensClickIntents, DefaultLifecycleObserver {
private val debouncer = Debouncer()
private val stateFactory = ManageTokensStateFactory(
currentStateProvider = Provider { uiState },
clickIntents = this,
)
var uiState: ManageTokensState by mutableStateOf(stateFactory.getInitialState(flowOf(PagingData.from(emptyList()))))
private set
private var allAddedCurrencies: MutableList<CryptoCurrency> = mutableListOf()
private var wallets: List<UserWallet> by Delegates.notNull()
private var addedCurrenciesByWallet: MutableMap<UserWallet, MutableList<CryptoCurrency>> = mutableMapOf()
private var selectedWallet: UserWallet? = null
private var neededDerivations: Map<UserWallet, List<DerivationData>> = emptyMap()
private val selectedAppCurrencyFlow: StateFlow<AppCurrency> = createSelectedAppCurrencyFlow()
private val quotesStateConverter = QuotesToQuotesStateConverter()
private val networkToNetworkItemStateConverter = NetworkToNetworkItemStateConverter(
addedCurrenciesByWalletProvider = Provider { addedCurrenciesByWallet },
selectedWalletProvider = Provider { selectedWallet },
onNetworkToggleClick = this::onNetworkToggleClick,
)
private val networksToChooseNetworkStateConverter = NetworksToChooseNetworkStateConverter(
networkToNetworkItemStateConverter = networkToNetworkItemStateConverter,
onNonNativeNetworkHintClick = this::onNonNativeNetworkHintClick,
onCloseChooseNetworkScreen = this::onCloseChooseNetworkScreen,
)
private val tokenConverter = TokenConverter(
quotesStateConverter = quotesStateConverter,
networksToChooseNetworkStateConverter = networksToChooseNetworkStateConverter,
allAddedCurrencies = Provider { allAddedCurrencies },
selectedAppCurrency = Provider { selectedAppCurrencyFlow.value },
onTokenItemButtonClick = this::onTokenItemButtonClick,
)
init {
viewModelScope.launch(dispatchers.io) {
getWalletsUseCase()
.distinctUntilChanged()
.collectLatest { userWallets ->
wallets = userWallets.filter { it.isMultiCurrency && !it.isLocked }
wallets.map { wallet ->
val currencies = getCurrenciesUseCase(wallet.walletId).fold(
ifLeft = { emptyList() },
ifRight = { it },
)
allAddedCurrencies += currencies
addedCurrenciesByWallet[wallet] = currencies.toMutableList()
}
withContext(dispatchers.main) {
uiState = uiState.copy(tokens = getInitialTokensList())
}
selectedWallet = getSelectedWalletSyncUseCase().fold(
ifLeft = { null },
ifRight = { if (!it.isMultiCurrency || it.isLocked) null else it },
)
if (selectedWallet == null && wallets.isNotEmpty()) {
selectWalletUseCase(wallets.first().walletId)
selectedWallet = wallets.first()
}
withContext(dispatchers.main) {
neededDerivations = MissingDerivationsIdentifier
.getMissingDerivationsForWallets(wallets, addedCurrenciesByWallet)
updateDerivationNotificationState()
uiState = stateFactory.updateChooseWalletState(wallets, userWallets, selectedWallet)
}
}
}
}
private fun getInitialTokensList(searchText: String = ""): Flow<PagingData<TokenItemState>> {
return getGlobalTokenListUseCase(searchText = searchText).map {
it.map { token -> tokenConverter.convert(token) }
}
}
private fun isAdded(address: String?, blockchain: Blockchain, currencies: Collection<CryptoCurrency>): Boolean {
return if (address != null) {
currencies.any {
!it.isCustom && it is CryptoCurrency.Token && it.contractAddress == address
}
} else {
currencies.any {
!it.isCustom && it is CryptoCurrency.Coin && it.name == blockchain.fullName
}
}
}
private fun updateDerivationNotificationState() {
val derivationNotificationState = if (neededDerivations.isEmpty()) {
null
} else {
DerivationNotificationState(
totalNeeded = neededDerivations.values.map { it.map { it.derivationsNeeded() } }.sumOf { it.sum() },
totalWallets = wallets.size,
walletsToDerive = neededDerivations.keys.size,
onGenerateClick = this::onGenerateDerivationClick,
)
}
uiState = uiState.copy(derivationNotification = derivationNotificationState)
}
private fun createSelectedAppCurrencyFlow(): StateFlow<AppCurrency> {
return getSelectedAppCurrencyUseCase()
.map { maybeAppCurrency ->
maybeAppCurrency.getOrElse { AppCurrency.Default }
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = AppCurrency.Default,
)
}
override fun onAddCustomTokensButtonClick() {
TODO("Not yet implemented") // TODO: add when custom tokens are implemented
}
override fun onSearchQueryChange(query: String) {
uiState = uiState.copy(searchBarState = uiState.searchBarState.copy(query = query))
debouncer.debounce(waitMs = 500L, coroutineScope = viewModelScope + dispatchers.io) {
val state = stateFactory.showAddCustomTokensButton(query.isNotBlank())
uiState = state.copy(tokens = getInitialTokensList(query))
}
}
override fun onSearchActiveChange(active: Boolean) {
uiState = uiState.copy(searchBarState = uiState.searchBarState.copy(active = active))
}
override fun onTokenItemButtonClick(token: TokenItemState.Loaded) {
when (token.availableAction.value) {
TokenButtonType.ADD, TokenButtonType.EDIT -> {
uiState = uiState.copy(selectedToken = token)
val addedCurrenciesOnWallet = addedCurrenciesByWallet[selectedWallet] ?: listOf()
token.chooseNetworkState.nativeNetworks.forEach {
if (it is NetworkItemState.Toggleable) {
val isAdded = isAdded(it.address, it.blockchain, addedCurrenciesOnWallet)
if (isAdded != it.isAdded.value) (it as? NetworkItemState.Toggleable)?.changeToggleState()
}
}
token.chooseNetworkState.nonNativeNetworks.forEach {
if (it is NetworkItemState.Toggleable) {
val isAdded = isAdded(it.address, it.blockchain, addedCurrenciesOnWallet)
if (isAdded != it.isAdded.value) (it as? NetworkItemState.Toggleable)?.changeToggleState()
}
}
}
TokenButtonType.NOT_AVAILABLE -> {
uiState = stateFactory.getStateAndTriggerEvent(
state = uiState,
event = Event.ShowAlert(
AlertState.TokenUnavailable(
onUpvoteClick = {}, // TODO later, when endpoint is available
),
),
setUiState = { uiState = it },
)
}
}
}
override fun onGenerateDerivationClick() {
if (neededDerivations.isNotEmpty()) {
viewModelScope.launch(dispatchers.io) {
val wallet = neededDerivations.keys.firstOrNull()
val derivations = neededDerivations[wallet]?.toMapOfDerivations()
if (wallet == null || derivations.isNullOrEmpty()) return@launch
derivePublicKeysUseCase(cardId = null, derivations = derivations)
.onRight {
val newDerivedKeys = it.entries
val oldDerivedKeys = wallet.scanResponse.derivedKeys
val walletKeys = (newDerivedKeys.keys + oldDerivedKeys.keys).toSet()
val updatedDerivedKeys = walletKeys.associateWith { walletKey ->
val oldDerivations = ExtendedPublicKeysMap(oldDerivedKeys[walletKey] ?: emptyMap())
val newDerivations = newDerivedKeys[walletKey] ?: ExtendedPublicKeysMap(emptyMap())
ExtendedPublicKeysMap(oldDerivations + newDerivations)
}
val updatedScanResponse = wallet.scanResponse.copy(derivedKeys = updatedDerivedKeys)
updateWalletUseCase(
userWalletId = wallet.walletId,
update = { userWallet -> userWallet.copy(scanResponse = updatedScanResponse) },
).onRight { userWallet ->
fetchTokenListUseCase(userWalletId = userWallet.walletId)
}
updateDerivationNotificationState()
}
}
}
}
override fun onBackClick() {
TODO("Not yet implemented") // TODO: implement if needed when custom tokens and bottom sheet is complete
}
override fun onCloseChooseNetworkScreen() {
uiState = uiState.copy(selectedToken = null)
}
override fun onNetworkToggleClick(token: TokenItemState.Loaded, network: NetworkItemState.Toggleable) {
val selectedWallet = selectedWallet ?: return
if (!selectedWallet.isMultiCurrency || selectedWallet.isLocked) return
if (network.isAdded.value) {
toggleToken(token, network, selectedWallet)
} else {
TokenCompatibility(selectedWallet)
.check(network.blockchain, network.address == null)
.fold(
ifLeft = { error ->
val alert = when (error) {
TokenCompatibility.AddTokenError.SolanaTokensUnsupported ->
AlertState.TokensUnsupported(network.blockchain.fullName)
TokenCompatibility.AddTokenError.UnsupportedBlockchain ->
AlertState.TokensUnsupportedBlockchainByCard(network.blockchain.fullName)
TokenCompatibility.AddTokenError.UnsupportedCurve ->
AlertState.TokensUnsupportedCurve
}
uiState = stateFactory.getStateAndTriggerEvent(
state = uiState,
event = Event.ShowAlert(alert),
setUiState = { uiState = it },
)
},
ifRight = {
toggleToken(token, network, selectedWallet)
},
)
}
}
private fun toggleToken(
token: TokenItemState.Loaded,
network: NetworkItemState.Toggleable,
selectedWallet: UserWallet,
) {
val cryptoCurrency = requireNotNull(
TokenToCryptoCurrencyConverter(
network = network,
derivationStyleProvider = selectedWallet.scanResponse.derivationStyleProvider,
).convert(token),
) {
"It is only null if Blockchain is Unknown, which mustn't happen here"
}
if (!network.isAdded.value) {
addedCurrenciesByWallet[selectedWallet]?.add(cryptoCurrency)
allAddedCurrencies.add(cryptoCurrency)
viewModelScope.launch(dispatchers.io) {
addCryptoCurrenciesUseCase(selectedWallet.walletId, cryptoCurrency)
}
} else {
addedCurrenciesByWallet[selectedWallet]?.remove(cryptoCurrency)
allAddedCurrencies.remove(cryptoCurrency)
viewModelScope.launch(dispatchers.io) {
removeCurrencyUseCase(selectedWallet.walletId, cryptoCurrency)
}
}
network.changeToggleState()
val isAnyAdded = token.chooseNetworkState.nativeNetworks.any {
it is NetworkItemState.Toggleable && isAdded(
address = it.address,
blockchain = it.blockchain,
currencies = allAddedCurrencies,
)
} || token.chooseNetworkState.nonNativeNetworks.any {
it is NetworkItemState.Toggleable && isAdded(
address = it.address,
blockchain = it.blockchain,
currencies = allAddedCurrencies,
)
}
val buttonType = if (isAnyAdded) TokenButtonType.EDIT else TokenButtonType.ADD
token.availableAction.value = buttonType
neededDerivations = MissingDerivationsIdentifier
.getMissingDerivationsForWallets(wallets, addedCurrenciesByWallet)
updateDerivationNotificationState()
}
override fun onNonNativeNetworkHintClick() {
uiState = stateFactory.getStateAndTriggerEvent(
state = uiState,
event = Event.ShowAlert(AlertState.NonNative),
setUiState = { uiState = it },
)
}
override fun onSelectWalletsClick() {
uiState = uiState.copy(
showChooseWalletScreen = true,
)
}
override fun onChooseWalletClick() {
uiState = uiState.copy(
showChooseWalletScreen = true,
)
}
override fun onCloseChoosingWalletClick() {
uiState = uiState.copy(
showChooseWalletScreen = false,
)
}
override fun onWalletSelected(walletId: String) {
viewModelScope.launch(dispatchers.io) {
selectWalletUseCase(UserWalletId(walletId))
}
selectedWallet = wallets.find { it.walletId.stringValue == walletId }
uiState.selectedToken?.let { onTokenItemButtonClick(it) }
uiState = stateFactory.updateSelectedWallet(selectedWalletId = selectedWallet?.walletId?.stringValue)
}
}

View file

@ -0,0 +1,95 @@
package com.tangem.managetokens.presentation.managetokens.viewmodels
import com.tangem.blockchain.blockchains.cardano.CardanoUtils
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.card.EllipticCurve
import com.tangem.common.extensions.ByteArrayKey
import com.tangem.common.extensions.toMapKey
import com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.domain.common.configs.CardConfig
import com.tangem.domain.common.util.derivationStyleProvider
import com.tangem.domain.models.scan.ScanResponse
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.operations.derivation.ExtendedPublicKeysMap
object MissingDerivationsIdentifier {
fun getMissingDerivationsForWallets(
wallets: List<UserWallet>,
addedCurrenciesByWallet: MutableMap<UserWallet, MutableList<CryptoCurrency>>,
): Map<UserWallet, List<DerivationData>> {
return wallets.associateWith { wallet ->
val currencies = addedCurrenciesByWallet[wallet] ?: emptyList()
val config = CardConfig.createConfig(wallet.scanResponse.card)
val missingDerivationsByCurrency = currencies.mapNotNull { currency ->
config.primaryCurve(blockchain = Blockchain.fromId(currency.network.id.value))?.let { curve ->
getMissingDerivations(curve, wallet.scanResponse, currency)
}
}
collectMissingDerivationsByPublicKey(missingDerivationsByCurrency)
}.filter { it.value.isNotEmpty() }
}
private fun getMissingDerivations(
curve: EllipticCurve,
scanResponse: ScanResponse,
currency: CryptoCurrency,
): DerivationData? {
val wallet = scanResponse.card.wallets.firstOrNull { it.curve == curve } ?: return null
val blockchain = Blockchain.fromId(currency.network.id.value)
val supportedCurves = blockchain.getSupportedCurves()
val path = blockchain.derivationPath(scanResponse.derivationStyleProvider.getDerivationStyle())
.takeIf { supportedCurves.contains(curve) }
val customPath = currency.network.derivationPath.value?.let {
DerivationPath(it)
}.takeIf { supportedCurves.contains(curve) }
val bothCandidates = listOfNotNull(path, customPath).distinct().toMutableList()
if (bothCandidates.isEmpty()) return null
if (currency is CryptoCurrency.Coin && blockchain == Blockchain.Cardano) {
currency.network.derivationPath.value?.let {
bothCandidates.add(CardanoUtils.extendedDerivationPath(DerivationPath(it)))
}
}
val mapKeyOfWalletPublicKey = wallet.publicKey.toMapKey()
val alreadyDerivedKeys: ExtendedPublicKeysMap =
scanResponse.derivedKeys[mapKeyOfWalletPublicKey] ?: ExtendedPublicKeysMap(emptyMap())
val alreadyDerivedPaths = alreadyDerivedKeys.keys.toList()
val toDerive = bothCandidates.filterNot { alreadyDerivedPaths.contains(it) }
if (toDerive.isEmpty()) return null
return DerivationData(derivations = mapKeyOfWalletPublicKey to toDerive)
}
private fun collectMissingDerivationsByPublicKey(
missingDerivationsByCurrency: List<DerivationData>,
): List<DerivationData> {
return buildMap<ByteArrayKey, MutableList<DerivationPath>> {
missingDerivationsByCurrency.forEach { derivationData ->
val currentEntry = this[derivationData.derivations.first]
if (currentEntry != null) {
currentEntry.addAll(derivationData.derivations.second)
currentEntry.distinct()
} else {
this[derivationData.derivations.first] = derivationData.derivations.second.toMutableList()
}
}
}.map { DerivationData(it.key to it.value) }
}
}
class DerivationData(val derivations: Pair<ByteArrayKey, List<DerivationPath>>) {
fun derivationsNeeded(): Int {
return derivations.second.size
}
}
internal fun List<DerivationData>.toMapOfDerivations(): Map<ByteArrayKey, List<DerivationPath>> {
return associate { it.derivations }
}

View file

@ -0,0 +1,51 @@
package com.tangem.managetokens.presentation.managetokens.viewmodels
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.tangem.blockchain.common.Blockchain
import com.tangem.domain.common.extensions.canHandleBlockchain
import com.tangem.domain.common.extensions.canHandleToken
import com.tangem.domain.common.extensions.supportedTokens
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.wallets.models.UserWallet
class TokenCompatibility(private val selectedWallet: UserWallet) {
fun check(blockchain: Blockchain, isMainNetwork: Boolean): Either<AddTokenError, Unit> {
val error = if (isMainNetwork) isUnsupportedBlockchain(blockchain) else isUnsupportedToken(blockchain)
return error?.left() ?: Unit.right()
}
private fun isUnsupportedToken(blockchain: Blockchain): AddTokenError? {
val cardTypesResolver = selectedWallet.scanResponse.cardTypesResolver
val blockchainsSupportingTokens = selectedWallet.scanResponse.card.supportedTokens(cardTypesResolver)
if (blockchain == Blockchain.Solana && !blockchainsSupportingTokens.contains(Blockchain.Solana)) {
return AddTokenError.SolanaTokensUnsupported
}
val canHandleToken = selectedWallet.scanResponse.card.canHandleToken(
supportedTokens = blockchainsSupportingTokens,
blockchain = blockchain,
cardTypesResolver = cardTypesResolver,
)
if (!canHandleToken) {
return AddTokenError.UnsupportedCurve
}
return null
}
private fun isUnsupportedBlockchain(blockchain: Blockchain): AddTokenError? {
val isSupported = selectedWallet.scanResponse.card.canHandleBlockchain(
blockchain = blockchain,
cardTypesResolver = selectedWallet.scanResponse.cardTypesResolver,
)
return if (!isSupported) AddTokenError.UnsupportedBlockchain else null
}
sealed class AddTokenError {
object SolanaTokensUnsupported : AddTokenError()
object UnsupportedCurve : AddTokenError()
object UnsupportedBlockchain : AddTokenError()
}
}