Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-29 14:54:25 +02:00
parent 3069b8d32d
commit 7b4ae4e23d
39 changed files with 1985 additions and 329 deletions

View file

@ -374,6 +374,7 @@ internal class ChildFactory @Inject constructor(
is AppRoute.QrScanning.Source.Send -> SourceType.SEND is AppRoute.QrScanning.Source.Send -> SourceType.SEND
is AppRoute.QrScanning.Source.WalletConnect -> SourceType.WALLET_CONNECT is AppRoute.QrScanning.Source.WalletConnect -> SourceType.WALLET_CONNECT
is AppRoute.QrScanning.Source.MainScreen -> SourceType.MAIN_SCREEN is AppRoute.QrScanning.Source.MainScreen -> SourceType.MAIN_SCREEN
is AppRoute.QrScanning.Source.AddressBook -> SourceType.ADDRESS_BOOK
} }
createComponentChild( createComponentChild(
context = context, context = context,

View file

@ -195,6 +195,7 @@ sealed class AppRoute(val path: String) : Route {
is Send -> "/$networkName" is Send -> "/$networkName"
WalletConnect -> "" WalletConnect -> ""
MainScreen -> "" MainScreen -> ""
AddressBook -> ""
} }
data class Send(val networkName: String) : Source() data class Send(val networkName: String) : Source()
@ -202,6 +203,8 @@ sealed class AppRoute(val path: String) : Route {
data object WalletConnect : Source() data object WalletConnect : Source()
data object MainScreen : Source() data object MainScreen : Source()
data object AddressBook : Source()
} }
} }

View file

