Updated on 2026-08-14

This commit is contained in:
Tangem 2022-05-31 15:34:12 +03:00
commit 3981db48c8
7 changed files with 420 additions and 428 deletions

View file

@ -23,18 +23,66 @@ import com.tangem.domain.features.addCustomToken.redux.ViewStates
import com.tangem.domain.redux.domainStore
import com.tangem.tap.common.compose.*
import com.tangem.tap.common.moduleMessage.ModuleMessageConverter
import com.tangem.tap.features.tokens.addCustomToken.compose.test.TestAddCustomTokenActions
import com.tangem.tap.features.tokens.addCustomToken.compose.test.TestCase
import com.tangem.tap.features.tokens.addCustomToken.compose.test.TestCasesList
import com.tangem.wallet.R
import kotlinx.coroutines.launch
/**
[REDACTED_AUTHOR]
*/
private class AddCustomTokenScreen {} // for simple search
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun AddCustomTokenScreen(
state: MutableState<AddCustomTokenState>,
closePopupTrigger: ClosePopupTrigger,
) {
val selectedTestCase = remember { mutableStateOf(TestCase.ContractAddress) }
val bottomSheetScaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = BottomSheetState(BottomSheetValue.Collapsed)
)
val coroutineScope = rememberCoroutineScope()
val toggleBottomSheet = { coroutineScope.launch { bottomSheetScaffoldState.toggle() } }
BottomSheetScaffold(
scaffoldState = bottomSheetScaffoldState,
sheetContent = {
Surface(color = colorResource(id = R.color.lightGray5)) {
selectedTestCase.value.content(toggleBottomSheet)
}
},
sheetPeekHeight = 0.dp,
) {
Column() {
TestCasesList(onItemClick = {
selectedTestCase.value = it
toggleBottomSheet()
})
ScreenContent(state, closePopupTrigger)
}
}
ComposeDialogManager()
LaunchedEffect(key1 = Unit, block = { domainStore.dispatch(AddCustomTokenAction.OnCreate) })
DisposableEffect(key1 = Unit, effect = { onDispose { domainStore.dispatch(AddCustomTokenAction.OnDestroy) } })
}
@OptIn(ExperimentalMaterialApi::class)
private suspend fun BottomSheetScaffoldState.toggle() {
if (bottomSheetState.isCollapsed) {
bottomSheetState.expand()
} else {
bottomSheetState.collapse()
}
}
@Composable
private fun ScreenContent(
state: MutableState<AddCustomTokenState>,
closePopupTrigger: ClosePopupTrigger,
) {
val scaffoldState = rememberScaffoldState()
@ -50,9 +98,8 @@ fun AddCustomTokenScreen(
) {
Box(Modifier.fillMaxSize()) {
LazyColumn(
contentPadding = PaddingValues(bottom = 90.dp)
contentPadding = PaddingValues(bottom = 90.dp),
) {
item { TestAddCustomTokenActions() }
item {
Surface(
modifier = Modifier.padding(16.dp),
@ -71,11 +118,7 @@ fun AddCustomTokenScreen(
item { Warnings(state.value.warnings.toList()) }
}
}
ComposeDialogManager()
}
LaunchedEffect(key1 = Unit, block = { domainStore.dispatch(AddCustomTokenAction.OnCreate) })
DisposableEffect(key1 = Unit, effect = { onDispose { domainStore.dispatch(AddCustomTokenAction.OnDestroy) } })
}
@Composable

View file

@ -0,0 +1,123 @@
package com.tangem.tap.features.tokens.addCustomToken.compose.test
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.Button
import androidx.compose.material.Divider
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.extensions.VoidCallback
import com.tangem.domain.common.form.Field
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenAction
import com.tangem.domain.redux.domainStore
/**
[REDACTED_AUTHOR]
*/
@Composable
fun ContractAddressTests(
onItemClick: VoidCallback
) {
val casesInfo = listOf(
"USDC on ETH" to "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
"BUSD on ETH" to "0x4fabb145d64652a948d72533023f6e7a623c7c53",
"ETH on AVALANCHE" to "0xf20d962a6c8f70c731bd838a3a388d7d48fa6e15",
"USDC on ETH (invalid - cut address)" to "0xa0b86991c6218b36c1d1",
"Custom EVM" to "0x1111111111111111112111111111111111111113",
"Supported by several networks" to "0xa1faa113cbe53436df28ff0aee54275c13b40975",
"Invalid" to "!@#_ _-%%^&&*((){P P2iOWsdfFQLA",
)
CasesListContent(casesInfo, onItemClick)
}
@Composable
fun SolanaAddressTests(
onItemClick: VoidCallback
) {
val casesInfo = listOf(
"USDT (full)" to "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB",
"USDT (valid - 2/3 of address)" to "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8Ben",
"USDT (invalid - 1/3 of address)" to "Es9vMFrzaCERmJ",
"ETH (full)" to "2FPyTwcZLUg1MDrwsyoP4D6s1tM7hAkHYRjkNb5w6Pxk",
)
CasesListContent(casesInfo, onItemClick)
}
@Composable
private fun CasesListContent(
casesList: List<Pair<String, String>>,
onItemClick: VoidCallback,
) {
LazyColumn(content = {
item {
Row() {
ResetContractAddressButton(onItemClick)
Text("", modifier = Modifier.weight(1f))
ResetAllFieldsButton(onItemClick)
}
Divider()
}
items(casesList.size) {
val (info, address) = casesList[it]
ContractAddressButton(info, address, onItemClick)
}
})
}
@Composable
fun ResetAllFieldsButton(
onItemClick: VoidCallback
) {
ActionButton(name = "Reset") {
onItemClick()
domainStore.dispatch(AddCustomTokenAction.OnTokenContractAddressChanged(Field.Data("", false)))
domainStore.dispatch(AddCustomTokenAction.OnTokenNetworkChanged(Field.Data(Blockchain.Unknown, false)))
domainStore.dispatch(AddCustomTokenAction.OnTokenNameChanged(Field.Data("", false)))
domainStore.dispatch(AddCustomTokenAction.OnTokenSymbolChanged(Field.Data("", false)))
domainStore.dispatch(AddCustomTokenAction.OnTokenDecimalsChanged(Field.Data("", false)))
domainStore.dispatch(AddCustomTokenAction.OnTokenDerivationPathChanged(Field.Data(Blockchain.Unknown, false)))
}
}
@Composable
fun ResetContractAddressButton(
onItemClick: VoidCallback
) {
ActionButton(name = "Set empty address") {
onItemClick()
domainStore.dispatch(AddCustomTokenAction.OnTokenContractAddressChanged(Field.Data("", false)))
}
}
@Composable
private fun ContractAddressButton(
name: String,
address: String,
onItemClick: VoidCallback
) {
ActionButton(
modifier = Modifier.fillMaxWidth(),
name = name,
) {
onItemClick()
domainStore.dispatch(AddCustomTokenAction.OnTokenContractAddressChanged(Field.Data(address, false)))
}
}
@Composable
fun ActionButton(
modifier: Modifier = Modifier,
name: String,
onClick: () -> Unit,
) {
Button(
modifier = modifier.padding(horizontal = 8.dp),
onClick = onClick
) { Text(name, fontSize = 12.sp) }
}

View file

@ -1,225 +0,0 @@
package com.tangem.tap.features.tokens.addCustomToken.compose.test
import androidx.compose.foundation.layout.*
import androidx.compose.material.Button
import androidx.compose.material.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.unit.dp
import androidx.compose.ui.unit.sp
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.extensions.VoidCallback
import com.tangem.domain.common.form.Field
import com.tangem.domain.features.addCustomToken.redux.AddCustomTokenAction.*
import com.tangem.domain.redux.domainStore
import com.tangem.wallet.BuildConfig
/**
[REDACTED_AUTHOR]
*/
@Composable
fun TestAddCustomTokenActions() {
if (!BuildConfig.TEST_ACTION_ENABLED) return
Column() {
// deep test
// ActionRow("Invalid field value") { InvalidFieldValue() }
// ActionRow("Active(true)") { ActiveTrue() }
// ActionRow("Active(false) && decimalCount != null") { ActiveFalseDecimals() }
// ActionRow("In several networks") { InSeveralNetworks() }
// ActionRow("Address not found") { UnknownContracts() }
// test
ActionRow("All in one") { AllInOne() }
// Any action
// ActionRow("CustomActions - find coins active=false, decimals != null") { CustomActions() }
}
}
@Composable
private fun AllInOne() {
// validation error
// ContractAddressButton(
// name = "invalid",
// address = "unk"
// )
// active = true
ContractAddressButton(
name = "true",
address = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"
)
// more than one network
ContractAddressButton(
name = ">1 network",
address = "0xa1faa113cbe53436df28ff0aee54275c13b40975"
)
// unknown
ContractAddressButton(
name = "unknown",
address = "0x1111111111111111112111111111111111111113"
)
ContractAddressButton(
name = "full custom",
address = "0x3019BF2a2eF8040C242C9a4c5c4BD4C81678b2A1",
tokenNetwork = Blockchain.Ethereum,
tokenName = "Test unknown",
tokenSymbol = "TU",
tokenDecimals = "5",
)
}
@Composable
private fun InvalidFieldValue() {
ContractAddressButton(
name = "unk",
address = "unk"
)
ContractAddressButton(
name = "someText",
address = "someText"
)
ContractAddressButton(
name = "alskdml...fa s",
address = "alskdmlasd asdln alsdknflasd flasd fa s"
)
}
@Composable
private fun ActiveTrue() {
ContractAddressButton(
name = "USDC- Ethereum",
address = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"
)
ContractAddressButton(
name = "USDT- Avalanche",
address = "0xc7198437980c041c805a1edcba50c1ce5db95118"
)
ContractAddressButton(
name = "VID- Fantom",
address = "0x922d641a426dcffaef11680e5358f34d97d112e1"
)
}
@Composable
private fun ActiveFalseDecimals() {
ContractAddressButton(
name = "ALPHA- avalanche",
address = "0x2147efff675e4a4ee1c2f918d181cdbd7a8e208f"
)
ContractAddressButton(
name = "BETA- avalanche",
address = "0x511d35c52a3c244e7b8bd92c0c297755fbd89212"
)
}
@Composable
private fun InSeveralNetworks() {
ContractAddressButton(
name = "ALPHA- Eth,Bsc",
address = "0xa1faa113cbe53436df28ff0aee54275c13b40975"
)
ContractAddressButton(
name = "BETA- Eth,Bsc",
address = "0xbe1a001fe942f96eea22ba08783140b9dcc09d28"
)
}
@Composable
private fun UnknownContracts() {
ContractAddressButton(
name = "0x11...113",
address = "0x1111111111111111112111111111111111111113"
)
ContractAddressButton(
name = "0xc7...111",
address = "0xc7198437980c041c805a1edcba50c1ce5db95111"
)
}
@Composable
private fun CustomActions() {
}
@Composable
private fun ActionRow(
name: String,
content: @Composable () -> Unit
) {
Column() {
Text(
modifier = Modifier.padding(horizontal = 16.dp),
text = name,
fontSize = 14.sp
)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Start
) { content() }
}
}
@Composable
private fun ActionButton(
name: String,
onClick: VoidCallback,
) {
Button(
modifier = Modifier.padding(horizontal = 4.dp),
onClick = onClick
) { Text(name, fontSize = 8.sp) }
}
@Composable
private fun ContractAddressButton(
name: String,
address: String,
tokenNetwork: Blockchain? = null,
tokenName: String? = null,
tokenSymbol: String? = null,
tokenDecimals: String? = null,
) {
ActionButton(name = name) {
resetTokenValues()
domainStore.dispatch(OnTokenContractAddressChanged(Field.Data(address, false)))
tokenNetwork?.let {
domainStore.dispatch(OnTokenNetworkChanged(Field.Data(tokenNetwork, false)))
}
tokenName?.let {
domainStore.dispatch(OnTokenNameChanged(Field.Data(tokenName, false)))
}
tokenSymbol?.let {
domainStore.dispatch(OnTokenSymbolChanged(Field.Data(tokenSymbol, false)))
}
tokenDecimals?.let {
domainStore.dispatch(OnTokenDecimalsChanged(Field.Data(tokenDecimals, false)))
}
}
}
@Composable
private fun CustomActionButton(
name: String,
action: suspend () -> Unit
) {
val startValue = 0
val anyValue = remember { mutableStateOf(startValue) }
LaunchedEffect(key1 = anyValue.value, block = {
if (anyValue.value != startValue) {
action()
}
})
ActionButton(name) { anyValue.value = anyValue.value + 1 }
}
private fun resetTokenValues() {
domainStore.dispatch(OnTokenNetworkChanged(Field.Data(Blockchain.Unknown, false)))
// domainStore.dispatch(AddCustomTokenAction.OnTokenNameChanged(Field.Data("", false)))
// domainStore.dispatch(AddCustomTokenAction.OnTokenSymbolChanged(Field.Data("", false)))
// domainStore.dispatch(AddCustomTokenAction.OnTokenDecimalsChanged(Field.Data("", false)))
}

View file

@ -0,0 +1,63 @@
package com.tangem.tap.features.tokens.addCustomToken.compose.test
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Button
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.unit.dp
import com.tangem.common.extensions.VoidCallback
import com.tangem.wallet.BuildConfig
import com.tangem.wallet.R
/**
[REDACTED_AUTHOR]
*/
@Composable
fun TestCasesList(
onItemClick: (TestCase) -> Unit
) {
if (!BuildConfig.TEST_ACTION_ENABLED) return
Surface(
color = colorResource(id = R.color.lightGray5)
) {
Column(
Modifier.padding(horizontal = 16.dp)
) {
listOf(
TestCase.ContractAddress,
TestCase.SolanaTokens,
).map { TestCaseListItem(it, onItemClick) }
}
}
}
@Composable
fun TestCaseListItem(
testCase: TestCase,
onItemClick: (TestCase) -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
modifier = Modifier.weight(1f),
text = testCase.description,
)
Button(
onClick = { onItemClick(testCase) }
) { Text("Start") }
}
}
enum class TestCase(val description: String, val content: @Composable (VoidCallback) -> Unit) {
ContractAddress("Test contract address field", { ContractAddressTests(it) }),
Auto("Test contract address field", { ContractAddressTests(it) }),
SolanaTokens("Test Solana contract addresses", { SolanaAddressTests(it) }), ;
}

View file

@ -8,6 +8,7 @@ import com.tangem.domain.common.form.Field
import com.tangem.domain.common.form.FieldId
import com.tangem.domain.features.addCustomToken.CustomCurrency
import com.tangem.domain.features.addCustomToken.CustomTokenFieldId
import com.tangem.network.api.tangemTech.CoinsResponse
import org.rekotlin.Action
/**
@ -34,7 +35,7 @@ sealed class AddCustomTokenAction : Action {
data class OnTokenDecimalsChanged(val tokenDecimals: Field.Data<String>) : AddCustomTokenAction()
object OnAddCustomTokenClicked : AddCustomTokenAction()
data class SetFoundTokenId(val id: String?) : AddCustomTokenAction()
data class SetFoundTokenInfo(val foundToken: CoinsResponse.Coin?) : AddCustomTokenAction()
// form fields
data class UpdateForm(val state: AddCustomTokenState) : AddCustomTokenAction()

View file

@ -6,8 +6,7 @@ import com.tangem.blockchain.common.DerivationStyle
import com.tangem.common.extensions.guard
import com.tangem.common.services.Result
import com.tangem.domain.AddCustomTokenError
import com.tangem.domain.AddCustomTokenError.Warning.PotentialScamToken
import com.tangem.domain.AddCustomTokenError.Warning.TokenAlreadyAdded
import com.tangem.domain.AddCustomTokenError.Warning.*
import com.tangem.domain.AddCustomTokenException
import com.tangem.domain.DomainDialog
import com.tangem.domain.DomainWrapped
@ -73,10 +72,10 @@ internal class AddCustomTokenHub : BaseStoreHub<AddCustomTokenState>("AddCustomT
validateContractAddressAndNotify(ContractAddress.getFieldValue())
}
is OnTokenDerivationPathChanged -> {
checkAndUpdateAddButton()
updateAddButton()
}
is OnTokenNameChanged, is OnTokenSymbolChanged, is OnTokenDecimalsChanged -> {
checkAndUpdateAddButton()
updateAddButton()
}
is OnAddCustomTokenClicked -> {
val state = hubState
@ -109,74 +108,82 @@ internal class AddCustomTokenHub : BaseStoreHub<AddCustomTokenState>("AddCustomT
null -> {
// valid contract address
ContractAddress.removeError()
reassembleBlockchainNetworkList()
findTokenAndUpdateFields(contractAddress)
}
AddCustomTokenError.InvalidContractAddress -> {
// invalid contract address
ContractAddress.addError(error)
updateTokenDetailFields(hubState.tokensAnyFieldsIsFilled())
reassembleBlockchainNetworkList()
checkAndUpdateAddButton()
enableDisableTokenDetailFields(hubState.tokensAnyFieldsIsFilled())
}
AddCustomTokenError.FieldIsEmpty -> {
// empty contract address
ContractAddress.removeError()
clearTokenDetailsFields()
updateTokenDetailFields(false)
reassembleBlockchainNetworkList()
checkAndUpdateAddButton()
disableTokenDetailFields()
}
else -> {}
}
} else {
// is default selection (Blockchain.Unknown)
when (error) {
null -> {
// valid contract address
// Blockchain.Unknown has always valid contract address
ContractAddress.removeError()
reassembleBlockchainNetworkList()
findTokenAndUpdateFields(contractAddress)
}
else -> {
ContractAddress.removeError()
clearTokenDetailsFields()
updateTokenDetailFields(false)
reassembleBlockchainNetworkList()
checkAndUpdateAddButton()
disableTokenDetailFields()
}
}
}
updateDerivationPath(Network.getFieldValue())
updateWarnings()
updateAddButton()
}
private suspend fun reassembleBlockchainNetworkList() {
val state = hubState
val networkBlockchainList = Network.getField<TokenBlockchainField>().itemList
val card = requireNotNull(globalState.scanResponse?.card)
val newNetworkBlockchainList: List<Blockchain> = state.getNetworks(card, state.getCustomTokenType())
val listsIdentical = newNetworkBlockchainList.toSet() == networkBlockchainList.toSet()
if (listsIdentical) return
val networkFieldIsFilled = Network.isFilled()
val newNetworkField = Network.getField<TokenBlockchainField>().copy(itemList = newNetworkBlockchainList)
newNetworkField.data = Field.Data(Network.getFieldValue(), newNetworkField.data.isUserInput)
if (networkFieldIsFilled) {
val listSameSize = networkBlockchainList.size == newNetworkBlockchainList.size
val newListLessThanOld = networkBlockchainList.size > newNetworkBlockchainList.size
if (listSameSize || newListLessThanOld) {
// check selected blockchain
val selectedBlockchain = Network.getFieldValue<Blockchain>()
val defaultSelection = Blockchain.Unknown
if (!newNetworkBlockchainList.contains(selectedBlockchain)
&& newNetworkBlockchainList.contains(defaultSelection)) {
// selectedBlockchain not present in the new list. Change selection to default
newNetworkField.data = Field.Data(defaultSelection, false)
}
}
private suspend fun findTokenAndUpdateFields(contractAddress: String) {
val foundTokens = requestInfoAboutToken(contractAddress)
if (foundTokens.isEmpty()) {
// token not found - it's completely custom
dispatchOnMain(SetFoundTokenInfo(null))
enableTokenDetailFields()
return
}
hubState.setField(newNetworkField)
dispatchOnMain(UpdateForm(hubState))
// foundToken - contains all info about the token
val foundToken = foundTokens[0]
dispatchOnMain(SetFoundTokenInfo(foundToken))
when {
foundToken.networks.isEmpty() -> {
Timber.e("Unexpected state -> throw to FB")
}
foundToken.networks.size == 1 -> {
// token with single contract address
val singleTokenContract = foundToken.networks[0]
fillTokenFields(foundToken, singleTokenContract)
disableTokenDetailFields()
}
else -> {
val dialog = DomainDialog.SelectTokenDialog(
items = foundToken.networks,
networkIdConverter = { networkId ->
val blockchain = Blockchain.fromNetworkId(networkId)
if (blockchain == null || blockchain == Blockchain.Unknown) {
throw AddCustomTokenException.SelectTokeNetworkException(networkId)
}
hubState.blockchainToName(blockchain) ?: ""
},
onSelect = { selectedContract ->
hubScope.launch {
// find how to connect to the upper coroutineContext and dispatch through them
fillTokenFields(foundToken, selectedContract)
disableTokenDetailFields()
}
},
)
dispatchOnMain(DomainGlobalAction.ShowDialog(dialog))
}
}
}
private suspend fun updateDerivationPath(blockchainNetwork: Blockchain) {
@ -204,6 +211,105 @@ internal class AddCustomTokenHub : BaseStoreHub<AddCustomTokenState>("AddCustomT
}
}
private suspend fun updateWarnings() {
val state = hubState
val warningsAdd = mutableSetOf<AddCustomTokenError.Warning>()
val warningsRemove = mutableSetOf<AddCustomTokenError.Warning>()
val tokenIsSupported = tokenIsSupported(Network.getFieldValue())
val alreadyAdded = isPersistIntoAppSavedTokensList()
when (state.getCustomTokenType()) {
CustomTokenType.Blockchain -> {
warningsRemove.add(UnsupportedSolanaToken)
if (alreadyAdded) {
warningsAdd.add(TokenAlreadyAdded)
} else {
warningsRemove.add(TokenAlreadyAdded)
}
if (state.derivationPathIsSelected()) {
warningsAdd.add(PotentialScamToken)
} else {
warningsRemove.add(PotentialScamToken)
}
}
CustomTokenType.Token -> {
if (tokenIsSupported) {
warningsRemove.add(UnsupportedSolanaToken)
} else {
val error = ContractAddress.validateValue(ContractAddress.getFieldValue())
when (error) {
AddCustomTokenError.FieldIsEmpty -> warningsRemove.add(UnsupportedSolanaToken)
else -> {
warningsAdd.add(UnsupportedSolanaToken)
}
}
}
if (isPersistIntoAppSavedTokensList()) {
warningsAdd.add(TokenAlreadyAdded)
} else {
warningsRemove.add(TokenAlreadyAdded)
}
if (state.foundToken == null) {
if (state.tokensAnyFieldsIsFilled()) {
warningsAdd.add(PotentialScamToken)
} else {
warningsRemove.add(PotentialScamToken)
}
} else {
if (state.foundToken.active) {
warningsRemove.add(PotentialScamToken)
} else {
warningsAdd.add(PotentialScamToken)
}
}
}
}
dispatchOnMain(Warning.Replace(
remove = warningsRemove,
add = warningsAdd,
))
}
private suspend fun updateAddButton() {
if (isPersistIntoAppSavedTokensList()) {
TokenAlreadyAdded.add()
disableAddButton()
return
} else {
TokenAlreadyAdded.remove()
}
val state = hubState
when {
// token
state.tokensFieldsIsFilled() && state.networkIsSelected() -> {
val error = ContractAddress.validateValue(ContractAddress.getFieldValue<String>())
val tokenIsSupported = tokenIsSupported(Network.getFieldValue())
enableDisableAddButton(tokenIsSupported && error == null)
}
// token
state.tokensAnyFieldsIsFilled() -> {
disableAddButton()
}
// blockchain
else -> {
if (state.networkIsSelected()) {
val alreadyAdded = isBlockchainPersistIntoAppSavedTokensList()
if (alreadyAdded) {
disableAddButton()
} else {
enableAddButton()
}
} else {
disableAddButton()
}
}
}
}
private suspend fun requestInfoAboutToken(
contractAddress: String,
): List<CoinsResponse.Coin> {
@ -232,132 +338,16 @@ internal class AddCustomTokenHub : BaseStoreHub<AddCustomTokenState>("AddCustomT
return result
}
private suspend fun findTokenAndUpdateFields(contractAddress: String) {
val foundTokens = requestInfoAboutToken(contractAddress)
if (foundTokens.isEmpty()) {
// token not found - it's completely custom
PotentialScamToken.add()
dispatchOnMain(SetFoundTokenId(null))
updateTokenDetailFields(true)
checkAndUpdateAddButton()
return
}
// foundToken - contains all info about the token
val foundToken = foundTokens[0]
dispatchOnMain(SetFoundTokenId(foundToken.id))
when {
foundToken.networks.isEmpty() -> {
Timber.e("Unexpected state -> throw to FB")
}
foundToken.networks.size == 1 -> {
// token with single contract address
val singleTokenContract = foundToken.networks[0]
fillTokenFields(foundToken, singleTokenContract)
if (canHandleToken(Network.getFieldValue())) {
AddCustomTokenError.Warning.UnsupportedSolanaToken.remove()
} else {
AddCustomTokenError.Warning.UnsupportedSolanaToken.add()
updateTokenDetailFields(false)
updateAddButton(false)
return
}
val isInAppSavedTokens = isTokenPersistIntoAppSavedTokensList()
if (isInAppSavedTokens) {
updateTokenDetailFields(false)
updateAddButton(false)
PotentialScamToken.replaceBy(TokenAlreadyAdded)
} else {
// not in the saved coins list
if (foundToken.active) {
updateTokenDetailFields(false)
updateAddButton(true)
if (hubState.derivationPathIsSelected()) {
PotentialScamToken.add()
} else {
TokenAlreadyAdded.remove()
PotentialScamToken.remove()
}
} else {
updateAddButton(true)
PotentialScamToken.add()
}
}
}
else -> {
val dialog = DomainDialog.SelectTokenDialog(
items = foundToken.networks,
networkIdConverter = { networkId ->
val blockchain = Blockchain.fromNetworkId(networkId)
if (blockchain == null || blockchain == Blockchain.Unknown) {
throw AddCustomTokenException.SelectTokeNetworkException(networkId)
}
hubState.blockchainToName(blockchain) ?: ""
},
onSelect = { selectedContract ->
hubScope.launch {
// find how to connect to the upper coroutineContext and dispatch through them
PotentialScamToken.remove()
fillTokenFields(foundToken, selectedContract)
updateTokenDetailFields(false)
checkAndUpdateAddButton()
}
},
)
dispatchOnMain(DomainGlobalAction.ShowDialog(dialog))
}
}
}
private suspend fun checkAndUpdateAddButton() {
val alreadyAddedToAppTokensList = if (hubState.tokensAnyFieldsIsFilled()) {
isTokenPersistIntoAppSavedTokensList()
} else {
isBlockchainPersistIntoAppSavedTokensList()
}
if (alreadyAddedToAppTokensList) {
TokenAlreadyAdded.add()
updateAddButton(false)
return
} else {
TokenAlreadyAdded.remove()
}
val state = hubState
when {
// token
state.tokensFieldsIsFilled() && state.networkIsSelected() -> {
val error = ContractAddress.validateValue(ContractAddress.getFieldValue<String>())
updateAddButton(error == null)
}
// token
state.tokensAnyFieldsIsFilled() -> {
updateAddButton(false)
}
// blockchain
else -> {
if (state.networkIsSelected()) {
val alreadyAdded = isBlockchainPersistIntoAppSavedTokensList()
if (alreadyAdded) {
updateAddButton(false)
} else {
updateAddButton(true)
}
} else {
updateAddButton(false)
}
}
}
}
/**
* These are helper functions.
*/
private fun isPersistIntoAppSavedTokensList(): Boolean = when (hubState.getCustomTokenType()) {
CustomTokenType.Blockchain -> isBlockchainPersistIntoAppSavedTokensList()
CustomTokenType.Token -> isTokenPersistIntoAppSavedTokensList()
}
private fun isTokenPersistIntoAppSavedTokensList(
tokenId: String? = hubState.tokenId,
tokenId: String? = hubState.foundToken?.id,
tokenContractAddress: String = ContractAddress.getFieldValue(),
tokenNetworkId: String = Network.getFieldValue<Blockchain>().toNetworkId(),
selectedDerivation: Blockchain = DerivationPath.getFieldValue()
@ -430,7 +420,15 @@ internal class AddCustomTokenHub : BaseStoreHub<AddCustomTokenState>("AddCustomT
dispatchOnMain(UpdateForm(hubState))
}
private suspend fun updateTokenDetailFields(isEnabled: Boolean = true) {
private suspend fun enableTokenDetailFields() {
enableDisableTokenDetailFields(true)
}
private suspend fun disableTokenDetailFields() {
enableDisableTokenDetailFields(false)
}
private suspend fun enableDisableTokenDetailFields(isEnabled: Boolean = true) {
val state = hubState
val action = Screen.UpdateTokenFields(listOf(
Name to state.screenState.name.copy(isEnabled = isEnabled),
@ -440,20 +438,21 @@ internal class AddCustomTokenHub : BaseStoreHub<AddCustomTokenState>("AddCustomT
dispatchOnMain(action)
}
private suspend fun updateBlockchainNetworkField(isEnabled: Boolean) {
val action = Screen.UpdateTokenFields(listOf(
Name to hubState.screenState.name.copy(isEnabled = isEnabled),
))
dispatchOnMain(action)
private suspend fun enableAddButton() {
enableDisableAddButton(true)
}
private suspend fun updateAddButton(isEnabled: Boolean) {
private suspend fun disableAddButton() {
enableDisableAddButton(false)
}
private suspend fun enableDisableAddButton(isEnabled: Boolean) {
dispatchOnMain(Screen.UpdateAddButton(ViewStates.AddButton(isEnabled)))
}
private fun canHandleToken(blockchain: Blockchain): Boolean {
val card = globalState.scanResponse?.card ?: return false
return card.canHandleToken(blockchain)
private fun tokenIsSupported(blockchain: Blockchain): Boolean = when (blockchain) {
Blockchain.Unknown -> true
else -> globalState.scanResponse?.card?.canHandleToken(blockchain) ?: false
}
@Throws
@ -537,19 +536,6 @@ internal class AddCustomTokenHub : BaseStoreHub<AddCustomTokenState>("AddCustomT
}
}
/**
* The field is being validated.
* If there is an error, then it adds it to the field.
*/
private suspend fun CustomTokenFieldId.validateAndUpdateError(value: Any): AddCustomTokenError? {
val error = this.validateValue(value)
when (error) {
null -> this.removeError()
else -> this.addError(error)
}
return error
}
private suspend fun AddCustomTokenError.Warning.add() {
dispatchOnMain(Warning.Add(setOf(this)))
}
@ -643,8 +629,8 @@ private class AddCustomTokenReducer(
val newMap = state.formErrors.toMutableMap().apply { remove(action.id) }
state.copy(formErrors = newMap)
}
is SetFoundTokenId -> {
state.copy(tokenId = action.id)
is SetFoundTokenInfo -> {
state.copy(foundToken = action.foundToken)
}
is Warning.Add -> {
val newList = state.warnings.toMutableSet().apply { addAll(action.warnings) }

View file

@ -14,6 +14,7 @@ import com.tangem.domain.features.addCustomToken.*
import com.tangem.domain.features.addCustomToken.CustomTokenFieldId.*
import com.tangem.domain.redux.DomainState
import com.tangem.domain.redux.state.StringActionStateConverter
import com.tangem.network.api.tangemTech.CoinsResponse
import org.rekotlin.Action
import org.rekotlin.StateType
@ -24,7 +25,7 @@ data class AddCustomTokenState(
val form: Form = Form(listOf()),
val formValidators: Map<CustomTokenFieldId, CustomTokenValidator<out Any>> = createFormValidators(),
val formErrors: Map<CustomTokenFieldId, AddCustomTokenError> = emptyMap(),
val tokenId: String? = null,
val foundToken: CoinsResponse.Coin? = null,
val warnings: Set<AddCustomTokenError.Warning> = emptySet(),
val screenState: ScreenState = createInitialScreenState(),
val tangemTechServiceManager: AddCustomTokenService? = null
@ -116,7 +117,7 @@ data class AddCustomTokenState(
cardDerivationStyle = null,
form = Form(createFormFields(card, CustomTokenType.Blockchain)),
formErrors = emptyMap(),
tokenId = null,
foundToken = null,
warnings = emptySet(),
screenState = createInitialScreenState(),
tangemTechServiceManager = null,
@ -124,7 +125,7 @@ data class AddCustomTokenState(
}
private fun getToken(): CustomCurrency.CustomToken {
return CustomCurrency.CustomToken.Converter(tokenId, cardDerivationStyle)
return CustomCurrency.CustomToken.Converter(foundToken?.id, cardDerivationStyle)
.apply { visitDataConverter(this) }
.getConvertedData()
}