Updated on 2026-08-14
This commit is contained in:
parent
3069b8d32d
commit
7b4ae4e23d
39 changed files with 1985 additions and 329 deletions
|
|
@ -374,6 +374,7 @@ internal class ChildFactory @Inject constructor(
|
|||
is AppRoute.QrScanning.Source.Send -> SourceType.SEND
|
||||
is AppRoute.QrScanning.Source.WalletConnect -> SourceType.WALLET_CONNECT
|
||||
is AppRoute.QrScanning.Source.MainScreen -> SourceType.MAIN_SCREEN
|
||||
is AppRoute.QrScanning.Source.AddressBook -> SourceType.ADDRESS_BOOK
|
||||
}
|
||||
createComponentChild(
|
||||
context = context,
|
||||
|
|
|
|||
|
|
@ -195,6 +195,7 @@ sealed class AppRoute(val path: String) : Route {
|
|||
is Send -> "/$networkName"
|
||||
WalletConnect -> ""
|
||||
MainScreen -> ""
|
||||
AddressBook -> ""
|
||||
}
|
||||
|
||||
data class Send(val networkName: String) : Source()
|
||||
|
|
@ -202,6 +203,8 @@ sealed class AppRoute(val path: String) : Route {
|
|||
data object WalletConnect : Source()
|
||||
|
||||
data object MainScreen : Source()
|
||||
|
||||
data object AddressBook : Source()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import androidx.annotation.VisibleForTesting
|
|||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchain.common.FeePaidCurrency
|
||||
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
|
||||
import com.tangem.blockchainsdk.utils.getSupportedTransactionExtras
|
||||
import com.tangem.blockchainsdk.utils.toBlockchain
|
||||
import com.tangem.blockchainsdk.utils.toNetworkId
|
||||
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 {
|
||||
return when (this) {
|
||||
Blockchain.Ethereum, Blockchain.EthereumTestnet -> Network.NameResolvingType.ENS
|
||||
|
|
|
|||
|
|
@ -4,4 +4,5 @@ enum class SourceType {
|
|||
WALLET_CONNECT,
|
||||
SEND,
|
||||
MAIN_SCREEN,
|
||||
ADDRESS_BOOK,
|
||||
}
|
||||
|
|
@ -19,6 +19,8 @@ dependencies {
|
|||
implementation(projects.domain.account)
|
||||
implementation(projects.domain.addressBook)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.qrScanning)
|
||||
implementation(projects.domain.qrScanning.models)
|
||||
implementation(projects.domain.wallets)
|
||||
|
||||
/** Common */
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ internal class DefaultAddAddressComponent(
|
|||
|
||||
data class Params(
|
||||
val onBackClick: () -> Unit,
|
||||
val onSelectNetworksClick: (address: String, selectedNetworkIds: List<String>) -> Unit,
|
||||
val onConfirm: (ValidatedAddress) -> Unit,
|
||||
)
|
||||
}
|
||||
|
|
@ -1,66 +1,114 @@
|
|||
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.model.Model
|
||||
import com.tangem.core.decompose.model.ParamsContainer
|
||||
import com.tangem.core.decompose.navigation.Router
|
||||
import com.tangem.core.ui.clipboard.ClipboardManager
|
||||
import com.tangem.domain.account.supplier.MultiAccountListSupplier
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.qrscanning.models.SourceType
|
||||
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
|
||||
import com.tangem.features.addressbook.addaddress.DefaultAddAddressComponent
|
||||
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.UpdateAddressInputTransformer
|
||||
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.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 kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.*
|
||||
import javax.inject.Inject
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
@Suppress("LongParameterList")
|
||||
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
|
||||
@ModelScoped
|
||||
internal class AddAddressModel @Inject constructor(
|
||||
paramsContainer: ParamsContainer,
|
||||
override val dispatchers: CoroutineDispatcherProvider,
|
||||
multiAccountListSupplier: MultiAccountListSupplier,
|
||||
private val supportedNetworksMatcher: SupportedNetworksMatcher,
|
||||
private val memoValidator: AddressMemoValidator,
|
||||
private val listenToQrScanningUseCase: ListenToQrScanningUseCase,
|
||||
private val clipboardManager: ClipboardManager,
|
||||
private val stateController: AddAddressStateController,
|
||||
private val selectNetworksResultHolder: SelectNetworksResultHolder,
|
||||
private val router: Router,
|
||||
) : Model() {
|
||||
|
||||
private val params: DefaultAddAddressComponent.Params = paramsContainer.require()
|
||||
|
||||
val state: StateFlow<AddAddressUM> get() = stateController.uiState
|
||||
|
||||
private val availableCoins: StateFlow<List<CryptoCurrency.Coin>> = multiAccountListSupplier()
|
||||
.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
|
||||
private val validation: StateFlow<AddressValidation> = state
|
||||
.map { it.addressField.value }
|
||||
.distinctUntilChanged()
|
||||
.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 {
|
||||
// Drop any selection left over from a previous AddAddress session before subscribing to it.
|
||||
selectNetworksResultHolder.clear()
|
||||
updateInitialState()
|
||||
subscribeToAddressValidation()
|
||||
subscribeToValidation()
|
||||
subscribeToMemoValidation()
|
||||
resetSelectionOnAddressChange()
|
||||
subscribeToSelectedNetworks()
|
||||
subscribeToQrScanResult()
|
||||
}
|
||||
|
||||
private fun updateInitialState() {
|
||||
stateController.update(
|
||||
UpdateAddAddressInitialStateTransformer(
|
||||
onAddressChange = { onAddressChange(value = it) },
|
||||
intents = UpdateAddAddressInitialStateTransformer.Intents(
|
||||
onAddressChange = ::onAddressChange,
|
||||
onAddressClear = { onAddressChange("") },
|
||||
onPasteClick = ::onPaste,
|
||||
onQrClick = { /* [REDACTED_TODO_COMMENT] */ },
|
||||
onQrClick = ::onQrClick,
|
||||
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))
|
||||
}
|
||||
|
||||
private fun subscribeToAddressValidation() {
|
||||
combine(addressInput, availableCoins) { input, coins ->
|
||||
UpdateAddressValidationTransformer(address = input, coins = coins)
|
||||
private fun onMemoChange(value: String) {
|
||||
stateController.update(UpdateMemoInputTransformer(value = value))
|
||||
}
|
||||
|
||||
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)
|
||||
.flowOn(dispatchers.default)
|
||||
.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() {
|
||||
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() {
|
||||
// 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 {
|
||||
private const val ADD_ADDRESS_DEBOUNCE = 500L
|
||||
private const val MEMO_DEBOUNCE = 300L
|
||||
}
|
||||
}
|
||||
|
|
@ -31,13 +31,21 @@ internal class AddAddressStateController @Inject constructor() {
|
|||
label = resourceReference(R.string.common_address),
|
||||
isError = false,
|
||||
),
|
||||
memoField = AddAddressUM.MemoFieldUM(
|
||||
isVisible = false,
|
||||
value = "",
|
||||
label = resourceReference(R.string.send_extras_hint_memo),
|
||||
isError = false,
|
||||
onValueChange = {},
|
||||
onPasteClick = {},
|
||||
),
|
||||
buttonUM = TangemButtonUM(
|
||||
text = TextReference.Res(R.string.address_book_add_address),
|
||||
type = TangemButtonType.Primary,
|
||||
isEnabled = false,
|
||||
onClick = {},
|
||||
),
|
||||
chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Empty,
|
||||
chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Hidden,
|
||||
onAddressChange = {},
|
||||
onAddressClear = {},
|
||||
onPasteClick = {},
|
||||
|
|
|
|||
|
|
@ -8,22 +8,34 @@ import com.tangem.utils.transformer.Transformer
|
|||
* state produced by [com.tangem.features.addressbook.addaddress.state.AddAddressStateController].
|
||||
*/
|
||||
internal class UpdateAddAddressInitialStateTransformer(
|
||||
private val onAddressChange: (String) -> Unit,
|
||||
private val onAddressClear: () -> Unit,
|
||||
private val onPasteClick: () -> Unit,
|
||||
private val onQrClick: () -> Unit,
|
||||
private val onBackClick: () -> Unit,
|
||||
private val onConfirmClick: () -> Unit,
|
||||
private val intents: Intents,
|
||||
) : Transformer<AddAddressUM> {
|
||||
|
||||
override fun transform(prevState: AddAddressUM): AddAddressUM {
|
||||
return prevState.copy(
|
||||
onAddressChange = onAddressChange,
|
||||
onAddressClear = onAddressClear,
|
||||
onPasteClick = onPasteClick,
|
||||
onQrClick = onQrClick,
|
||||
onBackClick = onBackClick,
|
||||
buttonUM = prevState.buttonUM.copy(onClick = onConfirmClick),
|
||||
onAddressChange = intents.onAddressChange,
|
||||
onAddressClear = intents.onAddressClear,
|
||||
onPasteClick = intents.onPasteClick,
|
||||
onQrClick = intents.onQrClick,
|
||||
onBackClick = intents.onBackClick,
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
|
@ -3,23 +3,41 @@ package com.tangem.features.addressbook.addaddress.state.transformers
|
|||
import com.tangem.core.ui.R
|
||||
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.ChosenNetworkStateUM
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
|
||||
/**
|
||||
* 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(
|
||||
private val value: String,
|
||||
) : Transformer<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(
|
||||
addressField = prevState.addressField.copy(
|
||||
value = value,
|
||||
isError = false,
|
||||
label = resourceReference(R.string.common_address),
|
||||
),
|
||||
chosenNetworkStateUM = chosenNetworkState,
|
||||
buttonUM = prevState.buttonUM.copy(isEnabled = false),
|
||||
memoField = memoField,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +1,51 @@
|
|||
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.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.ChosenNetworkStateUM
|
||||
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
|
||||
* at least one of the available networks — the same blockchain check the Send flow uses. An invalid (non-empty,
|
||||
* matching nothing) address surfaces the error in the field label and disables the confirm button.
|
||||
* [matchedBlockchains] are all supported networks the address resolves to. [displayedBlockchains] is what the network
|
||||
* block shows — all matched networks until the user narrows them down on the SelectNetworks screen, then the picked
|
||||
* 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(
|
||||
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> {
|
||||
|
||||
override fun transform(prevState: AddAddressUM): AddAddressUM {
|
||||
val hasMatchedAnyNetwork = address.isNotBlank() &&
|
||||
coins.any { it.network.toBlockchain().validateAddress(address) }
|
||||
val isError = address.isNotBlank() && !hasMatchedAnyNetwork
|
||||
val hasMatch = matchedBlockchains.isNotEmpty()
|
||||
val isError = address.isNotBlank() && !hasMatch
|
||||
|
||||
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) {
|
||||
resourceReference(R.string.address_book_invalid_address_error)
|
||||
} else {
|
||||
|
|
@ -30,7 +53,32 @@ internal class UpdateAddressValidationTransformer(
|
|||
}
|
||||
return prevState.copy(
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
@ -1,11 +1,14 @@
|
|||
package com.tangem.features.addressbook.addaddress.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
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.extensions.TextReference
|
||||
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.addaddress.ui.state.AddAddressUM
|
||||
|
|
@ -70,17 +74,12 @@ internal fun AddAddressContent(state: AddAddressUM, modifier: Modifier = Modifie
|
|||
onQrClick = state.onQrClick,
|
||||
onPasteClick = state.onPasteClick,
|
||||
)
|
||||
SpacerH(20.dp)
|
||||
NetworkBlock(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.fillMaxWidth()
|
||||
.background(color = TangemTheme.colors3.bg.secondary),
|
||||
MemoSection(memoField = state.memoField)
|
||||
NetworkSelector(
|
||||
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
|
||||
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))
|
||||
TangemButton(
|
||||
modifier = Modifier
|
||||
|
|
@ -114,13 +177,21 @@ private fun Preview_AddAddressContent() {
|
|||
placeholder = resourceReference(R.string.address_book_enter_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(
|
||||
text = TextReference.Res(R.string.address_book_add_address),
|
||||
type = TangemButtonType.Primary,
|
||||
isEnabled = false,
|
||||
onClick = { },
|
||||
),
|
||||
chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Empty,
|
||||
chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Hidden,
|
||||
onAddressChange = {},
|
||||
onAddressClear = {},
|
||||
onPasteClick = {},
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -20,12 +20,10 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.compose.ui.util.fastForEachIndexed
|
||||
import com.tangem.core.ui.R
|
||||
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.TangemLoaderSize
|
||||
import com.tangem.core.ui.ds2.row.TangemRow
|
||||
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.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
|
|
@ -48,7 +46,10 @@ internal fun NetworkBlock(
|
|||
chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val isClickable = chosenNetworkStateUM is AddAddressUM.ChosenNetworkStateUM.Result &&
|
||||
chosenNetworkStateUM.isClickable
|
||||
TangemRow(
|
||||
onClick = if (isClickable) onNetworkSelectClick else null,
|
||||
verticalAlignment = TangemRowVerticalAlignment.Center,
|
||||
modifier = modifier,
|
||||
titleSlot = {
|
||||
|
|
@ -59,45 +60,27 @@ internal fun NetworkBlock(
|
|||
)
|
||||
},
|
||||
endSlot = {
|
||||
SelectNetworkButton(
|
||||
onNetworkSelectClick = onNetworkSelectClick,
|
||||
chosenNetworkStateUM = chosenNetworkStateUM,
|
||||
)
|
||||
when (chosenNetworkStateUM) {
|
||||
AddAddressUM.ChosenNetworkStateUM.Loading -> TangemLoader(size = TangemLoaderSize.X20)
|
||||
is AddAddressUM.ChosenNetworkStateUM.Result -> NetworkRow(chosenNetworkStateUM = chosenNetworkStateUM)
|
||||
AddAddressUM.ChosenNetworkStateUM.Hidden -> Unit
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SelectNetworkButton(
|
||||
onNetworkSelectClick: () -> Unit,
|
||||
chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM,
|
||||
) {
|
||||
Row(
|
||||
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,
|
||||
private fun NetworkRow(chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM.Result) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
NetworkIconsResolver(
|
||||
networks = chosenNetworkStateUM.networkUMList,
|
||||
showChevron = chosenNetworkStateUM.isClickable,
|
||||
)
|
||||
SpacerW(4.dp)
|
||||
ChevronIcon()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NetworkIconsResolver(networks: ImmutableList<NetworkUM>) {
|
||||
private fun NetworkIconsResolver(networks: ImmutableList<NetworkUM>, showChevron: Boolean) {
|
||||
when (networks.size) {
|
||||
0 -> Unit
|
||||
1 -> {
|
||||
|
|
@ -112,13 +95,13 @@ private fun NetworkIconsResolver(networks: ImmutableList<NetworkUM>) {
|
|||
style = TangemTheme.typography3.body.medium,
|
||||
color = TangemTheme.colors3.text.secondary,
|
||||
)
|
||||
ChevronIcon()
|
||||
if (showChevron) ChevronIcon()
|
||||
}
|
||||
// 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.
|
||||
else -> {
|
||||
OverlappingNetworkIcons(networks)
|
||||
ChevronIcon()
|
||||
if (showChevron) ChevronIcon()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -191,6 +174,7 @@ private fun Preview_NetworkBlock() {
|
|||
networkUMList = persistentListOf(
|
||||
NetworkUM(networkName = "Ethereum", iconResId = R.drawable.img_eth_22),
|
||||
),
|
||||
isClickable = false,
|
||||
),
|
||||
)
|
||||
SpacerH12()
|
||||
|
|
@ -202,6 +186,7 @@ private fun Preview_NetworkBlock() {
|
|||
NetworkUM(networkName = "BSC", iconResId = R.drawable.img_bsc_22),
|
||||
NetworkUM(networkName = "Polygon", iconResId = R.drawable.img_polygon_22),
|
||||
),
|
||||
isClickable = true,
|
||||
),
|
||||
)
|
||||
SpacerH12()
|
||||
|
|
@ -211,12 +196,9 @@ private fun Preview_NetworkBlock() {
|
|||
networkUMList = List(15) {
|
||||
NetworkUM(networkName = "Network", iconResId = R.drawable.img_eth_22)
|
||||
}.toImmutableList(),
|
||||
isClickable = true,
|
||||
),
|
||||
)
|
||||
SpacerH12()
|
||||
NetworkBlock(chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Loading, onNetworkSelectClick = {})
|
||||
SpacerH12()
|
||||
NetworkBlock(chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Empty, onNetworkSelectClick = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,11 +3,13 @@ package com.tangem.features.addressbook.addaddress.ui.state
|
|||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.tangem.core.ui.ds.button.TangemButtonUM
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Immutable
|
||||
internal data class AddAddressUM(
|
||||
val addressField: AddressFieldUM,
|
||||
val memoField: MemoFieldUM,
|
||||
val buttonUM: TangemButtonUM,
|
||||
val chosenNetworkStateUM: ChosenNetworkStateUM,
|
||||
val onAddressChange: (String) -> Unit,
|
||||
|
|
@ -17,12 +19,39 @@ internal data class AddAddressUM(
|
|||
val onBackClick: () -> 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
|
||||
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(
|
||||
val networkName: String,
|
||||
@DrawableRes val iconResId: Int,
|
||||
|
|
|
|||
|
|
@ -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.list.DefaultAddressBookListComponent
|
||||
import com.tangem.features.addressbook.route.AddressBookRoute
|
||||
import com.tangem.features.addressbook.selectnetworks.DefaultSelectNetworksComponent
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import javax.inject.Inject
|
||||
|
||||
|
|
@ -47,9 +48,19 @@ internal class AddressBookChildFactory @Inject constructor(
|
|||
appComponentContext = context,
|
||||
params = DefaultAddAddressComponent.Params(
|
||||
onBackClick = clickIntents::onAddAddressBack,
|
||||
onSelectNetworksClick = clickIntents::onSelectNetworksClick,
|
||||
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. */
|
||||
|
|
|
|||
|
|
@ -23,4 +23,10 @@ internal interface AddressBookClickIntents {
|
|||
fun onAddAddressBack()
|
||||
|
||||
fun onAddressConfirmed(address: ValidatedAddress)
|
||||
|
||||
fun onSelectNetworksClick(address: String, selectedNetworkIds: List<String>)
|
||||
|
||||
fun onSelectNetworksBack()
|
||||
|
||||
fun onNetworksSelected(selectedNetworkIds: Set<String>)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -29,13 +29,15 @@ internal class DefaultAddressBookComponent @AssistedInject constructor(
|
|||
@Assisted private val params: AddressBookComponent.Params,
|
||||
private val childFactory: AddressBookChildFactory,
|
||||
private val resultHolder: AddressBookResultHolder,
|
||||
private val selectNetworksResultHolder: SelectNetworksResultHolder,
|
||||
) : AddressBookComponent, AppComponentContext by context {
|
||||
|
||||
private val navigation = StackNavigation<AddressBookRoute>()
|
||||
|
||||
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()
|
||||
selectNetworksResultHolder.clear()
|
||||
}
|
||||
|
||||
private val clickIntents = object : AddressBookClickIntents {
|
||||
|
|
@ -64,6 +66,21 @@ internal class DefaultAddressBookComponent @AssistedInject constructor(
|
|||
resultHolder.setConfirmedAddress(address)
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.list.model.AddressBookListModel
|
||||
import com.tangem.features.addressbook.editcontact.model.EditContactModel
|
||||
import com.tangem.features.addressbook.selectnetworks.model.SelectNetworksModel
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
|
|
@ -35,4 +36,9 @@ internal interface AddressBookModelModule {
|
|||
@IntoMap
|
||||
@ClassKey(AddAddressModel::class)
|
||||
fun bindAddAddressModel(model: AddAddressModel): Model
|
||||
|
||||
@Binds
|
||||
@IntoMap
|
||||
@ClassKey(SelectNetworksModel::class)
|
||||
fun bindSelectNetworksModel(model: SelectNetworksModel): Model
|
||||
}
|
||||
|
|
@ -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
|
||||
* [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.
|
||||
* [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
|
||||
data class ValidatedAddress(
|
||||
val address: String,
|
||||
val networkIds: ImmutableList<String>,
|
||||
val memo: String? = null,
|
||||
)
|
||||
|
|
@ -30,6 +30,16 @@ internal sealed class AddressBookRoute {
|
|||
@Serializable
|
||||
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. */
|
||||
@Serializable
|
||||
sealed interface ListMode {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -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()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -1,27 +1,33 @@
|
|||
package com.tangem.features.addressbook.addaddress.model
|
||||
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
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.ParamsContainer
|
||||
import com.tangem.core.decompose.navigation.Router
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.clipboard.ClipboardManager
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.domain.account.models.AccountList
|
||||
import com.tangem.domain.account.supplier.MultiAccountListSupplier
|
||||
import com.tangem.domain.models.account.Account
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.qrscanning.models.SourceType
|
||||
import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase
|
||||
import com.tangem.features.addressbook.addaddress.DefaultAddAddressComponent
|
||||
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.test.mock.MockAccounts
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
|
|
@ -33,25 +39,30 @@ import org.junit.jupiter.api.*
|
|||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
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 cryptoCurrencyFactory = MockCryptoCurrencyFactory()
|
||||
private val ethereum = cryptoCurrencyFactory.createCoin(Blockchain.Ethereum)
|
||||
private val bitcoin = cryptoCurrencyFactory.createCoin(Blockchain.Bitcoin)
|
||||
private val listenToQrScanningUseCase: ListenToQrScanningUseCase = mockk()
|
||||
private val router: Router = mockk(relaxed = true)
|
||||
private val selectNetworksResultHolder = SelectNetworksResultHolder()
|
||||
|
||||
private var model: AddAddressModel? = null
|
||||
|
||||
@BeforeEach
|
||||
fun resetMocks() {
|
||||
clearMocks(multiAccountListSupplier, clipboardManager)
|
||||
// Default: no accounts, so no coins are available unless a test overrides it.
|
||||
every { multiAccountListSupplier.invoke() } returns flowOf(emptyList())
|
||||
clearMocks(supportedNetworksMatcher, memoValidator, clipboardManager, listenToQrScanningUseCase, router)
|
||||
selectNetworksResultHolder.clear()
|
||||
// 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
|
||||
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 = null
|
||||
}
|
||||
|
|
@ -98,13 +109,14 @@ internal class AddAddressModelTest {
|
|||
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
|
||||
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
|
||||
var confirmed: ValidatedAddress? = null
|
||||
val model = createModel(testScope = this, onConfirm = { confirmed = it })
|
||||
model.state.value.onAddressChange("0xABC")
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
model.state.value.buttonUM.onClick()
|
||||
|
|
@ -119,14 +131,14 @@ internal class AddAddressModelTest {
|
|||
inner class Validation {
|
||||
|
||||
@Test
|
||||
fun `GIVEN coins available WHEN valid address typed THEN no error AND button enabled`() = runTest {
|
||||
// Arrange
|
||||
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(accountListWith(ethereum, bitcoin)))
|
||||
fun `GIVEN single matching network WHEN typed THEN no error AND button enabled`() = runTest {
|
||||
// Arrange — a single matched network is auto-selected, so the button is enabled right away.
|
||||
every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.Ethereum)
|
||||
val model = createModel(testScope = this)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
model.state.value.onAddressChange(VALID_ETH_ADDRESS)
|
||||
model.state.value.onAddressChange(ADDRESS)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Assert
|
||||
|
|
@ -136,14 +148,31 @@ internal class AddAddressModelTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN coins available WHEN address matches no network THEN error AND button disabled`() = runTest {
|
||||
// Arrange
|
||||
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(accountListWith(ethereum, bitcoin)))
|
||||
fun `GIVEN several matching networks WHEN typed THEN no error but button disabled until selection`() = runTest {
|
||||
// Arrange — several matches are shown for context, but none is selected until the user picks explicitly.
|
||||
every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.Ethereum, Blockchain.BSC)
|
||||
val model = createModel(testScope = this)
|
||||
advanceUntilIdle()
|
||||
|
||||
// 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()
|
||||
|
||||
// Assert
|
||||
|
|
@ -157,7 +186,6 @@ internal class AddAddressModelTest {
|
|||
@Test
|
||||
fun `GIVEN empty address WHEN validated THEN no error AND button disabled`() = runTest {
|
||||
// Arrange
|
||||
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(accountListWith(ethereum)))
|
||||
val model = createModel(testScope = this)
|
||||
advanceUntilIdle()
|
||||
|
||||
|
|
@ -170,54 +198,362 @@ internal class AddAddressModelTest {
|
|||
assertThat(state.addressField.isError).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
|
||||
fun `GIVEN address typed before coins load WHEN coins emitted THEN validated reactively`() = runTest {
|
||||
// Arrange
|
||||
val accountsFlow = MutableStateFlow<List<AccountList>>(emptyList())
|
||||
every { multiAccountListSupplier.invoke() } returns accountsFlow
|
||||
fun `GIVEN blank address WHEN validated THEN selector hidden`() = runTest {
|
||||
// Act
|
||||
val model = createModel(testScope = this)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act — type while coins are still empty
|
||||
model.state.value.onAddressChange(VALID_ETH_ADDRESS)
|
||||
advanceUntilIdle()
|
||||
// Assert intermediate: nothing to match yet
|
||||
assertThat(model.state.value.buttonUM.isEnabled).isFalse()
|
||||
// Assert
|
||||
assertThat(model.state.value.chosenNetworkStateUM).isEqualTo(ChosenNetworkStateUM.Hidden)
|
||||
}
|
||||
|
||||
// Act — coins arrive later
|
||||
accountsFlow.value = listOf(accountListWith(ethereum, bitcoin))
|
||||
@Test
|
||||
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()
|
||||
|
||||
// Assert
|
||||
val state = model.state.value
|
||||
assertThat(state.buttonUM.isEnabled).isTrue()
|
||||
assertThat(state.addressField.isError).isFalse()
|
||||
assertThat(model.state.value.chosenNetworkStateUM).isEqualTo(ChosenNetworkStateUM.Hidden)
|
||||
}
|
||||
|
||||
@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 {
|
||||
val walletId = MockAccounts.userWalletId
|
||||
val accounts = listOf(
|
||||
Account.CryptoPortfolio.createMainAccount(
|
||||
userWalletId = walletId,
|
||||
cryptoCurrencies = currencies.toList(),
|
||||
@Nested
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
inner class Memo {
|
||||
|
||||
@Test
|
||||
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)
|
||||
val model = createModel(testScope = this)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Act
|
||||
model.state.value.onAddressChange(ADDRESS)
|
||||
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",
|
||||
),
|
||||
)
|
||||
return AccountList(
|
||||
userWalletId = walletId,
|
||||
accounts = accounts,
|
||||
totalAccounts = accounts.size,
|
||||
totalArchivedAccounts = 0,
|
||||
).getOrNull()!!
|
||||
}
|
||||
|
||||
@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(
|
||||
testScope: TestScope,
|
||||
onConfirm: (ValidatedAddress) -> Unit = {},
|
||||
onSelectNetworksClick: (String, List<String>) -> Unit = { _, _ -> },
|
||||
params: DefaultAddAddressComponent.Params = DefaultAddAddressComponent.Params(
|
||||
onBackClick = {},
|
||||
onSelectNetworksClick = onSelectNetworksClick,
|
||||
onConfirm = onConfirm,
|
||||
),
|
||||
paramsContainer: ParamsContainer = MutableParamsContainer(value = params),
|
||||
|
|
@ -225,9 +561,13 @@ internal class AddAddressModelTest {
|
|||
return AddAddressModel(
|
||||
paramsContainer = paramsContainer,
|
||||
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
|
||||
multiAccountListSupplier = multiAccountListSupplier,
|
||||
supportedNetworksMatcher = supportedNetworksMatcher,
|
||||
memoValidator = memoValidator,
|
||||
listenToQrScanningUseCase = listenToQrScanningUseCase,
|
||||
clipboardManager = clipboardManager,
|
||||
stateController = AddAddressStateController(),
|
||||
selectNetworksResultHolder = selectNetworksResultHolder,
|
||||
router = router,
|
||||
).also { model = it }
|
||||
}
|
||||
|
||||
|
|
@ -243,7 +583,6 @@ internal class AddAddressModelTest {
|
|||
}
|
||||
|
||||
private companion object {
|
||||
// EIP-55 checksummed address from the spec — guaranteed to pass Ethereum validation.
|
||||
const val VALID_ETH_ADDRESS = "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed"
|
||||
const val ADDRESS = "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ internal class InitializeQrScanningStateTransformer(
|
|||
SourceType.SEND -> network?.let { resourceReference(R.string.send_qrcode_scan_info, wrappedList(it)) }
|
||||
SourceType.WALLET_CONNECT -> resourceReference(R.string.wc_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(
|
||||
|
|
@ -49,6 +50,10 @@ internal class InitializeQrScanningStateTransformer(
|
|||
title = null,
|
||||
startIcon = R.drawable.ic_close_24,
|
||||
)
|
||||
SourceType.ADDRESS_BOOK -> TopBarConfig(
|
||||
title = null,
|
||||
startIcon = R.drawable.ic_back_24,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue