Updated on 2026-08-14

This commit is contained in:
Tangem 2023-12-22 16:56:44 +03:00
parent e4f1557d72
commit 32834b314f
40 changed files with 1453 additions and 30 deletions

View file

@ -254,4 +254,36 @@ internal object TokensDomainModule {
): CheckCurrencyCompatibilityUseCase {
return CheckCurrencyCompatibilityUseCase(repository)
}
@Provides
@ViewModelScoped
fun provideFindTokenByContractAddressUseCase(
tokensListRepository: TokensListRepository,
): FindTokenByContractAddressUseCase {
return FindTokenByContractAddressUseCase(repository = tokensListRepository)
}
@Provides
@ViewModelScoped
fun provideValidateContractAddressUseCase(
tokensListRepository: TokensListRepository,
): ValidateContractAddressUseCase {
return ValidateContractAddressUseCase(tokensListRepository = tokensListRepository)
}
@Provides
@ViewModelScoped
fun provideAreTokensSupportedByNetworkUseCase(
repository: NetworksCompatibilityRepository,
): AreTokensSupportedByNetworkUseCase {
return AreTokensSupportedByNetworkUseCase(repository = repository)
}
@Provides
@ViewModelScoped
fun provideGetNetworksSupportedByWallet(
repository: NetworksCompatibilityRepository,
): GetNetworksSupportedByWallet {
return GetNetworksSupportedByWallet(repository = repository)
}
}

View file

@ -30,4 +30,8 @@ class Debouncer {
fun release() {
debounceJob?.cancel()
}
companion object {
const val DEFAULT_WAIT_TIME_MS = 500L
}
}

View file