@ -4,6 +4,7 @@ import androidx.annotation.VisibleForTesting
import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.FeePaidCurrency import com.tangem.blockchain.common.FeePaidCurrency
import com.tangem.blockchainsdk.utils.ExcludedBlockchains import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.blockchainsdk.utils.getSupportedTransactionExtras
import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.blockchainsdk.utils.toNetworkId import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.domain.card.common.extensions.canHandleToken import com.tangem.domain.card.common.extensions.canHandleToken
@ -217,172 +218,6 @@ class NetworkFactory @Inject constructor(
} }
} }
@Suppress("LongMethod")
private fun Blockchain.getSupportedTransactionExtras(): Network.TransactionExtrasType {
return when (this) {
Blockchain.XRP -> Network.TransactionExtrasType.DESTINATION_TAG
Blockchain.Binance,
Blockchain.TON,
Blockchain.Cosmos,
Blockchain.TerraV1,
Blockchain.TerraV2,
Blockchain.Stellar,
Blockchain.Hedera,
Blockchain.Algorand,
Blockchain.Sei,
Blockchain.InternetComputer,
Blockchain.Casper,
-> Network.TransactionExtrasType.MEMO
// region Other blockchains
Blockchain.Unknown,
Blockchain.Alephium,
Blockchain.AlephiumTestnet,
Blockchain.Arbitrum,
Blockchain.ArbitrumTestnet,
Blockchain.Avalanche,
Blockchain.AvalancheTestnet,
Blockchain.BinanceTestnet,
Blockchain.BSC,
Blockchain.BSCTestnet,
Blockchain.Bitcoin,
Blockchain.BitcoinTestnet,
Blockchain.BitcoinCash,
Blockchain.BitcoinCashTestnet,
Blockchain.Cardano,
Blockchain.CosmosTestnet,
Blockchain.Dogecoin,
Blockchain.Ducatus,
Blockchain.Ethereum,
Blockchain.EthereumTestnet,
Blockchain.EthereumClassic,
Blockchain.EthereumClassicTestnet,
Blockchain.Fantom,
Blockchain.FantomTestnet,
Blockchain.Litecoin,
Blockchain.Near,
Blockchain.NearTestnet,
Blockchain.Polkadot,
Blockchain.PolkadotTestnet,
Blockchain.Kava,
Blockchain.KavaTestnet,
Blockchain.Kusama,
Blockchain.Polygon,
Blockchain.PolygonTestnet,
Blockchain.RSK,
Blockchain.SeiTestnet,
Blockchain.StellarTestnet,
Blockchain.Solana,
Blockchain.SolanaTestnet,
Blockchain.Tezos,
Blockchain.Tron,
Blockchain.TronTestnet,
Blockchain.Gnosis,
Blockchain.Dash,
Blockchain.Optimism,
Blockchain.OptimismTestnet,
Blockchain.Dischain,
Blockchain.EthereumPow,
Blockchain.EthereumPowTestnet,
Blockchain.Kaspa,
Blockchain.KaspaTestnet,
Blockchain.Telos,
Blockchain.TelosTestnet,
Blockchain.TONTestnet,
Blockchain.Ravencoin,
Blockchain.Clore,
Blockchain.RavencoinTestnet,
Blockchain.Cronos,
Blockchain.AlephZero,
Blockchain.AlephZeroTestnet,
Blockchain.OctaSpace,
Blockchain.OctaSpaceTestnet,
Blockchain.Chia,
Blockchain.ChiaTestnet,
Blockchain.Decimal,
Blockchain.DecimalTestnet,
Blockchain.XDC,
Blockchain.XDCTestnet,
Blockchain.VeChain,
Blockchain.VeChainTestnet,
Blockchain.Aptos,
Blockchain.AptosTestnet,
Blockchain.Playa3ull,
Blockchain.Shibarium,
Blockchain.ShibariumTestnet,
Blockchain.AlgorandTestnet,
Blockchain.HederaTestnet,
Blockchain.Aurora,
Blockchain.AuroraTestnet,
Blockchain.Areon,
Blockchain.AreonTestnet,
Blockchain.PulseChain,
Blockchain.PulseChainTestnet,
Blockchain.ZkSyncEra,
Blockchain.ZkSyncEraTestnet,
Blockchain.Nexa,
Blockchain.NexaTestnet,
Blockchain.Moonbeam,
Blockchain.MoonbeamTestnet,
Blockchain.Manta,
Blockchain.MantaTestnet,
Blockchain.PolygonZkEVM,
Blockchain.PolygonZkEVMTestnet,
Blockchain.Radiant,
Blockchain.Fact0rn,
Blockchain.Base,
Blockchain.BaseTestnet,
Blockchain.Moonriver,
Blockchain.MoonriverTestnet,
Blockchain.Mantle,
Blockchain.MantleTestnet,
Blockchain.Flare,
Blockchain.FlareTestnet,
Blockchain.Taraxa,
Blockchain.TaraxaTestnet,
Blockchain.Koinos,
Blockchain.KoinosTestnet,
Blockchain.Joystream,
Blockchain.Bittensor,
Blockchain.Filecoin,
Blockchain.Blast,
Blockchain.BlastTestnet,
Blockchain.Cyber,
Blockchain.CyberTestnet,
Blockchain.Sui,
Blockchain.SuiTestnet,
Blockchain.EnergyWebChain,
Blockchain.EnergyWebChainTestnet,
Blockchain.EnergyWebX,
Blockchain.EnergyWebXTestnet,
Blockchain.CasperTestnet,
Blockchain.Core,
Blockchain.CoreTestnet,
Blockchain.Xodex,
Blockchain.Canxium,
Blockchain.Chiliz,
Blockchain.ChilizTestnet,
Blockchain.VanarChain,
Blockchain.VanarChainTestnet,
Blockchain.OdysseyChain, Blockchain.OdysseyChainTestnet,
Blockchain.Bitrock, Blockchain.BitrockTestnet,
Blockchain.Sonic, Blockchain.SonicTestnet,
Blockchain.ApeChain, Blockchain.ApeChainTestnet,
Blockchain.Scroll, Blockchain.ScrollTestnet,
Blockchain.ZkLinkNova, Blockchain.ZkLinkNovaTestnet,
Blockchain.Pepecoin, Blockchain.PepecoinTestnet,
Blockchain.Hyperliquid, Blockchain.HyperliquidTestnet,
Blockchain.Quai, Blockchain.QuaiTestnet,
Blockchain.Linea, Blockchain.LineaTestnet,
Blockchain.ArbitrumNova,
Blockchain.Plasma, Blockchain.PlasmaTestnet,
Blockchain.Adi, Blockchain.AdiTestnet,
Blockchain.SeiEvm, Blockchain.SeiEvmTestnet,
Blockchain.Monad, Blockchain.MonadTestnet,
-> Network.TransactionExtrasType.NONE
// endregion
}
}
private fun Blockchain.getNameResolvingType(): Network.NameResolvingType { private fun Blockchain.getNameResolvingType(): Network.NameResolvingType {
return when (this) { return when (this) {
Blockchain.Ethereum, Blockchain.EthereumTestnet -> Network.NameResolvingType.ENS Blockchain.Ethereum, Blockchain.EthereumTestnet -> Network.NameResolvingType.ENS

View file

@ -4,4 +4,5 @@ enum class SourceType {
WALLET_CONNECT, WALLET_CONNECT,
SEND, SEND,
MAIN_SCREEN, MAIN_SCREEN,
ADDRESS_BOOK,
} }

View file

@ -19,6 +19,8 @@ dependencies {
implementation(projects.domain.account) implementation(projects.domain.account)
implementation(projects.domain.addressBook) implementation(projects.domain.addressBook)
implementation(projects.domain.models) implementation(projects.domain.models)
implementation(projects.domain.qrScanning)
implementation(projects.domain.qrScanning.models)
implementation(projects.domain.wallets) implementation(projects.domain.wallets)
/** Common */ /** Common */

View file

@ -31,6 +31,7 @@ internal class DefaultAddAddressComponent(
data class Params( data class Params(
val onBackClick: () -> Unit, val onBackClick: () -> Unit,
val onSelectNetworksClick: (address: String, selectedNetworkIds: List<String>) -> Unit,
val onConfirm: (ValidatedAddress) -> Unit, val onConfirm: (ValidatedAddress) -> Unit,
) )
} }

View file

@ -1,65 +1,113 @@
package com.tangem.features.addressbook.addaddress.model package com.tangem.features.addressbook.addaddress.model
import arrow.core.getOrElse
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.getSupportedTransactionExtras
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.common.routing.AppRoute
import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.ui.clipboard.ClipboardManager import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.domain.account.supplier.MultiAccountListSupplier import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
import com.tangem.features.addressbook.addaddress.DefaultAddAddressComponent import com.tangem.features.addressbook.addaddress.DefaultAddAddressComponent
import com.tangem.features.addressbook.addaddress.state.AddAddressStateController import com.tangem.features.addressbook.addaddress.state.AddAddressStateController
import com.tangem.features.addressbook.addaddress.state.transformers.UpdateAddAddressInitialStateTransformer import com.tangem.features.addressbook.addaddress.state.transformers.UpdateAddAddressInitialStateTransformer
import com.tangem.features.addressbook.addaddress.state.transformers.UpdateAddressInputTransformer import com.tangem.features.addressbook.addaddress.state.transformers.UpdateAddressInputTransformer
import com.tangem.features.addressbook.addaddress.state.transformers.UpdateAddressValidationTransformer import com.tangem.features.addressbook.addaddress.state.transformers.UpdateAddressValidationTransformer
import com.tangem.features.addressbook.addaddress.state.transformers.UpdateMemoInputTransformer
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM
import com.tangem.features.addressbook.common.AddressMemoValidator
import com.tangem.features.addressbook.common.SelectNetworksResultHolder
import com.tangem.features.addressbook.common.SupportedNetworksMatcher
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import javax.inject.Inject import javax.inject.Inject
@OptIn(FlowPreview::class) @Suppress("LongParameterList")
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
@ModelScoped @ModelScoped
internal class AddAddressModel @Inject constructor( internal class AddAddressModel @Inject constructor(
paramsContainer: ParamsContainer, paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider, override val dispatchers: CoroutineDispatcherProvider,
multiAccountListSupplier: MultiAccountListSupplier, private val supportedNetworksMatcher: SupportedNetworksMatcher,
private val memoValidator: AddressMemoValidator,
private val listenToQrScanningUseCase: ListenToQrScanningUseCase,
private val clipboardManager: ClipboardManager, private val clipboardManager: ClipboardManager,
private val stateController: AddAddressStateController, private val stateController: AddAddressStateController,
private val selectNetworksResultHolder: SelectNetworksResultHolder,
private val router: Router,
) : Model() { ) : Model() {
private val params: DefaultAddAddressComponent.Params = paramsContainer.require() private val params: DefaultAddAddressComponent.Params = paramsContainer.require()
val state: StateFlow<AddAddressUM> get() = stateController.uiState val state: StateFlow<AddAddressUM> get() = stateController.uiState
private val availableCoins: StateFlow<List<CryptoCurrency.Coin>> = multiAccountListSupplier() private val validation: StateFlow<AddressValidation> = state
.map { accountLists ->
accountLists
.flatMap { it.flattenCurrencies() }
.filterIsInstance<CryptoCurrency.Coin>()
.distinctBy { it.network.id }
}
.flowOn(dispatchers.default)
.stateIn(modelScope, SharingStarted.Eagerly, emptyList())
private val addressInput = state
.map { it.addressField.value } .map { it.addressField.value }
.distinctUntilChanged() .distinctUntilChanged()
.debounce(ADD_ADDRESS_DEBOUNCE) .debounce(ADD_ADDRESS_DEBOUNCE)
.map { address ->
AddressValidation(address = address, matchedBlockchains = supportedNetworksMatcher.match(address))
}
.flowOn(dispatchers.default)
.stateIn(modelScope, SharingStarted.Eagerly, AddressValidation(address = "", matchedBlockchains = emptyList()))
/** `true` when a non-blank memo doesn't pass the chosen network's format rules (e.g. XRP destination tag). */
private val isMemoInvalid = MutableStateFlow(false)
private val selectedNetworkIds = MutableStateFlow<Set<String>?>(null)
private val chosenNetworks: StateFlow<ChosenNetworks> = combine(
validation,
selectedNetworkIds,
) { validation, selected ->
val matched = validation.matchedBlockchains
ChosenNetworks(
address = validation.address,
matched = matched,
displayed = displayedNetworks(matched, selected),
selected = selectedNetworks(matched, selected),
)
}
.flowOn(dispatchers.default)
.stateIn(
modelScope,
SharingStarted.Eagerly,
ChosenNetworks(address = "", matched = emptyList(), displayed = emptyList(), selected = emptyList()),
)
init { init {
// Drop any selection left over from a previous AddAddress session before subscribing to it.
selectNetworksResultHolder.clear()
updateInitialState() updateInitialState()
subscribeToAddressValidation() subscribeToValidation()
subscribeToMemoValidation()
resetSelectionOnAddressChange()
subscribeToSelectedNetworks()
subscribeToQrScanResult()
} }
private fun updateInitialState() { private fun updateInitialState() {
stateController.update( stateController.update(
UpdateAddAddressInitialStateTransformer( UpdateAddAddressInitialStateTransformer(
onAddressChange = { onAddressChange(value = it) }, intents = UpdateAddAddressInitialStateTransformer.Intents(
onAddressClear = { onAddressChange("") }, onAddressChange = ::onAddressChange,
onPasteClick = ::onPaste, onAddressClear = { onAddressChange("") },
onQrClick = { /* [REDACTED_TODO_COMMENT] */ }, onPasteClick = ::onPaste,
onBackClick = params.onBackClick, onQrClick = ::onQrClick,
onConfirmClick = ::validateAndConfirm, onBackClick = params.onBackClick,
onNetworkClick = ::onNetworkClick,
onMemoChange = ::onMemoChange,
onMemoPasteClick = ::onMemoPaste,
onConfirmClick = ::validateAndConfirm,
),
), ),
) )
} }
@ -68,24 +116,138 @@ internal class AddAddressModel @Inject constructor(
stateController.update(UpdateAddressInputTransformer(value = value)) stateController.update(UpdateAddressInputTransformer(value = value))
} }
private fun subscribeToAddressValidation() { private fun onMemoChange(value: String) {
combine(addressInput, availableCoins) { input, coins -> stateController.update(UpdateMemoInputTransformer(value = value))
UpdateAddressValidationTransformer(address = input, coins = coins) }
private fun subscribeToValidation() {
combine(chosenNetworks, isMemoInvalid) { networks, memoInvalid ->
UpdateAddressValidationTransformer(
address = networks.address,
matchedBlockchains = networks.matched,
displayedBlockchains = networks.displayed,
selectedBlockchains = networks.selected,
isMemoInvalid = memoInvalid,
)
} }
.onEach(stateController::update) .onEach(stateController::update)
.flowOn(dispatchers.default) .flowOn(dispatchers.default)
.launchIn(modelScope) .launchIn(modelScope)
} }
private fun subscribeToMemoValidation() {
val memoInput = state.map { it.memoField.value }.distinctUntilChanged().debounce(MEMO_DEBOUNCE)
combine(memoInput, chosenNetworks) { memo, networks -> memo to networks.extrasBlockchain }
.mapLatest { (memo, blockchain) ->
blockchain != null && memo.isNotBlank() && !memoValidator.isValid(blockchain, memo)
}
.onEach { isMemoInvalid.value = it }
.flowOn(dispatchers.default)
.launchIn(modelScope)
}
private fun resetSelectionOnAddressChange() {
validation
.map { it.address }
.distinctUntilChanged()
.onEach { selectedNetworkIds.value = null }
.launchIn(modelScope)
}
private fun subscribeToSelectedNetworks() {
selectNetworksResultHolder.selectedNetworkIds
.filterNotNull()
.onEach { ids ->
selectedNetworkIds.value = ids
selectNetworksResultHolder.clear()
}
.launchIn(modelScope)
}
private fun onPaste() { private fun onPaste() {
onAddressChange(value = clipboardManager.getText().orEmpty()) onAddressChange(value = clipboardManager.getText().orEmpty())
} }
private fun onMemoPaste() {
onMemoChange(value = clipboardManager.getText().orEmpty())
}
private fun onQrClick() {
router.push(AppRoute.QrScanning(source = AppRoute.QrScanning.Source.AddressBook))
}
private fun subscribeToQrScanResult() {
listenToQrScanningUseCase(SourceType.ADDRESS_BOOK)
.getOrElse { emptyFlow() }
.onEach { onAddressChange(value = normalizeScannedAddress(it)) }
.flowOn(dispatchers.default)
.launchIn(modelScope)
}
/**
* Extracts the bare address from a scanned payment URI like `ethereum:0xADDR@1?amount=1.5`: drops the query
* (`?`), the chain suffix (`@`) and the scheme (`scheme:`). A plain address is returned unchanged.
*/
private fun normalizeScannedAddress(raw: String): String {
val withoutQueryAndChain = raw.trim().substringBefore('?').substringBefore('@')
return withoutQueryAndChain.substringAfter(':', missingDelimiterValue = withoutQueryAndChain)
}
private fun onNetworkClick() {
params.onSelectNetworksClick(
stateController.uiState.value.addressField.value,
selectedNetworkIds.value?.toList().orEmpty(),
)
}
private fun validateAndConfirm() { private fun validateAndConfirm() {
// TODO Address book ([REDACTED_TASK_KEY]): navigate to the network-selection with the address and its matching networks. val networks = chosenNetworks.value
if (networks.selected.isEmpty()) return
val memoField = stateController.uiState.value.memoField
val memo = memoField.value.trim().takeIf { memoField.isVisible && it.isNotEmpty() }
params.onConfirm(
ValidatedAddress(
address = networks.address,
networkIds = networks.selected.map { it.toNetworkId() }.toImmutableList(),
memo = memo,
),
)
}
/** What the network block shows: all matched networks until the user narrows them down, then the picked subset. */
private fun displayedNetworks(matched: List<Blockchain>, selected: Set<String>?): List<Blockchain> {
if (selected == null) return matched
return matched.filter { it.toNetworkId() in selected }
}
/**
* What is actually selected for saving. A single matched network is auto-selected (there is nothing to choose and
* the selection screen can't be opened); otherwise the user must pick explicitly before saving.
*/
private fun selectedNetworks(matched: List<Blockchain>, selected: Set<String>?): List<Blockchain> {
if (selected == null) return listOfNotNull(matched.singleOrNull())
return matched.filter { it.toNetworkId() in selected }
}
private data class AddressValidation(
val address: String,
val matchedBlockchains: List<Blockchain>,
)
private data class ChosenNetworks(
val address: String,
val matched: List<Blockchain>,
val displayed: List<Blockchain>,
val selected: List<Blockchain>,
) {
/** The first selected network that supports a memo / destination tag, if any. */
val extrasBlockchain: Blockchain?
get() = selected.firstOrNull { it.getSupportedTransactionExtras().isTxExtrasSupported() }
} }
companion object { companion object {
private const val ADD_ADDRESS_DEBOUNCE = 500L private const val ADD_ADDRESS_DEBOUNCE = 500L
private const val MEMO_DEBOUNCE = 300L
} }
} }

View file

@ -31,13 +31,21 @@ internal class AddAddressStateController @Inject constructor() {
label = resourceReference(R.string.common_address), label = resourceReference(R.string.common_address),
isError = false, isError = false,
), ),
memoField = AddAddressUM.MemoFieldUM(
isVisible = false,
value = "",
label = resourceReference(R.string.send_extras_hint_memo),
isError = false,
onValueChange = {},
onPasteClick = {},
),
buttonUM = TangemButtonUM( buttonUM = TangemButtonUM(
text = TextReference.Res(R.string.address_book_add_address), text = TextReference.Res(R.string.address_book_add_address),
type = TangemButtonType.Primary, type = TangemButtonType.Primary,
isEnabled = false, isEnabled = false,
onClick = {}, onClick = {},
), ),
chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Empty, chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Hidden,
onAddressChange = {}, onAddressChange = {},
onAddressClear = {}, onAddressClear = {},
onPasteClick = {}, onPasteClick = {},

View file

@ -8,22 +8,34 @@ import com.tangem.utils.transformer.Transformer
* state produced by [com.tangem.features.addressbook.addaddress.state.AddAddressStateController]. * state produced by [com.tangem.features.addressbook.addaddress.state.AddAddressStateController].
*/ */
internal class UpdateAddAddressInitialStateTransformer( internal class UpdateAddAddressInitialStateTransformer(
private val onAddressChange: (String) -> Unit, private val intents: Intents,
private val onAddressClear: () -> Unit,
private val onPasteClick: () -> Unit,
private val onQrClick: () -> Unit,
private val onBackClick: () -> Unit,
private val onConfirmClick: () -> Unit,
) : Transformer<AddAddressUM> { ) : Transformer<AddAddressUM> {
override fun transform(prevState: AddAddressUM): AddAddressUM { override fun transform(prevState: AddAddressUM): AddAddressUM {
return prevState.copy( return prevState.copy(
onAddressChange = onAddressChange, onAddressChange = intents.onAddressChange,
onAddressClear = onAddressClear, onAddressClear = intents.onAddressClear,
onPasteClick = onPasteClick, onPasteClick = intents.onPasteClick,
onQrClick = onQrClick, onQrClick = intents.onQrClick,
onBackClick = onBackClick, onBackClick = intents.onBackClick,
buttonUM = prevState.buttonUM.copy(onClick = onConfirmClick), onNetworkClick = intents.onNetworkClick,
memoField = prevState.memoField.copy(
onValueChange = intents.onMemoChange,
onPasteClick = intents.onMemoPasteClick,
),
buttonUM = prevState.buttonUM.copy(onClick = intents.onConfirmClick),
) )
} }
data class Intents(
val onAddressChange: (String) -> Unit,
val onAddressClear: () -> Unit,
val onPasteClick: () -> Unit,
val onQrClick: () -> Unit,
val onBackClick: () -> Unit,
val onNetworkClick: () -> Unit,
val onMemoChange: (String) -> Unit,
val onMemoPasteClick: () -> Unit,
val onConfirmClick: () -> Unit,
)
} }

View file

@ -3,23 +3,41 @@ package com.tangem.features.addressbook.addaddress.state.transformers
import com.tangem.core.ui.R import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM.ChosenNetworkStateUM
import com.tangem.utils.transformer.Transformer import com.tangem.utils.transformer.Transformer
/** /**
* Updates the address field with a freshly entered/pasted [value] and clears any previous error, restoring the default * Updates the address field with a freshly entered/pasted [value] and clears any previous error, restoring the default
* label. The actual (re)validation runs after a debounce see [UpdateAddressValidationTransformer]. * label. The confirm button is disabled while validation is pending; the actual (re)validation runs after a debounce
* see [UpdateAddressValidationTransformer].
*
* The network selector reflects the pending validation: a non-blank address shows [ChosenNetworkStateUM.Loading], but
* an already-resolved selector keeps its networks on screen instead of flashing back to the spinner on every keystroke.
*/ */
internal class UpdateAddressInputTransformer( internal class UpdateAddressInputTransformer(
private val value: String, private val value: String,
) : Transformer<AddAddressUM> { ) : Transformer<AddAddressUM> {
override fun transform(prevState: AddAddressUM): AddAddressUM { override fun transform(prevState: AddAddressUM): AddAddressUM {
val chosenNetworkState = when {
value.isBlank() -> ChosenNetworkStateUM.Hidden
prevState.chosenNetworkStateUM is ChosenNetworkStateUM.Result -> prevState.chosenNetworkStateUM
else -> ChosenNetworkStateUM.Loading
}
val memoField = if (value.isBlank()) {
prevState.memoField.copy(isVisible = false, value = "", isError = false)
} else {
prevState.memoField
}
return prevState.copy( return prevState.copy(
addressField = prevState.addressField.copy( addressField = prevState.addressField.copy(
value = value, value = value,
isError = false, isError = false,
label = resourceReference(R.string.common_address), label = resourceReference(R.string.common_address),
), ),
chosenNetworkStateUM = chosenNetworkState,
buttonUM = prevState.buttonUM.copy(isEnabled = false),
memoField = memoField,
) )
} }
} }

View file

@ -1,28 +1,51 @@
package com.tangem.features.addressbook.addaddress.state.transformers package com.tangem.features.addressbook.addaddress.state.transformers
import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.getSupportedTransactionExtras
import com.tangem.core.ui.R import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network
import com.tangem.features.addressbook.addaddress.state.transformers.converter.ChosenNetworkConverter
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM.ChosenNetworkStateUM
import com.tangem.utils.transformer.Transformer import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.toImmutableList
/** /**
* Validates [address] against the wallet's [coins] and reflects the result in the UI. * Reflects the result of validating an address (and its memo) in the UI.
* *
* The network is not chosen on this screen (it is selected on the next screen), so the address is valid when it matches * [matchedBlockchains] are all supported networks the address resolves to. [displayedBlockchains] is what the network
* at least one of the available networks the same blockchain check the Send flow uses. An invalid (non-empty, * block shows all matched networks until the user narrows them down on the SelectNetworks screen, then the picked
* matching nothing) address surfaces the error in the field label and disables the confirm button. * subset. [selectedBlockchains] is what is actually chosen for saving (a single match is auto-selected; for several
* matches the user must pick explicitly). While the address is blank or matches nothing the network selector stays
* [ChosenNetworkStateUM.Hidden]; an invalid (non-empty, matching nothing) address surfaces the error in the field label.
*
* The confirm button is enabled only once at least one network is actually selected (and the memo, if any, is valid)
* showing the available networks is not the same as selecting them. The memo field is shown when a selected network
* supports transaction extras; [isMemoInvalid] marks a malformed memo.
*/ */
internal class UpdateAddressValidationTransformer( internal class UpdateAddressValidationTransformer(
private val address: String, private val address: String,
private val coins: List<CryptoCurrency.Coin>, private val matchedBlockchains: List<Blockchain>,
private val displayedBlockchains: List<Blockchain>,
private val selectedBlockchains: List<Blockchain>,
private val isMemoInvalid: Boolean,
) : Transformer<AddAddressUM> { ) : Transformer<AddAddressUM> {
override fun transform(prevState: AddAddressUM): AddAddressUM { override fun transform(prevState: AddAddressUM): AddAddressUM {
val hasMatchedAnyNetwork = address.isNotBlank() && val hasMatch = matchedBlockchains.isNotEmpty()
coins.any { it.network.toBlockchain().validateAddress(address) } val isError = address.isNotBlank() && !hasMatch
val isError = address.isNotBlank() && !hasMatchedAnyNetwork
val chosenNetworkState = if (hasMatch) {
ChosenNetworkStateUM.Result(
networkUMList = displayedBlockchains.map(ChosenNetworkConverter()::convert).toImmutableList(),
// A single matched network leaves nothing to choose, so the selection screen is not opened.
isClickable = matchedBlockchains.size > 1,
)
} else {
ChosenNetworkStateUM.Hidden
}
val label = if (isError) { val label = if (isError) {
resourceReference(R.string.address_book_invalid_address_error) resourceReference(R.string.address_book_invalid_address_error)
} else { } else {
@ -30,7 +53,32 @@ internal class UpdateAddressValidationTransformer(
} }
return prevState.copy( return prevState.copy(
addressField = prevState.addressField.copy(isError = isError, label = label), addressField = prevState.addressField.copy(isError = isError, label = label),
buttonUM = prevState.buttonUM.copy(isEnabled = hasMatchedAnyNetwork), chosenNetworkStateUM = chosenNetworkState,
memoField = resolveMemoField(prevState.memoField),
buttonUM = prevState.buttonUM.copy(isEnabled = selectedBlockchains.isNotEmpty() && !isMemoInvalid),
)
}
/**
* Shows the memo field with the right label when a chosen network supports transaction extras; hides it and clears
* the value otherwise (e.g. the supporting network was deselected or the address changed). A malformed memo
* ([isMemoInvalid]) turns the field label into an error.
*/
private fun resolveMemoField(prevMemoField: AddAddressUM.MemoFieldUM): AddAddressUM.MemoFieldUM {
val extrasType = selectedBlockchains
.map { it.getSupportedTransactionExtras() }
.firstOrNull { it.isTxExtrasSupported() }
?: return prevMemoField.copy(isVisible = false, value = "", isError = false)
val fieldLabelRes = when (extrasType) {
Network.TransactionExtrasType.DESTINATION_TAG -> R.string.send_destination_tag_field
else -> R.string.send_extras_hint_memo
}
val labelRes = if (isMemoInvalid) R.string.send_memo_destination_tag_error else fieldLabelRes
return prevMemoField.copy(
isVisible = true,
label = resourceReference(labelRes),
isError = isMemoInvalid,
) )
} }
} }

View file

@ -0,0 +1,15 @@
package com.tangem.features.addressbook.addaddress.state.transformers
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM
import com.tangem.utils.transformer.Transformer
internal class UpdateMemoInputTransformer(
private val value: String,
) : Transformer<AddAddressUM> {
override fun transform(prevState: AddAddressUM): AddAddressUM {
return prevState.copy(
memoField = prevState.memoField.copy(value = value),
)
}
}

View file

@ -0,0 +1,14 @@
package com.tangem.features.addressbook.addaddress.state.transformers.converter
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.ui.extensions.getActiveIconRes
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM.ChosenNetworkStateUM.Result.NetworkUM
import com.tangem.utils.converter.Converter
internal class ChosenNetworkConverter : Converter<Blockchain, NetworkUM> {
override fun convert(value: Blockchain): NetworkUM = NetworkUM(
networkName = value.fullName,
iconResId = getActiveIconRes(value),
)
}

View file

@ -1,11 +1,14 @@
package com.tangem.features.addressbook.addaddress.ui package com.tangem.features.addressbook.addaddress.ui
import android.content.res.Configuration import android.content.res.Configuration
import androidx.compose.animation.*
import androidx.compose.animation.core.snap
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -21,6 +24,7 @@ import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds2.button.TangemButton import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM
@ -70,17 +74,12 @@ internal fun AddAddressContent(state: AddAddressUM, modifier: Modifier = Modifie
onQrClick = state.onQrClick, onQrClick = state.onQrClick,
onPasteClick = state.onPasteClick, onPasteClick = state.onPasteClick,
) )
SpacerH(20.dp) MemoSection(memoField = state.memoField)
NetworkBlock( NetworkSelector(
modifier = Modifier
.padding(horizontal = 16.dp)
.clip(RoundedCornerShape(16.dp))
.fillMaxWidth()
.background(color = TangemTheme.colors3.bg.secondary),
chosenNetworkStateUM = state.chosenNetworkStateUM, chosenNetworkStateUM = state.chosenNetworkStateUM,
onNetworkSelectClick = state.onNetworkClick, onNetworkClick = state.onNetworkClick,
) )
PrimaryButton(state.buttonUM) AddButton(state.buttonUM)
} }
} }
} }
@ -88,7 +87,71 @@ internal fun AddAddressContent(state: AddAddressUM, modifier: Modifier = Modifie
} }
@Composable @Composable
private fun ColumnScope.PrimaryButton(buttonUM: TangemButtonUM) { private fun MemoSection(memoField: AddAddressUM.MemoFieldUM) {
AnimatedVisibility(
visible = memoField.isVisible,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
Column(modifier = Modifier.fillMaxWidth()) {
MemoRow(
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(top = 12.dp),
memoField = memoField,
)
SpacerH(10.dp)
Text(
modifier = Modifier.padding(horizontal = 32.dp),
text = stringResourceSafe(R.string.send_recipient_memo_footer_v2),
style = TangemTheme.typography3.caption.medium,
color = TangemTheme.colors3.text.secondary,
)
Text(
modifier = Modifier.padding(horizontal = 32.dp),
text = stringResourceSafe(R.string.send_recipient_memo_footer_v2_highlighted),
style = TangemTheme.typography3.caption.medium,
color = TangemTheme.colors3.text.primary,
)
}
}
}
@Composable
private fun NetworkSelector(chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM, onNetworkClick: () -> Unit) {
AnimatedContent(
targetState = chosenNetworkStateUM,
transitionSpec = {
ContentTransform(
targetContentEnter = fadeIn(),
initialContentExit = fadeOut(),
sizeTransform = SizeTransform(clip = false) { _, _ -> snap() },
)
},
contentKey = { it::class },
modifier = Modifier.animateContentSize(),
label = "network_selector",
) { networkState ->
when (networkState) {
AddAddressUM.ChosenNetworkStateUM.Hidden -> Box(modifier = Modifier.fillMaxWidth())
AddAddressUM.ChosenNetworkStateUM.Loading,
is AddAddressUM.ChosenNetworkStateUM.Result,
-> NetworkBlock(
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(top = 16.dp)
.clip(RoundedCornerShape(16.dp))
.fillMaxWidth()
.background(color = TangemTheme.colors3.bg.secondary),
chosenNetworkStateUM = networkState,
onNetworkSelectClick = onNetworkClick,
)
}
}
}
@Composable
private fun ColumnScope.AddButton(buttonUM: TangemButtonUM) {
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
TangemButton( TangemButton(
modifier = Modifier modifier = Modifier
@ -114,13 +177,21 @@ private fun Preview_AddAddressContent() {
placeholder = resourceReference(R.string.address_book_enter_address), placeholder = resourceReference(R.string.address_book_enter_address),
label = resourceReference(R.string.common_address), label = resourceReference(R.string.common_address),
), ),
memoField = AddAddressUM.MemoFieldUM(
isVisible = false,
value = "",
label = resourceReference(R.string.send_extras_hint_memo),
isError = false,
onValueChange = {},
onPasteClick = {},
),
buttonUM = TangemButtonUM( buttonUM = TangemButtonUM(
text = TextReference.Res(R.string.address_book_add_address), text = TextReference.Res(R.string.address_book_add_address),
type = TangemButtonType.Primary, type = TangemButtonType.Primary,
isEnabled = false, isEnabled = false,
onClick = { }, onClick = { },
), ),
chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Empty, chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Hidden,
onAddressChange = {}, onAddressChange = {},
onAddressClear = {}, onAddressClear = {},
onPasteClick = {}, onPasteClick = {},

View file

@ -0,0 +1,101 @@
package com.tangem.features.addressbook.addaddress.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.fields.SimpleTextField
import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.ds2.row.TangemRow
import com.tangem.core.ui.ds2.row.TangemRowContentLead
import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_cross_circle_20_filled
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM
@Composable
internal fun MemoRow(memoField: AddAddressUM.MemoFieldUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.clip(RoundedCornerShape(24.dp))
.fillMaxWidth()
.background(TangemTheme.colors3.bg.secondary),
) {
Text(
modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 4.dp),
text = memoField.label.resolveReference(),
style = TangemTheme.typography3.caption.medium,
color = if (memoField.isError) {
TangemTheme.colors3.text.status.error
} else {
TangemTheme.colors3.text.secondary
},
)
TangemRow(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = TangemRowVerticalAlignment.Center,
contentLead = TangemRowContentLead.Start,
titleSlot = {
SimpleTextField(
modifier = Modifier.weight(1f),
value = memoField.value,
onValueChange = memoField.onValueChange,
placeholder = resourceReference(R.string.send_optional_field),
)
},
endSlot = {
if (memoField.value.isNotEmpty()) {
Icon(
modifier = Modifier
.clip(CircleShape)
.clickable(onClick = { memoField.onValueChange("") }),
imageVector = Icons.ic_cross_circle_20_filled,
tint = TangemTheme.colors3.icon.tertiary,
contentDescription = null,
)
} else {
TangemButton(
size = TangemButton.Size.X9,
text = TextReference.Res(id = R.string.common_paste),
onClick = memoField.onPasteClick,
)
}
},
)
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_MemoRow() {
TangemThemePreviewRedesign {
MemoRow(
memoField = AddAddressUM.MemoFieldUM(
isVisible = true,
value = "123456",
label = resourceReference(R.string.send_destination_tag_field),
isError = false,
onValueChange = {},
onPasteClick = {},
),
)
}
}

View file

@ -20,12 +20,10 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEachIndexed import androidx.compose.ui.util.fastForEachIndexed
import com.tangem.core.ui.R import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerH12 import com.tangem.core.ui.components.SpacerH12
import com.tangem.core.ui.components.SpacerW
import com.tangem.core.ui.ds2.loader.TangemLoader import com.tangem.core.ui.ds2.loader.TangemLoader
import com.tangem.core.ui.ds2.loader.TangemLoaderSize import com.tangem.core.ui.ds2.loader.TangemLoaderSize
import com.tangem.core.ui.ds2.row.TangemRow import com.tangem.core.ui.ds2.row.TangemRow
import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment
import com.tangem.core.ui.extensions.clickableSingle
import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.res.TangemThemePreviewRedesign
@ -48,7 +46,10 @@ internal fun NetworkBlock(
chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM, chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val isClickable = chosenNetworkStateUM is AddAddressUM.ChosenNetworkStateUM.Result &&
chosenNetworkStateUM.isClickable
TangemRow( TangemRow(
onClick = if (isClickable) onNetworkSelectClick else null,
verticalAlignment = TangemRowVerticalAlignment.Center, verticalAlignment = TangemRowVerticalAlignment.Center,
modifier = modifier, modifier = modifier,
titleSlot = { titleSlot = {
@ -59,45 +60,27 @@ internal fun NetworkBlock(
) )
}, },
endSlot = { endSlot = {
SelectNetworkButton( when (chosenNetworkStateUM) {
onNetworkSelectClick = onNetworkSelectClick, AddAddressUM.ChosenNetworkStateUM.Loading -> TangemLoader(size = TangemLoaderSize.X20)
chosenNetworkStateUM = chosenNetworkStateUM, is AddAddressUM.ChosenNetworkStateUM.Result -> NetworkRow(chosenNetworkStateUM = chosenNetworkStateUM)
) AddAddressUM.ChosenNetworkStateUM.Hidden -> Unit
}
}, },
) )
} }
@Composable @Composable
private fun SelectNetworkButton( private fun NetworkRow(chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM.Result) {
onNetworkSelectClick: () -> Unit, Row(verticalAlignment = Alignment.CenterVertically) {
chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM, NetworkIconsResolver(
) { networks = chosenNetworkStateUM.networkUMList,
Row( showChevron = chosenNetworkStateUM.isClickable,
modifier = Modifier.clickableSingle( )
onClick = onNetworkSelectClick,
enabled = chosenNetworkStateUM !is AddAddressUM.ChosenNetworkStateUM.Loading,
),
verticalAlignment = Alignment.CenterVertically,
) {
when (chosenNetworkStateUM) {
is AddAddressUM.ChosenNetworkStateUM.Result -> NetworkIconsResolver(chosenNetworkStateUM.networkUMList)
AddAddressUM.ChosenNetworkStateUM.Loading -> TangemLoader(size = TangemLoaderSize.X20)
AddAddressUM.ChosenNetworkStateUM.Empty -> {
Text(
modifier = Modifier.padding(start = 8.dp),
text = stringResourceSafe(R.string.address_book_select_network),
style = TangemTheme.typography3.body.medium,
color = TangemTheme.colors3.text.secondary,
)
SpacerW(4.dp)
ChevronIcon()
}
}
} }
} }
@Composable @Composable
private fun NetworkIconsResolver(networks: ImmutableList<NetworkUM>) { private fun NetworkIconsResolver(networks: ImmutableList<NetworkUM>, showChevron: Boolean) {
when (networks.size) { when (networks.size) {
0 -> Unit 0 -> Unit
1 -> { 1 -> {
@ -112,13 +95,13 @@ private fun NetworkIconsResolver(networks: ImmutableList<NetworkUM>) {
style = TangemTheme.typography3.body.medium, style = TangemTheme.typography3.body.medium,
color = TangemTheme.colors3.text.secondary, color = TangemTheme.colors3.text.secondary,
) )
ChevronIcon() if (showChevron) ChevronIcon()
} }
// 3 and any larger count share the same rendering: up to MAX_VISIBLE_NETWORKS overlapping // 3 and any larger count share the same rendering: up to MAX_VISIBLE_NETWORKS overlapping
// icons, plus a "+N" badge that appears only when there are more than that. // icons, plus a "+N" badge that appears only when there are more than that.
else -> { else -> {
OverlappingNetworkIcons(networks) OverlappingNetworkIcons(networks)
ChevronIcon() if (showChevron) ChevronIcon()
} }
} }
} }
@ -191,6 +174,7 @@ private fun Preview_NetworkBlock() {
networkUMList = persistentListOf( networkUMList = persistentListOf(
NetworkUM(networkName = "Ethereum", iconResId = R.drawable.img_eth_22), NetworkUM(networkName = "Ethereum", iconResId = R.drawable.img_eth_22),
), ),
isClickable = false,
), ),
) )
SpacerH12() SpacerH12()
@ -202,6 +186,7 @@ private fun Preview_NetworkBlock() {
NetworkUM(networkName = "BSC", iconResId = R.drawable.img_bsc_22), NetworkUM(networkName = "BSC", iconResId = R.drawable.img_bsc_22),
NetworkUM(networkName = "Polygon", iconResId = R.drawable.img_polygon_22), NetworkUM(networkName = "Polygon", iconResId = R.drawable.img_polygon_22),
), ),
isClickable = true,
), ),
) )
SpacerH12() SpacerH12()
@ -211,12 +196,9 @@ private fun Preview_NetworkBlock() {
networkUMList = List(15) { networkUMList = List(15) {
NetworkUM(networkName = "Network", iconResId = R.drawable.img_eth_22) NetworkUM(networkName = "Network", iconResId = R.drawable.img_eth_22)
}.toImmutableList(), }.toImmutableList(),
isClickable = true,
), ),
) )
SpacerH12()
NetworkBlock(chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Loading, onNetworkSelectClick = {})
SpacerH12()
NetworkBlock(chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Empty, onNetworkSelectClick = {})
} }
} }
} }

