Updated on 2026-08-14

This commit is contained in:
Tangem 2024-02-09 10:31:06 +01:00
parent 1771ce7847
commit 3bd0392018
25 changed files with 371 additions and 45 deletions

View file

@ -10,7 +10,7 @@ import com.tangem.domain.AddCustomTokenError
*
[REDACTED_AUTHOR]
*/
object ContactAddressValidator {
object ContractAddressValidator {
/** Validate a [address] using [blockchain] */
fun validate(address: String, blockchain: Blockchain): ContractAddressValidatorResult {

View file

@ -33,7 +33,7 @@ import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTok
import com.tangem.tap.features.customtoken.impl.presentation.models.AddCustomTokenSelectorField.SelectorItem
import com.tangem.tap.features.customtoken.impl.presentation.routers.CustomTokenRouter
import com.tangem.tap.features.customtoken.impl.presentation.states.AddCustomTokenStateHolder
import com.tangem.tap.features.customtoken.impl.presentation.validators.ContactAddressValidator
import com.tangem.tap.features.customtoken.impl.presentation.validators.ContractAddressValidator
import com.tangem.tap.features.customtoken.impl.presentation.validators.ContractAddressValidatorResult
import com.tangem.tap.features.details.ui.cardsettings.TextReference
import com.tangem.utils.coroutines.AppCoroutineDispatcherProvider
@ -461,7 +461,7 @@ internal class AddCustomTokenViewModel @Inject constructor(
private fun getTokenWarningSet(): Set<AddCustomTokenWarning> {
val networkSelectorValue = uiState.form.networkSelectorField.selectedItem.blockchain
val isContractAddressFieldEmpty = ContactAddressValidator.validate(
val isContractAddressFieldEmpty = ContractAddressValidator.validate(
address = uiState.form.contractAddressInputField.value,
blockchain = networkSelectorValue,
).let {
@ -513,7 +513,7 @@ internal class AddCustomTokenViewModel @Inject constructor(
val state = when {
isAllTokenFieldsFilled() && isNetworkSelected() -> {
val networkSelectorValue = uiState.form.networkSelectorField.selectedItem.blockchain
val error = ContactAddressValidator.validate(
val error = ContractAddressValidator.validate(
address = uiState.form.contractAddressInputField.value,
blockchain = networkSelectorValue,
)
@ -727,7 +727,7 @@ internal class AddCustomTokenViewModel @Inject constructor(
)
val selectedNetwork = uiState.form.networkSelectorField.selectedItem.blockchain
val validatorResult = ContactAddressValidator.validate(
val validatorResult = ContractAddressValidator.validate(
address = enteredValue,
blockchain = selectedNetwork,
)

View file

@ -285,7 +285,6 @@
<string name="manage_tokens_network_selector_non_native_info">Using non-native networks for tokens enables cross-blockchain interoperability, allowing assets to be utilized in diverse decentralized applications and smart contracts across platforms. However, this often involves a custodian or smart contract to hold the original asset securely, introducing centralization and counterparty risk.</string>
<string name="manage_tokens_network_selector_non_native_subtitle">Not original or primary blockchain the token is hosted</string>
<string name="manage_tokens_network_selector_non_native_title">Non-native networks</string>
<string name="manage_tokens_network_selector_other_subtitle">Blockchain the cryptocurrency was initially created</string>
<string name="manage_tokens_network_selector_other_title">Networks</string>
<string name="manage_tokens_network_selector_title">Choose networks</string>
<string name="manage_tokens_network_selector_wallet">Wallet</string>

View file

@ -13,7 +13,11 @@ class ValidateContractAddressUseCase(private val tokensListRepository: TokensLis
block = {
if (address.isEmpty()) raise(AddCustomTokenError.FieldIsEmpty)
if (!tokensListRepository.validateAddress(networkId, address)) {
if (!tokensListRepository.validateAddress(
contractAddress = address,
networkId = networkId,
)
) {
raise(AddCustomTokenError.InvalidContractAddress)
}
},

View file

@ -41,6 +41,8 @@ dependencies {
kapt(deps.hilt.kapt)
/** Core modules */
implementation(projects.core.analytics)
implementation(projects.core.analytics.models)
implementation(projects.core.featuretoggles)
implementation(projects.core.navigation)
implementation(projects.core.ui)

View file

@ -0,0 +1,102 @@
package com.tangem.managetokens.presentation.common.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
sealed class ManageTokens(
event: String,
params: Map<String, String> = emptyMap(),
) : AnalyticsEvent("Manage Tokens", event, params) {
class ScreenOpened : ManageTokens("Manage Tokens Screen Opened")
class TokenIsNotFound(userInput: String) : ManageTokens(
event = "Token Is Not Found",
params = mapOf("Input" to userInput),
)
class TokenSwitcherChanged(
token: String,
state: AnalyticsParam.OnOffState,
) : ManageTokens(
event = "Token Switcher Changed",
params = mapOf(
"Token" to token,
"State" to state.value,
),
)
class ButtonAdd(token: String) : ManageTokens(
event = "Button - Add",
params = mapOf("Token" to token),
)
class ButtonEdit(token: String) : ManageTokens(
event = "Button - Edit",
params = mapOf("Token" to token),
)
object ButtonChooseWallet : ManageTokens(event = "Button - Choose Wallet")
class WalletSelected(source: Source) : ManageTokens(
event = "Wallet Selected",
params = mapOf("Source" to source.name),
) {
enum class Source(name: String) {
MainToken("Main Token"),
CustomToken("Custom Token"),
}
}
object NoticeNonNativeNetworkClicked : ManageTokens(event = "Notice - Non Native Network Clicked")
class ButtonGenerateAddresses(cardCount: Int) : ManageTokens(
event = "Button - Get Addresses",
params = mapOf("CardCount" to cardCount.toString()),
)
object ButtonCustomToken : ManageTokens("Button - Custom Token")
class CustomTokenWasAdded(
val derivation: String,
val networkId: String,
val token: String? = null,
val contractAddress: String? = null,
) : ManageTokens(
event = "Custom Token Was Added",
params = mutableMapOf(
"Derivation" to derivation,
"Network Id" to networkId,
).apply {
token?.let { put("Token", it) }
contractAddress?.let { put("Contract Address", it) }
},
)
class CustomTokenNetworkSelected(blockchain: String) : ManageTokens(
event = "Custom Token Network Selected",
params = mapOf("blockchain" to blockchain),
)
class CustomTokenDerivationSelected(derivation: String) : ManageTokens(
event = "Custom Token Derivation Selected",
params = mapOf("Derivation" to derivation),
)
class CustomTokenAddress(validated: Boolean) : ManageTokens(
"Custom Token Address",
params = mapOf("Validation" to if (validated) "Ok" else "Error"),
)
object CustomTokenName : ManageTokens("Custom Token Name")
object CustomTokenSymbol : ManageTokens("Custom Token Symbol")
object CustomTokenDecimals : ManageTokens("Custom Token Decimals")
enum class Derivation(val value: String) {
DEFAULT("Default"),
CUSTOM("Custom"),
}
}

View file

@ -38,7 +38,7 @@ internal sealed interface NetworkItemState {
* @property onToggleClick lambda be invoked when switch is been toggled
*/
@Suppress("LongParameterList")
class Toggleable(
data class Toggleable(
override val name: String,
override val protocolName: String,
override val id: String,
@ -72,7 +72,7 @@ internal sealed interface NetworkItemState {
* @property onNetworkClick lambda be invoked when network item is been clicked
*
*/
class Selectable(
data class Selectable(
override val name: String,
override val protocolName: String,
val iconResId: Int,

View file

@ -8,6 +8,7 @@ internal sealed class TextFieldState {
val isEnabled: Boolean,
val error: AddCustomTokenWarning? = null,
val onValueChange: (String) -> Unit,
val onFocusExit: () -> Unit,
) : TextFieldState()
fun isInputValid(): Boolean = this is Editable && value.isNotBlank() && error == null

View file

@ -39,7 +39,7 @@ internal class AddCustomTokenStateToCryptoCurrencyConverter(
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 name = (tokenData?.nameTextField as? TextFieldState.Editable)?.value
val decimals = (tokenData?.decimalsTextField as? TextFieldState.Editable)?.value?.toIntOrNull()
return if (
!contractAddress.isNullOrBlank() && !symbol.isNullOrBlank() && !name.isNullOrBlank() && decimals != null

View file

@ -14,21 +14,25 @@ internal class ContractAddressToCustomTokenDataConverter(
value = value,
isEnabled = true,
onValueChange = clickIntents::onContractAddressChange,
onFocusExit = clickIntents::onContractAddressFocusExit,
),
nameTextField = TextFieldState.Editable(
value = "",
isEnabled = true,
onValueChange = clickIntents::onTokenNameChange,
onFocusExit = clickIntents::onTokenNameFocusExit,
),
symbolTextField = TextFieldState.Editable(
value = "",
isEnabled = true,
onValueChange = clickIntents::onSymbolChange,
onFocusExit = clickIntents::onSymbolFocusExit,
),
decimalsTextField = TextFieldState.Editable(
value = "",
isEnabled = true,
onValueChange = clickIntents::onDecimalsChange,
onFocusExit = clickIntents::onDecimalsFocusExit,
),
)
}

View file

@ -201,21 +201,25 @@ internal class CustomTokensStateFactory(
value = "",
isEnabled = true,
onValueChange = clickIntents::onContractAddressChange,
onFocusExit = clickIntents::onContractAddressFocusExit,
),
nameTextField = TextFieldState.Editable(
value = "",
isEnabled = false,
onValueChange = clickIntents::onTokenNameChange,
onFocusExit = clickIntents::onTokenNameFocusExit,
),
symbolTextField = TextFieldState.Editable(
value = "",
isEnabled = false,
onValueChange = clickIntents::onSymbolChange,
onFocusExit = clickIntents::onSymbolFocusExit,
),
decimalsTextField = TextFieldState.Editable(
value = "",
isEnabled = false,
onValueChange = clickIntents::onDecimalsChange,
onFocusExit = clickIntents::onDecimalsFocusExit,
),
)
} else {
@ -268,6 +272,8 @@ internal class CustomTokensStateFactory(
value = contractAddress,
isEnabled = true,
onValueChange = clickIntents::onContractAddressChange,
onFocusExit = clickIntents::onContractAddressFocusExit,
),
nameTextField = TextFieldState.Loading,
symbolTextField = TextFieldState.Loading,

View file

@ -15,21 +15,25 @@ internal class FoundTokenToCustomTokenDataConverter(
value = value.contractAddress,
isEnabled = true,
onValueChange = clickIntents::onContractAddressChange,
onFocusExit = clickIntents::onContractAddressFocusExit,
),
nameTextField = TextFieldState.Editable(
value = value.name,
isEnabled = false,
onValueChange = clickIntents::onTokenNameChange,
onFocusExit = clickIntents::onTokenNameFocusExit,
),
symbolTextField = TextFieldState.Editable(
value = value.symbol,
isEnabled = false,
onValueChange = clickIntents::onSymbolChange,
onFocusExit = clickIntents::onSymbolFocusExit,
),
decimalsTextField = TextFieldState.Editable(
value = value.decimals.toString(),
isEnabled = false,
onValueChange = clickIntents::onDecimalsChange,
onFocusExit = clickIntents::onDecimalsFocusExit,
),
)
}

View file

@ -38,6 +38,7 @@ internal object AddCustomTokenPreviewData {
value = "0x4ace7262705b68bcba5b91de96889349394",
isEnabled = false,
onValueChange = {},
onFocusExit = {},
),
nameTextField = TextFieldState.Loading,
symbolTextField = TextFieldState.Loading,

View file

@ -6,7 +6,11 @@ import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
@ -20,6 +24,11 @@ internal fun TokenTextField(
placeholder: String,
keyboardType: KeyboardType = KeyboardType.Text,
) {
val isInitiallyComposed = remember { mutableStateOf(false) }
LaunchedEffect(key1 = true) {
isInitiallyComposed.value = true
}
BasicTextField(
value = state.value,
onValueChange = state.onValueChange,
@ -32,7 +41,12 @@ internal fun TokenTextField(
),
cursorBrush = SolidColor(TangemTheme.colors.icon.primary1),
modifier = Modifier
.fillMaxWidth(),
.fillMaxWidth()
.onFocusChanged {
if (!it.isFocused && isInitiallyComposed.value) {
state.onFocusExit()
}
},
decorationBox = { innerTextField ->
Row(modifier = Modifier.fillMaxWidth()) {
if (state.value.isEmpty()) {

View file

@ -3,6 +3,7 @@ package com.tangem.managetokens.presentation.customtokens.viewmodels
import com.tangem.managetokens.presentation.common.state.NetworkItemState
import com.tangem.managetokens.presentation.customtokens.state.Derivation
@Suppress("TooManyFunctions")
internal interface CustomTokensClickIntents {
fun onNetworkSelected(networkItemState: NetworkItemState)
@ -25,6 +26,14 @@ internal interface CustomTokensClickIntents {
fun onDecimalsChange(input: String)
fun onContractAddressFocusExit()
fun onTokenNameFocusExit()
fun onSymbolFocusExit()
fun onDecimalsFocusExit()
fun onDerivationSelected(derivation: Derivation)
fun onChooseDerivationClick()

View file

@ -6,16 +6,20 @@ import androidx.compose.runtime.setValue
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import arrow.core.getOrElse
import com.tangem.core.analytics.api.AnalyticsEventHandler
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.CryptoCurrency
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.analytics.ManageTokens
import com.tangem.managetokens.presentation.common.state.AlertState
import com.tangem.managetokens.presentation.common.state.Event
import com.tangem.managetokens.presentation.common.state.NetworkItemState
@ -39,7 +43,7 @@ import kotlinx.coroutines.withContext
import javax.inject.Inject
import kotlin.properties.Delegates
@Suppress("LongParameterList")
@Suppress("LongParameterList", "TooManyFunctions", "LargeClass")
@HiltViewModel
internal class CustomTokensViewModel @Inject constructor(
private val dispatchers: CoroutineDispatcherProvider,
@ -52,6 +56,7 @@ internal class CustomTokensViewModel @Inject constructor(
private val validateContractAddressUseCase: ValidateContractAddressUseCase,
private val getNetworksSupportedByWallet: GetNetworksSupportedByWallet,
private val areTokensSupportedByNetworkUseCase: AreTokensSupportedByNetworkUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
) : ViewModel(), CustomTokensClickIntents, DefaultLifecycleObserver {
private val debouncer = Debouncer()
@ -110,6 +115,7 @@ internal class CustomTokensViewModel @Inject constructor(
}
override fun onNetworkSelected(networkItemState: NetworkItemState) {
analyticsEventHandler.send(ManageTokens.CustomTokenNetworkSelected(networkItemState.name))
selectNetwork(networkItemState)
router.popBackStack()
}
@ -136,6 +142,7 @@ internal class CustomTokensViewModel @Inject constructor(
}
override fun onWalletSelected(walletId: String) {
analyticsEventHandler.send(ManageTokens.WalletSelected(ManageTokens.WalletSelected.Source.CustomToken))
viewModelScope.launch(dispatchers.io) {
val userWalletId = UserWalletId(walletId)
selectWalletUseCase(userWalletId)
@ -166,6 +173,7 @@ internal class CustomTokensViewModel @Inject constructor(
value = input,
isEnabled = true,
onValueChange = this::onContractAddressChange,
onFocusExit = this::onContractAddressFocusExit,
),
),
)
@ -202,9 +210,14 @@ internal class CustomTokensViewModel @Inject constructor(
ifLeft = {
val tokenData = ContractAddressToCustomTokenDataConverter(this@CustomTokensViewModel)
.convert(contractAddress)
val isButtonEnabled = tokenData.isRequiredInformationProvided()
uiState = uiState.copy(
tokenData = tokenData,
warnings = (uiState.warnings + AddCustomTokenWarning.PotentialScamToken).toPersistentSet(),
addTokenButton = uiState.addTokenButton.copy(
isEnabled = isButtonEnabled,
),
)
},
ifRight = { token ->
@ -215,7 +228,14 @@ internal class CustomTokensViewModel @Inject constructor(
contractAddress,
)
}
uiState = uiState.copy(tokenData = tokenData)
val isButtonEnabled = tokenData.isRequiredInformationProvided()
uiState = uiState.copy(
tokenData = tokenData,
addTokenButton = uiState.addTokenButton.copy(
isEnabled = isButtonEnabled,
),
)
},
)
}
@ -229,8 +249,12 @@ internal class CustomTokensViewModel @Inject constructor(
value = input,
isEnabled = true,
onValueChange = this::onTokenNameChange,
onFocusExit = this::onTokenNameFocusExit,
),
),
addTokenButton = uiState.addTokenButton.copy(
isEnabled = uiState.tokenData?.isRequiredInformationProvided() == true,
),
)
}
@ -241,8 +265,12 @@ internal class CustomTokensViewModel @Inject constructor(
value = input,
isEnabled = true,
onValueChange = this::onSymbolChange,
onFocusExit = this::onSymbolFocusExit,
),
),
addTokenButton = uiState.addTokenButton.copy(
isEnabled = uiState.tokenData?.isRequiredInformationProvided() == true,
),
)
}
@ -260,14 +288,40 @@ internal class CustomTokensViewModel @Inject constructor(
isEnabled = true,
onValueChange = this::onDecimalsChange,
error = error,
onFocusExit = this::onDecimalsFocusExit,
),
),
addTokenButton = uiState.addTokenButton.copy(
isEnabled = uiState.tokenData?.isRequiredInformationProvided() == true,
),
)
}
override fun onContractAddressFocusExit() {
val error = (uiState.tokenData?.contractAddressTextField as? TextFieldState.Editable)?.error
val validated = error !is AddCustomTokenWarning.InvalidContractAddress
analyticsEventHandler.send(ManageTokens.CustomTokenAddress(validated = validated))
}
override fun onTokenNameFocusExit() {
analyticsEventHandler.send(ManageTokens.CustomTokenName)
}
override fun onSymbolFocusExit() {
analyticsEventHandler.send(ManageTokens.CustomTokenSymbol)
}
override fun onDecimalsFocusExit() {
analyticsEventHandler.send(ManageTokens.CustomTokenDecimals)
}
override fun onDerivationSelected(derivation: Derivation) {
uiState =
uiState.copy(chooseDerivationState = uiState.chooseDerivationState?.copy(selectedDerivation = derivation))
derivation.standardType?.let {
analyticsEventHandler.send(ManageTokens.CustomTokenDerivationSelected(derivation.networkName))
}
uiState = uiState.copy(
chooseDerivationState = uiState.chooseDerivationState?.copy(selectedDerivation = derivation),
)
router.popBackStack()
}
@ -280,6 +334,7 @@ internal class CustomTokensViewModel @Inject constructor(
}
override fun onCustomDerivationChange(input: String) {
analyticsEventHandler.send(ManageTokens.CustomTokenDerivationSelected(ManageTokens.Derivation.CUSTOM.value))
uiState = uiState.copy(
chooseDerivationState = uiState.chooseDerivationState?.copy(
enterCustomDerivationState = uiState.chooseDerivationState?.enterCustomDerivationState?.copy(
@ -332,21 +387,77 @@ internal class CustomTokensViewModel @Inject constructor(
val cryptoCurrency = AddCustomTokenStateToCryptoCurrencyConverter(
selectedWallet.scanResponse.derivationStyleProvider,
).convert(uiState)
val alreadyAdded =
getCurrenciesUseCase(selectedWallet.walletId).getOrNull()?.any { it == cryptoCurrency }
if (alreadyAdded == true) {
val alreadyAdded = isCryptoCurrencyAlreadyAdded(selectedWallet, cryptoCurrency)
if (alreadyAdded) {
uiState = stateFactory.getStateAndTriggerEvent(
state = uiState,
event = Event.ShowAlert(AlertState.TokenAlreadyAdded),
setUiState = { uiState = it },
)
} else {
sendTokenAddedEvent(cryptoCurrency)
addCryptoCurrenciesUseCase(selectedWallet.walletId, currency = cryptoCurrency)
withContext(dispatchers.main) { router.popBackStack() }
}
}
}
private suspend fun isCryptoCurrencyAlreadyAdded(
selectedWallet: UserWallet,
cryptoCurrency: CryptoCurrency,
): Boolean {
val currenciesList = getCurrenciesUseCase(selectedWallet.walletId).getOrElse { emptyList() }
return when (cryptoCurrency) {
is CryptoCurrency.Coin -> {
currenciesList.any {
it is CryptoCurrency.Coin &&
it.id == cryptoCurrency.id &&
it.network.derivationPath == cryptoCurrency.network.derivationPath
}
}
is CryptoCurrency.Token -> {
currenciesList.any {
(it as? CryptoCurrency.Token)?.let {
it.id == cryptoCurrency.id &&
it.contractAddress == cryptoCurrency.contractAddress &&
it.network.id == cryptoCurrency.network.id &&
it.network.derivationPath == cryptoCurrency.network.derivationPath
} ?: false
}
}
}
}
private fun sendTokenAddedEvent(cryptoCurrency: CryptoCurrency) {
val selectedDerivation = uiState.chooseDerivationState?.selectedDerivation
val derivation = when {
selectedDerivation == null -> ManageTokens.Derivation.DEFAULT.value
selectedDerivation.networkName.isNotEmpty() -> selectedDerivation.networkName
else -> ManageTokens.Derivation.CUSTOM.value
}
when (cryptoCurrency) {
is CryptoCurrency.Token -> {
analyticsEventHandler.send(
ManageTokens.CustomTokenWasAdded(
derivation = derivation,
networkId = cryptoCurrency.network.name,
contractAddress = cryptoCurrency.contractAddress,
token = cryptoCurrency.symbol,
),
)
}
is CryptoCurrency.Coin -> {
analyticsEventHandler.send(
ManageTokens.CustomTokenWasAdded(
derivation = derivation,
networkId = cryptoCurrency.network.name,
),
)
}
}
}
override fun onBack() {
router.popBackStack()
}

View file

@ -16,6 +16,7 @@ internal data class ManageTokensState(
val selectedToken: TokenItemState.Loaded? = null,
val showChooseWalletScreen: Boolean = false,
val event: StateEvent<Event>,
val onEmptySearchResult: (String) -> Unit,
)
data class AddCustomTokenButton(

View file

@ -10,6 +10,7 @@ import com.tangem.managetokens.presentation.common.state.*
import com.tangem.managetokens.presentation.common.utils.CurrencyUtils
import com.tangem.managetokens.presentation.managetokens.state.*
import com.tangem.managetokens.presentation.managetokens.viewmodels.ManageTokensClickIntents
import com.tangem.managetokens.presentation.managetokens.viewmodels.ManageTokensUiEvents
import com.tangem.utils.Provider
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.Flow
@ -17,6 +18,7 @@ import kotlinx.coroutines.flow.Flow
internal class ManageTokensStateFactory(
private val currentStateProvider: Provider<ManageTokensState>,
private val clickIntents: ManageTokensClickIntents,
private val uiIntents: ManageTokensUiEvents,
) {
fun getInitialState(tokens: Flow<PagingData<TokenItemState>>): ManageTokensState {
@ -36,6 +38,7 @@ internal class ManageTokensStateFactory(
isLoading = false,
event = consumedEvent(),
chooseWalletState = ChooseWalletState.NoSelection,
onEmptySearchResult = uiIntents::onEmptySearchResult,
)
}
@ -176,7 +179,7 @@ internal class ManageTokensStateFactory(
totalNeeded = totalNeeded,
totalWallets = totalWallets,
walletsToDerive = walletsToDerive,
onGenerateClick = clickIntents::onGenerateDerivationClick,
onGenerateClick = clickIntents::onGetAddressesClick,
)
}
return currentStateProvider().copy(derivationNotification = derivationNotificationState)

View file

@ -37,7 +37,7 @@ internal val nonNativeNetworks = listOf(
iconResId = mutableStateOf(R.drawable.img_kusama_22),
isMainNetwork = false,
isAdded = mutableStateOf(true),
id = "",
id = "1",
onToggleClick = { _, _ -> },
address = "",
decimals = 0,
@ -48,7 +48,7 @@ internal val nonNativeNetworks = listOf(
iconResId = mutableStateOf(R.drawable.ic_bsc_16),
isMainNetwork = false,
isAdded = mutableStateOf(false),
id = "",
id = "2",
onToggleClick = { _, _ -> },
address = "",
decimals = 0,

View file

@ -19,6 +19,7 @@ internal object ManageTokensStatePreviewData {
derivationNotification = DerivationNotificationStatePreviewData.state,
event = consumedEvent(),
chooseWalletState = ChooseWalletStatePreviewData.state,
onEmptySearchResult = {},
)
val loadingState: ManageTokensState

View file

@ -14,6 +14,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.res.TangemTheme
@ -77,6 +79,14 @@ private fun Content(state: ManageTokensState) {
)
}
val tokens = state.tokens.collectAsLazyPagingItems()
val query = state.searchBarState.query
TrackPossibleEmptySearchResult(
tokens = tokens,
query = query,
onEmptySearchResult = state.onEmptySearchResult,
)
TokensList(tokens = tokens, addCustomTokenButton = state.addCustomTokenButton)
}
state.derivationNotification?.let {
@ -92,6 +102,27 @@ private fun Content(state: ManageTokensState) {
}
}
@Composable
private fun TrackPossibleEmptySearchResult(
tokens: LazyPagingItems<TokenItemState>,
query: String,
onEmptySearchResult: (String) -> Unit,
) {
val wasLoading = remember { mutableStateOf(false) }
LaunchedEffect(tokens.loadState) {
val isLoading = tokens.loadState.refresh == LoadState.Loading
val stoppedLoading = wasLoading.value && !isLoading
val queryAndTokensCondition = query.isNotEmpty() && tokens.itemSnapshotList.isEmpty()
if (stoppedLoading && queryAndTokensCondition) {
onEmptySearchResult(query)
}
wasLoading.value = isLoading
}
}
@Composable
private fun ManageTokensBottomSheet(selectedToken: TokenItemState.Loaded, state: ManageTokensState) {
if (state.showChooseWalletScreen && state.chooseWalletState is ChooseWalletState.Choose) {

View file

@ -4,23 +4,24 @@ 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 onGetAddressesClick()
fun onBackClick()
fun onCloseChooseNetworkScreen()
fun onNetworkToggleClick(token: TokenItemState.Loaded, network: NetworkItemState.Toggleable)
fun onNonNativeNetworkHintClick()
fun onSelectWalletsClick()
fun onNonNativeNetworkHintClick()
fun onChooseWalletClick()

View file

@ -0,0 +1,6 @@
package com.tangem.managetokens.presentation.managetokens.viewmodels
internal interface ManageTokensUiEvents {
fun onEmptySearchResult(query: String)
}

View file

@ -9,6 +9,8 @@ import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.map
import arrow.core.getOrElse
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.card.DerivePublicKeysUseCase
@ -20,6 +22,7 @@ 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.analytics.ManageTokens
import com.tangem.managetokens.presentation.common.state.AlertState
import com.tangem.managetokens.presentation.common.state.Event
import com.tangem.managetokens.presentation.common.state.NetworkItemState
@ -58,13 +61,15 @@ internal class ManageTokensViewModel @Inject constructor(
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val checkCurrencyCompatibilityUseCase: CheckCurrencyCompatibilityUseCase,
private val isCryptoCurrencyCoinCouldHide: IsCryptoCurrencyCoinCouldHideUseCase,
) : ViewModel(), ManageTokensClickIntents, DefaultLifecycleObserver {
private val analyticsEventHandler: AnalyticsEventHandler,
) : ViewModel(), ManageTokensClickIntents, ManageTokensUiEvents, DefaultLifecycleObserver {
private val debouncer = Debouncer()
private val stateFactory = ManageTokensStateFactory(
currentStateProvider = Provider { uiState },
clickIntents = this,
uiIntents = this,
)
var router: InnerManageTokensRouter by Delegates.notNull()
@ -80,7 +85,7 @@ internal class ManageTokensViewModel @Inject constructor(
private var selectedWallet: UserWallet? = null
private var neededDerivations: Map<UserWalletId, List<CryptoCurrency>> = emptyMap()
private var currenciesToGenerateAddresses: Map<UserWalletId, List<CryptoCurrency>> = emptyMap()
private val selectedAppCurrencyFlow: StateFlow<AppCurrency> = createSelectedAppCurrencyFlow()
@ -107,6 +112,8 @@ internal class ManageTokensViewModel @Inject constructor(
)
init {
analyticsEventHandler.send(ManageTokens.ScreenOpened())
viewModelScope.launch(dispatchers.io) {
getWalletsUseCase()
.distinctUntilChanged()
@ -151,7 +158,7 @@ internal class ManageTokensViewModel @Inject constructor(
.distinctUntilChanged()
.collectLatest {
it.onRight { mapOfMissingDerivations ->
neededDerivations = mapOfMissingDerivations
currenciesToGenerateAddresses = mapOfMissingDerivations
withContext(dispatchers.main) { updateDerivation() }
}
}
@ -159,8 +166,8 @@ internal class ManageTokensViewModel @Inject constructor(
}
private fun updateDerivation() {
val totalNeeded = neededDerivations.values.sumOf { derivations -> derivations.size }
val walletsToDerive = neededDerivations.values
val totalNeeded = currenciesToGenerateAddresses.values.sumOf { derivations -> derivations.size }
val walletsToDerive = currenciesToGenerateAddresses.values
.filter { derivations -> derivations.isNotEmpty() }.size
uiState = stateFactory.updateDerivationNotification(
totalNeeded = totalNeeded,
@ -182,6 +189,7 @@ internal class ManageTokensViewModel @Inject constructor(
}
override fun onAddCustomTokensButtonClick() {
analyticsEventHandler.send(ManageTokens.ButtonCustomToken)
router.openCustomTokensScreen()
}
@ -201,6 +209,13 @@ internal class ManageTokensViewModel @Inject constructor(
override fun onTokenItemButtonClick(token: TokenItemState.Loaded) {
when (token.availableAction.value) {
TokenButtonType.ADD, TokenButtonType.EDIT -> {
if (token.availableAction.value == TokenButtonType.ADD) {
analyticsEventHandler.send(ManageTokens.ButtonAdd(token.currencySymbol))
}
if (token.availableAction.value == TokenButtonType.EDIT) {
analyticsEventHandler.send(ManageTokens.ButtonEdit(token.currencySymbol))
}
uiState = uiState.copy(selectedToken = token)
val addedCurrenciesOnWallet = addedCurrenciesByWallet[selectedWallet] ?: listOf()
stateFactory.updateTokenNetworksOnTokenSelection(token, addedCurrenciesOnWallet)
@ -219,17 +234,21 @@ internal class ManageTokensViewModel @Inject constructor(
}
}
override fun onGenerateDerivationClick() {
if (neededDerivations.isNotEmpty()) {
override fun onGetAddressesClick() {
if (currenciesToGenerateAddresses.isNotEmpty()) {
viewModelScope.launch(dispatchers.io) {
val walletId = neededDerivations.keys.firstOrNull()
val currenciesToDerive = neededDerivations[walletId]
if (walletId == null || currenciesToDerive.isNullOrEmpty()) return@launch
derivePublicKeysUseCase(walletId, currenciesToDerive)
.onRight {
updateDerivationNotificationState()
fetchTokenListUseCase(userWalletId = walletId)
val cardCount = currenciesToGenerateAddresses.count { it.value.isNotEmpty() }
analyticsEventHandler.send(ManageTokens.ButtonGenerateAddresses(cardCount))
currenciesToGenerateAddresses.forEach { (walletId, currenciesToDerive) ->
if (currenciesToDerive.isNotEmpty()) {
derivePublicKeysUseCase(walletId, currenciesToDerive)
.onRight {
updateDerivationNotificationState()
fetchTokenListUseCase(userWalletId = walletId)
}
}
}
}
}
}
@ -247,8 +266,14 @@ internal class ManageTokensViewModel @Inject constructor(
if (!selectedWallet.isMultiCurrency || selectedWallet.isLocked) return
if (network.isAdded.value) {
analyticsEventHandler.send(
ManageTokens.TokenSwitcherChanged(token = token.currencySymbol, AnalyticsParam.OnOffState.Off),
)
toggleToken(token, network, selectedWallet)
} else {
analyticsEventHandler.send(
ManageTokens.TokenSwitcherChanged(token = token.currencySymbol, AnalyticsParam.OnOffState.On),
)
viewModelScope.launch(dispatchers.io) {
checkCompatibilityAndToggleToken(token, network, selectedWallet)
}
@ -348,6 +373,7 @@ internal class ManageTokensViewModel @Inject constructor(
}
override fun onNonNativeNetworkHintClick() {
analyticsEventHandler.send(ManageTokens.NoticeNonNativeNetworkClicked)
uiState = stateFactory.getStateAndTriggerEvent(
state = uiState,
event = Event.ShowAlert(AlertState.NonNative),
@ -355,13 +381,8 @@ internal class ManageTokensViewModel @Inject constructor(
)
}
override fun onSelectWalletsClick() {
uiState = uiState.copy(
showChooseWalletScreen = true,
)
}
override fun onChooseWalletClick() {
analyticsEventHandler.send(ManageTokens.ButtonChooseWallet)
uiState = uiState.copy(
showChooseWalletScreen = true,
)
@ -374,6 +395,7 @@ internal class ManageTokensViewModel @Inject constructor(
}
override fun onWalletSelected(walletId: String) {
analyticsEventHandler.send(ManageTokens.WalletSelected(ManageTokens.WalletSelected.Source.MainToken))
viewModelScope.launch(dispatchers.io) {
selectWalletUseCase(UserWalletId(walletId))
}
@ -381,4 +403,8 @@ internal class ManageTokensViewModel @Inject constructor(
uiState.selectedToken?.let { onTokenItemButtonClick(it) }
uiState = stateFactory.updateSelectedWallet(selectedWalletId = selectedWallet?.walletId?.stringValue)
}
override fun onEmptySearchResult(query: String) {
analyticsEventHandler.send(ManageTokens.TokenIsNotFound(query))
}
}

View file

@ -92,7 +92,7 @@ tangemCardSdk = "develop-324"
# endregion Tangem
# region Tools
detektComposeRules = "1.2.2"
detektComposeRules = "1.3.0"
detekt = "1.22.0"
# endregion Tools