@ -1,12 +1,12 @@
package com.tangem.data.tokens.repository
import com.tangem.blockchain.common.Blockchain
import com.tangem.data.tokens.utils.getNetwork
import com.tangem.datasource.local.userwallet.UserWalletsStore
import com.tangem.domain.common.extensions.canHandleBlockchain
import com.tangem.domain.common.extensions.canHandleToken
import com.tangem.domain.common.extensions.fromNetworkId
import com.tangem.domain.common.extensions.supportedTokens
import com.tangem.domain.common.extensions.*
import com.tangem.domain.common.util.cardTypesResolver
import com.tangem.domain.common.util.derivationStyleProvider
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.tokens.repository.NetworksCompatibilityRepository
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
@ -58,6 +58,23 @@ internal class DefaultNetworksCompatibilityRepository(
}
}
@Throws(IllegalArgumentException::class)
override suspend fun getSupportedNetworks(userWalletId: UserWalletId): List<Network> {
val scanResponse = getWalletOrThrow(userWalletId).scanResponse
return Blockchain.values()
.filter { blockchain ->
scanResponse.card.supportedBlockchains(scanResponse.cardTypesResolver).contains(blockchain)
}
.sortedBy(Blockchain::fullName)
.mapNotNull { blockchain ->
getNetwork(blockchain, null, scanResponse.derivationStyleProvider)
}
}
override fun areTokensSupportedByNetwork(networkId: String): Boolean {
return Blockchain.fromNetworkId(networkId)?.canHandleTokens() ?: false
}
private suspend fun getWalletOrThrow(userWalletId: UserWalletId): UserWallet {
return requireNotNull(userWalletsStore.getSyncOrNull(userWalletId)) {
"Requested UserWallet not found"

View file

@ -3,13 +3,19 @@ package com.tangem.data.tokens.repository
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import com.tangem.blockchain.common.Blockchain
import com.tangem.data.tokens.paging.CoinsPagingSource
import com.tangem.data.tokens.utils.FoundTokenConverter
import com.tangem.datasource.api.common.response.getOrThrow
import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.domain.common.extensions.fromNetworkId
import com.tangem.domain.tokens.model.FoundToken
import com.tangem.domain.tokens.model.Token
import com.tangem.domain.tokens.repository.QuotesRepository
import com.tangem.domain.tokens.repository.TokensListRepository
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
/**
* Default repository implementation for managing operations related to a complete set of tokens
@ -42,4 +48,32 @@ internal class DefaultTokensListRepository(
},
).flow
}
override suspend fun findToken(contractAddress: String, networkId: String): FoundToken? {
return withContext(dispatchers.io) {
val foundCoin = tangemTechApi.getCoins(
contractAddress = contractAddress,
networkIds = networkId,
).getOrThrow().coins.firstNotNullOfOrNull { coin ->
val tokenNetwork = coin.networks.filter { network ->
network.contractAddress != null && network.decimalCount != null &&
network.contractAddress?.equals(contractAddress, ignoreCase = true) == true &&
networkId == network.networkId
}
if (tokenNetwork.isNotEmpty()) {
coin.copy(networks = tokenNetwork)
} else {
null
}
}
foundCoin?.let { FoundTokenConverter.convert(foundCoin) }
}
}
override fun validateAddress(contractAddress: String, networkId: String): Boolean {
return when (val blockchain = Blockchain.fromNetworkId(networkId) ?: Blockchain.Unknown) {
Blockchain.Unknown, Blockchain.Binance, Blockchain.BinanceTestnet -> true
else -> blockchain.validateAddress(contractAddress)
}
}
}

View file

@ -0,0 +1,18 @@
package com.tangem.data.tokens.utils
import com.tangem.datasource.api.tangemTech.models.CoinsResponse
import com.tangem.domain.tokens.model.FoundToken
import com.tangem.utils.converter.Converter
internal object FoundTokenConverter : Converter<CoinsResponse.Coin, FoundToken> {
override fun convert(value: CoinsResponse.Coin): FoundToken {
return FoundToken(
id = value.id,
name = value.name,
symbol = value.symbol,
contractAddress = requireNotNull(value.networks.first().contractAddress),
decimals = requireNotNull(value.networks.first().decimalCount).intValueExact(),
)
}
}

View file

@ -0,0 +1,18 @@
package com.tangem.domain.tokens.model
/**
* Found token model
*
* @property id id
* @property name name
* @property symbol symbol
* @property decimals decimals
* @property contractAddress contractAddress
*/
data class FoundToken(
val id: String,
val name: String,
val symbol: String,
val decimals: Int,
val contractAddress: String,
)

View file

@ -0,0 +1,34 @@
package com.tangem.domain.tokens
import arrow.core.Either
import arrow.core.raise.catch
import arrow.core.raise.either
import com.tangem.domain.tokens.repository.NetworksCompatibilityRepository
import com.tangem.domain.wallets.models.UserWalletId
class AreTokensSupportedByNetworkUseCase(private val repository: NetworksCompatibilityRepository) {
suspend operator fun invoke(networkId: String, userWalletId: UserWalletId?): Either<Throwable, Boolean> {
return either {
catch(
block = {
if (userWalletId == null) {
repository.areTokensSupportedByNetwork(networkId)
} else {
repository.areTokensSupportedByNetwork(networkId, userWalletId)
}
},
catch = { throwable -> raise(throwable) },
)
}
}
operator fun invoke(networkId: String): Either<Throwable, Boolean> {
return either {
catch(
block = { repository.areTokensSupportedByNetwork(networkId) },
catch = { throwable -> raise(throwable) },
)
}
}
}

View file

@ -0,0 +1,18 @@
package com.tangem.domain.tokens
import arrow.core.Either
import arrow.core.raise.catch
import arrow.core.raise.either
import com.tangem.domain.tokens.model.FoundToken
import com.tangem.domain.tokens.repository.TokensListRepository
class FindTokenByContractAddressUseCase(private val repository: TokensListRepository) {
suspend operator fun invoke(contractAddress: String, networkId: String): Either<Throwable, FoundToken?> {
return either {
catch(
block = { repository.findToken(contractAddress, networkId) },
catch = { throwable -> raise(throwable) },
)
}
}
}

View file

@ -0,0 +1,23 @@
package com.tangem.domain.tokens
import arrow.core.Either
import arrow.core.raise.catch
import arrow.core.raise.either
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.tokens.repository.NetworksCompatibilityRepository
import com.tangem.domain.wallets.models.UserWalletId
class GetNetworksSupportedByWallet(
private val repository: NetworksCompatibilityRepository,
) {
suspend operator fun invoke(userWalletId: UserWalletId): Either<Throwable, List<Network>> {
return either {
catch(
block = {
repository.getSupportedNetworks(userWalletId)
},
catch = { throwable -> raise(throwable) },
)
}
}
}

View file

@ -0,0 +1,26 @@
package com.tangem.domain.tokens
import arrow.core.Either
import arrow.core.raise.catch
import arrow.core.raise.either
import com.tangem.domain.AddCustomTokenError
import com.tangem.domain.tokens.repository.TokensListRepository
class ValidateContractAddressUseCase(private val tokensListRepository: TokensListRepository) {
operator fun invoke(address: String, networkId: String): Either<AddCustomTokenError, Unit> {
return either {
catch(
block = {
if (address.isEmpty()) raise(AddCustomTokenError.FieldIsEmpty)
if (!tokensListRepository.validateAddress(networkId, address)) {
raise(AddCustomTokenError.InvalidContractAddress)
}
},
catch = {
AddCustomTokenError.InvalidContractAddress
},
)
}
}
}

View file

@ -1,5 +1,6 @@
package com.tangem.domain.tokens.repository
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWalletId
interface NetworksCompatibilityRepository {
@ -12,4 +13,9 @@ interface NetworksCompatibilityRepository {
@Throws(IllegalArgumentException::class)
suspend fun isNetworkSupported(networkId: String, userWalletId: UserWalletId): Boolean
@Throws(IllegalArgumentException::class)
suspend fun getSupportedNetworks(userWalletId: UserWalletId): List<Network>
fun areTokensSupportedByNetwork(networkId: String): Boolean
}

View file

@ -1,6 +1,7 @@
package com.tangem.domain.tokens.repository
import androidx.paging.PagingData
import com.tangem.domain.tokens.model.FoundToken
import com.tangem.domain.tokens.model.Token
import kotlinx.coroutines.flow.Flow
@ -18,4 +19,25 @@ interface TokensListRepository {
* @throws com.tangem.datasource.api.common.response.ApiResponseError
*/
fun getTokens(searchText: String?): Flow<PagingData<Token>>
/**
* Retrieves a token information with the specified contract address on the provided network.
*
* @param contractAddress contract address of the token
* @param networkId network of the token
* @return [FoundToken] object containing token information or null if no token is found with the provided
* contract address
*/
@Throws
suspend fun findToken(contractAddress: String, networkId: String): FoundToken?
/**
* Validates a contract address on a particular network.
*
* @param contractAddress contract address of the token
* @param networkId network of the token
* @return [Boolean] true is address is valid (possible on the network), false if its format is not
* supported on the network
*/
fun validateAddress(contractAddress: String, networkId: String): Boolean
}

View file

@ -2,15 +2,12 @@ package com.tangem.managetokens
import androidx.compose.runtime.Composable
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.res.TangemTheme
import com.tangem.core.ui.screen.ComposeFragment
import com.tangem.core.ui.theme.AppThemeModeHolder
import com.tangem.features.managetokens.navigation.ManageTokensRouter
import com.tangem.managetokens.presentation.managetokens.ui.ManageTokensScreen
import com.tangem.managetokens.presentation.managetokens.viewmodels.ManageTokensViewModel
import com.tangem.managetokens.presentation.router.InnerManageTokensRouter
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@ -23,23 +20,18 @@ internal class ManageTokensFragment : ComposeFragment() {
@Inject
lateinit var manageTokensRouter: ManageTokensRouter
// private val internalManageTokensRouter: InnerManageTokensRouter
// get() = requireNotNull(manageTokensRouter as? InnerManageTokensRouter) {
// "internalManageTokensRouter should be instance of InnerManageTokensRouter"
// }
private val innerManageTokensRouter: InnerManageTokensRouter
get() = requireNotNull(manageTokensRouter as? InnerManageTokensRouter) {
"internalManageTokensRouter should be instance of InnerManageTokensRouter"
}
@Composable
override fun ScreenContent(modifier: Modifier) {
val viewModel = hiltViewModel<ManageTokensViewModel>()
// viewModel.router = [REDACTED_EMAIL]
//
LocalLifecycleOwner.current.lifecycle.addObserver(viewModel)
val systemBarsColor = TangemTheme.colors.background.secondary
SystemBarsEffect {
setSystemBarsColor(systemBarsColor)
}
ManageTokensScreen(state = viewModel.uiState)
innerManageTokensRouter.Initialize(viewModelStoreOwner = this)
}
}

View file

@ -1,5 +1,6 @@
package com.tangem.managetokens.di
import com.tangem.core.navigation.ReduxNavController
import com.tangem.features.managetokens.navigation.ManageTokensRouter
import com.tangem.managetokens.presentation.router.DefaultManageTokensRouter
import dagger.Module
@ -14,7 +15,7 @@ internal object ManageTokensRouterModule {
@Provides
@ActivityScoped
fun provideManageTokensRouter(): ManageTokensRouter {
return DefaultManageTokensRouter()
fun provideManageTokensRouter(reduxNavController: ReduxNavController): ManageTokensRouter {
return DefaultManageTokensRouter(reduxNavController)
}
}

View file

@ -49,4 +49,8 @@ internal sealed class AlertState {
formatArgs = wrappedList(tokenName, networkName),
)
}
object TokenAlreadyAdded : AlertState() {
override val message: TextReference = resourceReference(R.string.custom_token_validation_error_already_added)
}
}

View file

@ -10,6 +10,7 @@ internal sealed class ChooseWalletState {
val selectedWallet: WalletState?,
val onChooseWalletClick: () -> Unit,
val onCloseChoosingWalletClick: () -> Unit,
val show: Boolean = false,
) : ChooseWalletState()
object NoSelection : ChooseWalletState()
@ -19,13 +20,10 @@ internal sealed class ChooseWalletState {
get() = when (type) {
ChooseWalletWarning.SINGLE_CURRENCY ->
TextReference.Res(R.string.manage_tokens_wallet_support_only_one_network_title)
ChooseWalletWarning.WALLET_INCOMPATIBLE ->
TextReference.Res(R.string.manage_tokens_wallet_does_not_supported_blockchain)
}
}
}
enum class ChooseWalletWarning {
SINGLE_CURRENCY,
WALLET_INCOMPATIBLE,
}

View file

@ -17,6 +17,7 @@ internal fun Alert(state: AlertState, onDismiss: () -> Unit) {
is AlertState.TokensUnsupported,
is AlertState.TokensUnsupportedBlockchainByCard,
is AlertState.CannotHideNetworkWithTokens,
is AlertState.TokenAlreadyAdded,
-> DefaultAlert(state, onDismiss)
is AlertState.TokenUnavailable -> TokenUnavailableAlert(state, onDismiss)
}

View file

@ -1,6 +1,7 @@
package com.tangem.managetokens.presentation.common.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon
@ -24,11 +25,23 @@ import com.tangem.managetokens.presentation.managetokens.state.TokenItemState
import com.tangem.managetokens.presentation.managetokens.state.previewdata.TokenItemStatePreviewData
@Composable
internal fun NetworkItem(state: NetworkItemState, tokenState: TokenItemState.Loaded?, modifier: Modifier = Modifier) {
internal fun NetworkItem(
state: NetworkItemState,
tokenState: TokenItemState.Loaded?,
modifier: Modifier = Modifier,
isSelected: Boolean = false,
) {
Row(
modifier = modifier
.background(TangemTheme.colors.background.action)
.defaultMinSize(minHeight = TangemTheme.dimens.size68)
.then(
if (state is NetworkItemState.Selectable) {
Modifier.clickable { state.onNetworkClick(state) }
} else {
Modifier
},
)
.padding(horizontal = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
@ -55,6 +68,12 @@ internal fun NetworkItem(state: NetworkItemState, tokenState: TokenItemState.Loa
},
checked = state.isAdded.value,
)
} else if (state is NetworkItemState.Selectable && isSelected) {
Icon(
painter = painterResource(id = R.drawable.ic_check_24),
contentDescription = null,
tint = TangemTheme.colors.icon.accent,
)
}
}
}

View file

@ -0,0 +1,18 @@
package com.tangem.managetokens.presentation.customtokens.state
import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.event.consumedEvent
import com.tangem.managetokens.presentation.common.state.ChooseWalletState
import com.tangem.managetokens.presentation.common.state.Event
import kotlinx.collections.immutable.ImmutableSet
internal data class AddCustomTokenState(
val chooseWalletState: ChooseWalletState,
val chooseNetworkState: ChooseNetworkState,
val chooseDerivationState: ChooseDerivationState?,
val tokenData: CustomTokenData?,
val warnings: ImmutableSet<AddCustomTokenWarning>,
val addTokenButton: ButtonState,
val showChooseWalletScreen: Boolean = false,
val event: StateEvent<Event> = consumedEvent(),
)

View file

@ -0,0 +1,32 @@
package com.tangem.managetokens.presentation.customtokens.state
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.features.managetokens.impl.R
/**
* Warning model of add custom token screen
*
* @property title warning description
*/
internal sealed class AddCustomTokenWarning(val title: TextReference, val subtitle: TextReference? = null) {
object PotentialScamToken : AddCustomTokenWarning(
title = resourceReference(R.string.custom_token_validation_error_not_found_title),
subtitle = resourceReference(R.string.custom_token_validation_error_not_found_description),
)
object InvalidContractAddress : AddCustomTokenWarning(
title = resourceReference(R.string.custom_token_creation_error_invalid_contract_address),
)
object WrongDecimals : AddCustomTokenWarning(
title =
resourceReference(R.string.custom_token_creation_error_wrong_decimals, wrappedList(MAXIMUM_DECIMAL_NUMBER)),
)
private companion object {
const val MAXIMUM_DECIMAL_NUMBER = 30
}
}

View file

@ -0,0 +1,6 @@
package com.tangem.managetokens.presentation.customtokens.state
internal data class ButtonState(
val isEnabled: Boolean,
val onClick: () -> Unit,
)

View file

@ -0,0 +1,13 @@
package com.tangem.managetokens.presentation.customtokens.state
import kotlinx.collections.immutable.ImmutableList
internal data class ChooseDerivationState(
val derivations: ImmutableList<Derivation>,
val selectedDerivation: Derivation?,
val enterCustomDerivationState: EnterCustomDerivationState?,
val onChooseDerivationClick: () -> Unit,
val onCloseChoosingDerivationClick: () -> Unit,
val onEnterCustomDerivation: () -> Unit,
val show: Boolean = false,
)

View file

@ -0,0 +1,12 @@
package com.tangem.managetokens.presentation.customtokens.state
import com.tangem.managetokens.presentation.common.state.NetworkItemState
import kotlinx.collections.immutable.ImmutableList
internal data class ChooseNetworkState(
val networks: ImmutableList<NetworkItemState>,
val selectedNetwork: NetworkItemState?,
val onChooseNetworkClick: () -> Unit,
val onCloseChoosingNetworkClick: () -> Unit,
val show: Boolean = false,
)

View file

@ -0,0 +1,14 @@
package com.tangem.managetokens.presentation.customtokens.state
internal data class CustomTokenData(
val contractAddressTextField: TextFieldState,
val nameTextField: TextFieldState,
val symbolTextField: TextFieldState,
val decimalsTextField: TextFieldState,
) {
fun isRequiredInformationProvided(): Boolean {
return contractAddressTextField.isInputValid() && nameTextField.isInputValid() &&
symbolTextField.isInputValid() && decimalsTextField.isInputValid()
}
}

View file

@ -0,0 +1,9 @@
package com.tangem.managetokens.presentation.customtokens.state
internal data class Derivation(
val networkName: String,
val standardType: String?,
val path: String,
val networkId: String?,
val onDerivationSelected: (Derivation) -> Unit,
)

View file

@ -0,0 +1,10 @@
package com.tangem.managetokens.presentation.customtokens.state
internal data class EnterCustomDerivationState(
val value: String,
val onValueChange: (String) -> Unit,
val confirmButtonEnabled: Boolean,
val derivationIncorrect: Boolean,
val onConfirmButtonClick: () -> Unit,
val onDismiss: () -> Unit,
)

View file

@ -0,0 +1,31 @@
package com.tangem.managetokens.presentation.customtokens.state
internal sealed class TextFieldState {
object Loading : TextFieldState()
data class Editable(
val value: String,
val isEnabled: Boolean,
val error: AddCustomTokenWarning? = null,
val onValueChange: (String) -> Unit,
) : TextFieldState()
fun isInputValid(): Boolean = this is Editable && value.isNotBlank() && error == null
fun copySealed(
value: String = (this as? Editable)?.value ?: "",
isEnabled: Boolean = (this as? Editable)?.isEnabled ?: true,
error: AddCustomTokenWarning? = (this as? Editable)?.error,
onValueChange: (String) -> Unit = (this as? Editable)?.onValueChange ?: {},
): TextFieldState {
return when (this) {
is Editable -> this.copy(
value = value,
isEnabled = isEnabled,
error = error,
onValueChange = onValueChange,
)
is Loading -> this
}
}
}

View file

@ -0,0 +1,58 @@
package com.tangem.managetokens.presentation.customtokens.state.factory
import com.tangem.data.tokens.utils.CryptoCurrencyFactory
import com.tangem.domain.common.DerivationStyleProvider
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.managetokens.presentation.customtokens.state.AddCustomTokenState
import com.tangem.managetokens.presentation.customtokens.state.CustomTokenData
import com.tangem.managetokens.presentation.customtokens.state.TextFieldState
import com.tangem.utils.converter.Converter
internal class AddCustomTokenStateToCryptoCurrencyConverter(
private val derivationStyleProvider: DerivationStyleProvider,
) : Converter<AddCustomTokenState, CryptoCurrency> {
override fun convert(value: AddCustomTokenState): CryptoCurrency {
val derivationPath = value.chooseDerivationState?.selectedDerivation?.path
val token = parseTokenOrNull(value.tokenData)
val cryptoCurrency = if (token != null) {
CryptoCurrencyFactory().createToken(
token = token,
networkId = value.chooseNetworkState.selectedNetwork?.id ?: "",
derivationStyleProvider = derivationStyleProvider,
extraDerivationPath = derivationPath,
)
} else {
CryptoCurrencyFactory().createCoin(
networkId = value.chooseNetworkState.selectedNetwork?.id ?: "",
derivationStyleProvider = derivationStyleProvider,
extraDerivationPath = derivationPath,
)
}
return requireNotNull(cryptoCurrency) {
"Unless network is not Unknown blockchain, CryptoCurrency cannot be null"
}
}
@Suppress("ComplexCondition")
private fun parseTokenOrNull(tokenData: CustomTokenData?): CryptoCurrencyFactory.Token? {
val contractAddress = (tokenData?.contractAddressTextField as? TextFieldState.Editable)?.value
val symbol = (tokenData?.symbolTextField as? TextFieldState.Editable)?.value
val name = (tokenData?.contractAddressTextField as? TextFieldState.Editable)?.value
val decimals = (tokenData?.decimalsTextField as? TextFieldState.Editable)?.value?.toIntOrNull()
return if (
!contractAddress.isNullOrBlank() && !symbol.isNullOrBlank() && !name.isNullOrBlank() && decimals != null
) {
CryptoCurrencyFactory.Token(
symbol = symbol,
name = name,
contractAddress = contractAddress,
decimals = decimals,
id = null,
)
} else {
null
}
}
}

View file

@ -0,0 +1,35 @@
package com.tangem.managetokens.presentation.customtokens.state.factory
import com.tangem.managetokens.presentation.customtokens.state.CustomTokenData
import com.tangem.managetokens.presentation.customtokens.state.TextFieldState
import com.tangem.managetokens.presentation.customtokens.viewmodels.CustomTokensClickIntents
import com.tangem.utils.converter.Converter
internal class ContractAddressToCustomTokenDataConverter(
private val clickIntents: CustomTokensClickIntents,
) : Converter<String, CustomTokenData> {
override fun convert(value: String): CustomTokenData {
return CustomTokenData(
contractAddressTextField = TextFieldState.Editable(
value = value,
isEnabled = true,
onValueChange = clickIntents::onContractAddressChange,
),
nameTextField = TextFieldState.Editable(
value = "",
isEnabled = true,
onValueChange = clickIntents::onTokenNameChange,
),
symbolTextField = TextFieldState.Editable(
value = "",
isEnabled = true,
onValueChange = clickIntents::onSymbolChange,
),
decimalsTextField = TextFieldState.Editable(
value = "",
isEnabled = true,
onValueChange = clickIntents::onDecimalsChange,
),
)
}
}

View file

@ -0,0 +1,300 @@
package com.tangem.managetokens.presentation.customtokens.state.factory
import com.tangem.core.ui.event.consumedEvent
import com.tangem.core.ui.event.triggeredEvent
import com.tangem.domain.AddCustomTokenError
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.managetokens.presentation.common.state.*
import com.tangem.managetokens.presentation.customtokens.state.*
import com.tangem.managetokens.presentation.customtokens.viewmodels.CustomTokensClickIntents
import com.tangem.utils.Provider
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.collections.immutable.toPersistentSet
internal class CustomTokensStateFactory(
private val currentStateProvider: Provider<AddCustomTokenState>,
private val clickIntents: CustomTokensClickIntents,
) {
fun getInitialState(): AddCustomTokenState {
return AddCustomTokenState(
chooseWalletState = ChooseWalletState.NoSelection,
chooseNetworkState = ChooseNetworkState(
networks = persistentListOf(),
selectedNetwork = null,
onChooseNetworkClick = clickIntents::onChooseNetworkClick,
onCloseChoosingNetworkClick = clickIntents::onCloseChoosingNetworkClick,
),
chooseDerivationState = ChooseDerivationState(
derivations = persistentListOf(),
selectedDerivation = null,
enterCustomDerivationState = null,
onChooseDerivationClick = clickIntents::onChooseDerivationClick,
onCloseChoosingDerivationClick = clickIntents::onCloseChoosingDerivationClick,
onEnterCustomDerivation = clickIntents::onEnterCustomDerivation,
),
tokenData = null,
warnings = persistentSetOf(),
addTokenButton = ButtonState(isEnabled = false, onClick = clickIntents::onAddCustomButtonClick),
)
}
fun getFullState(
suitableUserWallets: List<UserWallet>,
allUserWallets: List<UserWallet>,
selectedWalletId: UserWalletId?,
supportedNetworks: List<Network>,
): AddCustomTokenState {
val derivations = getListOfDerivations(supportedNetworks)
val chooseDerivationState = createChooseDerivationState(derivations)
val networkConverter = NetworkToNetworkItemStateConverter(clickIntents::onNetworkSelected)
val networks = supportedNetworks.map { networkConverter.convert(it) }
val chooseWalletState = getNewChooseWalletState(allUserWallets, suitableUserWallets, selectedWalletId)
return AddCustomTokenState(
chooseWalletState = chooseWalletState,
chooseNetworkState = ChooseNetworkState(
networks = networks.toPersistentList(),
selectedNetwork = null,
onChooseNetworkClick = clickIntents::onChooseNetworkClick,
onCloseChoosingNetworkClick = clickIntents::onBack,
),
chooseDerivationState = chooseDerivationState,
tokenData = null,
warnings = persistentSetOf(),
addTokenButton = ButtonState(isEnabled = false, onClick = clickIntents::onAddCustomButtonClick),
)
}
private fun getListOfDerivations(supportedNetworks: List<Network>): List<Derivation> {
return supportedNetworks.mapNotNull { network ->
network.derivationPath.value?.let { rawPath ->
Derivation(
networkName = network.name,
standardType = network.standardType.name,
path = rawPath,
networkId = network.backendId,
onDerivationSelected = clickIntents::onDerivationSelected,
)
}
}
}
private fun createChooseDerivationState(derivations: List<Derivation>): ChooseDerivationState? {
return if (derivations.isNotEmpty()) {
ChooseDerivationState(
derivations = derivations.toPersistentList(),
selectedDerivation = null,
enterCustomDerivationState = null,
onChooseDerivationClick = clickIntents::onChooseDerivationClick,
onCloseChoosingDerivationClick = clickIntents::onBack,
onEnterCustomDerivation = clickIntents::onEnterCustomDerivation,
)
} else {
null
}
}
fun updateWithNewWalletSelected(
selectedWalletId: UserWalletId,
supportedNetworks: List<Network>,
): AddCustomTokenState {
val derivations = getListOfDerivations(supportedNetworks)
val chooseDerivationState = createChooseDerivationState(derivations)
val networkConverter = NetworkToNetworkItemStateConverter(clickIntents::onNetworkSelected)
val networks = supportedNetworks.map { networkConverter.convert(it) }
val currentWalletState = requireNotNull(
currentStateProvider().chooseWalletState as? ChooseWalletState.Choose,
) {
"If user wallet was chosen, ChooseWalletState type must be Choose"
}
val selectedWalletState = currentWalletState.wallets.find { it.walletId == selectedWalletId.stringValue }
val chooseWalletState = currentWalletState.copy(selectedWallet = selectedWalletState)
return AddCustomTokenState(
chooseWalletState = chooseWalletState,
chooseNetworkState = ChooseNetworkState(
networks = networks.toPersistentList(),
selectedNetwork = null,
onChooseNetworkClick = clickIntents::onChooseNetworkClick,
onCloseChoosingNetworkClick = clickIntents::onBack,
),
chooseDerivationState = chooseDerivationState,
tokenData = null,
warnings = persistentSetOf(),
addTokenButton = ButtonState(isEnabled = false, onClick = clickIntents::onAddCustomButtonClick),
)
}
private fun getNewChooseWalletState(
suitableUserWallets: List<UserWallet>,
allUserWallets: List<UserWallet>,
selectedWalletId: UserWalletId?,
): ChooseWalletState {
val chooseWalletState = if (suitableUserWallets.size == 1) {
ChooseWalletState.NoSelection
} else if (suitableUserWallets.isEmpty() && allUserWallets.all { !it.isMultiCurrency }) {
ChooseWalletState.Warning(ChooseWalletWarning.SINGLE_CURRENCY)
} else {
var selectedWalletState: WalletState? = null
ChooseWalletState.Choose(
wallets = suitableUserWallets.map { wallet ->
val walletState = WalletState(
walletId = wallet.walletId.stringValue,
artworkUrl = wallet.artworkUrl,
onSelected = clickIntents::onWalletSelected,
walletName = wallet.name,
)
if (wallet.walletId.stringValue == selectedWalletId?.stringValue) {
selectedWalletState = walletState
}
walletState
}.toPersistentList(),
selectedWallet = requireNotNull(selectedWalletState),
onChooseWalletClick = clickIntents::onChooseWalletClick,
onCloseChoosingWalletClick = clickIntents::onCloseChoosingWalletClick,
)
}
return chooseWalletState
}
fun removeTokenAddressError(): AddCustomTokenState {
return addTokenAddressFieldError(null)
}
private fun addTokenAddressFieldError(error: AddCustomTokenWarning?): AddCustomTokenState {
val tokenData = currentStateProvider().tokenData ?: return currentStateProvider()
val contractAddressField = tokenData.contractAddressTextField.copySealed(error = error)
return currentStateProvider().copy(tokenData = tokenData.copy(contractAddressTextField = contractAddressField))
}
fun getStateAndTriggerEvent(
state: AddCustomTokenState,
event: Event,
setUiState: (AddCustomTokenState) -> Unit,
): AddCustomTokenState {
return state.copy(
event = triggeredEvent(
data = event,
onConsume = {
val currentState = currentStateProvider()
setUiState(currentState.copy(event = consumedEvent()))
},
),
)
}
fun updateStateOnNetworkSelected(networkItemState: NetworkItemState, supportsTokens: Boolean): AddCustomTokenState {
val uiState = currentStateProvider()
val tokenData = if (supportsTokens) {
uiState.tokenData
?: CustomTokenData(
contractAddressTextField = TextFieldState.Editable(
value = "",
isEnabled = true,
onValueChange = clickIntents::onContractAddressChange,
),
nameTextField = TextFieldState.Editable(
value = "",
isEnabled = false,
onValueChange = clickIntents::onTokenNameChange,
),
symbolTextField = TextFieldState.Editable(
value = "",
isEnabled = false,
onValueChange = clickIntents::onSymbolChange,
),
decimalsTextField = TextFieldState.Editable(
value = "",
isEnabled = false,
onValueChange = clickIntents::onDecimalsChange,
),
)
} else {
null
}
return uiState.copy(
chooseNetworkState = uiState.chooseNetworkState.copy(selectedNetwork = networkItemState),
tokenData = tokenData,
addTokenButton = uiState.addTokenButton.copy(isEnabled = true),
)
}
fun updateOnCustomDerivationSelected(): AddCustomTokenState {
val uiState = currentStateProvider()
return uiState.copy(
chooseDerivationState = uiState.chooseDerivationState?.copy(
enterCustomDerivationState = null,
selectedDerivation = Derivation(
networkName = "",
path = uiState.chooseDerivationState.enterCustomDerivationState?.value ?: "",
networkId = null,
standardType = null,
onDerivationSelected = clickIntents::onDerivationSelected,
),
),
)
}
fun updateStateOnEnterCustomDerivation(): AddCustomTokenState {
val customDerivationState = EnterCustomDerivationState(
value = "",
onValueChange = clickIntents::onCustomDerivationChange,
confirmButtonEnabled = false,
derivationIncorrect = false,
onConfirmButtonClick = clickIntents::onCustomDerivationSelected,
onDismiss = clickIntents::onCustomDerivationDialogDismissed,
)
val uiState = currentStateProvider()
return uiState.copy(
chooseDerivationState = uiState.chooseDerivationState?.copy(
enterCustomDerivationState = customDerivationState,
),
)
}
fun updateStateOnLoadingTokenInfo(contractAddress: String): AddCustomTokenState {
return currentStateProvider().copy(
tokenData = CustomTokenData(
contractAddressTextField = TextFieldState.Editable(
value = contractAddress,
isEnabled = true,
onValueChange = clickIntents::onContractAddressChange,
),
nameTextField = TextFieldState.Loading,
symbolTextField = TextFieldState.Loading,
decimalsTextField = TextFieldState.Loading,
),
)
}
fun handleAddressError(error: AddCustomTokenError): AddCustomTokenState {
val uiState = currentStateProvider()
return when (error) {
AddCustomTokenError.InvalidContractAddress -> {
addTokenAddressFieldError(AddCustomTokenWarning.InvalidContractAddress)
.copy(
addTokenButton = uiState.addTokenButton.copy(isEnabled = false),
warnings = uiState.warnings
.filterNot { it is AddCustomTokenWarning.PotentialScamToken }
.toPersistentSet(),
)
}
AddCustomTokenError.FieldIsEmpty ->
removeTokenAddressError()
.copy(
addTokenButton = uiState.addTokenButton.copy(
isEnabled = uiState.tokenData?.isRequiredInformationProvided() == true,
),
)
}
}
}

View file

@ -0,0 +1,36 @@
package com.tangem.managetokens.presentation.customtokens.state.factory
import com.tangem.domain.tokens.model.FoundToken
import com.tangem.managetokens.presentation.customtokens.state.CustomTokenData
import com.tangem.managetokens.presentation.customtokens.state.TextFieldState
import com.tangem.managetokens.presentation.customtokens.viewmodels.CustomTokensClickIntents
import com.tangem.utils.converter.Converter
internal class FoundTokenToCustomTokenDataConverter(
private val clickIntents: CustomTokensClickIntents,
) : Converter<FoundToken, CustomTokenData> {
override fun convert(value: FoundToken): CustomTokenData {
return CustomTokenData(
contractAddressTextField = TextFieldState.Editable(
value = value.contractAddress,
isEnabled = true,
onValueChange = clickIntents::onContractAddressChange,
),
nameTextField = TextFieldState.Editable(
value = value.name,
isEnabled = false,
onValueChange = clickIntents::onTokenNameChange,
),
symbolTextField = TextFieldState.Editable(
value = value.symbol,
isEnabled = false,
onValueChange = clickIntents::onSymbolChange,
),
decimalsTextField = TextFieldState.Editable(
value = value.decimals.toString(),
isEnabled = false,
onValueChange = clickIntents::onDecimalsChange,
),
)
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.managetokens.presentation.customtokens.state.factory
import com.tangem.core.ui.extensions.getActiveIconResByNetworkId
import com.tangem.domain.tokens.model.Network
import com.tangem.managetokens.presentation.common.state.NetworkItemState
import com.tangem.utils.converter.Converter
internal class NetworkToNetworkItemStateConverter(
private val onNetworkItemSelected: (NetworkItemState) -> Unit,
) : Converter<Network, NetworkItemState> {
override fun convert(value: Network): NetworkItemState {
return NetworkItemState.Selectable(
name = value.name,
protocolName = value.standardType.name,
iconResId = getActiveIconResByNetworkId(value.backendId),
id = value.backendId,
onNetworkClick = onNetworkItemSelected,
)
}
}

View file

@ -0,0 +1,45 @@
package com.tangem.managetokens.presentation.customtokens.viewmodels
import com.tangem.managetokens.presentation.common.state.NetworkItemState
import com.tangem.managetokens.presentation.customtokens.state.Derivation
internal interface CustomTokensClickIntents {
fun onNetworkSelected(networkItemState: NetworkItemState)
fun onChooseNetworkClick()
fun onCloseChoosingNetworkClick()
fun onWalletSelected(walletId: String)
fun onChooseWalletClick()
fun onCloseChoosingWalletClick()
fun onContractAddressChange(input: String)
fun onTokenNameChange(input: String)
fun onSymbolChange(input: String)
fun onDecimalsChange(input: String)
fun onDerivationSelected(derivation: Derivation)
fun onChooseDerivationClick()
fun onCloseChoosingDerivationClick()
fun onEnterCustomDerivation()
fun onCustomDerivationChange(input: String)
fun onCustomDerivationSelected()
fun onCustomDerivationDialogDismissed()
fun onAddCustomButtonClick()
fun onBack()
}

View file

@ -0,0 +1,353 @@
package com.tangem.managetokens.presentation.customtokens.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 com.tangem.crypto.hdWallet.DerivationPath
import com.tangem.crypto.hdWallet.HDWalletError
import com.tangem.domain.common.util.derivationStyleProvider
import com.tangem.domain.tokens.*
import com.tangem.domain.tokens.model.Network
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.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.customtokens.state.*
import com.tangem.managetokens.presentation.customtokens.state.factory.AddCustomTokenStateToCryptoCurrencyConverter
import com.tangem.managetokens.presentation.customtokens.state.factory.ContractAddressToCustomTokenDataConverter
import com.tangem.managetokens.presentation.customtokens.state.factory.CustomTokensStateFactory
import com.tangem.managetokens.presentation.customtokens.state.factory.FoundTokenToCustomTokenDataConverter
import com.tangem.managetokens.presentation.router.InnerManageTokensRouter
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.Debouncer
import com.tangem.utils.coroutines.Debouncer.Companion.DEFAULT_WAIT_TIME_MS
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.collections.immutable.toPersistentSet
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import kotlinx.coroutines.withContext
import javax.inject.Inject
import kotlin.properties.Delegates
@Suppress("LongParameterList")
@HiltViewModel
internal class CustomTokensViewModel @Inject constructor(
private val dispatchers: CoroutineDispatcherProvider,
private val getWalletsUseCase: GetWalletsUseCase,
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
private val addCryptoCurrenciesUseCase: AddCryptoCurrenciesUseCase,
private val selectWalletUseCase: SelectWalletUseCase,
private val getCurrenciesUseCase: GetCryptoCurrenciesUseCase,
private val findTokenByContractAddressUseCase: FindTokenByContractAddressUseCase,
private val validateContractAddressUseCase: ValidateContractAddressUseCase,
private val getNetworksSupportedByWallet: GetNetworksSupportedByWallet,
private val areTokensSupportedByNetworkUseCase: AreTokensSupportedByNetworkUseCase,
) : ViewModel(), CustomTokensClickIntents, DefaultLifecycleObserver {
private val debouncer = Debouncer()
private val stateFactory = CustomTokensStateFactory(
currentStateProvider = Provider { uiState },
clickIntents = this,
)
var router: InnerManageTokensRouter by Delegates.notNull()
var uiState: AddCustomTokenState by mutableStateOf(stateFactory.getInitialState())
private set
init {
viewModelScope.launch(dispatchers.io) {
getWalletsUseCase()
.distinctUntilChanged()
.collectLatest { userWallets ->
val suitableUserWallets = userWallets.filter { it.isMultiCurrency && !it.isLocked }
val selectedWalletId = selectSuitableWallet(suitableUserWallets)
val networks = selectedWalletId?.let { getSupportedNetworks(selectedWalletId) } ?: emptyList()
withContext(dispatchers.main) {
uiState = stateFactory.getFullState(
allUserWallets = suitableUserWallets,
suitableUserWallets = userWallets,
selectedWalletId = selectedWalletId,
supportedNetworks = networks,
)
}
}
}
}
private suspend fun selectSuitableWallet(suitableUserWallets: List<UserWallet>): UserWalletId? {
val selectedWallet = getSelectedWalletSyncUseCase().getOrNull()
val selectedWalletId = if (walletSupportsAddingTokens(selectedWallet) && suitableUserWallets.isNotEmpty()) {
val walletId = suitableUserWallets.first().walletId
selectWalletUseCase(walletId)
walletId
} else {
selectedWallet?.walletId
}
return selectedWalletId
}
private fun walletSupportsAddingTokens(userWallet: UserWallet?): Boolean {
return userWallet != null && userWallet.isMultiCurrency && !userWallet.isLocked
}
private suspend fun getSupportedNetworks(userWalletId: UserWalletId): List<Network> {
return getNetworksSupportedByWallet(userWalletId).fold(
ifLeft = { emptyList() },
ifRight = { it },
)
}
override fun onNetworkSelected(networkItemState: NetworkItemState) {
selectNetwork(networkItemState)
router.popBackStack()
}
private fun selectNetwork(networkItemState: NetworkItemState) {
viewModelScope.launch(dispatchers.io) {
val selectedWalletId = getSelectedWalletSyncUseCase().getOrNull()?.walletId
val supportsTokens = areTokensSupportedByNetworkUseCase(
networkId = networkItemState.id,
userWalletId = selectedWalletId,
).getOrNull() ?: false
withContext(dispatchers.main) {
uiState = stateFactory.updateStateOnNetworkSelected(networkItemState, supportsTokens)
}
}
}
override fun onChooseNetworkClick() {
router.openCustomTokensChooseNetwork()
}
override fun onCloseChoosingNetworkClick() {
router.popBackStack()
}
override fun onWalletSelected(walletId: String) {
viewModelScope.launch(dispatchers.io) {
val userWalletId = UserWalletId(walletId)
selectWalletUseCase(userWalletId)
val supportedNetworks = getSupportedNetworks(UserWalletId(walletId))
withContext(dispatchers.main) {
uiState = stateFactory.updateWithNewWalletSelected(
selectedWalletId = userWalletId,
supportedNetworks = supportedNetworks,
)
router.popBackStack()
}
}
}
override fun onChooseWalletClick() {
router.openCustomTokensChooseWallet()
}
override fun onCloseChoosingWalletClick() {
router.popBackStack()
}
override fun onContractAddressChange(input: String) {
uiState = uiState.copy(
tokenData = uiState.tokenData?.copy(
contractAddressTextField = TextFieldState.Editable(
value = input,
isEnabled = true,
onValueChange = this::onContractAddressChange,
),
),
)
debouncer.debounce(waitMs = DEFAULT_WAIT_TIME_MS, coroutineScope = viewModelScope + dispatchers.io) {
uiState.chooseNetworkState.selectedNetwork?.let { networkItemState ->
validateContractAddressUseCase(input, networkItemState.id).fold(
ifRight = {
uiState = stateFactory.removeTokenAddressError()
.copy(
addTokenButton = uiState.addTokenButton.copy(
isEnabled = uiState.tokenData?.isRequiredInformationProvided() == true,
),
warnings = uiState.warnings
.filterNot { it is AddCustomTokenWarning.PotentialScamToken }.toPersistentSet(),
)
fetchTokenInformation(contractAddress = input, networkId = networkItemState.id)
},
ifLeft = { error ->
uiState = stateFactory.handleAddressError(error)
},
)
}
}
}
private fun fetchTokenInformation(contractAddress: String, networkId: String) {
viewModelScope.launch(dispatchers.main) {
uiState = stateFactory.updateStateOnLoadingTokenInfo(contractAddress)
withContext(dispatchers.io) {
findTokenByContractAddressUseCase(
contractAddress = contractAddress,
networkId = networkId,
).fold(
ifLeft = {
val tokenData = ContractAddressToCustomTokenDataConverter(this@CustomTokensViewModel)
.convert(contractAddress)
uiState = uiState.copy(
tokenData = tokenData,
warnings = (uiState.warnings + AddCustomTokenWarning.PotentialScamToken).toPersistentSet(),
)
},
ifRight = { token ->
val tokenData = if (token != null) {
FoundTokenToCustomTokenDataConverter(this@CustomTokensViewModel).convert(token)
} else {
ContractAddressToCustomTokenDataConverter(this@CustomTokensViewModel).convert(
contractAddress,
)
}
uiState = uiState.copy(tokenData = tokenData)
},
)
}
}
}
override fun onTokenNameChange(input: String) {
uiState = uiState.copy(
tokenData = uiState.tokenData?.copy(
nameTextField = TextFieldState.Editable(
value = input,
isEnabled = true,
onValueChange = this::onTokenNameChange,
),
),
)
}
override fun onSymbolChange(input: String) {
uiState = uiState.copy(
tokenData = uiState.tokenData?.copy(
symbolTextField = TextFieldState.Editable(
value = input,
isEnabled = true,
onValueChange = this::onSymbolChange,
),
),
)
}
override fun onDecimalsChange(input: String) {
val correctInput = input.toIntOrNull()
val error = if (input.isNotBlank() && correctInput == null) {
AddCustomTokenWarning.WrongDecimals
} else {
null
}
uiState = uiState.copy(
tokenData = uiState.tokenData?.copy(
decimalsTextField = TextFieldState.Editable(
value = input,
isEnabled = true,
onValueChange = this::onDecimalsChange,
error = error,
),
),
)
}
override fun onDerivationSelected(derivation: Derivation) {
uiState =
uiState.copy(chooseDerivationState = uiState.chooseDerivationState?.copy(selectedDerivation = derivation))
router.popBackStack()
}
override fun onChooseDerivationClick() {
router.openCustomTokensChooseDerivation()
}
override fun onCloseChoosingDerivationClick() {
router.popBackStack()
}
override fun onCustomDerivationChange(input: String) {
uiState = uiState.copy(
chooseDerivationState = uiState.chooseDerivationState?.copy(
enterCustomDerivationState = uiState.chooseDerivationState?.enterCustomDerivationState?.copy(
value = input,
),
),
)
debouncer.debounce(waitMs = DEFAULT_WAIT_TIME_MS, coroutineScope = viewModelScope + dispatchers.io) {
val path = createDerivationPathOrNull(input)
val enterDerivationState = uiState.chooseDerivationState?.enterCustomDerivationState?.copy(
confirmButtonEnabled = path != null,
derivationIncorrect = input.isNotBlank() && path == null,
)
uiState = uiState.copy(
chooseDerivationState = uiState.chooseDerivationState?.copy(
enterCustomDerivationState = enterDerivationState,
),
)
}
}
private fun createDerivationPathOrNull(rawPath: String): DerivationPath? {
return try {
DerivationPath(rawPath)
} catch (error: HDWalletError) {
null
}
}
override fun onCustomDerivationSelected() {
uiState = stateFactory.updateOnCustomDerivationSelected()
router.popBackStack()
}
override fun onEnterCustomDerivation() {
uiState = stateFactory.updateStateOnEnterCustomDerivation()
}
override fun onCustomDerivationDialogDismissed() {
uiState = uiState.copy(
chooseDerivationState = uiState.chooseDerivationState?.copy(
enterCustomDerivationState = null,
),
)
}
override fun onAddCustomButtonClick() {
viewModelScope.launch(dispatchers.io) {
val selectedWallet = getSelectedWalletSyncUseCase().getOrNull() ?: return@launch
val cryptoCurrency = AddCustomTokenStateToCryptoCurrencyConverter(
selectedWallet.scanResponse.derivationStyleProvider,
).convert(uiState)
val alreadyAdded =
getCurrenciesUseCase(selectedWallet.walletId).getOrNull()?.any { it == cryptoCurrency }
if (alreadyAdded == true) {
uiState = stateFactory.getStateAndTriggerEvent(
state = uiState,
event = Event.ShowAlert(AlertState.TokenAlreadyAdded),
setUiState = { uiState = it },
)
} else {
addCryptoCurrenciesUseCase(selectedWallet.walletId, currency = cryptoCurrency)
withContext(dispatchers.main) { router.popBackStack() }
}
}
}
override fun onBack() {
router.popBackStack()
}
}

View file

@ -31,7 +31,7 @@ internal class NetworkToNetworkItemStateConverter(
currencies = addedCurrencies,
)
return NetworkItemState.Toggleable(
name = network.name.uppercase(),
name = network.name,
iconResId = mutableIntStateOf(
getNetworkIconResId(isAdded, network.networkId), // todo
),

View file

@ -55,7 +55,7 @@ internal fun TokensList(tokens: LazyPagingItems<TokenItemState>, addCustomTokenB
if (addCustomTokenButton.isVisible) {
item {
AddCustomTokenButton(onButtonClick = { addCustomTokenButton.onClick })
AddCustomTokenButton(onButtonClick = addCustomTokenButton.onClick)
}
}
}

View file

@ -27,9 +27,11 @@ 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.managetokens.presentation.router.InnerManageTokensRouter
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.Debouncer
import com.tangem.utils.coroutines.Debouncer.Companion.DEFAULT_WAIT_TIME_MS
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
@ -65,6 +67,8 @@ internal class ManageTokensViewModel @Inject constructor(
clickIntents = this,
)
var router: InnerManageTokensRouter by Delegates.notNull()
var uiState: ManageTokensState by mutableStateOf(stateFactory.getInitialState(flowOf(PagingData.from(emptyList()))))
private set
@ -178,13 +182,13 @@ internal class ManageTokensViewModel @Inject constructor(
}
override fun onAddCustomTokensButtonClick() {
TODO("Not yet implemented") // TODO: add when custom tokens are implemented
router.openCustomTokensScreen()
}
override fun onSearchQueryChange(query: String) {
uiState = uiState.copy(searchBarState = uiState.searchBarState.copy(query = query))
debouncer.debounce(waitMs = 500L, coroutineScope = viewModelScope + dispatchers.io) {
debouncer.debounce(waitMs = DEFAULT_WAIT_TIME_MS, coroutineScope = viewModelScope + dispatchers.io) {
val state = stateFactory.showAddCustomTokensButton(query.isNotBlank())
uiState = state.copy(tokens = getInitialTokensList(query))
}

View file

@ -1,9 +1,120 @@
package com.tangem.managetokens.presentation.router
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.fragment.app.Fragment
import com.tangem.features.managetokens.navigation.ManageTokensRouter
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModelStoreOwner
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navigation
import com.tangem.core.navigation.AppScreen
import com.tangem.core.navigation.NavigationAction
import com.tangem.core.navigation.ReduxNavController
import com.tangem.managetokens.ManageTokensFragment
import com.tangem.managetokens.presentation.customtokens.viewmodels.CustomTokensViewModel
import com.tangem.managetokens.presentation.managetokens.ui.ManageTokensScreen
import com.tangem.managetokens.presentation.managetokens.viewmodels.ManageTokensViewModel
import kotlin.properties.Delegates
internal class DefaultManageTokensRouter(
private val reduxNavController: ReduxNavController,
) : InnerManageTokensRouter {
private var navController: NavHostController by Delegates.notNull()
internal class DefaultManageTokensRouter : ManageTokensRouter {
override fun getEntryFragment(): Fragment = ManageTokensFragment()
@Composable
override fun Initialize(viewModelStoreOwner: ViewModelStoreOwner) {
NavHost(
navController = rememberNavController().apply { navController = this },
startDestination = ManageTokensRoute.ManageTokens.route,
) {
composable(ManageTokensRoute.ManageTokens.route) {
val viewModel = hiltViewModel<ManageTokensViewModel>().apply { router = this@DefaultManageTokensRouter }
LocalLifecycleOwner.current.lifecycle.addObserver(viewModel)
ManageTokensScreen(state = viewModel.uiState)
}
navigation(
startDestination = ManageTokensRoute.CustomTokens.Main.route,
route = ManageTokensRoute.CustomTokens.route,
) {
composable(
ManageTokensRoute.CustomTokens.Main.route,
) {
val viewModel = hiltViewModel<CustomTokensViewModel>(viewModelStoreOwner).apply {
router = this@DefaultManageTokensRouter
}
// CustomTokensScreen(state = viewModel.uiState)
// TODO: enable in [REDACTED_JIRA]
}
composable(
ManageTokensRoute.CustomTokens.ChooseNetwork.route,
) {
val viewModel = hiltViewModel<CustomTokensViewModel>(viewModelStoreOwner).apply {
router = this@DefaultManageTokensRouter
}
// ChooseNetworkCustomScreen(
// state = viewModel.uiState.chooseNetworkState,
// )
// TODO: enable in [REDACTED_JIRA]
}
composable(
ManageTokensRoute.CustomTokens.ChooseDerivation.route,
) {
val viewModel = hiltViewModel<CustomTokensViewModel>(viewModelStoreOwner).apply {
router = this@DefaultManageTokensRouter
}
// ChooseDerivationScreen(
// state = requireNotNull(viewModel.uiState.chooseDerivationState),
// )
// TODO: enable in [REDACTED_JIRA]
}
composable(
ManageTokensRoute.CustomTokens.ChooseWallet.route,
) {
val viewModel = hiltViewModel<CustomTokensViewModel>(viewModelStoreOwner).apply {
router = this@DefaultManageTokensRouter
}
// CustomTokensChooseWalletScreen(
// state = viewModel.uiState.chooseWalletState as ChooseWalletState.Choose,
// )
// TODO: enable in [REDACTED_JIRA]
}
}
}
}
override fun popBackStack(screen: AppScreen?) {
if (screen != null) {
reduxNavController.navigate(action = NavigationAction.PopBackTo(screen))
} else {
navController.popBackStack()
}
}
override fun openManageTokensScreen() {
navController.navigate(ManageTokensRoute.ManageTokens.route)
}
override fun openCustomTokensScreen() {
navController.navigate(ManageTokensRoute.CustomTokens.route)
}
override fun openCustomTokensChooseNetwork() {
navController.navigate(ManageTokensRoute.CustomTokens.ChooseNetwork.route)
}
override fun openCustomTokensChooseDerivation() {
navController.navigate(ManageTokensRoute.CustomTokens.ChooseDerivation.route)
}
override fun openCustomTokensChooseWallet() {
navController.navigate(ManageTokensRoute.CustomTokens.ChooseWallet.route)
}
}

View file

@ -0,0 +1,30 @@
package com.tangem.managetokens.presentation.router
import androidx.compose.runtime.Composable
import androidx.lifecycle.ViewModelStoreOwner
import com.tangem.core.navigation.AppScreen
import com.tangem.features.managetokens.navigation.ManageTokensRouter
internal interface InnerManageTokensRouter : ManageTokensRouter {
/**
* Initialize router
**/
@Suppress("TopLevelComposableFunctions")
@Composable
fun Initialize(viewModelStoreOwner: ViewModelStoreOwner)
/** Pop back stack */
fun popBackStack(screen: AppScreen? = null)
/** Open manage tokens screen */
fun openManageTokensScreen()
/** Open custom tokens screen */
fun openCustomTokensScreen()
fun openCustomTokensChooseNetwork()
fun openCustomTokensChooseDerivation()
fun openCustomTokensChooseWallet()
}

View file

@ -0,0 +1,19 @@
package com.tangem.managetokens.presentation.router
/**
* Manage Tokens screens
*
* @property route route string representation
*
*/
internal sealed class ManageTokensRoute(val route: String) {
object ManageTokens : ManageTokensRoute(route = "manage_tokens")
object CustomTokens : ManageTokensRoute(route = "manage_tokens/custom_tokens") {
object Main : ManageTokensRoute(CustomTokens.route + "/main")
object ChooseNetwork : ManageTokensRoute(CustomTokens.route + "/choose_network")
object ChooseWallet : ManageTokensRoute(CustomTokens.route + "/choose_wallet")
object ChooseDerivation : ManageTokensRoute(CustomTokens.route + "/choose_derivation")
}
}