View file

@ -3,11 +3,13 @@ package com.tangem.features.addressbook.addaddress.ui.state
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import com.tangem.core.ui.ds.button.TangemButtonUM import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@Immutable @Immutable
internal data class AddAddressUM( internal data class AddAddressUM(
val addressField: AddressFieldUM, val addressField: AddressFieldUM,
val memoField: MemoFieldUM,
val buttonUM: TangemButtonUM, val buttonUM: TangemButtonUM,
val chosenNetworkStateUM: ChosenNetworkStateUM, val chosenNetworkStateUM: ChosenNetworkStateUM,
val onAddressChange: (String) -> Unit, val onAddressChange: (String) -> Unit,
@ -17,12 +19,39 @@ internal data class AddAddressUM(
val onBackClick: () -> Unit, val onBackClick: () -> Unit,
val onNetworkClick: () -> Unit, val onNetworkClick: () -> Unit,
) { ) {
/**
* Optional memo / destination-tag input shown below the address only when a chosen network supports transaction
* extras. [isVisible] toggles the whole field; [label] adapts to memo vs destination tag.
*/
@Immutable
data class MemoFieldUM(
val isVisible: Boolean,
val value: String,
val label: TextReference,
val isError: Boolean,
val onValueChange: (String) -> Unit,
val onPasteClick: () -> Unit,
)
@Immutable @Immutable
sealed interface ChosenNetworkStateUM { sealed interface ChosenNetworkStateUM {
data object Loading : ChosenNetworkStateUM
data object Empty : ChosenNetworkStateUM
data class Result(val networkUMList: ImmutableList<NetworkUM>) : ChosenNetworkStateUM { /** No address entered yet, or the address matched nothing — the network selector is not shown. */
data object Hidden : ChosenNetworkStateUM
/** A non-blank address is being validated against the supported networks. */
data object Loading : ChosenNetworkStateUM
/**
* A valid address resolved to [networkUMList] (the currently selected networks). [isClickable] is `false` when
* the address matched only a single network there is nothing to choose, so the network-selection screen is
* not opened.
*/
data class Result(
val networkUMList: ImmutableList<NetworkUM>,
val isClickable: Boolean,
) : ChosenNetworkStateUM {
data class NetworkUM( data class NetworkUM(
val networkName: String, val networkName: String,
@DrawableRes val iconResId: Int, @DrawableRes val iconResId: Int,

View file

@ -9,6 +9,7 @@ import com.tangem.features.addressbook.editcontact.DefaultEditContactComponent
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
import com.tangem.features.addressbook.list.DefaultAddressBookListComponent import com.tangem.features.addressbook.list.DefaultAddressBookListComponent
import com.tangem.features.addressbook.route.AddressBookRoute import com.tangem.features.addressbook.route.AddressBookRoute
import com.tangem.features.addressbook.selectnetworks.DefaultSelectNetworksComponent
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import javax.inject.Inject import javax.inject.Inject
@ -47,9 +48,19 @@ internal class AddressBookChildFactory @Inject constructor(
appComponentContext = context, appComponentContext = context,
params = DefaultAddAddressComponent.Params( params = DefaultAddAddressComponent.Params(
onBackClick = clickIntents::onAddAddressBack, onBackClick = clickIntents::onAddAddressBack,
onSelectNetworksClick = clickIntents::onSelectNetworksClick,
onConfirm = clickIntents::onAddressConfirmed, onConfirm = clickIntents::onAddressConfirmed,
), ),
) )
is AddressBookRoute.SelectNetworks -> DefaultSelectNetworksComponent(
appComponentContext = context,
params = DefaultSelectNetworksComponent.Params(
address = route.address,
selectedNetworkIds = route.selectedNetworkIds,
onBackClick = clickIntents::onSelectNetworksBack,
onDone = clickIntents::onNetworksSelected,
),
)
} }
/** Builds the address attached up-front in WithContactCreation mode, when both the address and network are known. */ /** Builds the address attached up-front in WithContactCreation mode, when both the address and network are known. */

View file

@ -23,4 +23,10 @@ internal interface AddressBookClickIntents {
fun onAddAddressBack() fun onAddAddressBack()
fun onAddressConfirmed(address: ValidatedAddress) fun onAddressConfirmed(address: ValidatedAddress)
fun onSelectNetworksClick(address: String, selectedNetworkIds: List<String>)
fun onSelectNetworksBack()
fun onNetworksSelected(selectedNetworkIds: Set<String>)
} }

View file

@ -0,0 +1,28 @@
package com.tangem.features.addressbook.common
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.memo.MemoState
import com.tangem.blockchain.extensions.Result
import com.tangem.blockchainsdk.BlockchainSDKFactory
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.withContext
import javax.inject.Inject
internal class AddressMemoValidator @Inject constructor(
private val blockchainSDKFactory: BlockchainSDKFactory,
private val dispatchers: CoroutineDispatcherProvider,
) {
suspend fun isValid(blockchain: Blockchain, memo: String): Boolean = withContext(dispatchers.io) {
val factory = blockchainSDKFactory.getMemoValidatorFactorySync() ?: return@withContext true
when (val result = factory.create(blockchain).validateMemo(memo)) {
is Result.Success -> when (result.data) {
MemoState.Valid,
MemoState.NotSupported,
-> true
MemoState.Invalid -> false
}
is Result.Failure -> true
}
}
}

View file

@ -29,13 +29,15 @@ internal class DefaultAddressBookComponent @AssistedInject constructor(
@Assisted private val params: AddressBookComponent.Params, @Assisted private val params: AddressBookComponent.Params,
private val childFactory: AddressBookChildFactory, private val childFactory: AddressBookChildFactory,
private val resultHolder: AddressBookResultHolder, private val resultHolder: AddressBookResultHolder,
private val selectNetworksResultHolder: SelectNetworksResultHolder,
) : AddressBookComponent, AppComponentContext by context { ) : AddressBookComponent, AppComponentContext by context {
private val navigation = StackNavigation<AddressBookRoute>() private val navigation = StackNavigation<AddressBookRoute>()
init { init {
// Drop any address left over from a previous session before the (possibly preloaded) stack starts collecting. // Drop any results left over from a previous session before the (possibly preloaded) stack starts collecting.
resultHolder.clear() resultHolder.clear()
selectNetworksResultHolder.clear()
} }
private val clickIntents = object : AddressBookClickIntents { private val clickIntents = object : AddressBookClickIntents {
@ -64,6 +66,21 @@ internal class DefaultAddressBookComponent @AssistedInject constructor(
resultHolder.setConfirmedAddress(address) resultHolder.setConfirmedAddress(address)
navigation.pop() navigation.pop()
} }
override fun onSelectNetworksClick(address: String, selectedNetworkIds: List<String>) {
navigation.pushNew(
AddressBookRoute.SelectNetworks(address = address, selectedNetworkIds = selectedNetworkIds),
)
}
override fun onSelectNetworksBack() {
navigation.pop()
}
override fun onNetworksSelected(selectedNetworkIds: Set<String>) {
selectNetworksResultHolder.setSelectedNetworkIds(selectedNetworkIds)
navigation.pop()
}
} }
private val contentStack = childStack( private val contentStack = childStack(

View file

@ -0,0 +1,30 @@
package com.tangem.features.addressbook.common
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
import javax.inject.Singleton
/**
* Carries the set of network ids confirmed on the SelectNetworks screen back to the AddAddress screen.
*
* The two screens live in independent model scopes, so a shared singleton holder hands the result over instead of
* routing it through navigation. Only the "Done" action sets a result; the producer calls [setSelectedNetworkIds], the
* consumer observes [selectedNetworkIds] and calls [clear] after applying it so it is not re-applied on resubscription.
*
* Mirrors [AddressBookResultHolder].
*/
@Singleton
internal class SelectNetworksResultHolder @Inject constructor() {
val selectedNetworkIds: StateFlow<Set<String>?>
field = MutableStateFlow<Set<String>?>(null)
fun setSelectedNetworkIds(networkIds: Set<String>) {
selectedNetworkIds.value = networkIds
}
fun clear() {
selectedNetworkIds.value = null
}
}

View file

@ -0,0 +1,26 @@
package com.tangem.features.addressbook.common
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import javax.inject.Inject
/**
* Finds every supported mainnet network whose address format matches a given address.
*
* The match runs over the whole SDK blockchain set (minus testnets and excluded chains), not just the networks already
* added to the wallet entering/scanning an address must surface every network it could belong to.
*/
internal class SupportedNetworksMatcher @Inject constructor(
excludedBlockchains: ExcludedBlockchains,
) {
private val supportedBlockchains: List<Blockchain> = Blockchain.entries
.filter { !it.isTestnet() && it !in excludedBlockchains }
fun match(address: String): List<Blockchain> {
if (address.isBlank()) return emptyList()
return supportedBlockchains.filter { blockchain ->
runCatching { blockchain.validateAddress(address) }.getOrDefault(false)
}
}
}

View file

@ -6,6 +6,7 @@ import com.tangem.features.addressbook.addaddress.model.AddAddressModel
import com.tangem.features.addressbook.block.model.ContactsBlockModel import com.tangem.features.addressbook.block.model.ContactsBlockModel
import com.tangem.features.addressbook.list.model.AddressBookListModel import com.tangem.features.addressbook.list.model.AddressBookListModel
import com.tangem.features.addressbook.editcontact.model.EditContactModel import com.tangem.features.addressbook.editcontact.model.EditContactModel
import com.tangem.features.addressbook.selectnetworks.model.SelectNetworksModel
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -35,4 +36,9 @@ internal interface AddressBookModelModule {
@IntoMap @IntoMap
@ClassKey(AddAddressModel::class) @ClassKey(AddAddressModel::class)
fun bindAddAddressModel(model: AddAddressModel): Model fun bindAddAddressModel(model: AddAddressModel): Model
@Binds
@IntoMap
@ClassKey(SelectNetworksModel::class)
fun bindSelectNetworksModel(model: SelectNetworksModel): Model
} }

View file

@ -9,9 +9,12 @@ import kotlinx.collections.immutable.ImmutableList
* A single address can belong to several networks (e.g. the same address across EVM chains), so it carries a list of * A single address can belong to several networks (e.g. the same address across EVM chains), so it carries a list of
* [networkIds]. This is the in-progress (pre-save) representation accumulated in [EditContactUM]; the [networkIds] are * [networkIds]. This is the in-progress (pre-save) representation accumulated in [EditContactUM]; the [networkIds] are
* used to rebuild the domain `AddressEntry`s when the contact is persisted. * used to rebuild the domain `AddressEntry`s when the contact is persisted.
* [memo] is an optional destination tag / memo entered for networks that support transaction extras (XRP, Stellar, TON,
* ). It is `null` when the matched networks don't support extras or the user left it empty.
*/ */
@Immutable @Immutable
data class ValidatedAddress( data class ValidatedAddress(
val address: String, val address: String,
val networkIds: ImmutableList<String>, val networkIds: ImmutableList<String>,
val memo: String? = null,
) )

View file

@ -30,6 +30,16 @@ internal sealed class AddressBookRoute {
@Serializable @Serializable
data object AddAddress : AddressBookRoute() data object AddAddress : AddressBookRoute()
/**
* Network-selection screen for the [address] entered on [AddAddress]. [selectedNetworkIds] carries the current
* selection so it can be restored; empty means nothing is pre-selected.
*/
@Serializable
data class SelectNetworks(
val address: String,
val selectedNetworkIds: kotlin.collections.List<String> = emptyList(),
) : AddressBookRoute()
/** How the contacts list is shown — agnostic of which feature opened it. */ /** How the contacts list is shown — agnostic of which feature opened it. */
@Serializable @Serializable
sealed interface ListMode { sealed interface ListMode {

View file

@ -0,0 +1,37 @@
package com.tangem.features.addressbook.selectnetworks
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.addressbook.selectnetworks.model.SelectNetworksModel
import com.tangem.features.addressbook.selectnetworks.ui.SelectNetworksContent
internal class DefaultSelectNetworksComponent(
appComponentContext: AppComponentContext,
params: Params,
) : ComposableContentComponent, AppComponentContext by appComponentContext {
private val model: SelectNetworksModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle()
SelectNetworksContent(
state = state,
modifier = modifier,
)
BackHandler(onBack = state.onBackClick)
}
data class Params(
val address: String,
val selectedNetworkIds: List<String>,
val onBackClick: () -> Unit,
val onDone: (selectedNetworkIds: Set<String>) -> Unit,
)
}

View file

@ -0,0 +1,97 @@
package com.tangem.features.addressbook.selectnetworks.model
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.features.addressbook.common.SupportedNetworksMatcher
import com.tangem.features.addressbook.selectnetworks.DefaultSelectNetworksComponent
import com.tangem.features.addressbook.selectnetworks.state.SelectNetworksStateController
import com.tangem.features.addressbook.selectnetworks.state.transformers.UpdateNetworksContentTransformer
import com.tangem.features.addressbook.selectnetworks.state.transformers.UpdateSelectNetworksInitialStateTransformer
import com.tangem.features.addressbook.selectnetworks.state.transformers.UpdateSelectNetworksSearchBarTransformer
import com.tangem.features.addressbook.selectnetworks.ui.state.SelectNetworksUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.*
import javax.inject.Inject
@Suppress("NamedArguments")
@ModelScoped
internal class SelectNetworksModel @Inject constructor(
paramsContainer: ParamsContainer,
supportedNetworksMatcher: SupportedNetworksMatcher,
override val dispatchers: CoroutineDispatcherProvider,
private val stateController: SelectNetworksStateController,
) : Model() {
private val params: DefaultSelectNetworksComponent.Params = paramsContainer.require()
private val query = MutableStateFlow("")
private val isSearchActive = MutableStateFlow(false)
private val matchedBlockchains: List<Blockchain> = supportedNetworksMatcher.match(params.address)
private val selectedNetworks = MutableStateFlow(
params.selectedNetworkIds.toSet().intersect(
matchedBlockchains.map { blockchain -> blockchain.toNetworkId() }.toSet(),
),
)
val state: StateFlow<SelectNetworksUM> get() = stateController.uiState
init {
updateInitialState()
subscribeToContent()
}
private fun updateInitialState() {
stateController.update(
UpdateSelectNetworksInitialStateTransformer(
onQueryChange = ::onQueryChange,
onActiveChange = ::onActiveChange,
onBackClick = params.onBackClick,
onDoneClick = ::onDoneClick,
),
)
}
private fun subscribeToContent() {
combine(query, selectedNetworks) { query, selection ->
UpdateNetworksContentTransformer(
matchedBlockchains = matchedBlockchains,
query = query,
selectedNetworkIds = selection,
onToggle = ::onToggle,
)
}
.onEach(stateController::update)
.flowOn(dispatchers.default)
.launchIn(modelScope)
}
private fun onQueryChange(value: String) {
query.value = value
updateSearchBar(query = value, isActive = isSearchActive.value)
}
private fun onActiveChange(isActive: Boolean) {
isSearchActive.value = isActive
updateSearchBar(query = query.value, isActive = isActive)
}
/** Reflects the search field immediately on the caller (main) thread, decoupled from the content recomputation. */
private fun updateSearchBar(query: String, isActive: Boolean) {
stateController.update(UpdateSelectNetworksSearchBarTransformer(query = query, isActive = isActive))
}
private fun onToggle(networkId: String) {
val current = selectedNetworks.value
selectedNetworks.value = if (networkId in current) current - networkId else current + networkId
}
private fun onDoneClick() {
val selected = selectedNetworks.value
if (selected.isEmpty()) return
params.onDone(selected)
}
}

View file

@ -0,0 +1,47 @@
package com.tangem.features.addressbook.selectnetworks.state
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.ui.R
import com.tangem.core.ui.ds.button.TangemButtonType
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.ds2.search.TangemSearch
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.addressbook.selectnetworks.ui.state.SelectNetworksUM
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
@ModelScoped
internal class SelectNetworksStateController @Inject constructor() {
val uiState: StateFlow<SelectNetworksUM>
field = MutableStateFlow(value = getInitialState())
fun update(transformer: Transformer<SelectNetworksUM>) {
uiState.update(function = transformer::transform)
}
private fun getInitialState(): SelectNetworksUM = SelectNetworksUM(
searchBar = TangemSearch.State(
placeholderText = resourceReference(R.string.common_search),
query = "",
onQueryChange = {},
isActive = false,
onActiveChange = {},
onClearClick = {},
onCloseClick = {},
),
networks = persistentListOf(),
doneButton = TangemButtonUM(
text = TextReference.Res(R.string.common_done),
type = TangemButtonType.Primary,
isEnabled = false,
onClick = {},
),
onBackClick = {},
)
}

View file

@ -0,0 +1,45 @@
package com.tangem.features.addressbook.selectnetworks.state.transformers
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.features.addressbook.selectnetworks.state.transformers.converter.SelectNetworkItemConverter
import com.tangem.features.addressbook.selectnetworks.ui.state.SelectNetworksUM
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.toImmutableList
internal class UpdateNetworksContentTransformer(
private val matchedBlockchains: List<Blockchain>,
private val query: String,
private val selectedNetworkIds: Set<String>,
private val onToggle: (networkId: String) -> Unit,
) : Transformer<SelectNetworksUM> {
override fun transform(prevState: SelectNetworksUM): SelectNetworksUM {
val visible = if (query.isBlank()) {
matchedBlockchains
} else {
matchedBlockchains.filter { blockchain ->
blockchain.fullName.contains(query, ignoreCase = true) ||
blockchain.currency.contains(query, ignoreCase = true) ||
blockchain.name.contains(query, ignoreCase = true)
}
}
val networks = visible
.map { blockchain ->
SelectNetworkItemConverter().convert(
SelectNetworkItemConverter.Input(
blockchain = blockchain,
isSelected = blockchain.toNetworkId() in selectedNetworkIds,
onToggle = onToggle,
),
)
}
.toImmutableList()
// Search field is owned by UpdateSelectNetworksSearchBarTransformer and intentionally left untouched here.
return prevState.copy(
networks = networks,
doneButton = prevState.doneButton.copy(isEnabled = selectedNetworkIds.isNotEmpty()),
)
}
}

View file

@ -0,0 +1,25 @@
package com.tangem.features.addressbook.selectnetworks.state.transformers
import com.tangem.features.addressbook.selectnetworks.ui.state.SelectNetworksUM
import com.tangem.utils.transformer.Transformer
internal class UpdateSelectNetworksInitialStateTransformer(
private val onQueryChange: (String) -> Unit,
private val onActiveChange: (Boolean) -> Unit,
private val onBackClick: () -> Unit,
private val onDoneClick: () -> Unit,
) : Transformer<SelectNetworksUM> {
override fun transform(prevState: SelectNetworksUM): SelectNetworksUM {
return prevState.copy(
searchBar = prevState.searchBar.copy(
onQueryChange = onQueryChange,
onActiveChange = onActiveChange,
onCloseClick = { onActiveChange(false) },
onClearClick = { onQueryChange("") },
),
doneButton = prevState.doneButton.copy(onClick = onDoneClick),
onBackClick = onBackClick,
)
}
}

View file

@ -0,0 +1,16 @@
package com.tangem.features.addressbook.selectnetworks.state.transformers
import com.tangem.features.addressbook.selectnetworks.ui.state.SelectNetworksUM
import com.tangem.utils.transformer.Transformer
internal class UpdateSelectNetworksSearchBarTransformer(
private val query: String,
private val isActive: Boolean,
) : Transformer<SelectNetworksUM> {
override fun transform(prevState: SelectNetworksUM): SelectNetworksUM {
return prevState.copy(
searchBar = prevState.searchBar.copy(query = query, isActive = isActive),
)
}
}

View file

@ -0,0 +1,28 @@
package com.tangem.features.addressbook.selectnetworks.state.transformers.converter
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.common.ui.extensions.getActiveIconRes
import com.tangem.features.addressbook.selectnetworks.ui.state.SelectNetworksUM.NetworkItemUM
import com.tangem.utils.converter.Converter
internal class SelectNetworkItemConverter : Converter<SelectNetworkItemConverter.Input, NetworkItemUM> {
data class Input(
val blockchain: Blockchain,
val isSelected: Boolean,
val onToggle: (networkId: String) -> Unit,
)
override fun convert(value: Input): NetworkItemUM {
val id = value.blockchain.toNetworkId()
return NetworkItemUM(
id = id,
name = value.blockchain.fullName,
symbol = value.blockchain.currency,
iconResId = getActiveIconRes(value.blockchain),
isSelected = value.isSelected,
onCheckedChange = { value.onToggle(id) },
)
}
}

View file

@ -0,0 +1,195 @@
package com.tangem.features.addressbook.selectnetworks.ui
import android.content.res.Configuration
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerWMax
import com.tangem.core.ui.ds.button.TangemButtonType
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.ds2.checkbox.TangemCheckmark
import com.tangem.core.ui.ds2.search.TangemSearch
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.clickableSingle
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.addressbook.selectnetworks.ui.state.SelectNetworksUM
import com.tangem.features.addressbook.selectnetworks.ui.state.SelectNetworksUM.NetworkItemUM
import kotlinx.collections.immutable.persistentListOf
@Composable
internal fun SelectNetworksContent(state: SelectNetworksUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxSize()
.background(color = TangemTheme.colors3.bg.primary)
.systemBarsPadding(),
) {
TangemTopBar(
title = resourceReference(R.string.common_choose_network),
startContent = {
TangemButton(
iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_back_24),
onClick = state.onBackClick,
size = TangemButton.Size.X11,
variant = TangemButton.Variant.Material,
)
},
)
TangemSearch(
state = state.searchBar,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
)
val doneButtonVerticalPadding = 12.dp
val doneButtonAreaHeight = 48.dp + doneButtonVerticalPadding * 2
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
) {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = doneButtonAreaHeight)
.background(
color = TangemTheme.colors3.bg.secondary,
shape = RoundedCornerShape(24.dp),
),
contentPadding = PaddingValues(horizontal = 16.dp),
) {
item {
Text(
modifier = Modifier.padding(top = 16.dp, bottom = 4.dp),
text = stringResourceSafe(R.string.common_available_networks),
style = TangemTheme.typography3.caption.medium,
color = TangemTheme.colors3.text.secondary,
)
}
items(items = state.networks, key = NetworkItemUM::id) { item ->
NetworkRow(item = item)
}
}
TangemButton(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = doneButtonVerticalPadding)
.imePadding(),
onClick = state.doneButton.onClick,
isEnabled = state.doneButton.isEnabled,
size = TangemButton.Size.X12,
text = state.doneButton.text,
)
}
}
}
@Composable
private fun NetworkRow(item: NetworkItemUM) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickableSingle(onClick = item.onCheckedChange)
.padding(vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Image(
painter = painterResource(id = item.iconResId),
contentDescription = null,
modifier = Modifier
.size(36.dp)
.clip(CircleShape),
)
Text(
modifier = Modifier.padding(start = 12.dp),
text = item.name,
style = TangemTheme.typography3.body.medium,
color = TangemTheme.colors3.text.primary,
)
Text(
modifier = Modifier.padding(start = 4.dp),
text = item.symbol,
style = TangemTheme.typography3.body.medium,
color = TangemTheme.colors3.text.secondary,
)
SpacerWMax()
TangemCheckmark(
checked = item.isSelected,
onCheckedChange = { item.onCheckedChange() },
)
}
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_SelectNetworksContent() {
TangemThemePreviewRedesign {
SelectNetworksContent(
state = SelectNetworksUM(
searchBar = TangemSearch.State(
placeholderText = resourceReference(R.string.common_search),
query = "",
onQueryChange = {},
isActive = false,
onActiveChange = {},
onCloseClick = {},
),
networks = persistentListOf(
NetworkItemUM(
id = "ethereum",
name = "Ethereum",
symbol = "ETH",
iconResId = R.drawable.img_eth_22,
isSelected = true,
onCheckedChange = {},
),
NetworkItemUM(
id = "bsc",
name = "BNB Smart Chain",
iconResId = R.drawable.img_bsc_22,
isSelected = false,
symbol = "BNB",
onCheckedChange = {},
),
NetworkItemUM(
id = "polygon",
name = "Polygon",
iconResId = R.drawable.img_polygon_22,
isSelected = true,
symbol = "POL",
onCheckedChange = {},
),
),
doneButton = TangemButtonUM(
text = TextReference.Res(R.string.common_done),
type = TangemButtonType.Primary,
isEnabled = true,
onClick = {},
),
onBackClick = {},
),
)
}
}

View file

@ -0,0 +1,26 @@
package com.tangem.features.addressbook.selectnetworks.ui.state
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.ds2.search.TangemSearch
import kotlinx.collections.immutable.ImmutableList
@Immutable
internal data class SelectNetworksUM(
val searchBar: TangemSearch.State,
val networks: ImmutableList<NetworkItemUM>,
val doneButton: TangemButtonUM,
val onBackClick: () -> Unit,
) {
@Immutable
data class NetworkItemUM(
val id: String,
val name: String,
val symbol: String,
@DrawableRes val iconResId: Int,
val isSelected: Boolean,
val onCheckedChange: () -> Unit,
)
}

View file

@ -1,27 +1,33 @@
package com.tangem.features.addressbook.addaddress.model package com.tangem.features.addressbook.addaddress.model
import arrow.core.right
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.Blockchain
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.common.routing.AppRoute
import com.tangem.core.decompose.model.MutableParamsContainer import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.ui.R import com.tangem.core.ui.R
import com.tangem.core.ui.clipboard.ClipboardManager import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.account.models.AccountList import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.domain.account.supplier.MultiAccountListSupplier import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.addressbook.addaddress.DefaultAddAddressComponent import com.tangem.features.addressbook.addaddress.DefaultAddAddressComponent
import com.tangem.features.addressbook.addaddress.state.AddAddressStateController import com.tangem.features.addressbook.addaddress.state.AddAddressStateController
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM.ChosenNetworkStateUM
import com.tangem.features.addressbook.common.AddressMemoValidator
import com.tangem.features.addressbook.common.SelectNetworksResultHolder
import com.tangem.features.addressbook.common.SupportedNetworksMatcher
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
import com.tangem.test.mock.MockAccounts
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
@ -33,25 +39,30 @@ import org.junit.jupiter.api.*
@TestInstance(TestInstance.Lifecycle.PER_CLASS) @TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class AddAddressModelTest { internal class AddAddressModelTest {
private val multiAccountListSupplier: MultiAccountListSupplier = mockk() private val supportedNetworksMatcher: SupportedNetworksMatcher = mockk()
private val memoValidator: AddressMemoValidator = mockk()
private val clipboardManager: ClipboardManager = mockk() private val clipboardManager: ClipboardManager = mockk()
private val listenToQrScanningUseCase: ListenToQrScanningUseCase = mockk()
private val cryptoCurrencyFactory = MockCryptoCurrencyFactory() private val router: Router = mockk(relaxed = true)
private val ethereum = cryptoCurrencyFactory.createCoin(Blockchain.Ethereum) private val selectNetworksResultHolder = SelectNetworksResultHolder()
private val bitcoin = cryptoCurrencyFactory.createCoin(Blockchain.Bitcoin)
private var model: AddAddressModel? = null private var model: AddAddressModel? = null
@BeforeEach @BeforeEach
fun resetMocks() { fun resetMocks() {
clearMocks(multiAccountListSupplier, clipboardManager) clearMocks(supportedNetworksMatcher, memoValidator, clipboardManager, listenToQrScanningUseCase, router)
// Default: no accounts, so no coins are available unless a test overrides it. selectNetworksResultHolder.clear()
every { multiAccountListSupplier.invoke() } returns flowOf(emptyList()) // Default: an address matches nothing unless a test stubs a specific value.
every { supportedNetworksMatcher.match(any()) } returns emptyList()
// Default: any memo passes unless a test stubs an invalid one.
coEvery { memoValidator.isValid(any(), any()) } returns true
// Default: no QR results unless a test overrides it.
every { listenToQrScanningUseCase(SourceType.ADDRESS_BOOK) } returns flowOf<String>().right()
} }
@AfterEach @AfterEach
fun tearDown() { fun tearDown() {
// Cancels modelScope, stopping the long-lived availableCoins / address-input collectors. // Cancels modelScope, stopping the long-lived validation / address-input collectors.
model?.onDestroy() model?.onDestroy()
model = null model = null
} }
@ -98,13 +109,14 @@ internal class AddAddressModelTest {
assertThat(model.state.value.addressField.value).isEqualTo(address) assertThat(model.state.value.addressField.value).isEqualTo(address)
} }
// validateAndConfirm() is an unimplemented seam — the button click must NOT emit a result yet. // No network matches, so the button is disabled; clicking it must not emit a result.
@Test @Test
fun `GIVEN typed address WHEN button clicked THEN onConfirm not called yet`() = runTest { fun `GIVEN no matching network WHEN button clicked THEN onConfirm not called`() = runTest {
// Arrange // Arrange
var confirmed: ValidatedAddress? = null var confirmed: ValidatedAddress? = null
val model = createModel(testScope = this, onConfirm = { confirmed = it }) val model = createModel(testScope = this, onConfirm = { confirmed = it })
model.state.value.onAddressChange("0xABC") model.state.value.onAddressChange("0xABC")
advanceUntilIdle()
// Act // Act
model.state.value.buttonUM.onClick() model.state.value.buttonUM.onClick()
@ -119,14 +131,14 @@ internal class AddAddressModelTest {
inner class Validation { inner class Validation {
@Test @Test
fun `GIVEN coins available WHEN valid address typed THEN no error AND button enabled`() = runTest { fun `GIVEN single matching network WHEN typed THEN no error AND button enabled`() = runTest {
// Arrange // Arrange — a single matched network is auto-selected, so the button is enabled right away.
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(accountListWith(ethereum, bitcoin))) every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.Ethereum)
val model = createModel(testScope = this) val model = createModel(testScope = this)
advanceUntilIdle() advanceUntilIdle()
// Act // Act
model.state.value.onAddressChange(VALID_ETH_ADDRESS) model.state.value.onAddressChange(ADDRESS)
advanceUntilIdle() advanceUntilIdle()
// Assert // Assert
@ -136,14 +148,31 @@ internal class AddAddressModelTest {
} }
@Test @Test
fun `GIVEN coins available WHEN address matches no network THEN error AND button disabled`() = runTest { fun `GIVEN several matching networks WHEN typed THEN no error but button disabled until selection`() = runTest {
// Arrange // Arrange — several matches are shown for context, but none is selected until the user picks explicitly.
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(accountListWith(ethereum, bitcoin))) every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.Ethereum, Blockchain.BSC)
val model = createModel(testScope = this) val model = createModel(testScope = this)
advanceUntilIdle() advanceUntilIdle()
// Act // Act
model.state.value.onAddressChange("not-an-address") model.state.value.onAddressChange(ADDRESS)
advanceUntilIdle()
// Assert
val state = model.state.value
assertThat(state.addressField.isError).isFalse()
assertThat(state.buttonUM.isEnabled).isFalse()
}
@Test
fun `GIVEN address matching no network WHEN typed THEN error AND button disabled`() = runTest {
// Arrange
every { supportedNetworksMatcher.match(ADDRESS) } returns emptyList()
val model = createModel(testScope = this)
advanceUntilIdle()
// Act
model.state.value.onAddressChange(ADDRESS)
advanceUntilIdle() advanceUntilIdle()
// Assert // Assert
@ -157,7 +186,6 @@ internal class AddAddressModelTest {
@Test @Test
fun `GIVEN empty address WHEN validated THEN no error AND button disabled`() = runTest { fun `GIVEN empty address WHEN validated THEN no error AND button disabled`() = runTest {
// Arrange // Arrange
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(accountListWith(ethereum)))
val model = createModel(testScope = this) val model = createModel(testScope = this)
advanceUntilIdle() advanceUntilIdle()
@ -170,54 +198,362 @@ internal class AddAddressModelTest {
assertThat(state.addressField.isError).isFalse() assertThat(state.addressField.isError).isFalse()
assertThat(state.buttonUM.isEnabled).isFalse() assertThat(state.buttonUM.isEnabled).isFalse()
} }
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class NetworkSelector {
// The address is typed before coins load; validity must resolve reactively once the supplier emits them.
@Test @Test
fun `GIVEN address typed before coins load WHEN coins emitted THEN validated reactively`() = runTest { fun `GIVEN blank address WHEN validated THEN selector hidden`() = runTest {
// Arrange // Act
val accountsFlow = MutableStateFlow<List<AccountList>>(emptyList())
every { multiAccountListSupplier.invoke() } returns accountsFlow
val model = createModel(testScope = this) val model = createModel(testScope = this)
advanceUntilIdle() advanceUntilIdle()
// Act — type while coins are still empty // Assert
model.state.value.onAddressChange(VALID_ETH_ADDRESS) assertThat(model.state.value.chosenNetworkStateUM).isEqualTo(ChosenNetworkStateUM.Hidden)
advanceUntilIdle() }
// Assert intermediate: nothing to match yet
assertThat(model.state.value.buttonUM.isEnabled).isFalse()
// Act — coins arrive later @Test
accountsFlow.value = listOf(accountListWith(ethereum, bitcoin)) fun `GIVEN invalid address WHEN validated THEN selector hidden`() = runTest {
// Arrange
every { supportedNetworksMatcher.match(ADDRESS) } returns emptyList()
val model = createModel(testScope = this)
advanceUntilIdle()
// Act
model.state.value.onAddressChange(ADDRESS)
advanceUntilIdle() advanceUntilIdle()
// Assert // Assert
val state = model.state.value assertThat(model.state.value.chosenNetworkStateUM).isEqualTo(ChosenNetworkStateUM.Hidden)
assertThat(state.buttonUM.isEnabled).isTrue() }
assertThat(state.addressField.isError).isFalse()
@Test
fun `GIVEN address matching several networks WHEN validated THEN all shown AND clickable`() = runTest {
// Arrange
every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.Ethereum, Blockchain.BSC)
val model = createModel(testScope = this)
advanceUntilIdle()
// Act
model.state.value.onAddressChange(ADDRESS)
advanceUntilIdle()
// Assert — all matched networks are shown by default; the block opens the selection screen to narrow them.
val result = model.state.value.chosenNetworkStateUM as ChosenNetworkStateUM.Result
assertThat(result.networkUMList.map { it.networkName })
.containsExactly(Blockchain.Ethereum.fullName, Blockchain.BSC.fullName)
assertThat(result.isClickable).isTrue()
}
@Test
fun `GIVEN address matching a single network WHEN validated THEN selector is not clickable`() = runTest {
// Arrange
every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.Bitcoin)
val model = createModel(testScope = this)
advanceUntilIdle()
// Act
model.state.value.onAddressChange(ADDRESS)
advanceUntilIdle()
// Assert
val result = model.state.value.chosenNetworkStateUM as ChosenNetworkStateUM.Result
assertThat(result.networkUMList.map { it.networkName }).containsExactly(Blockchain.Bitcoin.fullName)
assertThat(result.isClickable).isFalse()
}
@Test
fun `GIVEN valid address WHEN onNetworkClick THEN opens selector with address and default selection`() =
runTest {
// Arrange
var openedAddress: String? = null
var openedSelection: List<String> = listOf("sentinel")
every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.Ethereum, Blockchain.BSC)
val model = createModel(
testScope = this,
onSelectNetworksClick = { address, selection ->
openedAddress = address
openedSelection = selection
},
)
advanceUntilIdle()
model.state.value.onAddressChange(ADDRESS)
advanceUntilIdle()
// Act
model.state.value.onNetworkClick()
// Assert — empty selection means "nothing selected yet" on the selection screen.
assertThat(openedAddress).isEqualTo(ADDRESS)
assertThat(openedSelection).isEmpty()
}
@Test
fun `GIVEN networks chosen via holder WHEN applied THEN selector reflects subset AND confirm uses it`() =
runTest {
// Arrange
var confirmed: ValidatedAddress? = null
every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.Ethereum, Blockchain.BSC)
val model = createModel(testScope = this, onConfirm = { confirmed = it })
advanceUntilIdle()
model.state.value.onAddressChange(ADDRESS)
advanceUntilIdle()
// Act — the user keeps only Ethereum on the network-selection screen.
selectNetworksResultHolder.setSelectedNetworkIds(setOf(Blockchain.Ethereum.toNetworkId()))
advanceUntilIdle()
// Assert — selector shows the subset and the result is consumed.
val chosen = model.state.value.chosenNetworkStateUM as ChosenNetworkStateUM.Result
assertThat(chosen.networkUMList.map { it.networkName }).containsExactly(Blockchain.Ethereum.fullName)
assertThat(selectNetworksResultHolder.selectedNetworkIds.value).isNull()
// And confirm persists only the kept network.
model.state.value.buttonUM.onClick()
assertThat(confirmed).isEqualTo(
ValidatedAddress(
address = ADDRESS,
networkIds = persistentListOf(Blockchain.Ethereum.toNetworkId()),
),
)
}
@Test
fun `GIVEN non-blank address WHEN typed THEN loading shown AND button blocked until validated`() = runTest {
// Arrange
every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.Ethereum, Blockchain.BSC)
val model = createModel(testScope = this)
advanceUntilIdle()
// Act — typed, but validation is still debounced.
model.state.value.onAddressChange(ADDRESS)
// Assert
assertThat(model.state.value.chosenNetworkStateUM).isEqualTo(ChosenNetworkStateUM.Loading)
assertThat(model.state.value.buttonUM.isEnabled).isFalse()
}
@Test
fun `GIVEN resolved networks WHEN address edited THEN keeps result without flashing loading`() = runTest {
// Arrange
every { supportedNetworksMatcher.match(any()) } returns listOf(Blockchain.Ethereum, Blockchain.BSC)
val model = createModel(testScope = this)
model.state.value.onAddressChange(ADDRESS)
advanceUntilIdle()
assertThat(model.state.value.chosenNetworkStateUM).isInstanceOf(ChosenNetworkStateUM.Result::class.java)
// Act — keep typing; validation is pending again.
model.state.value.onAddressChange(ADDRESS + "00")
// Assert — the resolved networks stay on screen (no spinner), but the button is blocked while validating.
assertThat(model.state.value.chosenNetworkStateUM).isInstanceOf(ChosenNetworkStateUM.Result::class.java)
assertThat(model.state.value.buttonUM.isEnabled).isFalse()
}
@Test
fun `GIVEN single matched network WHEN confirmed THEN it is persisted`() = runTest {
// Arrange — a single match is auto-selected, so confirm works without opening the selection screen.
var confirmed: ValidatedAddress? = null
every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.Ethereum)
val model = createModel(testScope = this, onConfirm = { confirmed = it })
advanceUntilIdle()
model.state.value.onAddressChange(ADDRESS)
advanceUntilIdle()
// Act
model.state.value.buttonUM.onClick()
// Assert
assertThat(confirmed).isEqualTo(
ValidatedAddress(
address = ADDRESS,
networkIds = persistentListOf(Blockchain.Ethereum.toNetworkId()),
),
)
}
@Test
fun `GIVEN several matched networks AND none selected WHEN confirmed THEN nothing persisted`() = runTest {
// Arrange
var confirmed: ValidatedAddress? = null
every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.Ethereum, Blockchain.BSC)
val model = createModel(testScope = this, onConfirm = { confirmed = it })
advanceUntilIdle()
model.state.value.onAddressChange(ADDRESS)
advanceUntilIdle()
// Act — networks are shown but not selected, so confirming is a no-op.
model.state.value.buttonUM.onClick()
// Assert
assertThat(confirmed).isNull()
} }
} }
private fun accountListWith(vararg currencies: CryptoCurrency): AccountList { @Nested
val walletId = MockAccounts.userWalletId @TestInstance(TestInstance.Lifecycle.PER_CLASS)
val accounts = listOf( inner class Memo {
Account.CryptoPortfolio.createMainAccount(
userWalletId = walletId, @Test
cryptoCurrencies = currencies.toList(), fun `GIVEN address matching an extras network WHEN validated THEN memo field shown`() = runTest {
), // Arrange — XRP supports a destination tag.
) every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.XRP)
return AccountList( val model = createModel(testScope = this)
userWalletId = walletId, advanceUntilIdle()
accounts = accounts,
totalAccounts = accounts.size, // Act
totalArchivedAccounts = 0, model.state.value.onAddressChange(ADDRESS)
).getOrNull()!! advanceUntilIdle()
// Assert
val memoField = model.state.value.memoField
assertThat(memoField.isVisible).isTrue()
assertThat(memoField.label).isEqualTo(resourceReference(R.string.send_destination_tag_field))
}
@Test
fun `GIVEN non-extras networks WHEN validated THEN memo field hidden`() = runTest {
// Arrange
every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.Ethereum, Blockchain.BSC)
val model = createModel(testScope = this)
advanceUntilIdle()
// Act
model.state.value.onAddressChange(ADDRESS)
advanceUntilIdle()
// Assert
assertThat(model.state.value.memoField.isVisible).isFalse()
}
@Test
fun `GIVEN extras network and memo entered WHEN confirmed THEN memo included`() = runTest {
// Arrange
var confirmed: ValidatedAddress? = null
every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.XRP)
val model = createModel(testScope = this, onConfirm = { confirmed = it })
advanceUntilIdle()
model.state.value.onAddressChange(ADDRESS)
advanceUntilIdle()
// Act
model.state.value.memoField.onValueChange("123456")
model.state.value.buttonUM.onClick()
// Assert
assertThat(confirmed).isEqualTo(
ValidatedAddress(
address = ADDRESS,
networkIds = persistentListOf(Blockchain.XRP.toNetworkId()),
memo = "123456",
),
)
}
@Test
fun `GIVEN invalid memo WHEN entered THEN memo error shown AND button blocked`() = runTest {
// Arrange
every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.XRP)
coEvery { memoValidator.isValid(Blockchain.XRP, "bad-tag") } returns false
val model = createModel(testScope = this)
advanceUntilIdle()
model.state.value.onAddressChange(ADDRESS)
advanceUntilIdle()
// Act — type a malformed destination tag.
model.state.value.memoField.onValueChange("bad-tag")
advanceUntilIdle()
// Assert
assertThat(model.state.value.memoField.isError).isTrue()
assertThat(model.state.value.buttonUM.isEnabled).isFalse()
}
@Test
fun `GIVEN non-extras network WHEN confirmed THEN memo is null`() = runTest {
// Arrange
var confirmed: ValidatedAddress? = null
every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.Ethereum)
val model = createModel(testScope = this, onConfirm = { confirmed = it })
advanceUntilIdle()
model.state.value.onAddressChange(ADDRESS)
advanceUntilIdle()
// Act
model.state.value.buttonUM.onClick()
// Assert
assertThat(confirmed?.memo).isNull()
}
@Test
fun `WHEN memo paste clicked THEN clipboard goes into memo and not address`() = runTest {
// Arrange
every { clipboardManager.getText() } returns "TAG-123"
val model = createModel(testScope = this)
// Act
model.state.value.memoField.onPasteClick()
// Assert
assertThat(model.state.value.memoField.value).isEqualTo("TAG-123")
assertThat(model.state.value.addressField.value).isEmpty()
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class QrScan {
@Test
fun `WHEN onQrClick THEN navigates to address-book QR scanning`() = runTest {
// Arrange
val model = createModel(testScope = this)
// Act
model.state.value.onQrClick()
// Assert
verify { router.push(AppRoute.QrScanning(source = AppRoute.QrScanning.Source.AddressBook)) }
}
@Test
fun `GIVEN scanned address WHEN emitted THEN address field updated`() = runTest {
// Arrange
every { listenToQrScanningUseCase(SourceType.ADDRESS_BOOK) } returns flowOf(ADDRESS).right()
val model = createModel(testScope = this)
// Act
advanceUntilIdle()
// Assert
assertThat(model.state.value.addressField.value).isEqualTo(ADDRESS)
}
@Test
fun `GIVEN scanned payment URI WHEN emitted THEN scheme and query stripped`() = runTest {
// Arrange
every { listenToQrScanningUseCase(SourceType.ADDRESS_BOOK) } returns
flowOf("ethereum:$ADDRESS?amount=1.5").right()
val model = createModel(testScope = this)
// Act
advanceUntilIdle()
// Assert
assertThat(model.state.value.addressField.value).isEqualTo(ADDRESS)
}
} }
private fun createModel( private fun createModel(
testScope: TestScope, testScope: TestScope,
onConfirm: (ValidatedAddress) -> Unit = {}, onConfirm: (ValidatedAddress) -> Unit = {},
onSelectNetworksClick: (String, List<String>) -> Unit = { _, _ -> },
params: DefaultAddAddressComponent.Params = DefaultAddAddressComponent.Params( params: DefaultAddAddressComponent.Params = DefaultAddAddressComponent.Params(
onBackClick = {}, onBackClick = {},
onSelectNetworksClick = onSelectNetworksClick,
onConfirm = onConfirm, onConfirm = onConfirm,
), ),
paramsContainer: ParamsContainer = MutableParamsContainer(value = params), paramsContainer: ParamsContainer = MutableParamsContainer(value = params),
@ -225,9 +561,13 @@ internal class AddAddressModelTest {
return AddAddressModel( return AddAddressModel(
paramsContainer = paramsContainer, paramsContainer = paramsContainer,
dispatchers = testScope.createTestingCoroutineDispatcherProvider(), dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
multiAccountListSupplier = multiAccountListSupplier, supportedNetworksMatcher = supportedNetworksMatcher,
memoValidator = memoValidator,
listenToQrScanningUseCase = listenToQrScanningUseCase,
clipboardManager = clipboardManager, clipboardManager = clipboardManager,
stateController = AddAddressStateController(), stateController = AddAddressStateController(),
selectNetworksResultHolder = selectNetworksResultHolder,
router = router,
).also { model = it } ).also { model = it }
} }
@ -243,7 +583,6 @@ internal class AddAddressModelTest {
} }
private companion object { private companion object {
// EIP-55 checksummed address from the spec — guaranteed to pass Ethereum validation. const val ADDRESS = "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed"
const val VALID_ETH_ADDRESS = "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed"
} }
} }

View file

@ -0,0 +1,181 @@
package com.tangem.features.addressbook.selectnetworks.model
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.toNetworkId
import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.features.addressbook.common.SupportedNetworksMatcher
import com.tangem.features.addressbook.selectnetworks.DefaultSelectNetworksComponent
import com.tangem.features.addressbook.selectnetworks.state.SelectNetworksStateController
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.*
@OptIn(ExperimentalCoroutinesApi::class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class SelectNetworksModelTest {
private val supportedNetworksMatcher: SupportedNetworksMatcher = mockk()
private val ethereum = Blockchain.Ethereum
private val bsc = Blockchain.BSC
private var model: SelectNetworksModel? = null
@BeforeEach
fun resetMocks() {
clearMocks(supportedNetworksMatcher)
// The address resolves to two networks unless a test overrides it.
every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(ethereum, bsc)
}
@AfterEach
fun tearDown() {
model?.onDestroy()
model = null
}
@Test
fun `GIVEN no prior selection WHEN created THEN nothing selected AND done disabled`() = runTest {
// Act
val model = createModel(testScope = this)
advanceUntilIdle()
// Assert — all matched networks are listed but none is checked by default.
val state = model.state.value
assertThat(state.networks.map { it.name }).containsExactly(ethereum.fullName, bsc.fullName)
assertThat(state.networks.none { it.isSelected }).isTrue()
assertThat(state.doneButton.isEnabled).isFalse()
}
@Test
fun `GIVEN explicit selection WHEN created THEN only those networks selected`() = runTest {
// Act
val model = createModel(testScope = this, selectedNetworkIds = listOf(ethereum.toNetworkId()))
advanceUntilIdle()
// Assert
val networks = model.state.value.networks
assertThat(networks.first { it.id == ethereum.toNetworkId() }.isSelected).isTrue()
assertThat(networks.first { it.id == bsc.toNetworkId() }.isSelected).isFalse()
}
@Test
fun `GIVEN nothing selected WHEN a network toggled on THEN it becomes selected AND done enabled`() = runTest {
// Arrange
val model = createModel(testScope = this)
advanceUntilIdle()
// Act
model.state.value.networks.first { it.id == ethereum.toNetworkId() }.onCheckedChange()
advanceUntilIdle()
// Assert
val networks = model.state.value.networks
assertThat(networks.first { it.id == ethereum.toNetworkId() }.isSelected).isTrue()
assertThat(networks.first { it.id == bsc.toNetworkId() }.isSelected).isFalse()
assertThat(model.state.value.doneButton.isEnabled).isTrue()
}
@Test
fun `GIVEN a selected network toggled off THEN done disabled again`() = runTest {
// Arrange
val model = createModel(testScope = this, selectedNetworkIds = listOf(ethereum.toNetworkId()))
advanceUntilIdle()
// Act
model.state.value.networks.first { it.id == ethereum.toNetworkId() }.onCheckedChange()
advanceUntilIdle()
// Assert
assertThat(model.state.value.networks.none { it.isSelected }).isTrue()
assertThat(model.state.value.doneButton.isEnabled).isFalse()
}
@Test
fun `GIVEN query WHEN typed THEN list filtered by network name`() = runTest {
// Arrange
val model = createModel(testScope = this)
advanceUntilIdle()
// Act
model.state.value.searchBar.onQueryChange(ethereum.fullName)
advanceUntilIdle()
// Assert
assertThat(model.state.value.networks.map { it.name }).containsExactly(ethereum.fullName)
}
@Test
fun `GIVEN selected networks WHEN done clicked THEN onDone called with them`() = runTest {
// Arrange
var result: Set<String>? = null
val model = createModel(testScope = this, onDone = { result = it })
advanceUntilIdle()
model.state.value.networks.first { it.id == ethereum.toNetworkId() }.onCheckedChange()
advanceUntilIdle()
// Act
model.state.value.doneButton.onClick()
// Assert
assertThat(result).containsExactly(ethereum.toNetworkId())
}
@Test
fun `GIVEN no networks selected WHEN done clicked THEN onDone not called`() = runTest {
// Arrange
var result: Set<String>? = null
val model = createModel(testScope = this, onDone = { result = it })
advanceUntilIdle()
// Act — nothing selected by default.
model.state.value.doneButton.onClick()
// Assert
assertThat(result).isNull()
}
private fun createModel(
testScope: TestScope,
selectedNetworkIds: List<String> = emptyList(),
onDone: (Set<String>) -> Unit = {},
params: DefaultSelectNetworksComponent.Params = DefaultSelectNetworksComponent.Params(
address = ADDRESS,
selectedNetworkIds = selectedNetworkIds,
onBackClick = {},
onDone = onDone,
),
paramsContainer: ParamsContainer = MutableParamsContainer(value = params),
): SelectNetworksModel {
return SelectNetworksModel(
paramsContainer = paramsContainer,
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
supportedNetworksMatcher = supportedNetworksMatcher,
stateController = SelectNetworksStateController(),
).also { model = it }
}
private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
val testDispatcher = StandardTestDispatcher(testScheduler)
return TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
)
}
private companion object {
const val ADDRESS = "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed"
}
}

View file

@ -23,6 +23,7 @@ internal class InitializeQrScanningStateTransformer(
SourceType.SEND -> network?.let { resourceReference(R.string.send_qrcode_scan_info, wrappedList(it)) } SourceType.SEND -> network?.let { resourceReference(R.string.send_qrcode_scan_info, wrappedList(it)) }
SourceType.WALLET_CONNECT -> resourceReference(R.string.wc_qr_scan_hint) SourceType.WALLET_CONNECT -> resourceReference(R.string.wc_qr_scan_hint)
SourceType.MAIN_SCREEN -> resourceReference(R.string.main_qr_scan_hint) SourceType.MAIN_SCREEN -> resourceReference(R.string.main_qr_scan_hint)
SourceType.ADDRESS_BOOK -> resourceReference(R.string.main_qr_scan_hint)
} }
return QrScanningState( return QrScanningState(
@ -49,6 +50,10 @@ internal class InitializeQrScanningStateTransformer(
title = null, title = null,
startIcon = R.drawable.ic_close_24, startIcon = R.drawable.ic_close_24,
) )
SourceType.ADDRESS_BOOK -> TopBarConfig(
title = null,
startIcon = R.drawable.ic_back_24,
)
} }
} }

View file

@ -0,0 +1,175 @@
package com.tangem.blockchainsdk.utils
import com.tangem.blockchain.common.Blockchain
import com.tangem.domain.models.network.Network
/**
* The kind of transaction extras (memo / destination tag) a [Blockchain] supports, mapped to the domain
* [Network.TransactionExtrasType]. Single source of truth for both [com.tangem.data.common.network.NetworkFactory] and
* any feature that needs to know whether an address on this chain can carry a memo/tag.
*/
@Suppress("LongMethod")
fun Blockchain.getSupportedTransactionExtras(): Network.TransactionExtrasType {
return when (this) {
Blockchain.XRP -> Network.TransactionExtrasType.DESTINATION_TAG
Blockchain.Binance,
Blockchain.TON,
Blockchain.Cosmos,
Blockchain.TerraV1,
Blockchain.TerraV2,
Blockchain.Stellar,
Blockchain.Hedera,
Blockchain.Algorand,
Blockchain.Sei,
Blockchain.InternetComputer,
Blockchain.Casper,
-> Network.TransactionExtrasType.MEMO
// region Other blockchains
Blockchain.Unknown,
Blockchain.Alephium,
Blockchain.AlephiumTestnet,
Blockchain.Arbitrum,
Blockchain.ArbitrumTestnet,
Blockchain.Avalanche,
Blockchain.AvalancheTestnet,
Blockchain.BinanceTestnet,
Blockchain.BSC,
Blockchain.BSCTestnet,
Blockchain.Bitcoin,
Blockchain.BitcoinTestnet,
Blockchain.BitcoinCash,
Blockchain.BitcoinCashTestnet,
Blockchain.Cardano,
Blockchain.CosmosTestnet,
Blockchain.Dogecoin,
Blockchain.Ducatus,
Blockchain.Ethereum,
Blockchain.EthereumTestnet,
Blockchain.EthereumClassic,
Blockchain.EthereumClassicTestnet,
Blockchain.Fantom,
Blockchain.FantomTestnet,
Blockchain.Litecoin,
Blockchain.Near,
Blockchain.NearTestnet,
Blockchain.Polkadot,
Blockchain.PolkadotTestnet,
Blockchain.Kava,
Blockchain.KavaTestnet,
Blockchain.Kusama,
Blockchain.Polygon,
Blockchain.PolygonTestnet,
Blockchain.RSK,
Blockchain.SeiTestnet,
Blockchain.StellarTestnet,
Blockchain.Solana,
Blockchain.SolanaTestnet,
Blockchain.Tezos,
Blockchain.Tron,
Blockchain.TronTestnet,
Blockchain.Gnosis,
Blockchain.Dash,
Blockchain.Optimism,
Blockchain.OptimismTestnet,
Blockchain.Dischain,
Blockchain.EthereumPow,
Blockchain.EthereumPowTestnet,
Blockchain.Kaspa,
Blockchain.KaspaTestnet,
Blockchain.Telos,
Blockchain.TelosTestnet,
Blockchain.TONTestnet,
Blockchain.Ravencoin,
Blockchain.Clore,
Blockchain.RavencoinTestnet,
Blockchain.Cronos,
Blockchain.AlephZero,
Blockchain.AlephZeroTestnet,
Blockchain.OctaSpace,
Blockchain.OctaSpaceTestnet,
Blockchain.Chia,
Blockchain.ChiaTestnet,
Blockchain.Decimal,
Blockchain.DecimalTestnet,
Blockchain.XDC,
Blockchain.XDCTestnet,
Blockchain.VeChain,
Blockchain.VeChainTestnet,
Blockchain.Aptos,
Blockchain.AptosTestnet,
Blockchain.Playa3ull,
Blockchain.Shibarium,
Blockchain.ShibariumTestnet,
Blockchain.AlgorandTestnet,
Blockchain.HederaTestnet,
Blockchain.Aurora,
Blockchain.AuroraTestnet,
Blockchain.Areon,
Blockchain.AreonTestnet,
Blockchain.PulseChain,
Blockchain.PulseChainTestnet,
Blockchain.ZkSyncEra,
Blockchain.ZkSyncEraTestnet,
Blockchain.Nexa,
Blockchain.NexaTestnet,
Blockchain.Moonbeam,
Blockchain.MoonbeamTestnet,
Blockchain.Manta,
Blockchain.MantaTestnet,
Blockchain.PolygonZkEVM,
Blockchain.PolygonZkEVMTestnet,
Blockchain.Radiant,
Blockchain.Fact0rn,
Blockchain.Base,
Blockchain.BaseTestnet,
Blockchain.Moonriver,
Blockchain.MoonriverTestnet,
Blockchain.Mantle,
Blockchain.MantleTestnet,
Blockchain.Flare,
Blockchain.FlareTestnet,
Blockchain.Taraxa,
Blockchain.TaraxaTestnet,
Blockchain.Koinos,
Blockchain.KoinosTestnet,
Blockchain.Joystream,
Blockchain.Bittensor,
Blockchain.Filecoin,
Blockchain.Blast,
Blockchain.BlastTestnet,
Blockchain.Cyber,
Blockchain.CyberTestnet,
Blockchain.Sui,
Blockchain.SuiTestnet,
Blockchain.EnergyWebChain,
Blockchain.EnergyWebChainTestnet,
Blockchain.EnergyWebX,
Blockchain.EnergyWebXTestnet,
Blockchain.CasperTestnet,
Blockchain.Core,
Blockchain.CoreTestnet,
Blockchain.Xodex,
Blockchain.Canxium,
Blockchain.Chiliz,
Blockchain.ChilizTestnet,
Blockchain.VanarChain,
Blockchain.VanarChainTestnet,
Blockchain.OdysseyChain, Blockchain.OdysseyChainTestnet,
Blockchain.Bitrock, Blockchain.BitrockTestnet,
Blockchain.Sonic, Blockchain.SonicTestnet,
Blockchain.ApeChain, Blockchain.ApeChainTestnet,
Blockchain.Scroll, Blockchain.ScrollTestnet,
Blockchain.ZkLinkNova, Blockchain.ZkLinkNovaTestnet,
Blockchain.Pepecoin, Blockchain.PepecoinTestnet,
Blockchain.Hyperliquid, Blockchain.HyperliquidTestnet,
Blockchain.Quai, Blockchain.QuaiTestnet,
Blockchain.Linea, Blockchain.LineaTestnet,
Blockchain.ArbitrumNova,
Blockchain.Plasma, Blockchain.PlasmaTestnet,
Blockchain.Adi, Blockchain.AdiTestnet,
Blockchain.SeiEvm, Blockchain.SeiEvmTestnet,
Blockchain.Monad, Blockchain.MonadTestnet,
-> Network.TransactionExtrasType.NONE
// endregion
}
}