Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-14 14:50:24 +03:00
commit 50fe9e603d
1919 changed files with 94940 additions and 15178 deletions

View file

@ -10,16 +10,12 @@ android {
dependencies {
/* Project - Core */
implementation(projects.core.decompose)
implementation(projects.core.ui)
implementation(projects.common.ui)
// region Project - Core
api(projects.core.decompose)
api(projects.core.ui)
// endregion
/* Project - Domain */
implementation(projects.domain.models)
implementation(projects.domain.core)
implementation(projects.domain.appCurrency.models)
implementation(projects.domain.tokens)
implementation(projects.domain.tokens.models)
implementation(projects.domain.account)
// region Project - Domain
api(projects.domain.models)
// endregion
}

View file

@ -13,43 +13,35 @@ android {
dependencies {
/** Api */
implementation(projects.features.account.api)
implementation(projects.features.wallet.api)
api(projects.features.account.api)
/** Core modules */
implementation(projects.core.analytics)
implementation(projects.core.analytics.models)
implementation(projects.core.utils)
api(projects.core.analytics)
api(projects.core.analytics.models)
api(projects.core.decompose)
api(projects.core.error)
api(projects.core.utils)
implementation(projects.core.ui)
implementation(projects.core.error)
implementation(projects.core.res)
implementation(projects.core.decompose)
implementation(projects.core.navigation)
implementation(projects.core.datasource)
/** Domain */
implementation(projects.domain.models)
implementation(projects.domain.account)
implementation(projects.domain.account.status)
api(projects.domain.account)
api(projects.domain.account.status)
api(projects.domain.models)
api(projects.domain.wallets)
implementation(projects.domain.core)
implementation(projects.domain.appCurrency)
implementation(projects.domain.appCurrency.models)
implementation(projects.domain.tokens)
implementation(projects.domain.tokens.models)
implementation(projects.domain.balanceHiding)
implementation(projects.domain.balanceHiding.models)
implementation(projects.domain.wallets)
implementation(projects.domain.wallets.models)
/** Tangem libraries */
implementation(tangemDeps.card.core)
runtimeOnly(projects.domain.appCurrency)
runtimeOnly(projects.domain.balanceHiding)
runtimeOnly(projects.domain.tokens)
/** Common */
implementation(projects.common.ui)
implementation(projects.common.routing)
/** AndroidX libraries */
implementation(deps.androidx.core.ktx)
implementation(deps.androidx.annotation)
implementation(deps.androidx.appCompat)
implementation(deps.lifecycle.compose)
implementation(deps.lifecycle.runtime.ktx)
/** Compose libraries */
@ -58,14 +50,13 @@ dependencies {
implementation(deps.compose.foundation)
implementation(deps.compose.ui)
implementation(deps.compose.ui.tooling)
implementation(deps.compose.coil)
implementation(deps.decompose.ext.compose)
implementation(deps.androidx.activity.compose)
/** Other libraries */
implementation(deps.arrow.core)
implementation(deps.kotlin.coroutines)
implementation(deps.kotlin.immutable.collections)
implementation(deps.kotlin.serialization)
implementation(deps.firebase.crashlytics)
/** DI */

View file

@ -10,13 +10,22 @@ android {
dependencies {
/* Project - Domain */
implementation(projects.domain.models)
// region Kotlin
api(deps.kotlin.coroutines)
api(deps.kotlin.immutable.collections)
// endregion
/* Project - Core */
implementation(projects.core.decompose)
implementation(projects.core.ui)
// region Project - Common
api(projects.common.routing)
api(projects.common.ui)
// endregion
/* Compose */
implementation(deps.compose.runtime)
// region Project - Core
api(projects.core.decompose)
api(projects.core.ui)
// endregion
// region Project - Domain
api(projects.domain.models)
// endregion
}

View file

@ -1,5 +1,6 @@
package com.tangem.features.addressbook
import com.tangem.common.routing.entity.AddressBookOpenMode
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
@ -7,5 +8,5 @@ interface AddressBookComponent : ComposableContentComponent {
interface Factory : ComponentFactory<Params, AddressBookComponent>
data class Params(val predefinedAddress: String?)
data class Params(val addressBookOpenMode: AddressBookOpenMode)
}

View file

@ -0,0 +1,30 @@
package com.tangem.features.addressbook
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.network.Network
import kotlinx.coroutines.flow.StateFlow
/**
* The contacts block shown on the Send address-entry screen, below the "recent" block. Lists up to five contacts that
* have an address in [Params.network], filtered live by [Params.queryFlow] (the recipient-input text, matched against
* contact name or address). Hidden when there are no matching contacts.
*/
interface AddressBookContactsBlockComponent : ComposableContentComponent {
interface Factory : ComponentFactory<Params, AddressBookContactsBlockComponent>
/**
* @property network the current send network; only contacts with an address in this network are shown
* @property queryFlow the live recipient-input text used to filter the block
* @property onContactClick invoked with the tapped contact and its network-matching entries; the host decides
* whether to apply it directly (single entry) or open the address selector (multiple entries)
* @property onSeeAllClick invoked when the user taps "See all" to open the full address book in selection mode
*/
data class Params(
val network: Network,
val queryFlow: StateFlow<String>,
val onContactClick: (MatchedContact) -> Unit,
val onSeeAllClick: () -> Unit,
)
}

View file

@ -0,0 +1,21 @@
package com.tangem.features.addressbook
import com.tangem.domain.models.wallet.UserWalletId
/**
* Address-book analytics that are triggered from outside the address-book feature (i.e. from the Send flow).
*
* The [com.tangem.features.addressbook.analytics] events are internal to the address-book impl module, so the Send
* feature cannot construct them directly it reports the address-book funnel through this contract instead.
*/
interface AddressBookSendAnalytics {
/**
* Fired when the recipient address (and memo, if any) picked from the address book has been substituted into the
* Send form the final step of choosing a recipient from the book.
*
* @param walletId the current (sending) wallet
* @param contactId id of the picked contact
*/
fun onAddressSubstitutedInSend(walletId: UserWalletId, contactId: String)
}

View file

@ -0,0 +1,19 @@
package com.tangem.features.addressbook
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
/**
* Bottom sheet shown when a picked contact has more than one address in the target network. Lets the user choose a
* concrete address; the chosen one is returned via [Params.onAddressSelected] as a [SelectedContact].
*/
interface AddressSelectorComponent : ComposableBottomSheetComponent {
interface Factory : ComponentFactory<Params, AddressSelectorComponent>
data class Params(
val contact: MatchedContact,
val onAddressSelected: (SelectedContact) -> Unit,
val onDismiss: () -> Unit,
)
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.addressbook
import kotlinx.coroutines.flow.SharedFlow
/**
* Delivers a contact picked in the full address-book list (opened in selection mode) back to whatever feature
* requested the selection. The picker and the requesting feature live in independent model scopes, so a one-shot
* [SharedFlow] is used instead of a retained holder: nothing is kept after emission, so there is nothing to clear.
*
* Mirrors the `SwapChooseTokenNetworkTrigger`/`Listener` pattern.
*/
interface ContactSelectionTrigger {
fun trigger(contact: SelectedContact)
}
interface ContactSelectionListener {
val resultFlow: SharedFlow<SelectedContact>
}

View file

@ -0,0 +1,38 @@
package com.tangem.features.addressbook
import com.tangem.common.ui.account.AccountIconUM
import kotlinx.collections.immutable.ImmutableList
/**
* A contact together with its address entries that match a given network. Emitted when a contact is tapped during
* selection; the host decides what to do with it:
* - exactly one [entries] item build a [SelectedContact] and proceed straight away;
* - more than one open the address selector so the user picks a concrete address first.
*/
data class MatchedContact(
val contactId: String,
val walletId: String,
val name: String,
val icon: AccountIconUM.CryptoPortfolio,
val networkId: String,
val entries: ImmutableList<ContactAddress>,
) {
/** Resolves this contact to a concrete pick using one of its [entries]. */
fun toSelectedContact(entry: ContactAddress): SelectedContact = SelectedContact(
contactId = contactId,
name = name,
icon = icon,
address = entry.address,
networkId = networkId,
memo = entry.memo,
)
/** A single network-matching address of the contact. */
data class ContactAddress(
val address: String,
val memo: String?,
val networkName: String,
)
}

View file

@ -0,0 +1,25 @@
package com.tangem.features.addressbook
import com.tangem.common.ui.account.AccountIconUM
/**
* A single resolved address-book pick. Produced once the concrete address within a contact is known either directly
* (the contact has a single matching-network address) or after the user chose one in the address selector.
*
* Feature-agnostic: any feature that opens the address book for selection receives this result.
*
* @property contactId the id of the source [com.tangem.domain.addressbook.model.Contact]
* @property name the contact name to display
* @property icon the contact avatar (initials + color), reusing the account icon UI model
* @property address the chosen on-chain address
* @property networkId raw id of the network the address belongs to
* @property memo optional memo/destination tag (only meaningful for networks that support it)
*/
data class SelectedContact(
val contactId: String,
val name: String,
val icon: AccountIconUM.CryptoPortfolio,
val address: String,
val networkId: String,
val memo: String?,
)

View file

@ -13,25 +13,31 @@ android {
dependencies {
/** Api */
implementation(projects.features.addressBook.api)
api(projects.features.addressBook.api)
api(projects.features.commonFeatures.api)
/** Domain */
implementation(projects.domain.account)
implementation(projects.domain.addressBook)
api(projects.domain.account)
api(projects.domain.addressBook)
api(projects.domain.wallets)
implementation(projects.domain.models)
implementation(projects.domain.qrScanning)
implementation(projects.domain.qrScanning.models)
/** Common */
implementation(projects.common.routing)
implementation(projects.common.ui)
/** Core modules */
implementation(projects.core.configToggles)
implementation(projects.core.decompose)
implementation(projects.core.ui)
implementation(projects.core.utils)
api(projects.core.configToggles)
api(projects.core.decompose)
api(projects.core.ui)
api(projects.core.utils)
/** Compose */
api(deps.compose.foundation)
implementation(deps.compose.animation)
implementation(deps.compose.runtime)
implementation(deps.compose.foundation)
implementation(deps.compose.ui)
implementation(deps.compose.ui.tooling)
implementation(deps.compose.material3)
@ -44,7 +50,12 @@ dependencies {
kapt(deps.hilt.kapt)
/** Other */
implementation(deps.kotlin.immutable.collections)
api(deps.kotlin.immutable.collections)
implementation(deps.androidx.annotation)
implementation(deps.androidx.appCompat)
implementation(deps.arrow.core)
implementation(deps.kotlin.coroutines)
implementation(deps.kotlin.serialization.core)
/** Utils */
implementation(projects.libs.blockchainSdk)

View file

@ -1,15 +0,0 @@
package com.tangem.features.addressbook.addaddress
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress
internal interface AddAddressComponent : ComposableContentComponent {
interface Factory : ComponentFactory<Params, AddAddressComponent>
data class Params(
val onBackClick: () -> Unit,
val onConfirm: (ValidatedAddress) -> Unit,
)
}

View file

@ -7,16 +7,15 @@ 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.addaddress.model.AddAddressModel
import com.tangem.features.addressbook.addaddress.ui.AddAddressContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
internal class DefaultAddAddressComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted params: AddAddressComponent.Params,
) : AddAddressComponent, AppComponentContext by context {
internal class DefaultAddAddressComponent(
appComponentContext: AppComponentContext,
params: Params,
) : ComposableContentComponent, AppComponentContext by appComponentContext {
private val model: AddAddressModel = getOrCreateModel(params)
@ -30,11 +29,14 @@ internal class DefaultAddAddressComponent @AssistedInject constructor(
BackHandler(onBack = state.onBackClick)
}
@AssistedFactory
interface Factory : AddAddressComponent.Factory {
override fun create(
context: AppComponentContext,
params: AddAddressComponent.Params,
): DefaultAddAddressComponent
}
data class Params(
val walletId: String?,
val excludeContactId: String?,
val prefillAddress: String?,
val prefillNetworkIds: List<String>,
val prefillMemo: String?,
val onBackClick: () -> Unit,
val onSelectNetworksClick: (matchedNetworkIds: List<String>, selectedNetworkIds: List<String>) -> Unit,
val onConfirm: (address: ValidatedAddress, replaces: String?) -> Unit,
)
}

View file

@ -1,34 +0,0 @@
package com.tangem.features.addressbook.addaddress.contract
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.domain.models.network.Network
import kotlinx.collections.immutable.ImmutableList
internal data class AddAddressUM(
val addressField: AddressFieldUM,
val availableNetworks: ImmutableList<Network>,
val buttonUM: TangemButtonUM,
val chosenNetworkStateUM: ChosenNetworkStateUM,
val onAddressChange: (String) -> Unit,
val onAddressClear: () -> Unit,
val onPasteClick: () -> Unit,
val onQrClick: () -> Unit,
val onBackClick: () -> Unit,
) {
@Immutable
sealed class ChosenNetworkStateUM {
data object Loading : ChosenNetworkStateUM()
data object Empty : ChosenNetworkStateUM()
data class Result(
val networkUMList: ImmutableList<NetworkUM>,
) : ChosenNetworkStateUM() {
data class NetworkUM(
val networkName: String,
@DrawableRes val iconResId: Int,
)
}
}
}

View file

@ -1,149 +1,297 @@
package com.tangem.features.addressbook.addaddress.model
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.common.ui.extensions.iconResId
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.ui.R
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.core.ui.ds.button.TangemButtonType
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.account.supplier.MultiAccountListSupplier
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.features.addressbook.addaddress.AddAddressComponent
import com.tangem.features.addressbook.addaddress.contract.AddAddressUM
import com.tangem.features.addressbook.addaddress.contract.AddressFieldUM
import com.tangem.domain.addressbook.model.ContactId
import com.tangem.domain.addressbook.usecase.CheckAddressDuplicateUseCase
import com.tangem.domain.models.wallet.UserWalletId
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.AddressBookAnalyticsSender
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.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.*
import javax.inject.Inject
import kotlin.collections.map
@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 checkAddressDuplicateUseCase: CheckAddressDuplicateUseCase,
private val analyticsSender: AddressBookAnalyticsSender,
private val router: Router,
) : Model() {
private val params: AddAddressComponent.Params = paramsContainer.require()
private val params: DefaultAddAddressComponent.Params = paramsContainer.require()
val state: StateFlow<AddAddressUM>
field = MutableStateFlow(getInitialState())
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 }
}
.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,
selected = selectedNetworks(matched, selected),
)
}
.flowOn(dispatchers.default)
.stateIn(
modelScope,
SharingStarted.Eagerly,
ChosenNetworks(address = "", matched = emptyList(), selected = emptyList()),
)
/**
* Name of the contact that already holds one of the selected `network + address` pairs in the target wallet, or
* `null` when the pair is free.
*/
private val duplicateName: StateFlow<String?> = chosenNetworks
.mapLatest { networks ->
val walletId = params.walletId ?: return@mapLatest null
if (networks.selected.isEmpty()) return@mapLatest null
networks.selected.firstNotNullOfOrNull { blockchain ->
checkAddressDuplicateUseCase(
userWalletId = UserWalletId(walletId),
networkId = blockchain.toNetworkId(),
address = networks.address,
excludeContactId = params.excludeContactId?.let(::ContactId),
)
}
}
.flowOn(dispatchers.default)
.stateIn(modelScope, SharingStarted.Eagerly, null)
init {
subscribeToAddressInput()
// Drop any selection left over from a previous AddAddress session before subscribing to it.
selectNetworksResultHolder.clear()
sendInitAnalytics()
updateInitialState()
subscribeToValidation()
subscribeToMemoValidation()
subscribeToSelectedNetworks()
subscribeToQrScanResult()
subscribeToAddressInvalid()
prefillData()
}
private fun onAddressChange(value: String, isPasted: Boolean = false) {
state.update { oldState ->
oldState.copy(
addressField = oldState.addressField.copy(
value = value,
isValuePasted = isPasted,
isError = false,
error = null,
private fun prefillData() {
val prefillAddress = params.prefillAddress ?: return
onAddressChange(prefillAddress)
selectedNetworkIds.value = params.prefillNetworkIds.toSet().ifEmpty { null }
params.prefillMemo?.let(::onMemoChange)
}
private fun updateInitialState() {
stateController.update(
UpdateAddAddressInitialStateTransformer(
intents = UpdateAddAddressInitialStateTransformer.Intents(
onAddressChange = ::onAddressChange,
onAddressClear = { onAddressChange("") },
onPasteClick = ::onPaste,
onQrClick = ::onQrClick,
onBackClick = params.onBackClick,
onNetworkClick = ::onNetworkClick,
onMemoChange = ::onMemoChange,
onMemoPasteClick = ::onMemoPaste,
onConfirmClick = ::validateAndConfirm,
),
chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Loading,
isEditMode = params.prefillAddress != null,
),
)
}
private fun onAddressChange(value: String) {
stateController.update(UpdateAddressInputTransformer(value = value))
selectedNetworkIds.value = null
}
private fun onMemoChange(value: String) {
stateController.update(UpdateMemoInputTransformer(value = value))
}
private fun subscribeToValidation() {
combine(chosenNetworks, isMemoInvalid, duplicateName) { networks, memoInvalid, duplicate ->
UpdateAddressValidationTransformer(
address = networks.address,
matchedBlockchains = networks.matched,
selectedBlockchains = networks.selected,
isMemoInvalid = memoInvalid,
duplicateName = duplicate,
)
}
.onEach(stateController::update)
.flowOn(dispatchers.default)
.launchIn(modelScope)
}
private fun subscribeToAddressInput() {
combine(addressInput, availableCoins) { input, coins ->
getUniqueNetworks(input, coins)
}
.onEach { availableNetworks ->
state.update { oldState ->
oldState.copy(
availableNetworks = availableNetworks,
chosenNetworkStateUM = createChosenNetworkState(availableNetworks),
)
}
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 subscribeToSelectedNetworks() {
selectNetworksResultHolder.selectedNetworkIds
.filterNotNull()
.onEach { ids ->
selectedNetworkIds.value = ids
selectNetworksResultHolder.clear()
}
.launchIn(modelScope)
}
private fun createChosenNetworkState(availableNetworks: ImmutableList<Network>): AddAddressUM.ChosenNetworkStateUM {
return if (availableNetworks.isEmpty()) {
AddAddressUM.ChosenNetworkStateUM.Empty
} else {
AddAddressUM.ChosenNetworkStateUM.Result(
networkUMList = availableNetworks
.map { network ->
AddAddressUM.ChosenNetworkStateUM.Result.NetworkUM(
networkName = network.name,
iconResId = network.iconResId,
)
}
.toImmutableList(),
)
}
}
private fun getUniqueNetworks(input: String, coins: List<CryptoCurrency.Coin>): ImmutableList<Network> {
return coins
.filter { it.network.toBlockchain().validateAddress(input) }
.map { it.network }
.toImmutableList()
private fun subscribeToAddressInvalid() {
val walletId = params.walletId ?: return
validation
.map { it.address.isNotBlank() && it.matchedBlockchains.isEmpty() }
.distinctUntilChanged()
.filter { isInvalid -> isInvalid }
.onEach {
analyticsSender.sendAddressInvalid(
walletId = UserWalletId(walletId),
contactId = params.excludeContactId.orEmpty(),
)
}
.flowOn(dispatchers.default)
.launchIn(modelScope)
}
private fun onPaste() {
onAddressChange(value = clipboardManager.getText().orEmpty(), isPasted = true)
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(
chosenNetworks.value.matched.map { it.toNetworkId() },
selectedNetworkIds.value?.toList().orEmpty(),
)
}
private fun validateAndConfirm() {
// TODO([REDACTED_TASK_KEY]): validate the address and invoke params.onConfirm
val networks = chosenNetworks.value
if (networks.selected.isEmpty()) return
if (duplicateName.value != null) 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,
),
// In the edit-address flow this confirmation supersedes the entry the screen was opened for.
params.prefillAddress,
)
}
private fun getInitialState(): AddAddressUM = AddAddressUM(
addressField = AddressFieldUM(
value = "",
placeholder = resourceReference(R.string.common_address),
label = resourceReference(R.string.address_book_enter_address),
isError = false,
error = null,
isValuePasted = false,
),
availableNetworks = persistentListOf(),
buttonUM = TangemButtonUM(
text = TextReference.Res(R.string.address_book_add_address),
type = TangemButtonType.Primary,
isEnabled = false,
onClick = ::validateAndConfirm,
),
chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Empty,
onAddressChange = { onAddressChange(value = it) },
onAddressClear = { onAddressChange("") },
onPasteClick = ::onPaste,
onQrClick = { /* [REDACTED_TODO_COMMENT] */ },
onBackClick = params.onBackClick,
/**
* 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 fun sendInitAnalytics() {
analyticsSender.sendAddressScreenOpened()
}
private data class AddressValidation(
val address: String,
val matchedBlockchains: List<Blockchain>,
)
private data class ChosenNetworks(
val address: String,
val matched: 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
}
}

View file

@ -0,0 +1,57 @@
package com.tangem.features.addressbook.addaddress.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.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM
import com.tangem.features.addressbook.addaddress.ui.state.AddressFieldUM
import com.tangem.utils.transformer.Transformer
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
@ModelScoped
internal class AddAddressStateController @Inject constructor() {
val uiState: StateFlow<AddAddressUM>
field = MutableStateFlow(value = getInitialState())
fun update(transformer: Transformer<AddAddressUM>) {
uiState.update(function = transformer::transform)
}
private fun getInitialState(): AddAddressUM = AddAddressUM(
addressField = AddressFieldUM(
value = "",
placeholder = resourceReference(R.string.address_book_enter_address),
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_save_address),
type = TangemButtonType.Primary,
isEnabled = false,
onClick = {},
),
chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Hidden,
onAddressChange = {},
onAddressClear = {},
onPasteClick = {},
onQrClick = {},
onBackClick = {},
onNetworkClick = {},
title = resourceReference(R.string.address_book_add_address),
)
}

View file

@ -0,0 +1,49 @@
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.utils.transformer.Transformer
/**
* Wires the callbacks owned by [com.tangem.features.addressbook.addaddress.model.AddAddressModel] into the initial
* state produced by [com.tangem.features.addressbook.addaddress.state.AddAddressStateController].
*/
internal class UpdateAddAddressInitialStateTransformer(
private val intents: Intents,
private val isEditMode: Boolean,
) : Transformer<AddAddressUM> {
override fun transform(prevState: AddAddressUM): AddAddressUM {
return prevState.copy(
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),
title = if (isEditMode) {
resourceReference(R.string.address_book_edit_address)
} else {
resourceReference(R.string.address_book_add_address)
},
)
}
data class Intents(
val onAddressChange: (String) -> Unit,
val onAddressClear: () -> Unit,
val onPasteClick: () -> Unit,
val onQrClick: () -> Unit,
val onBackClick: () -> Unit,
val onNetworkClick: () -> Unit,
val onMemoChange: (String) -> Unit,
val onMemoPasteClick: () -> Unit,
val onConfirmClick: () -> Unit,
)
}

View file

@ -0,0 +1,44 @@
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 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 (prevState.chosenNetworkStateUM) {
is ChosenNetworkStateUM.Result,
ChosenNetworkStateUM.SelectNetwork,
-> if (value.isBlank()) ChosenNetworkStateUM.Hidden else prevState.chosenNetworkStateUM
else -> if (value.isBlank()) ChosenNetworkStateUM.Hidden 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,
)
}
}

View file

@ -0,0 +1,88 @@
package com.tangem.features.addressbook.addaddress.state.transformers
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.core.ui.extensions.wrappedList
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
/**
* Reflects the result of validating an address (and its memo) in the UI.
*
* [matchedBlockchains] are all supported networks the address resolves to. [selectedBlockchains] is what is actually
* chosen for saving (a single match is auto-selected; for several matches the user must pick explicitly on the
* SelectNetworks screen). The network block shows only the selected networks while several match but none is picked
* yet it prompts with [ChosenNetworkStateUM.SelectNetwork] rather than showing every matched network. 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).
* 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 matchedBlockchains: List<Blockchain>,
private val selectedBlockchains: List<Blockchain>,
private val isMemoInvalid: Boolean,
private val duplicateName: String?,
) : Transformer<AddAddressUM> {
override fun transform(prevState: AddAddressUM): AddAddressUM {
val hasMatch = matchedBlockchains.isNotEmpty()
val isInvalidAddress = address.isNotBlank() && !hasMatch
val isDuplicate = duplicateName != null
val isError = isInvalidAddress || isDuplicate
val chosenNetworkState = when {
!hasMatch -> ChosenNetworkStateUM.Hidden
selectedBlockchains.isEmpty() -> ChosenNetworkStateUM.SelectNetwork
else -> ChosenNetworkStateUM.Result(
networkUMList = selectedBlockchains.map(ChosenNetworkConverter()::convert).toImmutableList(),
// A single matched network leaves nothing to choose, so the selection screen is not opened.
isClickable = matchedBlockchains.size > 1,
)
}
val label = when {
isDuplicate -> resourceReference(R.string.address_book_address_taken_error, wrappedList(duplicateName))
isInvalidAddress -> resourceReference(R.string.address_book_invalid_address_error)
else -> resourceReference(R.string.common_address)
}
val isConfirmEnabled = selectedBlockchains.isNotEmpty() && !isMemoInvalid && !isDuplicate
return prevState.copy(
addressField = prevState.addressField.copy(isError = isError, label = label),
chosenNetworkStateUM = chosenNetworkState,
memoField = resolveMemoField(prevState.memoField),
buttonUM = prevState.buttonUM.copy(isEnabled = isConfirmEnabled),
)
}
/**
* Shows the memo field with the right label when a chosen network supports transaction extras; hides it and clears
* the value otherwise (e.g. the supporting network was deselected or the address changed). A malformed memo
* ([isMemoInvalid]) turns the field label into an error.
*/
private fun resolveMemoField(prevMemoField: AddAddressUM.MemoFieldUM): AddAddressUM.MemoFieldUM {
val extrasType = selectedBlockchains
.map { it.getSupportedTransactionExtras() }
.firstOrNull { it.isTxExtrasSupported() }
?: return prevMemoField.copy(isVisible = false, value = "", isError = false)
val fieldLabelRes = when (extrasType) {
Network.TransactionExtrasType.DESTINATION_TAG -> R.string.send_destination_tag_field
else -> R.string.send_extras_hint_memo
}
val labelRes = if (isMemoInvalid) R.string.send_memo_destination_tag_error else fieldLabelRes
return prevMemoField.copy(
isVisible = true,
label = resourceReference(labelRes),
isError = isMemoInvalid,
)
}
}

View file

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

View file

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

View file

@ -1,28 +1,33 @@
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
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.SpacerH12
import com.tangem.core.ui.ds.button.PrimaryTangemButton
import com.tangem.core.ui.components.SpacerH
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.topnavigation.TangemTopNavigation
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.contract.AddAddressUM
import com.tangem.features.addressbook.addaddress.contract.AddressFieldUM
import kotlinx.collections.immutable.persistentListOf
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM
import com.tangem.features.addressbook.addaddress.ui.state.AddressFieldUM
@Composable
internal fun AddAddressContent(state: AddAddressUM, modifier: Modifier = Modifier) {
@ -30,45 +35,128 @@ internal fun AddAddressContent(state: AddAddressUM, modifier: Modifier = Modifie
modifier = modifier
.fillMaxSize()
.background(color = TangemTheme.colors3.bg.primary)
.systemBarsPadding(),
.navigationBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
TangemTopBar(
modifier = Modifier.statusBarsPadding(),
title = resourceReference(R.string.address_book_add_address),
startContent = {
TangemButton(
iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_back_24),
onClick = state.onBackClick,
size = TangemButton.Size.X11,
variant = TangemButton.Variant.Material,
)
},
TangemTopNavigation(
title = state.title,
contentAlign = TangemTopNavigation.ContentAlign.Center,
blurBackground = false,
onBack = state.onBackClick,
)
RecipientRow(
addressField = state.addressField,
onValueChange = state.onAddressChange,
onAddressClear = state.onAddressClear,
onQrClick = state.onQrClick,
onPasteClick = state.onPasteClick,
)
SpacerH12()
NetworkBlock(state.chosenNetworkStateUM)
PrimaryButton(state.buttonUM)
BoxWithConstraints(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
) {
val minContentHeight = maxHeight
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
) {
Column(
modifier = Modifier
.heightIn(min = minContentHeight)
.imePadding(),
) {
RecipientRow(
modifier = Modifier.padding(horizontal = 16.dp),
addressField = state.addressField,
onValueChange = state.onAddressChange,
onAddressClear = state.onAddressClear,
onQrClick = state.onQrClick,
onPasteClick = state.onPasteClick,
)
MemoSection(memoField = state.memoField)
NetworkSelector(
chosenNetworkStateUM = state.chosenNetworkStateUM,
onNetworkClick = state.onNetworkClick,
)
AddButton(state.buttonUM)
}
}
}
}
}
@Composable
private fun ColumnScope.PrimaryButton(buttonUM: TangemButtonUM) {
Spacer(modifier = Modifier.weight(1f))
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,
)
}
}
}
PrimaryTangemButton(
@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,
AddAddressUM.ChosenNetworkStateUM.SelectNetwork,
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
.fillMaxWidth()
.navigationBarsPadding()
.padding(start = 16.dp, end = 16.dp, bottom = 12.dp),
buttonUM = buttonUM,
.padding(horizontal = 16.dp, vertical = 12.dp),
onClick = buttonUM.onClick,
isEnabled = buttonUM.isEnabled,
isLoading = buttonUM.isLoading,
size = TangemButton.Size.X12,
text = buttonUM.text,
)
}
@ -84,19 +172,28 @@ private fun Preview_AddAddressContent() {
placeholder = resourceReference(R.string.address_book_enter_address),
label = resourceReference(R.string.common_address),
),
availableNetworks = persistentListOf(),
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 = {},
onQrClick = {},
onBackClick = {},
onNetworkClick = {},
title = resourceReference(R.string.address_book_add_address),
),
)
}

View file

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

View file

@ -1,94 +1,99 @@
package com.tangem.features.addressbook.addaddress.ui
import android.content.res.Configuration
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.layout.*
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
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.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.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.addressbook.addaddress.contract.AddAddressUM
import com.tangem.features.addressbook.addaddress.contract.AddAddressUM.ChosenNetworkStateUM.Result.NetworkUM
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM
import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM.ChosenNetworkStateUM.Result.NetworkUM
import com.tangem.utils.StringsSigns
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
private const val MAX_VISIBLE_NETWORKS = 3
private val NetworkIconSize = 24.dp
// Horizontal advance per icon. Smaller than the icon size so icons overlap; the bg-colored ring on
// the icon drawn on top carves the crescent cut-out from the icon below.
private val NetworkIconStep = 18.dp
@Composable
internal fun NetworkBlock(chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM) {
internal fun NetworkBlock(
onNetworkSelectClick: () -> Unit,
chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM,
modifier: Modifier = Modifier,
) {
val isClickable = chosenNetworkStateUM is AddAddressUM.ChosenNetworkStateUM.SelectNetwork ||
chosenNetworkStateUM is AddAddressUM.ChosenNetworkStateUM.Result && chosenNetworkStateUM.isClickable
TangemRow(
onClick = if (isClickable) onNetworkSelectClick else null,
verticalAlignment = TangemRowVerticalAlignment.Center,
modifier = Modifier
.padding(horizontal = 16.dp)
.clip(RoundedCornerShape(16.dp))
.fillMaxWidth()
.background(color = TangemTheme.colors3.bg.secondary)
.padding(horizontal = 4.dp),
modifier = modifier,
titleSlot = {
Text(
text = stringResourceSafe(R.string.common_network),
style = TangemTheme.typography.body2,
style = TangemTheme.typography3.body.medium,
color = TangemTheme.colors3.text.primary,
)
},
endSlot = {
SelectNetworkButton(chosenNetworkStateUM)
when (chosenNetworkStateUM) {
AddAddressUM.ChosenNetworkStateUM.Loading -> TangemLoader(size = TangemLoaderSize.X20)
AddAddressUM.ChosenNetworkStateUM.SelectNetwork -> SelectNetworkTextBlock()
is AddAddressUM.ChosenNetworkStateUM.Result -> NetworkRow(chosenNetworkStateUM = chosenNetworkStateUM)
AddAddressUM.ChosenNetworkStateUM.Hidden -> Unit
}
},
)
}
@Composable
private fun SelectNetworkButton(chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
when (chosenNetworkStateUM) {
is AddAddressUM.ChosenNetworkStateUM.Result -> NetworkIconsResolver(chosenNetworkStateUM.networkUMList)
AddAddressUM.ChosenNetworkStateUM.Loading -> TangemLoader()
AddAddressUM.ChosenNetworkStateUM.Empty -> {
Text(
modifier = Modifier.padding(start = 8.dp),
text = stringResourceSafe(R.string.address_book_select_network),
style = TangemTheme.typography.body2,
color = TangemTheme.colors3.text.secondary,
)
ChevronIcon()
}
}
private fun SelectNetworkTextBlock() {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResourceSafe(R.string.address_book_select_network),
style = TangemTheme.typography3.body.medium,
color = TangemTheme.colors3.text.secondary,
)
ChevronIcon()
}
}
@Composable
private fun NetworkIconsResolver(networks: ImmutableList<NetworkUM>) {
private fun NetworkRow(chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM.Result) {
Row(verticalAlignment = Alignment.CenterVertically) {
NetworkIconsResolver(
networks = chosenNetworkStateUM.networkUMList,
showChevron = chosenNetworkStateUM.isClickable,
)
}
}
@Composable
private fun NetworkIconsResolver(networks: ImmutableList<NetworkUM>, showChevron: Boolean) {
when (networks.size) {
0 -> Unit
1 -> {
@ -100,16 +105,16 @@ private fun NetworkIconsResolver(networks: ImmutableList<NetworkUM>) {
Text(
modifier = Modifier.padding(start = 8.dp),
text = network.networkName,
style = TangemTheme.typography.body2,
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()
}
}
}
@ -120,7 +125,7 @@ private fun OverlappingNetworkIcons(networks: ImmutableList<NetworkUM>) {
val remaining = networks.size - visible.size
Box(modifier = Modifier.wrapContentWidth()) {
visible.forEachIndexed { index, network ->
visible.fastForEachIndexed { index, network ->
Image(
painter = painterResource(id = network.iconResId),
contentDescription = null,
@ -128,7 +133,7 @@ private fun OverlappingNetworkIcons(networks: ImmutableList<NetworkUM>) {
modifier = Modifier
.padding(start = NetworkIconStep * index)
.networkIconRing()
.size(NetworkIconSize),
.size(20.dp),
)
}
if (remaining > 0) {
@ -137,12 +142,13 @@ private fun OverlappingNetworkIcons(networks: ImmutableList<NetworkUM>) {
.padding(start = NetworkIconStep * visible.size)
.networkIconRing()
.background(color = TangemTheme.colors3.bg.tertiary)
.size(NetworkIconSize),
.heightIn(min = 20.dp)
.padding(vertical = 2.dp, horizontal = 4.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = "+$remaining",
style = TangemTheme.typography.caption1,
text = "${StringsSigns.PLUS}$remaining",
style = TangemTheme.typography3.caption.medium,
color = TangemTheme.colors3.text.secondary,
)
}
@ -160,48 +166,57 @@ private fun Modifier.networkIconRing(): Modifier = this
@Composable
private fun ChevronIcon() {
Image(
modifier = Modifier.padding(start = 8.dp),
painter = painterResource(id = R.drawable.ic_select_18_24),
Icon(
modifier = Modifier
.padding(start = 4.dp)
.size(20.dp),
tint = TangemTheme.colors3.icon.secondary,
imageVector = ImageVector.vectorResource(id = R.drawable.ic_select_18_24),
contentDescription = null,
)
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(showBackground = true)
@Composable
private fun Preview_NetworkBlock() {
TangemThemePreviewRedesign {
Column {
NetworkBlock(
onNetworkSelectClick = {},
chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.SelectNetwork,
)
SpacerH12()
NetworkBlock(
onNetworkSelectClick = {},
chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Result(
networkUMList = persistentListOf(
NetworkUM(networkName = "Ethereum", iconResId = R.drawable.img_eth_22),
),
isClickable = false,
),
)
SpacerH12()
NetworkBlock(
onNetworkSelectClick = {},
chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Result(
networkUMList = persistentListOf(
NetworkUM(networkName = "Ethereum", iconResId = R.drawable.img_eth_22),
NetworkUM(networkName = "BSC", iconResId = R.drawable.img_bsc_22),
NetworkUM(networkName = "Polygon", iconResId = R.drawable.img_polygon_22),
),
isClickable = true,
),
)
SpacerH12()
NetworkBlock(
onNetworkSelectClick = {},
chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Result(
networkUMList = List(15) {
NetworkUM(networkName = "Network", iconResId = R.drawable.img_eth_22)
}.toImmutableList(),
isClickable = true,
),
)
SpacerH12()
NetworkBlock(chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Loading)
SpacerH12()
NetworkBlock(chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Empty)
}
}
}

View file

@ -3,11 +3,7 @@ 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.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
@ -19,7 +15,6 @@ 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.SpacerH12
import com.tangem.core.ui.components.SpacerW8
import com.tangem.core.ui.components.fields.SimpleTextField
import com.tangem.core.ui.ds.image.TangemIcon
import com.tangem.core.ui.ds.image.TangemIconUM
@ -28,13 +23,14 @@ 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.extensions.stringResourceSafe
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.contract.AddressFieldUM
import com.tangem.core.ui.res.generated.icons.ic_scan_20
import com.tangem.features.addressbook.addaddress.ui.state.AddressFieldUM
@Composable
internal fun RecipientRow(
@ -43,19 +39,23 @@ internal fun RecipientRow(
onAddressClear: () -> Unit,
onQrClick: () -> Unit,
onPasteClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = Modifier
.padding(horizontal = 16.dp)
.clip(RoundedCornerShape(16.dp))
modifier = modifier
.clip(RoundedCornerShape(24.dp))
.fillMaxWidth()
.background(TangemTheme.colors3.bg.secondary),
) {
Text(
modifier = Modifier.padding(start = 16.dp, top = 16.dp),
text = stringResourceSafe(R.string.common_address),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors3.text.secondary,
modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 4.dp),
text = addressField.label.resolveReference(),
style = TangemTheme.typography3.caption.medium,
color = if (addressField.isError) {
TangemTheme.colors3.text.status.error
} else {
TangemTheme.colors3.text.secondary
},
)
TangemRow(
modifier = Modifier.fillMaxWidth(),
@ -64,7 +64,7 @@ internal fun RecipientRow(
startSlot = {
TangemIcon(
modifier = Modifier
.size(36.dp)
.size(40.dp)
.clip(CircleShape)
.background(TangemTheme.colors3.bg.tertiary),
tangemIconUM = TangemIconUM.Ident(text = addressField.value),
@ -72,43 +72,55 @@ internal fun RecipientRow(
},
titleSlot = {
SimpleTextField(
modifier = Modifier
.weight(1f)
.padding(start = 12.dp),
modifier = Modifier.weight(1f),
value = addressField.value,
onValueChange = onValueChange,
placeholder = TextReference.Res(R.string.address_book_enter_address),
singleLine = false,
placeholder = addressField.placeholder,
)
},
endSlot = {
if (addressField.value.isNotEmpty()) {
Icon(
modifier = Modifier.clickable(onClick = onAddressClear),
imageVector = Icons.ic_cross_circle_20_filled,
tint = TangemTheme.colors3.icon.tertiary,
contentDescription = null,
)
} else {
Row {
TangemButton(
variant = TangemButton.Variant.Secondary,
iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_qrcode_scaner_24),
onClick = onQrClick,
)
SpacerW8()
TangemButton(
variant = TangemButton.Variant.Primary,
text = TextReference.Res(id = R.string.common_paste),
onClick = onPasteClick,
)
}
}
RecipientEndSlot(
hasValue = addressField.value.isNotEmpty(),
onAddressClear = onAddressClear,
onQrClick = onQrClick,
onPasteClick = onPasteClick,
)
},
)
}
}
@Composable
private fun RecipientEndSlot(
hasValue: Boolean,
onAddressClear: () -> Unit,
onQrClick: () -> Unit,
onPasteClick: () -> Unit,
) {
if (hasValue) {
Icon(
modifier = Modifier
.clip(CircleShape)
.clickable(onClick = onAddressClear),
imageVector = Icons.ic_cross_circle_20_filled,
tint = TangemTheme.colors3.icon.tertiary,
contentDescription = null,
)
} else {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TangemButton(
variant = TangemButton.Variant.Secondary,
iconStart = TangemIconUM.Icon(imageVector = Icons.ic_scan_20),
onClick = onQrClick,
)
TangemButton(
text = TextReference.Res(id = R.string.common_paste),
onClick = onPasteClick,
)
}
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable

View file

@ -0,0 +1,68 @@
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 title: TextReference,
val onAddressChange: (String) -> Unit,
val onAddressClear: () -> Unit,
val onPasteClick: () -> Unit,
val onQrClick: () -> Unit,
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 {
/** 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
/**
* The address matched several networks but the user hasn't picked any yet the row prompts them to open the
* selection screen instead of showing network icons.
*/
data object SelectNetwork : 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,
)
}
}
}

View file

@ -1,4 +1,4 @@
package com.tangem.features.addressbook.addaddress.contract
package com.tangem.features.addressbook.addaddress.ui.state
import com.tangem.core.ui.extensions.TextReference
@ -7,7 +7,4 @@ internal data class AddressFieldUM(
val placeholder: TextReference,
val label: TextReference,
val isError: Boolean = false,
val error: TextReference? = null,
val isValuePasted: Boolean = false,
val blockchainAddress: String? = null,
)

View file

@ -0,0 +1,34 @@
package com.tangem.features.addressbook.addressinfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.ComposableBottomSheetComponent
import com.tangem.features.addressbook.addressinfo.model.AddressInfoModel
import com.tangem.features.addressbook.addressinfo.ui.AddressInfoBottomSheet
internal class DefaultAddressInfoComponent(
appComponentContext: AppComponentContext,
private val params: Params,
) : ComposableBottomSheetComponent, AppComponentContext by appComponentContext {
private val model: AddressInfoModel = getOrCreateModel(params)
override fun dismiss() = params.onDismiss()
@Composable
override fun BottomSheet() {
val state by model.state.collectAsStateWithLifecycle()
AddressInfoBottomSheet(state = state, onDismiss = ::dismiss)
}
data class Params(
val address: String,
val networkCount: Int,
val onEditAddress: () -> Unit,
val onDeleteAddress: () -> Unit,
val onDismiss: () -> Unit,
)
}

View file

@ -0,0 +1,43 @@
package com.tangem.features.addressbook.addressinfo.model
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.ui.UiMessageSender
import com.tangem.core.ui.R
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.features.addressbook.addressinfo.DefaultAddressInfoComponent
import com.tangem.features.addressbook.addressinfo.ui.state.AddressInfoUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
@ModelScoped
internal class AddressInfoModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val clipboardManager: ClipboardManager,
private val messageSender: UiMessageSender,
) : Model() {
private val params: DefaultAddressInfoComponent.Params = paramsContainer.require()
val state: StateFlow<AddressInfoUM> = MutableStateFlow(
AddressInfoUM(
address = params.address,
networkCount = params.networkCount,
onCopy = ::onCopy,
onEditAddress = params.onEditAddress,
onDeleteAddress = params.onDeleteAddress,
),
)
private fun onCopy() {
clipboardManager.setText(text = params.address, isSensitive = false)
messageSender.send(SnackbarMessage(resourceReference(R.string.address_book_address_copied)))
params.onDismiss()
}
}

View file

@ -0,0 +1,164 @@
package com.tangem.features.addressbook.addressinfo.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
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.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetType
import com.tangem.core.ui.ds.image.TangemIcon
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds2.button.Close
import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation
import com.tangem.core.ui.extensions.*
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_copy_20
import com.tangem.features.addressbook.addressinfo.ui.state.AddressInfoUM
import com.tangem.features.addressbook.impl.R
@Composable
internal fun AddressInfoBottomSheet(state: AddressInfoUM, onDismiss: () -> Unit) {
TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
config = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = onDismiss,
content = TangemBottomSheetConfigContent.Empty,
),
type = TangemBottomSheetType.Modal,
containerColor = TangemTheme.colors3.bg.secondary,
title = {
TangemTopNavigation(
windowInsets = WindowInsets(0),
blurBackground = false,
endButton = { TangemButton.Close(onClick = onDismiss) },
)
},
content = {
AddressInfoContent(
address = state.address,
networkCount = state.networkCount,
onCopy = state.onCopy,
onEditAddress = state.onEditAddress,
onDeleteAddress = state.onDeleteAddress,
)
},
)
}
@Suppress("LongParameterList")
@Composable
private fun AddressInfoContent(
address: String,
networkCount: Int,
onCopy: () -> Unit,
onEditAddress: () -> Unit,
onDeleteAddress: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AddressHeader(address = address, networkCount = networkCount)
SpacerH(8.dp)
TangemButton(
onClick = onCopy,
iconEnd = TangemIconUM.Icon(imageVector = Icons.ic_copy_20),
text = resourceReference(R.string.common_copy_address),
variant = TangemButton.Variant.Secondary,
size = TangemButton.Size.X9,
)
SpacerH(48.dp)
Row(
modifier = Modifier
.fillMaxWidth()
.clip(CircleShape)
.background(TangemTheme.colors3.bg.opaque.primary)
.clickableSingle(onClick = onDeleteAddress),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
Text(
modifier = Modifier.padding(vertical = 14.dp),
text = stringResourceSafe(R.string.address_book_remove_address),
style = TangemTheme.typography3.body.medium,
color = TangemTheme.colors3.text.status.error,
)
}
SpacerH(8.dp)
TangemButton(
modifier = Modifier.fillMaxWidth(),
onClick = onEditAddress,
text = resourceReference(R.string.address_book_edit_address),
variant = TangemButton.Variant.Secondary,
size = TangemButton.Size.X12,
)
}
}
@Composable
private fun AddressHeader(address: String, networkCount: Int) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
TangemIcon(
tangemIconUM = TangemIconUM.Ident(text = address),
modifier = Modifier
.size(64.dp)
.clip(CircleShape),
)
SpacerH(32.dp)
Text(
text = address,
style = TangemTheme.typography3.heading.small,
color = TangemTheme.colors3.text.primary,
textAlign = TextAlign.Center,
overflow = TextOverflow.MiddleEllipsis,
maxLines = 1,
)
SpacerH(8.dp)
Text(
text = pluralReference(
id = R.plurals.common_networks_count,
count = networkCount,
formatArgs = wrappedList(networkCount),
).resolveReference(),
style = TangemTheme.typography3.subheading.medium,
color = TangemTheme.colors3.text.secondary,
)
}
}
@Preview(showBackground = true, widthDp = 360)
@Composable
private fun Preview_AddressInfoContent() {
TangemThemePreviewRedesign {
AddressInfoContent(
address = "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D",
networkCount = 3,
onCopy = {},
onEditAddress = {},
onDeleteAddress = {},
)
}
}

View file

@ -0,0 +1,12 @@
package com.tangem.features.addressbook.addressinfo.ui.state
import androidx.compose.runtime.Immutable
@Immutable
internal data class AddressInfoUM(
val address: String,
val networkCount: Int,
val onCopy: () -> Unit,
val onEditAddress: () -> Unit,
val onDeleteAddress: () -> Unit,
)

View file

@ -0,0 +1,34 @@
package com.tangem.features.addressbook.addressselector
import androidx.compose.runtime.Composable
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.features.addressbook.AddressSelectorComponent
import com.tangem.features.addressbook.addressselector.ui.AddressSelectorBottomSheet
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultAddressSelectorComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: AddressSelectorComponent.Params,
) : AddressSelectorComponent, AppComponentContext by appComponentContext {
override fun dismiss() = params.onDismiss()
@Composable
override fun BottomSheet() {
AddressSelectorBottomSheet(
contact = params.contact,
onAddressClick = { entry -> params.onAddressSelected(params.contact.toSelectedContact(entry)) },
onDismiss = ::dismiss,
)
}
@AssistedFactory
interface Factory : AddressSelectorComponent.Factory {
override fun create(
context: AppComponentContext,
params: AddressSelectorComponent.Params,
): DefaultAddressSelectorComponent
}
}

View file

@ -0,0 +1,172 @@
package com.tangem.features.addressbook.addressselector.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetType
import com.tangem.core.ui.ds.image.TangemIcon
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation
import com.tangem.core.ui.ds2.row.TangemRow
import com.tangem.core.ui.ds2.row.TangemRowText
import com.tangem.core.ui.ds2.row.TangemRowTextRole
import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.features.addressbook.MatchedContact
import com.tangem.features.addressbook.impl.R
import kotlinx.collections.immutable.persistentListOf
@Composable
internal fun AddressSelectorBottomSheet(
contact: MatchedContact,
onAddressClick: (MatchedContact.ContactAddress) -> Unit,
onDismiss: () -> Unit,
) {
TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
config = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = onDismiss,
content = TangemBottomSheetConfigContent.Empty,
),
containerColor = TangemTheme.colors3.bg.primary,
type = TangemBottomSheetType.Modal,
title = {
TangemTopNavigation(
title = resourceReference(R.string.address_book_choose_address),
contentAlign = TangemTopNavigation.ContentAlign.Center,
blurBackground = false,
onClose = onDismiss,
)
},
content = {
val density = LocalDensity.current
var buttonHeight by remember { mutableStateOf(0.dp) }
Box(modifier = Modifier.fillMaxWidth()) {
AddressSelectorList(
contact = contact,
onAddressClick = onAddressClick,
bottomContentPadding = buttonHeight,
)
TangemButton(
onClick = onDismiss,
text = resourceReference(R.string.common_cancel),
variant = TangemButton.Variant.Secondary,
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.onSizeChanged { size ->
with(density) { buttonHeight = size.height.toDp() }
}
.padding(16.dp),
size = TangemButton.Size.X12,
)
}
},
)
}
@Composable
private fun AddressSelectorList(
contact: MatchedContact,
onAddressClick: (MatchedContact.ContactAddress) -> Unit,
modifier: Modifier = Modifier,
bottomContentPadding: Dp = 0.dp,
) {
Column(
modifier = modifier
.padding(horizontal = 16.dp)
.padding(bottom = bottomContentPadding)
.background(
color = TangemTheme.colors3.bg.secondary,
shape = RoundedCornerShape(20.dp),
)
.verticalScroll(rememberScrollState()),
) {
contact.entries.fastForEach { entry ->
AddressRow(entry = entry, onClick = { onAddressClick(entry) })
}
}
}
@Composable
private fun AddressRow(entry: MatchedContact.ContactAddress, onClick: () -> Unit) {
TangemRow(
verticalAlignment = TangemRowVerticalAlignment.Center,
onClick = onClick,
startSlot = {
TangemIcon(
tangemIconUM = TangemIconUM.Ident(entry.address),
modifier = Modifier
.size(40.dp)
.clip(CircleShape),
)
},
titleSlot = {
TangemRowText(
text = entry.address,
role = TangemRowTextRole.Title,
overflow = TextOverflow.MiddleEllipsis,
)
},
subtitleSlot = {
TangemRowText(
text = entry.networkName,
role = TangemRowTextRole.Subtitle,
)
},
)
}
@Composable
@Preview(showBackground = true, widthDp = 360)
private fun Preview_AddressSelectorList() {
TangemThemePreviewRedesign {
AddressSelectorList(
contact = MatchedContact(
contactId = "1",
walletId = "00",
name = "Binance",
icon = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Letter,
color = CryptoPortfolioIcon.Color.Azure,
),
networkId = "ethereum",
entries = persistentListOf(
MatchedContact.ContactAddress(
address = "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D",
memo = null,
networkName = "Ethereum",
),
MatchedContact.ContactAddress(
address = "0x34B4492A412D84A6E606288f3Bd714b89135D4dE",
memo = "12345",
networkName = "Ethereum",
),
),
),
onAddressClick = {},
)
}
}

View file

@ -0,0 +1,206 @@
package com.tangem.features.addressbook.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_TYPE
import com.tangem.core.analytics.models.AnalyticsParam.Key.SOURCE
private const val ADDRESS_BOOK_CATEGORY = "Address Book"
private const val WALLET_ID = "Wallet Id"
private const val CONTACT_ID = "Contact Id"
private const val MODE = "Mode"
private const val CONTACTS_COUNT = "Contacts Count"
private const val ACTION = "Action"
sealed class AddressBookEvents(
event: String,
params: Map<String, String> = emptyMap(),
) : AnalyticsEvent(ADDRESS_BOOK_CATEGORY, event, params) {
// region Contact creation
data object SaveToButtonClicked : AddressBookEvents(event = "Button - Save To")
data object AddressScreenOpened : AddressBookEvents(event = "Address Screen Opened")
class ContactListScreenOpened(
walletId: String,
source: Source,
contactsCount: Int,
) : AddressBookEvents(
event = "Contact List Screen Opened",
params = mapOf(
WALLET_ID to walletId,
SOURCE to source.value,
CONTACTS_COUNT to contactsCount.toString(),
),
) {
enum class Source(val value: String) {
Settings("Settings"),
SendFlow("Send Flow"),
}
}
class AddContactTapped(
walletId: String,
source: Source,
) : AddressBookEvents(
event = "Add Contact Tapped",
params = mapOf(
WALLET_ID to walletId,
SOURCE to source.value,
),
) {
enum class Source(val value: String) {
Settings("Settings"),
SendSuccess("Send Flow"),
}
}
class ContactSaved(
walletId: String,
contactId: String,
mode: Mode,
) : AddressBookEvents(
event = "Contact Saved",
params = mapOf(
WALLET_ID to walletId,
CONTACT_ID to contactId,
MODE to mode.value,
),
) {
enum class Mode(val value: String) {
Create("Create"),
Edit("Edit"),
}
}
class SaveErrorShown(
walletId: String,
contactId: String?,
errorType: ErrorType,
) : AddressBookEvents(
event = "Save Error Shown",
params = buildMap {
put(WALLET_ID, walletId)
contactId?.let { put(CONTACT_ID, it) }
put(ERROR_TYPE, errorType.value)
},
) {
enum class ErrorType(val value: String) {
Network("Network"),
Server("Server"),
Signing("Signing"),
}
}
// endregion
// region Contact editing
class ContactScreenOpened(
walletId: String,
contactId: String,
) : AddressBookEvents(
event = "Contact Screen Opened",
params = mapOf(
WALLET_ID to walletId,
CONTACT_ID to contactId,
),
)
// endregion
// region Send flow
class SendFlowWidgetShown(
walletId: String,
) : AddressBookEvents(
event = "Send Flow Widget Shown",
params = mapOf(WALLET_ID to walletId),
)
class ContactSelectedInSend(
walletId: String,
contactId: String,
) : AddressBookEvents(
event = "Contact Selected",
params = mapOf(
WALLET_ID to walletId,
CONTACT_ID to contactId,
),
)
class AddressSubstitutedInSend(
walletId: String,
contactId: String,
) : AddressBookEvents(
event = "Address Substituted In Send",
params = mapOf(
WALLET_ID to walletId,
CONTACT_ID to contactId,
),
)
// endregion
// region Input errors
class AddressInvalid(
walletId: String,
contactId: String?,
) : AddressBookEvents(
event = "Address Invalid",
params = buildMap {
put(WALLET_ID, walletId)
put(CONTACT_ID, contactId.orEmpty())
},
)
class DuplicateNameErrorShown(
walletId: String,
contactId: String?,
) : AddressBookEvents(
event = "Duplicate Name Error Shown",
params = buildMap {
put(WALLET_ID, walletId)
contactId?.let { put(CONTACT_ID, it) }
},
)
// endregion
// region Choose network
class SelectAllNetworksTapped(
walletId: String,
action: Action,
) : AddressBookEvents(
event = "Select All Networks Tapped",
params = mapOf(
WALLET_ID to walletId,
ACTION to action.value,
),
) {
enum class Action(val value: String) {
SelectAll("Select All"),
ClearAll("Clear All"),
}
}
// endregion
// region Deletion
class AddressRemoved(
walletId: String,
contactId: String,
) : AddressBookEvents(
event = "Address Removed",
params = mapOf(
WALLET_ID to walletId,
CONTACT_ID to contactId,
),
)
class ContactDeleted(
walletId: String,
contactId: String,
) : AddressBookEvents(
event = "Contact Deleted",
params = mapOf(
WALLET_ID to walletId,
CONTACT_ID to contactId,
),
)
// endregion
}

View file

@ -0,0 +1,36 @@
package com.tangem.features.addressbook.block
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.features.addressbook.AddressBookContactsBlockComponent
import com.tangem.features.addressbook.block.model.ContactsBlockModel
import com.tangem.features.addressbook.block.ui.ContactsBlock
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultAddressBookContactsBlockComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted params: AddressBookContactsBlockComponent.Params,
) : AddressBookContactsBlockComponent, AppComponentContext by appComponentContext {
private val model: ContactsBlockModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle()
ContactsBlock(state = state, modifier = modifier)
}
@AssistedFactory
interface Factory : AddressBookContactsBlockComponent.Factory {
override fun create(
context: AppComponentContext,
params: AddressBookContactsBlockComponent.Params,
): DefaultAddressBookContactsBlockComponent
}
}

View file

@ -0,0 +1,77 @@
package com.tangem.features.addressbook.block.model
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.domain.addressbook.interactor.GetVerifiedContactsInteractor
import com.tangem.domain.addressbook.usecase.SyncAddressBooksUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.addressbook.AddressBookContactsBlockComponent
import com.tangem.features.addressbook.MatchedContact
import com.tangem.features.addressbook.block.state.ContactsBlockStateController
import com.tangem.features.addressbook.block.state.transformers.UpdateContactsBlockStateTransformer
import com.tangem.features.addressbook.block.ui.state.ContactsBlockUM
import com.tangem.features.addressbook.common.AddressBookAnalyticsSender
import com.tangem.features.addressbook.common.ContactMatcher
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@ModelScoped
@Suppress("LongParameterList")
internal class ContactsBlockModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val stateController: ContactsBlockStateController,
private val analyticsSender: AddressBookAnalyticsSender,
private val syncAddressBooksUseCase: SyncAddressBooksUseCase,
getVerifiedContactsInteractor: GetVerifiedContactsInteractor,
getWalletsUseCase: GetWalletsUseCase,
) : Model() {
private var isWidgetShownReported = false
private val params = paramsContainer.require<AddressBookContactsBlockComponent.Params>()
val state: StateFlow<ContactsBlockUM> get() = stateController.uiState
init {
modelScope.launch(context = dispatchers.default) { syncAddressBooksUseCase() }
combine(
params.queryFlow.flatMapLatest { query ->
getVerifiedContactsInteractor.getVerifiedContacts(query = query, userWalletId = null)
.map { verified -> verified.map { it.contact } }
},
getWalletsUseCase.invokeAsMap(isOnlyMultiCurrency = false, filterLocked = true),
) { contacts, wallets -> contacts to wallets.values.toList() }
.onEach { (contacts, wallets) ->
val matched = ContactMatcher.match(contacts = contacts, networkId = params.network.rawId)
reportWidgetShownIfNeeded(matched.isNotEmpty())
stateController.update(
UpdateContactsBlockStateTransformer(
matched = matched,
walletNamesById = wallets.associate { it.walletId.stringValue to it.name },
shouldShowWalletName = matched.mapTo(HashSet()) { it.walletId }.size > 1,
onSeeAllClick = params.onSeeAllClick,
onContactClick = ::onContactClick,
),
)
}
.flowOn(dispatchers.default)
.launchIn(modelScope)
}
private fun reportWidgetShownIfNeeded(isVisible: Boolean) {
if (isVisible && !isWidgetShownReported) {
isWidgetShownReported = true
analyticsSender.sendSendFlowWidgetShown(scope = modelScope)
}
}
private fun onContactClick(contact: MatchedContact) {
analyticsSender.sendContactSelectedInSend(contactId = contact.contactId, scope = modelScope)
params.onContactClick(contact)
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.addressbook.block.state
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.features.addressbook.block.ui.state.ContactsBlockUM
import com.tangem.utils.transformer.Transformer
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
@ModelScoped
internal class ContactsBlockStateController @Inject constructor() {
val uiState: StateFlow<ContactsBlockUM>
field = MutableStateFlow<ContactsBlockUM>(value = ContactsBlockUM.Hidden)
fun update(transformer: Transformer<ContactsBlockUM>) {
uiState.update(function = transformer::transform)
}
}

View file

@ -0,0 +1,45 @@
package com.tangem.features.addressbook.block.state.transformers
import com.tangem.features.addressbook.MatchedContact
import com.tangem.features.addressbook.block.ui.state.ContactsBlockUM
import com.tangem.features.addressbook.list.ui.state.ContactUM
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.toImmutableList
/** Builds the Send contacts block from the network-matching contacts; an empty result hides the block. */
internal class UpdateContactsBlockStateTransformer(
private val matched: List<MatchedContact>,
private val walletNamesById: Map<String, String>,
private val shouldShowWalletName: Boolean,
private val onSeeAllClick: () -> Unit,
private val onContactClick: (MatchedContact) -> Unit,
) : Transformer<ContactsBlockUM> {
override fun transform(prevState: ContactsBlockUM): ContactsBlockUM {
return if (matched.isEmpty()) {
ContactsBlockUM.Hidden
} else {
ContactsBlockUM.Content(
contacts = matched
.take(MAX_CONTACTS)
.map { it.toRowUM() }.toImmutableList(),
onSeeAllClick = onSeeAllClick,
shouldShowSeeAll = matched.size > MAX_CONTACTS,
)
}
}
private fun MatchedContact.toRowUM(): ContactUM = ContactUM(
id = contactId,
walletId = walletId,
name = name,
icon = icon,
networkAddressCount = entries.size,
walletName = if (shouldShowWalletName) walletNamesById[walletId] else null,
onClick = { onContactClick(this) },
)
private companion object {
const val MAX_CONTACTS = 5
}
}

View file

@ -0,0 +1,103 @@
package com.tangem.features.addressbook.block.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
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.common.ui.account.AccountIconUM
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.features.addressbook.block.ui.state.ContactsBlockUM
import com.tangem.features.addressbook.common.ui.ContactRow
import com.tangem.features.addressbook.list.ui.state.ContactUM
import kotlinx.collections.immutable.persistentListOf
@Composable
internal fun ContactsBlock(state: ContactsBlockUM, modifier: Modifier = Modifier) {
if (state !is ContactsBlockUM.Content) return
Column(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(TangemTheme.colors3.bg.secondary),
) {
Header(onSeeAllClick = state.onSeeAllClick, shouldShowSeeAll = state.shouldShowSeeAll)
state.contacts.forEach { contact ->
ContactRow(contact = contact)
}
}
}
@Composable
private fun Header(onSeeAllClick: () -> Unit, shouldShowSeeAll: Boolean) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(top = 16.dp, bottom = 4.dp),
) {
Text(
text = stringResourceSafe(R.string.address_book_title),
color = TangemTheme.colors3.text.secondary,
style = TangemTheme.typography3.caption.medium,
modifier = Modifier.weight(1f),
)
if (shouldShowSeeAll) {
Text(
text = stringResourceSafe(R.string.common_view_all),
color = TangemTheme.colors3.text.brand,
style = TangemTheme.typography3.caption.medium,
modifier = Modifier.clickable(onClick = onSeeAllClick),
)
}
}
}
@Composable
@Preview(showBackground = true, widthDp = 360)
private fun Preview_ContactsBlock() {
TangemThemePreviewRedesign {
ContactsBlock(
state = ContactsBlockUM.Content(
contacts = persistentListOf(
ContactUM(
id = "1",
walletId = "00",
name = "Binance",
icon = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Letter,
color = CryptoPortfolioIcon.Color.Azure,
),
networkAddressCount = 1,
onClick = {},
),
ContactUM(
id = "2",
walletId = "01",
name = "Alice",
icon = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Letter,
color = CryptoPortfolioIcon.Color.UFOGreen,
),
networkAddressCount = 3,
onClick = {},
),
),
onSeeAllClick = {},
shouldShowSeeAll = true,
),
)
}
}

View file

@ -0,0 +1,18 @@
package com.tangem.features.addressbook.block.ui.state
import androidx.compose.runtime.Immutable
import com.tangem.features.addressbook.list.ui.state.ContactUM
import kotlinx.collections.immutable.ImmutableList
/** UI state of the Send contacts block. [Hidden] is rendered as nothing (no matching contacts / feature off). */
@Immutable
internal sealed interface ContactsBlockUM {
data object Hidden : ContactsBlockUM
data class Content(
val shouldShowSeeAll: Boolean,
val contacts: ImmutableList<ContactUM>,
val onSeeAllClick: () -> Unit,
) : ContactsBlockUM
}

View file

@ -0,0 +1,171 @@
package com.tangem.features.addressbook.common
import com.tangem.common.extensions.calculateSha256
import com.tangem.common.extensions.toHexString
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.domain.addressbook.error.AddressBookSyncError
import com.tangem.domain.addressbook.error.SaveContactError
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.addressbook.AddressBookSendAnalytics
import com.tangem.features.addressbook.analytics.AddressBookEvents
import com.tangem.features.addressbook.analytics.AddressBookEvents.ContactListScreenOpened.Source
import com.tangem.features.addressbook.analytics.AddressBookEvents.SaveErrorShown.ErrorType
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
internal class AddressBookAnalyticsSender @Inject constructor(
private val analyticsEventHandler: AnalyticsEventHandler,
private val userWalletsListRepository: UserWalletsListRepository,
private val dispatcherProvider: CoroutineDispatcherProvider,
) : AddressBookSendAnalytics {
fun sendContactListScreenOpened(source: Source, contactsCount: Int, scope: CoroutineScope) {
scope.launch(dispatcherProvider.default) {
analyticsEventHandler.send(
AddressBookEvents.ContactListScreenOpened(
walletId = selectedWalletId(),
source = source,
contactsCount = contactsCount,
),
)
}
}
fun sendContactScreenOpened(contactId: String, scope: CoroutineScope) {
scope.launch(dispatcherProvider.default) {
analyticsEventHandler.send(
AddressBookEvents.ContactScreenOpened(walletId = selectedWalletId(), contactId = contactId),
)
}
}
fun sendSendFlowWidgetShown(scope: CoroutineScope) {
scope.launch(dispatcherProvider.default) {
analyticsEventHandler.send(AddressBookEvents.SendFlowWidgetShown(walletId = selectedWalletId()))
}
}
fun sendContactSelectedInSend(contactId: String, scope: CoroutineScope) {
scope.launch(dispatcherProvider.default) {
analyticsEventHandler.send(
AddressBookEvents.ContactSelectedInSend(walletId = selectedWalletId(), contactId = contactId),
)
}
}
override fun onAddressSubstitutedInSend(walletId: UserWalletId, contactId: String) {
analyticsEventHandler.send(
AddressBookEvents.AddressSubstitutedInSend(walletId = walletId.hashForAnalytics(), contactId = contactId),
)
}
fun sendAddressInvalid(walletId: UserWalletId, contactId: String) {
analyticsEventHandler.send(
AddressBookEvents.AddressInvalid(walletId = walletId.hashForAnalytics(), contactId = contactId),
)
}
fun sendDuplicateNameErrorShown(walletId: UserWalletId, contactId: String?) {
analyticsEventHandler.send(
AddressBookEvents.DuplicateNameErrorShown(walletId = walletId.hashForAnalytics(), contactId = contactId),
)
}
fun sendAddressRemoved(walletId: UserWalletId, contactId: String) {
analyticsEventHandler.send(
AddressBookEvents.AddressRemoved(walletId = walletId.hashForAnalytics(), contactId = contactId),
)
}
fun sendContactDeleted(walletId: UserWalletId, contactId: String) {
analyticsEventHandler.send(
AddressBookEvents.ContactDeleted(walletId = walletId.hashForAnalytics(), contactId = contactId),
)
}
fun sendSelectAllNetworksTapped(action: AddressBookEvents.SelectAllNetworksTapped.Action, scope: CoroutineScope) {
scope.launch(dispatcherProvider.default) {
analyticsEventHandler.send(
AddressBookEvents.SelectAllNetworksTapped(walletId = selectedWalletId(), action = action),
)
}
}
fun sendAddContactTapped(fromSendSuccess: Boolean, scope: CoroutineScope) {
scope.launch(dispatcherProvider.default) {
analyticsEventHandler.send(
AddressBookEvents.AddContactTapped(
walletId = selectedWalletId(),
source = if (fromSendSuccess) {
AddressBookEvents.AddContactTapped.Source.SendSuccess
} else {
AddressBookEvents.AddContactTapped.Source.Settings
},
),
)
}
}
fun sendContactSaved(walletId: UserWalletId, contactId: String, isEdit: Boolean) {
analyticsEventHandler.send(
AddressBookEvents.ContactSaved(
walletId = walletId.hashForAnalytics(),
contactId = contactId,
mode = if (isEdit) {
AddressBookEvents.ContactSaved.Mode.Edit
} else {
AddressBookEvents.ContactSaved.Mode.Create
},
),
)
}
/**
* Fired when a save failure is surfaced to the user. [contactId] is set only in edit mode. Validation failures
* ([SaveContactError.Name]) are shown inline rather than as a save error, so they do not produce this event.
*/
fun sendSaveErrorShown(walletId: UserWalletId, contactId: String?, error: SaveContactError) {
val errorType = error.toErrorType() ?: return
analyticsEventHandler.send(
AddressBookEvents.SaveErrorShown(
walletId = walletId.hashForAnalytics(),
contactId = contactId,
errorType = errorType,
),
)
}
private fun SaveContactError.toErrorType(): ErrorType? = when (this) {
is SaveContactError.Signing -> ErrorType.Signing
// Network means the backend was unreachable; every other backend outcome (5xx, 412, other codes) is Server.
is SaveContactError.Backend -> when (error) {
AddressBookSyncError.Network -> ErrorType.Network
else -> ErrorType.Server
}
is SaveContactError.Name -> null
}
fun sendSaveToButtonClicked() {
analyticsEventHandler.send(AddressBookEvents.SaveToButtonClicked)
}
fun sendAddressScreenOpened() {
analyticsEventHandler.send(AddressBookEvents.AddressScreenOpened)
}
private suspend fun selectedWalletId(): String = userWalletsListRepository.selectedUserWallet
.filterNotNull()
.first()
.walletId
.hashForAnalytics()
/** Analytics must never receive a raw wallet id — send its SHA-256 (uppercase hex) instead. */
private fun UserWalletId.hashForAnalytics(): String = value.calculateSha256().toHexString()
}

View file

@ -0,0 +1,80 @@
package com.tangem.features.addressbook.common
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.addressbook.model.ContactId
import com.tangem.features.addressbook.AddressSelectorComponent
import com.tangem.features.addressbook.addaddress.DefaultAddAddressComponent
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 com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent
import kotlinx.collections.immutable.persistentListOf
import javax.inject.Inject
/**
* Builds the child screens of the address book feature for a given [AddressBookRoute], wiring their callbacks to the
* container's [AddressBookClickIntents]. Mirrors the `FeedEntryChildFactory` pattern used by the feed feature.
*/
internal class AddressBookChildFactory @Inject constructor(
private val addressSelectorFactory: AddressSelectorComponent.Factory,
private val portfolioSelectorComponentFactory: PortfolioSelectorComponent.Factory,
) {
fun createChild(
route: AddressBookRoute,
context: AppComponentContext,
clickIntents: AddressBookClickIntents,
): ComposableContentComponent = when (route) {
is AddressBookRoute.List -> DefaultAddressBookListComponent(
appComponentContext = context,
params = DefaultAddressBookListComponent.Params(
mode = route.mode,
onContactClick = { clickIntents.onContactClick(ContactId(it)) },
onAddContactClick = clickIntents::onAddContactClick,
),
addressSelectorFactory = addressSelectorFactory,
)
is AddressBookRoute.EditContact -> DefaultEditContactComponent(
appComponentContext = context,
params = DefaultEditContactComponent.Params(
contactId = route.contactId?.let(::ContactId),
predefinedAddress = buildPredefinedAddress(route),
onBackClick = clickIntents::onEditContactBack,
onAddAddressClick = clickIntents::onAddAddressClick,
),
portfolioSelectorComponentFactory = portfolioSelectorComponentFactory,
)
is AddressBookRoute.AddAddress -> DefaultAddAddressComponent(
appComponentContext = context,
params = DefaultAddAddressComponent.Params(
walletId = route.walletId,
excludeContactId = route.excludeContactId,
prefillAddress = route.prefillAddress,
prefillNetworkIds = route.prefillNetworkIds,
prefillMemo = route.prefillMemo,
onBackClick = clickIntents::onAddAddressBack,
onSelectNetworksClick = clickIntents::onSelectNetworksClick,
onConfirm = clickIntents::onAddressConfirmed,
),
)
is AddressBookRoute.SelectNetworks -> DefaultSelectNetworksComponent(
appComponentContext = context,
params = DefaultSelectNetworksComponent.Params(
matchedNetworkIds = route.matchedNetworkIds,
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. */
private fun buildPredefinedAddress(route: AddressBookRoute.EditContact): ValidatedAddress? {
val address = route.predefinedAddress ?: return null
val networkId = route.predefinedNetworkId ?: return null
return ValidatedAddress(address = address, networkIds = persistentListOf(networkId))
}
}

View file

@ -0,0 +1,32 @@
package com.tangem.features.addressbook.common
import com.tangem.domain.addressbook.model.ContactId
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
/**
* Navigation/click contract that the container ([DefaultAddressBookComponent]) implements and passes down to its
* children through [AddressBookChildFactory]. Keeping all cross-screen intents in one place removes the need for the
* children to know about each other or about navigation.
*
* Result delivery (the confirmed address) is handled out-of-band by [AddressBookResultHolder], not by this contract.
*/
internal interface AddressBookClickIntents {
fun onContactClick(contactId: ContactId)
fun onAddContactClick()
fun onEditContactBack()
fun onAddAddressClick(walletId: String, excludeContactId: String?, prefill: ValidatedAddress?)
fun onAddAddressBack()
fun onAddressConfirmed(address: ValidatedAddress, replaces: String?)
fun onSelectNetworksClick(matchedNetworkIds: List<String>, selectedNetworkIds: List<String>)
fun onSelectNetworksBack()
fun onNetworksSelected(selectedNetworkIds: Set<String>)
}

View file

@ -0,0 +1,34 @@
package com.tangem.features.addressbook.common
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
import javax.inject.Singleton
/**
* Carries an address confirmed on the AddAddress screen over to the EditContact screen.
*
* The two screens live in independent model scopes, so a shared singleton holder is used to hand the result over
* instead of routing it through navigation/click intents. The producer calls [setConfirmedAddress]; the consumer
* observes [confirmedAddress] and calls [clear] after applying the value so it is not re-applied on resubscription.
*/
@Singleton
internal class AddressBookResultHolder @Inject constructor() {
val confirmedAddress: StateFlow<ConfirmedAddress?>
field = MutableStateFlow<ConfirmedAddress?>(null)
fun setConfirmedAddress(confirmed: ConfirmedAddress) {
confirmedAddress.value = confirmed
}
fun clear() {
confirmedAddress.value = null
}
}
internal data class ConfirmedAddress(
val address: ValidatedAddress,
val replaces: String?,
)

View file

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

View file

@ -0,0 +1,50 @@
package com.tangem.features.addressbook.common
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.domain.addressbook.model.AddressEntry
import com.tangem.domain.addressbook.model.Contact
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.features.addressbook.MatchedContact
import kotlinx.collections.immutable.toImmutableList
/**
* Maps contacts to [MatchedContact]s for a given [networkId], keeping only those that have at least one address in that
* network (with just the matching entries). Name/address query filtering is done upstream by `GetContactsUseCase`.
*/
internal object ContactMatcher {
fun match(contacts: List<Contact>, networkId: String): List<MatchedContact> {
return contacts.mapNotNull { contact ->
val entries = contact.addresses.filter { it.networkId.value == networkId }
if (entries.isEmpty()) return@mapNotNull null
MatchedContact(
contactId = contact.id.value,
walletId = contact.walletId.stringValue,
name = contact.name.value,
icon = contact.toAvatarIcon(),
networkId = networkId,
entries = entries.map { entry ->
MatchedContact.ContactAddress(
address = entry.address,
memo = entry.memo,
networkName = entry.displayNetworkName(),
)
}.toImmutableList(),
)
}
}
/** Builds the contact avatar from the domain [Contact.icon] / [Contact.iconColor] (enum names), with fallbacks. */
private fun Contact.toAvatarIcon(): AccountIconUM.CryptoPortfolio = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.entries.firstOrNull { it.name == icon } ?: CryptoPortfolioIcon.Icon.Letter,
color = CryptoPortfolioIcon.Color.entries.firstOrNull { it.name == iconColor }
?: CryptoPortfolioIcon.Color.Azure,
)
/** Display network name is derived from [AddressEntry.networkId] — it is not stored in the encrypted payload. */
private fun AddressEntry.displayNetworkName(): String =
Blockchain.fromNetworkId(networkId.value)?.fullName ?: networkId.value
}

View file

@ -0,0 +1,153 @@
package com.tangem.features.addressbook.common
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.stack.Children
import com.arkivanov.decompose.extensions.compose.stack.animation.slide
import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.stack.StackNavigation
import com.arkivanov.decompose.router.stack.childStack
import com.arkivanov.decompose.router.stack.pop
import com.arkivanov.decompose.router.stack.pushNew
import com.tangem.common.routing.entity.AddressBookOpenMode
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.addressbook.model.ContactId
import com.tangem.features.addressbook.AddressBookComponent
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
import com.tangem.features.addressbook.route.AddressBookRoute
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultAddressBookComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: AddressBookComponent.Params,
private val childFactory: AddressBookChildFactory,
private val resultHolder: AddressBookResultHolder,
private val selectNetworksResultHolder: SelectNetworksResultHolder,
private val analyticsSender: AddressBookAnalyticsSender,
) : AddressBookComponent, AppComponentContext by context {
private val navigation = StackNavigation<AddressBookRoute>()
init {
// Drop any results left over from a previous session before the (possibly preloaded) stack starts collecting.
resultHolder.clear()
selectNetworksResultHolder.clear()
if (params.addressBookOpenMode is AddressBookOpenMode.WithContactCreation) {
analyticsSender.sendAddContactTapped(fromSendSuccess = true, scope = componentScope)
}
}
private val clickIntents = object : AddressBookClickIntents {
override fun onContactClick(contactId: ContactId) {
navigation.pushNew(AddressBookRoute.EditContact(contactId = contactId.value))
}
override fun onAddContactClick() {
analyticsSender.sendAddContactTapped(fromSendSuccess = false, scope = componentScope)
navigation.pushNew(AddressBookRoute.EditContact())
}
override fun onEditContactBack() {
navigation.pop { isPopped ->
if (!isPopped) router.pop()
}
}
override fun onAddAddressClick(walletId: String, excludeContactId: String?, prefill: ValidatedAddress?) {
navigation.pushNew(
AddressBookRoute.AddAddress(
walletId = walletId,
excludeContactId = excludeContactId,
prefillAddress = prefill?.address,
prefillNetworkIds = prefill?.networkIds.orEmpty(),
prefillMemo = prefill?.memo,
),
)
}
override fun onAddAddressBack() {
navigation.pop()
}
override fun onAddressConfirmed(address: ValidatedAddress, replaces: String?) {
resultHolder.setConfirmedAddress(ConfirmedAddress(address = address, replaces = replaces))
navigation.pop()
}
override fun onSelectNetworksClick(matchedNetworkIds: List<String>, selectedNetworkIds: List<String>) {
navigation.pushNew(
AddressBookRoute.SelectNetworks(
matchedNetworkIds = matchedNetworkIds,
selectedNetworkIds = selectedNetworkIds,
),
)
}
override fun onSelectNetworksBack() {
navigation.pop()
}
override fun onNetworksSelected(selectedNetworkIds: Set<String>) {
selectNetworksResultHolder.setSelectedNetworkIds(selectedNetworkIds)
navigation.pop()
}
}
private val contentStack = childStack(
key = "address_book_stack",
source = navigation,
serializer = AddressBookRoute.serializer(),
initialStack = ::initialStack,
handleBackButton = false,
childFactory = ::screenChild,
)
@Composable
override fun Content(modifier: Modifier) {
val childStack by contentStack.subscribeAsState()
Children(
modifier = modifier,
stack = childStack,
animation = stackAnimation(slide()),
) { child ->
child.instance.Content(Modifier)
}
}
private fun screenChild(config: AddressBookRoute, componentContext: ComponentContext): ComposableContentComponent {
return childFactory.createChild(
route = config,
context = childByContext(componentContext),
clickIntents = clickIntents,
)
}
private fun initialStack(): List<AddressBookRoute> = when (val mode = params.addressBookOpenMode) {
AddressBookOpenMode.Default -> listOf(AddressBookRoute.List())
is AddressBookOpenMode.ContactSelection -> listOf(
AddressBookRoute.List(mode = AddressBookRoute.ListMode.Selector(networkId = mode.networkId)),
)
is AddressBookOpenMode.WithContactCreation -> listOf(
AddressBookRoute.EditContact(
predefinedAddress = mode.address,
predefinedNetworkId = mode.networkId,
),
)
}
@AssistedFactory
interface Factory : AddressBookComponent.Factory {
override fun create(
context: AppComponentContext,
params: AddressBookComponent.Params,
): DefaultAddressBookComponent
}
}

View file

@ -1,7 +1,8 @@
package com.tangem.features.addressbook
package com.tangem.features.addressbook.common
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.features.addressbook.AddressBookFeatureToggles
internal class DefaultAddressBookFeatureToggles(
private val featureTogglesManager: FeatureTogglesManager,

View file

@ -0,0 +1,34 @@
package com.tangem.features.addressbook.common
import com.tangem.features.addressbook.ContactSelectionListener
import com.tangem.features.addressbook.ContactSelectionTrigger
import com.tangem.features.addressbook.SelectedContact
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import javax.inject.Inject
import javax.inject.Singleton
/**
* One-shot delivery of a contact picked on the full address-book list back to the Send flow.
*
* Implements both [ContactSelectionTrigger] (the list emits) and [ContactSelectionListener] (Send collects). The flow
* is no-replay with a 1-item buffer so [trigger] is non-blocking ([tryEmit]) the picker can always close even if the
* collector is momentarily absent; nothing is retained for late subscribers, so there is no stale value to clear.
*/
@Singleton
internal class DefaultContactSelectionTrigger @Inject constructor() :
ContactSelectionTrigger,
ContactSelectionListener {
private val mutableResultFlow = MutableSharedFlow<SelectedContact>(
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
override val resultFlow: SharedFlow<SelectedContact> = mutableResultFlow
override fun trigger(contact: SelectedContact) {
mutableResultFlow.tryEmit(contact)
}
}

View file

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

View file

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

View file

@ -0,0 +1,72 @@
package com.tangem.features.addressbook.common.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.common.ui.account.AccountIcon
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.core.ui.R
import com.tangem.core.ui.components.account.AccountIconSize
import com.tangem.core.ui.ds2.row.*
import com.tangem.core.ui.extensions.pluralStringResourceSafe
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.features.addressbook.list.ui.state.ContactUM
import com.tangem.utils.StringsSigns
@Composable
internal fun ContactRow(contact: ContactUM, modifier: Modifier = Modifier) {
TangemRow(
modifier = modifier,
onClick = contact.onClick,
verticalAlignment = TangemRowVerticalAlignment.Center,
contentLead = TangemRowContentLead.Start,
startSlot = {
AccountIcon(
name = stringReference(contact.name),
icon = contact.icon,
size = AccountIconSize.ContactDefault,
)
},
titleSlot = {
TangemRowText(
text = contact.name,
role = TangemRowTextRole.Title,
)
},
subtitleSlot = {
val addresses = pluralStringResourceSafe(
R.plurals.address_book_addresses,
contact.networkAddressCount,
contact.networkAddressCount,
)
TangemRowText(
text = contact.walletName?.let { walletName -> "$addresses ${StringsSigns.DOT} $walletName" }
?: addresses,
role = TangemRowTextRole.Subtitle,
)
},
)
}
@Composable
@Preview(showBackground = true, widthDp = 360)
private fun Preview_ContactRow() {
TangemThemePreviewRedesign {
ContactRow(
ContactUM(
id = "1",
walletId = "00",
name = "Binance",
icon = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Letter,
color = CryptoPortfolioIcon.Color.Azure,
),
walletName = "Wallet 1",
networkAddressCount = 1,
onClick = {},
),
)
}
}

View file

@ -1,21 +0,0 @@
package com.tangem.features.addressbook.component
import kotlinx.serialization.Serializable
@Serializable
internal sealed class AddressBookRoute {
@Serializable
data object List : AddressBookRoute()
/**
* if [contactId] is not null we should fetch existing contact
*/
@Serializable
data class EditContact(
val contactId: String? = null,
) : AddressBookRoute()
@Serializable
data object AddAddress : AddressBookRoute()
}

View file

@ -1,108 +0,0 @@
package com.tangem.features.addressbook.component
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.stack.Children
import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.stack.StackNavigation
import com.arkivanov.decompose.router.stack.childStack
import com.arkivanov.decompose.router.stack.pop
import com.arkivanov.decompose.router.stack.pushNew
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.addressbook.model.ContactId
import com.tangem.features.addressbook.AddressBookComponent
import com.tangem.features.addressbook.addaddress.AddAddressComponent
import com.tangem.features.addressbook.editcontact.EditContactComponent
import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress
import com.tangem.features.addressbook.list.AddressBookListComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultAddressBookComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted private val params: AddressBookComponent.Params,
private val addressBookListComponentFactory: AddressBookListComponent.Factory,
private val editContactComponentFactory: EditContactComponent.Factory,
private val addAddressComponentFactory: AddAddressComponent.Factory,
) : AddressBookComponent, AppComponentContext by context {
private val navigation = StackNavigation<AddressBookRoute>()
/**
* Consumer for the address entered on the [AddressBookRoute.AddAddress] screen, registered by the EditContact
* screen when it requests adding an address and invoked when AddAddress confirms. Transient by design the
* entered addresses live only in EditContact's in-memory state until the contact is saved.
*/
private var pendingAddressSink: ((ValidatedAddress) -> Unit)? = null
private val contentStack = childStack(
key = "address_book_stack",
source = navigation,
serializer = AddressBookRoute.serializer(),
initialConfiguration = AddressBookRoute.List,
handleBackButton = false,
childFactory = ::screenChild,
)
@Suppress("ReusedModifierInstance")
@Composable
override fun Content(modifier: Modifier) {
val childStack by contentStack.subscribeAsState()
Children(stack = childStack, animation = stackAnimation()) { child ->
child.instance.Content(modifier = modifier)
}
}
private fun screenChild(config: AddressBookRoute, componentContext: ComponentContext): ComposableContentComponent =
when (config) {
AddressBookRoute.List -> addressBookListComponentFactory.create(
context = childByContext(componentContext),
params = AddressBookListComponent.Params(
onContactClick = { contactId ->
navigation.pushNew(AddressBookRoute.EditContact(contactId))
},
onAddContactClick = { navigation.pushNew(AddressBookRoute.EditContact()) },
),
)
is AddressBookRoute.EditContact -> editContactComponentFactory.create(
context = childByContext(componentContext),
params = EditContactComponent.Params(
contactId = config.contactId?.let(::ContactId),
onBackClick = { navigation.pop() },
onAddAddressClick = { onResult ->
pendingAddressSink = onResult
navigation.pushNew(AddressBookRoute.AddAddress)
},
),
)
AddressBookRoute.AddAddress -> addAddressComponentFactory.create(
context = childByContext(componentContext),
params = AddAddressComponent.Params(
onBackClick = {
pendingAddressSink = null
navigation.pop()
},
onConfirm = { address ->
pendingAddressSink?.invoke(address)
pendingAddressSink = null
navigation.pop()
},
),
)
}
@AssistedFactory
interface Factory : AddressBookComponent.Factory {
override fun create(
context: AppComponentContext,
params: AddressBookComponent.Params,
): DefaultAddressBookComponent
}
}

View file

@ -1,13 +1,11 @@
package com.tangem.features.addressbook.di
import com.tangem.features.addressbook.AddressBookComponent
import com.tangem.features.addressbook.addaddress.AddAddressComponent
import com.tangem.features.addressbook.addaddress.DefaultAddAddressComponent
import com.tangem.features.addressbook.component.DefaultAddressBookComponent
import com.tangem.features.addressbook.list.AddressBookListComponent
import com.tangem.features.addressbook.list.DefaultAddressBookListComponent
import com.tangem.features.addressbook.editcontact.DefaultEditContactComponent
import com.tangem.features.addressbook.editcontact.EditContactComponent
import com.tangem.features.addressbook.*
import com.tangem.features.addressbook.addressselector.DefaultAddressSelectorComponent
import com.tangem.features.addressbook.block.DefaultAddressBookContactsBlockComponent
import com.tangem.features.addressbook.common.AddressBookAnalyticsSender
import com.tangem.features.addressbook.common.DefaultAddressBookComponent
import com.tangem.features.addressbook.common.DefaultContactSelectionTrigger
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -24,15 +22,25 @@ internal interface AddressBookComponentModule {
@Binds
@Singleton
fun bindAddressBookListComponentFactory(
factory: DefaultAddressBookListComponent.Factory,
): AddressBookListComponent.Factory
fun bindContactsBlockComponentFactory(
factory: DefaultAddressBookContactsBlockComponent.Factory,
): AddressBookContactsBlockComponent.Factory
@Binds
@Singleton
fun bindEditContactComponentFactory(factory: DefaultEditContactComponent.Factory): EditContactComponent.Factory
fun bindAddressSelectorComponentFactory(
factory: DefaultAddressSelectorComponent.Factory,
): AddressSelectorComponent.Factory
@Binds
@Singleton
fun bindAddAddressComponentFactory(factory: DefaultAddAddressComponent.Factory): AddAddressComponent.Factory
fun bindContactSelectionTrigger(impl: DefaultContactSelectionTrigger): ContactSelectionTrigger
@Binds
@Singleton
fun bindContactSelectionListener(impl: DefaultContactSelectionTrigger): ContactSelectionListener
@Binds
@Singleton
fun bindAddressBookSendAnalytics(impl: AddressBookAnalyticsSender): AddressBookSendAnalytics
}

View file

@ -3,8 +3,11 @@ package com.tangem.features.addressbook.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.addressbook.addaddress.model.AddAddressModel
import com.tangem.features.addressbook.addressinfo.model.AddressInfoModel
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
@ -20,6 +23,11 @@ internal interface AddressBookModelModule {
@ClassKey(AddressBookListModel::class)
fun bindAddressBookModel(model: AddressBookListModel): Model
@Binds
@IntoMap
@ClassKey(ContactsBlockModel::class)
fun bindContactsBlockModel(model: ContactsBlockModel): Model
@Binds
@IntoMap
@ClassKey(EditContactModel::class)
@ -29,4 +37,14 @@ internal interface AddressBookModelModule {
@IntoMap
@ClassKey(AddAddressModel::class)
fun bindAddAddressModel(model: AddAddressModel): Model
@Binds
@IntoMap
@ClassKey(SelectNetworksModel::class)
fun bindSelectNetworksModel(model: SelectNetworksModel): Model
@Binds
@IntoMap
@ClassKey(AddressInfoModel::class)
fun bindAddressInfoModel(model: AddressInfoModel): Model
}

View file

@ -2,7 +2,7 @@ package com.tangem.features.addressbook.di
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.features.addressbook.AddressBookFeatureToggles
import com.tangem.features.addressbook.DefaultAddressBookFeatureToggles
import com.tangem.features.addressbook.common.DefaultAddressBookFeatureToggles
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn

View file

@ -5,36 +5,88 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.addressbook.model.ContactId
import com.tangem.features.addressbook.addressinfo.DefaultAddressInfoComponent
import com.tangem.features.addressbook.editcontact.model.EditContactModel
import com.tangem.features.addressbook.editcontact.ui.EditContactContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent
import kotlinx.serialization.builtins.serializer
internal class DefaultEditContactComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted params: EditContactComponent.Params,
) : EditContactComponent, AppComponentContext by context {
internal class DefaultEditContactComponent(
appComponentContext: AppComponentContext,
params: Params,
private val portfolioSelectorComponentFactory: PortfolioSelectorComponent.Factory,
) : ComposableContentComponent, AppComponentContext by appComponentContext {
private val model: EditContactModel = getOrCreateModel(params)
private val portfolioSelectorSlot = childSlot(
source = model.portfolioSelectorNavigation,
serializer = Unit.serializer(),
key = "edit_contact_portfolio_selector_slot",
handleBackButton = false,
childFactory = { _, componentContext -> portfolioSelectorChild(componentContext) },
)
private val addressInfoSlot = childSlot(
source = model.addressInfoNavigation,
serializer = String.serializer(),
key = "edit_contact_address_info_slot",
handleBackButton = false,
childFactory = { address, componentContext -> addressInfoChild(address, componentContext) },
)
private fun portfolioSelectorChild(componentContext: ComponentContext): ComposableBottomSheetComponent =
portfolioSelectorComponentFactory.create(
context = childByContext(componentContext),
params = PortfolioSelectorComponent.Params(
portfolioFetcher = model.portfolioFetcher,
controller = model.portfolioSelectorController,
bsCallback = model.portfolioSelectorCallback,
settings = PortfolioSelectorComponent.Settings(isWalletSelectionOnly = true),
),
)
private fun addressInfoChild(address: String, componentContext: ComponentContext): ComposableBottomSheetComponent =
DefaultAddressInfoComponent(
appComponentContext = childByContext(componentContext),
params = model.createAddressInfoParams(address),
)
@Composable
override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle()
val selectorSlot by portfolioSelectorSlot.subscribeAsState()
val infoSlot by addressInfoSlot.subscribeAsState()
BackHandler {
when {
selectorSlot.child != null -> model.portfolioSelectorCallback.onBack()
infoSlot.child != null -> model.addressInfoNavigation.dismiss()
else -> state.onCloseClick()
}
}
EditContactContent(
state = state,
modifier = modifier,
)
BackHandler(onBack = state.onCloseClick)
selectorSlot.child?.instance?.BottomSheet()
infoSlot.child?.instance?.BottomSheet()
}
@AssistedFactory
interface Factory : EditContactComponent.Factory {
override fun create(
context: AppComponentContext,
params: EditContactComponent.Params,
): DefaultEditContactComponent
}
data class Params(
val contactId: ContactId?,
val predefinedAddress: ValidatedAddress? = null,
val onBackClick: () -> Unit,
val onAddAddressClick: (walletId: String, excludeContactId: String?, prefill: ValidatedAddress?) -> Unit,
)
}

View file

@ -1,17 +0,0 @@
package com.tangem.features.addressbook.editcontact
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.addressbook.model.ContactId
import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress
internal interface EditContactComponent : ComposableContentComponent {
interface Factory : ComponentFactory<Params, EditContactComponent>
data class Params(
val contactId: ContactId?,
val onBackClick: () -> Unit,
val onAddAddressClick: (onResult: (ValidatedAddress) -> Unit) -> Unit,
)
}

View file

@ -1,14 +0,0 @@
package com.tangem.features.addressbook.editcontact.contract
import com.tangem.domain.models.network.Network
/**
* A recipient address that has been validated and resolved to a [Network] on the AddAddress screen.
*
* This is the in-progress (pre-save) representation accumulated in [EditContactUM]. It is converted to a domain
* `AddressEntry` only when the contact is persisted, since the entry's id and signature are produced at save time.
*/
data class ValidatedAddress(
val address: String,
val network: Network,
)

View file

@ -0,0 +1,44 @@
package com.tangem.features.addressbook.editcontact.model
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.blockchainsdk.utils.getSupportedTransactionExtras
import com.tangem.domain.addressbook.model.AddressEntry
import com.tangem.domain.addressbook.model.AddressEntryId
import com.tangem.domain.models.network.Network
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
import kotlinx.collections.immutable.toImmutableList
import java.util.UUID
internal class ContactAddressEntriesConverter {
fun convert(addresses: List<ValidatedAddress>): List<AddressEntry> {
return addresses.flatMap { address ->
address.networkIds.map { rawId -> address.toAddressEntry(rawId) }
}
}
fun toValidatedAddresses(entries: List<AddressEntry>): List<ValidatedAddress> {
return entries
.groupBy { it.address }
.map { (address, group) ->
ValidatedAddress(
address = address,
networkIds = group.map { it.networkId.value }.toImmutableList(),
memo = group.firstNotNullOfOrNull { it.memo },
)
}
}
private fun ValidatedAddress.toAddressEntry(rawId: String): AddressEntry {
val blockchain = Blockchain.fromNetworkId(rawId)
val hasExtrasSupport = blockchain?.getSupportedTransactionExtras()?.isTxExtrasSupported() == true
return AddressEntry(
id = AddressEntryId(UUID.randomUUID().toString()),
address = address,
networkId = Network.RawID(rawId),
memo = memo?.takeIf { hasExtrasSupport },
signature = "",
)
}
}

View file

@ -1,80 +1,570 @@
package com.tangem.features.addressbook.editcontact.model
import com.tangem.common.ui.account.AccountIconUM
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss
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.ui.UiMessageSender
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.domain.addressbook.error.ContactNameValidationError
import com.tangem.domain.addressbook.error.SaveContactError
import com.tangem.domain.addressbook.interactor.SaveContactInteractor
import com.tangem.domain.addressbook.model.Contact
import com.tangem.domain.addressbook.model.ContactName
import com.tangem.domain.addressbook.usecase.DeleteContactUseCase
import com.tangem.domain.addressbook.usecase.GetContactByIdUseCase
import com.tangem.domain.addressbook.validation.ContactNameValidator
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.features.addressbook.editcontact.EditContactComponent
import com.tangem.features.addressbook.editcontact.contract.EditContactUM
import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isLocked
import com.tangem.features.addressbook.addressinfo.DefaultAddressInfoComponent
import com.tangem.features.addressbook.common.AddressBookAnalyticsSender
import com.tangem.features.addressbook.common.AddressBookResultHolder
import com.tangem.features.addressbook.editcontact.DefaultEditContactComponent
import com.tangem.features.addressbook.editcontact.state.EditContactStateController
import com.tangem.features.addressbook.editcontact.state.transformers.*
import com.tangem.features.addressbook.editcontact.state.transformers.converter.ContactNameErrorConverter
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorController
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@Suppress("LongParameterList", "TooManyFunctions", "LargeClass", "NamedArguments")
@ModelScoped
internal class EditContactModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val stateController: EditContactStateController,
private val resultHolder: AddressBookResultHolder,
private val messageSender: UiMessageSender,
private val userWalletsListRepository: UserWalletsListRepository,
private val contactNameValidator: ContactNameValidator,
private val saveContactInteractor: SaveContactInteractor,
private val getContactByIdUseCase: GetContactByIdUseCase,
private val deleteContactUseCase: DeleteContactUseCase,
private val analyticsSender: AddressBookAnalyticsSender,
val portfolioSelectorController: PortfolioSelectorController,
portfolioFetcherFactory: PortfolioFetcher.Factory,
) : Model() {
private val params: EditContactComponent.Params = paramsContainer.require()
// region State
val state: StateFlow<EditContactUM>
field = MutableStateFlow(getInitialState())
private val params: DefaultEditContactComponent.Params = paramsContainer.require()
/** The contact being edited (null for a new contact). Drives create-vs-update and the delete/discard rules. */
private val loadedContact = MutableStateFlow<Contact?>(null)
/** The editor's starting point: the loaded contact once available, otherwise the empty (or predefined) new contact. */
private val newContactBaseline = EditSnapshot(
name = "",
colorName = CryptoPortfolioIcon.Color.entries.first().name,
addresses = listOfNotNull(params.predefinedAddress).map { it.address to it.networkIds.toSet() },
)
/** The in-flight save coroutine — its [Job.isActive] drives both the re-entrancy guard and the button state. */
private var saveJob: Job? = null
val state: StateFlow<EditContactUM> get() = stateController.uiState
val portfolioSelectorNavigation = SlotNavigation<Unit>()
val addressInfoNavigation = SlotNavigation<String>()
val portfolioFetcher: PortfolioFetcher by lazy {
portfolioFetcherFactory.create(
mode = PortfolioFetcher.Mode.All(isOnlyMultiCurrency = false),
scope = modelScope,
)
}
/** The wallet picked in the selector, if any — single source of the wallet the contact is being saved to. */
private val pickedWallet: StateFlow<UserWallet?> =
portfolioSelectorController.selectedAccountWithData(portfolioFetcher)
.map { it?.first }
.stateIn(modelScope, SharingStarted.Eagerly, null)
/**
* The wallet the contact is saved to. For an existing contact it is fixed to the contact's wallet; for a new
* contact it follows the selector pick and falls back to the app's currently selected wallet.
*/
private val selectedWallet: StateFlow<UserWallet?> = combine(
pickedWallet,
loadedContact,
userWalletsListRepository.selectedUserWallet,
userWalletsListRepository.userWallets,
) { picked, contact, currentSelected, wallets ->
when {
contact != null -> wallets?.firstOrNull { it.walletId == contact.walletId }
picked != null -> picked
else -> currentSelected
}
}.stateIn(modelScope, SharingStarted.Eagerly, null)
val portfolioSelectorCallback = object : PortfolioSelectorComponent.BottomSheetCallback {
override val onDismiss: () -> Unit = { portfolioSelectorNavigation.dismiss() }
override val onBack: () -> Unit = { portfolioSelectorNavigation.dismiss() }
}
// endregion
init {
updateInitialState()
prefillPredefinedAddress()
subscribeToConfirmedAddresses()
dismissSelectorOnPick()
observeWalletBlock()
observeNameValidation()
observeSaveButton()
loadExistingContact()
sendContactScreenOpenedEvent()
}
// region Initialization
private fun updateInitialState() {
stateController.update(
UpdateEditContactInitialStateTransformer(
isExistingContact = params.contactId != null,
onNameChange = ::onNameChange,
onColorSelect = ::onColorSelect,
onCloseClick = ::onCloseClick,
onAddAddressClick = ::onAddAddressClick,
onAddressClick = ::onAddressClick,
onSaveClick = ::onSaveClick,
onDeleteClick = if (params.contactId != null) ::onDeleteClick else null,
),
)
}
/** In WithContactCreation mode the contact opens with the already-known address attached. */
private fun prefillPredefinedAddress() {
params.predefinedAddress?.let(::addAddress)
}
/** Loads an existing contact and prefills the editor. A new contact needs nothing — its baseline is empty. */
private fun loadExistingContact() {
val contactId = params.contactId ?: return
modelScope.launch {
val contact = getContactByIdUseCase(contactId).first()
if (contact == null) {
params.onBackClick()
return@launch
}
loadedContact.value = contact
prefillFromContact(contact)
}
}
private fun prefillFromContact(contact: Contact) {
stateController.update(UpdateContactNameTransformer(name = contact.name.value))
CryptoPortfolioIcon.Color.entries.firstOrNull { it.name == contact.iconColor }
?.let { stateController.update(SelectContactColorTransformer(color = it)) }
val addresses = ContactAddressEntriesConverter().toValidatedAddresses(contact.addresses)
stateController.update(SetValidatedAddressesTransformer(addresses = addresses, maxAddresses = MAX_ADDRESSES))
}
// endregion
// region Subscriptions
private fun subscribeToConfirmedAddresses() {
resultHolder.confirmedAddress
.filterNotNull()
.onEach { confirmed ->
// Edit-address: the result carries the entry it supersedes, so we swap it in place.
confirmed.replaces?.let { old ->
stateController.update(
RemoveValidatedAddressTransformer(address = old, maxAddresses = MAX_ADDRESSES),
)
}
addAddress(confirmed.address)
resultHolder.clear()
}
.launchIn(modelScope)
}
/** Closes the wallet selector as soon as the user picks a wallet in it. */
private fun dismissSelectorOnPick() {
pickedWallet
.filterNotNull()
.onEach { portfolioSelectorNavigation.dismiss() }
.launchIn(modelScope)
}
private fun observeWalletBlock() {
combine(selectedWallet, userWalletsListRepository.userWallets) { wallet, wallets ->
UpdateWalletBlockTransformer(
walletName = wallet?.name.orEmpty(),
isChangeable = isWalletChangeable(wallets),
onClick = ::onWalletBlockClick,
)
}
.onEach(stateController::update)
.flowOn(dispatchers.default)
.launchIn(modelScope)
}
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
private fun observeNameValidation() {
combine(
stateController.uiState.map { it.name }.distinctUntilChanged().debounce(NAME_DEBOUNCE_MS),
selectedWallet.mapNotNull { it?.walletId }.distinctUntilChanged(),
) { name, walletId -> name to walletId }
.mapLatest { (name, walletId) -> walletId to validateName(name, walletId) }
.distinctUntilChanged()
.onEach { (walletId, error) ->
if (error == ContactNameValidationError.Duplicate) {
analyticsSender.sendDuplicateNameErrorShown(
walletId = walletId,
contactId = params.contactId?.value,
)
}
stateController.update(UpdateNameErrorTransformer(error?.let(ContactNameErrorConverter()::convert)))
}
.flowOn(dispatchers.default)
.launchIn(modelScope)
}
private fun observeSaveButton() {
// Recompute on input changes and on wallet changes (the wallet type drives the button's Tangem-logo icon).
combine(
stateController.uiState
.map { state ->
SaveButtonInputs(
name = state.name,
hasNameError = state.nameError != null,
hasAddresses = state.addresses.isNotEmpty(),
)
}
.distinctUntilChanged(),
selectedWallet.map { it is UserWallet.Cold }.distinctUntilChanged(),
) { _, _ -> }
.onEach { refreshSaveButton() }
.launchIn(modelScope)
}
// endregion
// region Clicks
private fun onNameChange(name: String) {
state.update { it.copy(name = name) }
stateController.update(UpdateContactNameTransformer(name = name))
}
private fun onColorSelect(color: CryptoPortfolioIcon.Color) {
state.update { oldState ->
oldState.copy(
colors = oldState.colors.copy(selected = color),
portfolioIcon = oldState.portfolioIcon.copy(color = color),
stateController.update(SelectContactColorTransformer(color = color))
}
private fun onWalletBlockClick() {
if (isWalletChangeable(userWalletsListRepository.userWallets.value)) {
analyticsSender.sendSaveToButtonClicked()
portfolioSelectorNavigation.activate(Unit)
}
}
private fun onAddAddressClick() {
if (stateController.uiState.value.addresses.size >= MAX_ADDRESSES) {
messageSender.send(
DialogMessage(
title = resourceReference(R.string.address_book_max_networks_alert_title),
message = resourceReference(R.string.address_book_max_networks_alert_description),
firstActionBuilder = {
EventMessageAction(
title = resourceReference(R.string.common_ok),
onClick = onDismissRequest,
)
},
),
)
} else {
val walletId = selectedWallet.value?.walletId?.stringValue ?: return
params.onAddAddressClick(walletId, params.contactId?.value, null)
}
}
private fun onSaveClick() {
if (saveJob?.isActive == true) return
val userWallet = selectedWallet.value ?: return
val ui = stateController.uiState.value
val addresses = ContactAddressEntriesConverter().convert(ui.addresses)
val existing = loadedContact.value
saveJob = modelScope.launch {
val result = if (existing != null) {
saveContactInteractor.updateContact(
userWallet = userWallet,
contact = existing,
name = ui.name,
iconColor = ui.colors.selected.name,
addresses = addresses,
)
} else {
saveContactInteractor.createContact(
userWallet = userWallet,
name = ui.name,
iconColor = ui.colors.selected.name,
addresses = addresses,
)
}
result.fold(
ifLeft = { error ->
handleSaveError(error)
analyticsSender.sendSaveErrorShown(
walletId = userWallet.walletId,
contactId = params.contactId?.value,
error = error,
)
saveJob?.cancel()
refreshSaveButton()
},
ifRight = { contact ->
if (existing == null) {
messageSender.send(
SnackbarMessage(
message = resourceReference(R.string.address_book_create_success_message),
startIconId = R.drawable.ic_success_20,
),
)
}
analyticsSender.sendContactSaved(
walletId = userWallet.walletId,
contactId = contact.id.value,
isEdit = params.contactId != null,
)
params.onBackClick()
},
)
}
refreshSaveButton()
}
private fun onCloseClick() {
if (isDirty()) showDiscardDialog() else params.onBackClick()
}
private fun onDeleteClick() {
showDeleteContactDialog(fromLastAddressRemoval = false)
}
private fun showDeleteContactDialog(fromLastAddressRemoval: Boolean) {
messageSender.send(
DialogMessage(
message = resourceReference(R.string.address_book_delete_contact_description),
firstActionBuilder = {
EventMessageAction(
title = resourceReference(R.string.common_delete),
isWarning = true,
onClick = { deleteContact(fromLastAddressRemoval = fromLastAddressRemoval) },
)
},
),
)
}
private fun onAddressClick(address: ValidatedAddress) {
addressInfoNavigation.activate(address.address)
}
fun createAddressInfoParams(address: String): DefaultAddressInfoComponent.Params {
val entry = stateController.uiState.value.addresses.firstOrNull { it.address == address }
return DefaultAddressInfoComponent.Params(
address = address,
networkCount = entry?.networkIds?.size ?: 0,
onEditAddress = { onEditAddress(address) },
onDeleteAddress = { onDeleteAddress(address) },
onDismiss = { addressInfoNavigation.dismiss() },
)
}
private fun onEditAddress(address: String) {
val entry = stateController.uiState.value.addresses.firstOrNull { it.address == address } ?: return
val walletId = selectedWallet.value?.walletId?.stringValue ?: return
addressInfoNavigation.dismiss()
params.onAddAddressClick(walletId, params.contactId?.value, entry)
}
private fun onDeleteAddress(address: String) {
addressInfoNavigation.dismiss()
val isLastAddress = stateController.uiState.value.addresses.size <= 1
if (isLastAddress && params.contactId != null) {
// Removing the last address deletes the whole contact — analytics is emitted from the confirmed delete.
showDeleteContactDialog(fromLastAddressRemoval = true)
} else {
contactWalletId()?.let { walletId ->
analyticsSender.sendAddressRemoved(walletId = walletId, contactId = params.contactId?.value.orEmpty())
}
stateController.update(RemoveValidatedAddressTransformer(address = address, maxAddresses = MAX_ADDRESSES))
}
}
// endregion
// region Helpers
private fun sendContactScreenOpenedEvent() {
analyticsSender.sendContactScreenOpened(contactId = params.contactId?.value.orEmpty(), scope = modelScope)
}
private fun addAddress(address: ValidatedAddress) {
stateController.update(AddValidatedAddressTransformer(address = address, maxAddresses = MAX_ADDRESSES))
}
private fun isWalletChangeable(wallets: List<UserWallet>?): Boolean {
val unlockedWalletsCount = wallets.orEmpty().count { !it.isLocked }
return params.contactId == null && unlockedWalletsCount > 1
}
private suspend fun validateName(name: String, walletId: UserWalletId): ContactNameValidationError? {
if (name.isBlank()) return null
if (name == loadedContact.value?.name?.value) return null
val error = contactNameValidator.validate(walletId, name).leftOrNull() ?: return null
if (error is ContactNameValidationError.Format && error.error is ContactName.Error.Empty) return null
return error
}
private fun refreshSaveButton() {
val ui = stateController.uiState.value
val isSaving = saveJob?.isActive == true
val isEnabled = ui.name.isNotBlank() && ui.nameError == null && ui.addresses.isNotEmpty() && !isSaving
stateController.update(
UpdateSaveButtonTransformer(
isEnabled = isEnabled,
isLoading = isSaving,
isColdWallet = selectedWallet.value is UserWallet.Cold,
),
)
}
private fun handleSaveError(error: SaveContactError) {
when (error) {
is SaveContactError.Name -> stateController.update(
UpdateNameErrorTransformer(ContactNameErrorConverter().convert(error.error)),
)
else -> messageSender.send(
DialogMessage(
title = resourceReference(R.string.common_something_went_wrong),
message = resourceReference(R.string.address_book_creating_error),
firstActionBuilder = {
EventMessageAction(
title = resourceReference(R.string.common_ok),
onClick = onDismissRequest,
)
},
),
)
}
}
private fun requestAddAddress() {
params.onAddAddressClick(::addAddress)
}
private fun addAddress(address: ValidatedAddress) {
state.update { it.copy(addresses = (it.addresses + address).toImmutableList()) }
}
private fun getInitialState(): EditContactUM {
val colors = CryptoPortfolioIcon.Color.entries.toImmutableList()
val selectedColor = colors.first()
val titleResId = if (params.contactId == null) {
R.string.address_book_new_contact
} else {
R.string.address_book_contact
private fun deleteContact(fromLastAddressRemoval: Boolean) {
val contactId = params.contactId ?: return
val walletId = contactWalletId()
// The address removal precedes the backend delete; the contact-deleted event follows a successful response.
if (fromLastAddressRemoval && walletId != null) {
analyticsSender.sendAddressRemoved(walletId = walletId, contactId = contactId.value)
}
return EditContactUM(
title = resourceReference(titleResId),
name = "",
namePlaceholder = resourceReference(R.string.address_book_new_contact),
portfolioIcon = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Letter,
color = selectedColor,
modelScope.launch {
deleteContactUseCase(contactId).fold(
ifLeft = { showDeleteError() },
ifRight = {
if (walletId != null) {
analyticsSender.sendContactDeleted(walletId = walletId, contactId = contactId.value)
}
params.onBackClick()
},
)
}
}
private fun contactWalletId(): UserWalletId? = loadedContact.value?.walletId ?: selectedWallet.value?.walletId
private fun showDiscardDialog() {
messageSender.send(
DialogMessage(
title = resourceReference(R.string.address_book_unsaved_changes),
message = resourceReference(R.string.address_book_unsaved_changes_description),
firstActionBuilder = {
EventMessageAction(
title = resourceReference(R.string.address_book_keep_editing),
onClick = onDismissRequest,
)
},
secondActionBuilder = {
EventMessageAction(
title = resourceReference(R.string.address_book_discard),
isWarning = true,
onClick = params.onBackClick,
)
},
),
colors = EditContactUM.Colors(
selected = selectedColor,
list = colors,
onColorSelect = ::onColorSelect,
),
addresses = persistentListOf(),
onNameChange = ::onNameChange,
onCloseClick = params.onBackClick,
onAddAddressClick = ::requestAddAddress,
)
}
private fun showDeleteError() {
messageSender.send(
DialogMessage(
title = resourceReference(R.string.common_something_went_wrong),
message = resourceReference(R.string.address_book_deleting_error),
),
)
}
/** Dirty when the current editor differs from its baseline — the loaded contact, or the empty new contact. */
private fun isDirty(): Boolean {
val baseline = loadedContact.value?.toSnapshot() ?: newContactBaseline
return currentSnapshot() != baseline
}
private fun currentSnapshot(): EditSnapshot {
val ui = stateController.uiState.value
return EditSnapshot(
name = ui.name,
colorName = ui.colors.selected.name,
addresses = ui.addresses.map { it.address to it.networkIds.toSet() },
)
}
private fun Contact.toSnapshot(): EditSnapshot = EditSnapshot(
name = name.value,
colorName = iconColor,
addresses = ContactAddressEntriesConverter().toValidatedAddresses(addresses)
.map { it.address to it.networkIds.toSet() },
)
// endregion
// region Models
private data class SaveButtonInputs(
val name: String,
val hasNameError: Boolean,
val hasAddresses: Boolean,
)
private data class EditSnapshot(
val name: String,
val colorName: String,
val addresses: List<Pair<String, Set<String>>>,
)
// endregion
private companion object {
const val MAX_ADDRESSES = 20
const val NAME_DEBOUNCE_MS = 300L
}
}

View file

@ -0,0 +1,67 @@
package com.tangem.features.addressbook.editcontact.state
import com.tangem.common.ui.account.AccountIconUM
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.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
@ModelScoped
internal class EditContactStateController @Inject constructor() {
val uiState: StateFlow<EditContactUM>
field = MutableStateFlow(value = getInitialState())
fun update(transformer: Transformer<EditContactUM>) {
uiState.update(function = transformer::transform)
}
private fun getInitialState(): EditContactUM {
val colors = CryptoPortfolioIcon.Color.entries.toImmutableList()
val selectedColor = colors.first()
return EditContactUM(
title = TextReference.EMPTY,
name = "",
namePlaceholder = resourceReference(R.string.address_book_new_contact),
nameError = null,
portfolioIcon = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Letter,
color = selectedColor,
),
colors = EditContactUM.Colors(
selected = selectedColor,
list = colors,
onColorSelect = {},
),
addresses = persistentListOf(),
walletBlock = EditContactUM.WalletBlockUM(
walletName = "",
isChangeable = false,
onClick = {},
),
isAddAddressEnabled = true,
saveButton = TangemButtonUM(
text = TextReference.Res(R.string.address_book_save_contact),
type = TangemButtonType.Primary,
isEnabled = false,
onClick = {},
),
onNameChange = {},
onCloseClick = {},
onAddAddressClick = {},
onAddressClick = {},
onDeleteClick = null,
)
}
}

View file

@ -0,0 +1,22 @@
package com.tangem.features.addressbook.editcontact.state.transformers
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.toImmutableList
internal class AddValidatedAddressTransformer(
private val address: ValidatedAddress,
private val maxAddresses: Int,
) : Transformer<EditContactUM> {
override fun transform(prevState: EditContactUM): EditContactUM {
// Skip duplicates: an address is identified by its string value (it already carries all its networks).
if (prevState.addresses.any { it.address == address.address }) return prevState
val addresses = (prevState.addresses + address).toImmutableList()
return prevState.copy(
addresses = addresses,
isAddAddressEnabled = addresses.size < maxAddresses,
)
}
}

View file

@ -0,0 +1,19 @@
package com.tangem.features.addressbook.editcontact.state.transformers
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.toImmutableList
internal class RemoveValidatedAddressTransformer(
private val address: String,
private val maxAddresses: Int,
) : Transformer<EditContactUM> {
override fun transform(prevState: EditContactUM): EditContactUM {
val addresses = prevState.addresses.filterNot { it.address == address }.toImmutableList()
return prevState.copy(
addresses = addresses,
isAddAddressEnabled = addresses.size < maxAddresses,
)
}
}

View file

@ -0,0 +1,17 @@
package com.tangem.features.addressbook.editcontact.state.transformers
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
import com.tangem.utils.transformer.Transformer
internal class SelectContactColorTransformer(
private val color: CryptoPortfolioIcon.Color,
) : Transformer<EditContactUM> {
override fun transform(prevState: EditContactUM): EditContactUM {
return prevState.copy(
colors = prevState.colors.copy(selected = color),
portfolioIcon = prevState.portfolioIcon.copy(color = color),
)
}
}

View file

@ -0,0 +1,19 @@
package com.tangem.features.addressbook.editcontact.state.transformers
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.toImmutableList
internal class SetValidatedAddressesTransformer(
private val addresses: List<ValidatedAddress>,
private val maxAddresses: Int,
) : Transformer<EditContactUM> {
override fun transform(prevState: EditContactUM): EditContactUM {
return prevState.copy(
addresses = addresses.toImmutableList(),
isAddAddressEnabled = addresses.size < maxAddresses,
)
}
}

View file

@ -0,0 +1,13 @@
package com.tangem.features.addressbook.editcontact.state.transformers
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
import com.tangem.utils.transformer.Transformer
internal class UpdateContactNameTransformer(
private val name: String,
) : Transformer<EditContactUM> {
override fun transform(prevState: EditContactUM): EditContactUM {
return prevState.copy(name = name)
}
}

View file

@ -0,0 +1,43 @@
package com.tangem.features.addressbook.editcontact.state.transformers
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
import com.tangem.utils.transformer.Transformer
/**
* Wires the title (derived from whether an existing contact is being edited) and the callbacks owned by
* [com.tangem.features.addressbook.editcontact.model.EditContactModel] into the initial state.
*/
@Suppress("LongParameterList")
internal class UpdateEditContactInitialStateTransformer(
private val isExistingContact: Boolean,
private val onNameChange: (String) -> Unit,
private val onColorSelect: (CryptoPortfolioIcon.Color) -> Unit,
private val onCloseClick: () -> Unit,
private val onAddAddressClick: () -> Unit,
private val onAddressClick: (ValidatedAddress) -> Unit,
private val onSaveClick: () -> Unit,
private val onDeleteClick: (() -> Unit)?,
) : Transformer<EditContactUM> {
override fun transform(prevState: EditContactUM): EditContactUM {
val titleResId = if (isExistingContact) {
R.string.address_book_contact
} else {
R.string.address_book_new_contact
}
return prevState.copy(
title = resourceReference(titleResId),
colors = prevState.colors.copy(onColorSelect = onColorSelect),
saveButton = prevState.saveButton.copy(onClick = onSaveClick),
onNameChange = onNameChange,
onCloseClick = onCloseClick,
onAddAddressClick = onAddAddressClick,
onAddressClick = onAddressClick,
onDeleteClick = onDeleteClick,
)
}
}

View file

@ -0,0 +1,12 @@
package com.tangem.features.addressbook.editcontact.state.transformers
import com.tangem.core.ui.extensions.TextReference
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
import com.tangem.utils.transformer.Transformer
internal class UpdateNameErrorTransformer(private val error: TextReference?) : Transformer<EditContactUM> {
override fun transform(prevState: EditContactUM): EditContactUM {
return prevState.copy(nameError = error)
}
}

View file

@ -0,0 +1,24 @@
package com.tangem.features.addressbook.editcontact.state.transformers
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_logo_tangem_24
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
import com.tangem.utils.transformer.Transformer
internal class UpdateSaveButtonTransformer(
private val isEnabled: Boolean,
private val isLoading: Boolean,
private val isColdWallet: Boolean,
) : Transformer<EditContactUM> {
override fun transform(prevState: EditContactUM): EditContactUM {
return prevState.copy(
saveButton = prevState.saveButton.copy(
isEnabled = isEnabled,
isLoading = isLoading,
tangemIconUM = TangemIconUM.Icon(imageVector = Icons.ic_logo_tangem_24).takeIf { isColdWallet },
),
)
}
}

View file

@ -0,0 +1,21 @@
package com.tangem.features.addressbook.editcontact.state.transformers
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
import com.tangem.utils.transformer.Transformer
internal class UpdateWalletBlockTransformer(
private val walletName: String,
private val isChangeable: Boolean,
private val onClick: () -> Unit,
) : Transformer<EditContactUM> {
override fun transform(prevState: EditContactUM): EditContactUM {
return prevState.copy(
walletBlock = EditContactUM.WalletBlockUM(
walletName = walletName,
isChangeable = isChangeable,
onClick = onClick,
),
)
}
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.addressbook.editcontact.state.transformers.converter
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.addressbook.error.ContactNameValidationError
import com.tangem.domain.addressbook.model.ContactName
import com.tangem.utils.converter.Converter
internal class ContactNameErrorConverter : Converter<ContactNameValidationError, TextReference> {
override fun convert(value: ContactNameValidationError): TextReference = when (value) {
ContactNameValidationError.Duplicate -> resourceReference(R.string.address_book_name_taken_error)
is ContactNameValidationError.Format -> when (value.error) {
ContactName.Error.ExceedsMaxLength -> resourceReference(R.string.address_book_name_max_chars_error)
ContactName.Error.InvalidCharacters -> resourceReference(R.string.address_book_name_invalid_chars_error)
ContactName.Error.Empty -> resourceReference(R.string.address_book_name_empty_error)
}
}
}

View file

@ -1,20 +1,23 @@
package com.tangem.features.addressbook.editcontact.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
@ -22,159 +25,328 @@ import com.tangem.common.ui.account.AccountIcon
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.common.ui.account.getUiColor
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.account.AccountIconSize
import com.tangem.core.ui.components.block.BlockCard
import com.tangem.core.ui.components.block.TangemBlockCardColors
import com.tangem.core.ui.components.fields.AutoSizeTextField
import com.tangem.core.ui.components.haze.hazeSourceTangem
import com.tangem.core.ui.ds.button.TangemButtonType
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.ds.image.TangemIcon
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.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.ds2.row.TangemRow
import com.tangem.core.ui.ds2.row.TangemRowText
import com.tangem.core.ui.ds2.row.TangemRowTextRole
import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment
import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation
import com.tangem.core.ui.extensions.*
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_logo_tangem_24
import com.tangem.core.ui.res.generated.icons.ic_sign_plus_20
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.features.addressbook.editcontact.contract.EditContactUM
import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@Composable
internal fun EditContactContent(state: EditContactUM, modifier: Modifier = Modifier) {
Column(
Box(
modifier = modifier
.fillMaxSize()
.background(color = TangemTheme.colors3.bg.primary)
.systemBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally,
.background(color = TangemTheme.colors3.bg.primary),
) {
TangemTopBar(
modifier = Modifier.statusBarsPadding(),
title = state.title,
endContent = {
TangemButton(
iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_close_24),
onClick = state.onCloseClick,
size = TangemButton.Size.X11,
variant = TangemButton.Variant.Material,
)
},
)
var topBarHeightPx by remember { mutableIntStateOf(0) }
val topBarHeight = with(LocalDensity.current) { topBarHeightPx.toDp() }
Column(
BoxWithConstraints(
modifier = Modifier
.padding(horizontal = 16.dp)
.weight(1f),
verticalArrangement = Arrangement.spacedBy(12.dp),
.fillMaxSize()
.hazeSourceTangem()
.background(color = TangemTheme.colors3.bg.primary)
.imePadding()
.navigationBarsPadding(),
) {
ContactSummary(state = state)
ContactColor(colors = state.colors)
ContactAddresses(addresses = state.addresses)
AddAddressRow(onClick = state.onAddAddressClick)
}
}
}
@Composable
private fun ContactAddresses(addresses: ImmutableList<ValidatedAddress>) {
if (addresses.isEmpty()) return
Column(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.fillMaxWidth()
.background(TangemTheme.colors3.bg.secondary),
) {
addresses.fastForEach { entry ->
val minContentHeight = maxHeight
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
.verticalScroll(rememberScrollState()),
) {
Text(
text = entry.network.name,
style = TangemTheme.typography3.caption.medium,
color = TangemTheme.colors3.text.tertiary,
)
Text(
text = entry.address,
style = TangemTheme.typography3.body.medium,
color = TangemTheme.colors3.text.primary,
maxLines = 1,
)
Column(
modifier = Modifier
.heightIn(min = minContentHeight)
.padding(horizontal = 16.dp)
.padding(top = topBarHeight + 12.dp, bottom = 12.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
ContactSummary(state = state)
ContactColor(colors = state.colors)
BlockCard(
shape = RoundedCornerShape(24.dp),
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors3.bg.secondary),
) {
ContactAddresses(addresses = state.addresses, onAddressClick = state.onAddressClick)
AddAddressRow(isEnabled = state.isAddAddressEnabled, onClick = state.onAddAddressClick)
}
WalletBlock(walletBlock = state.walletBlock)
state.onDeleteClick?.let {
DeleteContactButton(onClick = it)
}
}
Spacer(modifier = Modifier.weight(1f))
SaveButton(saveButton = state.saveButton)
}
}
}
TangemTopNavigation(
modifier = Modifier
.align(Alignment.TopCenter)
.onSizeChanged { topBarHeightPx = it.height },
title = state.title,
onClose = state.onCloseClick,
contentAlign = TangemTopNavigation.ContentAlign.Center,
)
}
}
@Composable
private fun AddAddressRow(onClick: () -> Unit) {
Row(
private fun SaveButton(saveButton: TangemButtonUM) {
TangemButton(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.fillMaxWidth()
.background(TangemTheme.colors3.bg.secondary)
.clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 15.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(TangemTheme.colors3.bg.status.infoSubtle),
) {
Icon(
modifier = Modifier.size(18.dp),
imageVector = ImageVector.vectorResource(R.drawable.ic_plus_24),
tint = TangemTheme.colors3.text.status.info,
contentDescription = null,
)
}
Column(
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = stringResourceSafe(R.string.address_book_add_address),
style = TangemTheme.typography3.body.medium,
color = TangemTheme.colors3.text.primary,
)
Text(
text = stringResourceSafe(R.string.address_book_add_address_description),
style = TangemTheme.typography3.caption.medium,
color = TangemTheme.colors3.text.tertiary,
)
}
.padding(top = 16.dp),
text = saveButton.text,
iconEnd = saveButton.tangemIconUM,
onClick = saveButton.onClick,
isEnabled = saveButton.isEnabled,
isLoading = saveButton.isLoading,
size = TangemButton.Size.X12,
)
}
@Composable
private fun ContactAddresses(addresses: ImmutableList<ValidatedAddress>, onAddressClick: (ValidatedAddress) -> Unit) {
addresses.fastForEach { entry ->
AddressRow(entry = entry, onClick = { onAddressClick(entry) })
}
}
@Composable
private fun AddressRow(entry: ValidatedAddress, onClick: () -> Unit) {
TangemRow(
verticalAlignment = TangemRowVerticalAlignment.Center,
onClick = onClick,
startSlot = {
TangemIcon(
tangemIconUM = TangemIconUM.Ident(text = entry.address),
modifier = Modifier
.size(40.dp)
.clip(CircleShape),
)
},
titleSlot = {
TangemRowText(
text = stringReference(entry.address),
role = TangemRowTextRole.Title,
overflow = TextOverflow.MiddleEllipsis,
)
},
subtitleSlot = {
TangemRowText(
text = pluralReference(
id = R.plurals.common_networks_count,
count = entry.networkIds.size,
formatArgs = wrappedList(entry.networkIds.size),
),
role = TangemRowTextRole.Subtitle,
)
},
)
}
@Composable
private fun AddAddressRow(isEnabled: Boolean, onClick: () -> Unit) {
TangemRow(
verticalAlignment = TangemRowVerticalAlignment.Center,
onClick = onClick,
startSlot = {
TangemIcon(
tangemIconUM = TangemIconUM.Icon(
imageVector = Icons.ic_sign_plus_20,
tintReference = {
if (isEnabled) {
TangemTheme.colors3.icon.brand
} else {
TangemTheme.colors3.icon.tertiary
}
},
),
modifier = Modifier
.size(40.dp)
.background(
color = if (isEnabled) {
TangemTheme.colors3.bg.status.infoSubtle
} else {
TangemTheme.colors3.bg.opaque.secondary
},
shape = RoundedCornerShape(10.dp),
)
.padding(8.dp),
)
},
titleSlot = {
if (isEnabled) {
TangemRowText(
text = TextReference.Res(R.string.address_book_add_address),
role = TangemRowTextRole.Title,
)
} else {
Text(
text = stringResourceSafe(R.string.address_book_add_address),
color = TangemTheme.colors3.text.tertiary,
style = TangemTheme.typography3.body.medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
},
subtitleSlot = {
if (isEnabled) {
TangemRowText(
text = TextReference.Res(R.string.address_book_add_address_description),
role = TangemRowTextRole.Subtitle,
)
} else {
Text(
text = stringResourceSafe(R.string.address_book_add_address_description),
color = TangemTheme.colors3.text.tertiary,
style = TangemTheme.typography3.caption.medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
},
)
}
@Composable
private fun WalletBlock(walletBlock: EditContactUM.WalletBlockUM, modifier: Modifier = Modifier) {
Column(modifier = modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(24.dp))
.fillMaxWidth()
.clickableSingle(
onClick = walletBlock.onClick,
enabled = walletBlock.isChangeable,
)
.background(TangemTheme.colors3.bg.secondary),
) {
TangemRow(
verticalAlignment = TangemRowVerticalAlignment.Center,
titleSlot = {
TangemRowText(
text = stringResourceSafe(R.string.address_book_save_to_wallet_title),
role = TangemRowTextRole.Title,
)
},
endSlot = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = walletBlock.walletName,
style = TangemTheme.typography3.body.medium,
color = TangemTheme.colors3.text.secondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
autoSize = TextAutoSize.StepBased(
minFontSize = TangemTheme.typography3.caption.medium.fontSize,
maxFontSize = TangemTheme.typography3.body.medium.fontSize,
),
)
if (walletBlock.isChangeable) {
WalletChevronIcon()
}
}
},
)
}
Text(
modifier = Modifier.padding(vertical = 10.dp, horizontal = 16.dp),
text = stringResourceSafe(R.string.address_book_save_wallet_to_description),
color = TangemTheme.colors3.text.secondary,
style = TangemTheme.typography3.caption.medium,
)
}
}
@Composable
private fun DeleteContactButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.clip(RoundedCornerShape(24.dp))
.fillMaxWidth()
.clickableSingle(onClick = onClick)
.background(TangemTheme.colors3.bg.secondary),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResourceSafe(R.string.address_book_delete_contact),
style = TangemTheme.typography3.body.medium,
color = TangemTheme.colors3.text.status.error,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
)
}
}
@Composable
private fun WalletChevronIcon() {
Icon(
modifier = Modifier
.padding(start = 4.dp)
.size(20.dp),
tint = TangemTheme.colors3.icon.secondary,
imageVector = ImageVector.vectorResource(id = R.drawable.ic_select_18_24),
contentDescription = null,
)
}
@Composable
private fun ContactSummary(state: EditContactUM) {
val avatarName = state.name.ifBlank { state.namePlaceholder.resolveReference() }
Column(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.clip(RoundedCornerShape(24.dp))
.fillMaxWidth()
.background(TangemTheme.colors3.bg.secondary),
.background(TangemTheme.colors3.bg.secondary)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(24.dp))
SpacerH(20.dp)
AccountIcon(
name = stringReference(avatarName),
icon = state.portfolioIcon,
size = AccountIconSize.Large,
size = AccountIconSize.ContactLarge,
)
Spacer(modifier = Modifier.height(24.dp))
SpacerH(28.dp)
Text(
text = stringResourceSafe(R.string.address_book_contact_name),
style = TangemTheme.typography3.caption.medium,
color = TangemTheme.colors3.text.tertiary,
color = TangemTheme.colors3.text.secondary,
)
Spacer(modifier = Modifier.height(2.dp))
SpacerH(4.dp)
AutoSizeTextField(
value = state.name,
@ -186,7 +358,18 @@ private fun ContactSummary(state: EditContactUM) {
color = TangemTheme.colors3.text.primary,
placeholderColor = TangemTheme.colors3.text.tertiary,
)
Spacer(modifier = Modifier.height(20.dp))
if (state.nameError != null) {
Text(
modifier = Modifier.padding(top = 4.dp),
textAlign = TextAlign.Center,
text = state.nameError.resolveReference(),
style = TangemTheme.typography3.caption.medium,
color = TangemTheme.colors3.text.status.error,
)
}
SpacerH(8.dp)
}
}
@ -196,16 +379,16 @@ private fun ContactSummary(state: EditContactUM) {
private fun ContactColor(colors: EditContactUM.Colors) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.clip(RoundedCornerShape(24.dp))
.fillMaxWidth()
.background(TangemTheme.colors3.bg.secondary),
.background(TangemTheme.colors3.bg.secondary)
.padding(16.dp),
) {
FlowRow(
maxItemsInEachRow = 6,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally),
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
colors.list.fastForEach { color ->
val isSelected = color == colors.selected
@ -219,12 +402,12 @@ private fun ContactColor(colors: EditContactUM.Colors) {
if (isSelected) {
Box(
modifier = Modifier
.size(47.dp)
.size(48.dp)
.border(2.dp, color.getUiColor(), shape = CircleShape),
)
Box(
modifier = Modifier
.size(36.dp)
.size(38.dp)
.background(color = color.getUiColor(), shape = CircleShape),
)
} else {
@ -251,6 +434,7 @@ private fun Preview_EditContactContent() {
title = stringReference("New contact"),
name = "",
namePlaceholder = stringReference("New contact"),
nameError = null,
portfolioIcon = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Letter,
color = colors.first(),
@ -260,10 +444,30 @@ private fun Preview_EditContactContent() {
list = colors,
onColorSelect = {},
),
addresses = persistentListOf(),
addresses = persistentListOf(
ValidatedAddress(
address = "0x1234567890abcdef1234567890abcdef12345678",
networkIds = persistentListOf("ethereum", "bsc", "polygon"),
),
),
walletBlock = EditContactUM.WalletBlockUM(
walletName = "Main Wallet",
isChangeable = true,
onClick = {},
),
isAddAddressEnabled = true,
saveButton = TangemButtonUM(
text = TextReference.Res(R.string.common_save),
type = TangemButtonType.Primary,
isEnabled = true,
tangemIconUM = TangemIconUM.Icon(imageVector = Icons.ic_logo_tangem_24),
onClick = {},
),
onNameChange = {},
onCloseClick = {},
onAddAddressClick = {},
onAddressClick = {},
onDeleteClick = {},
),
)
}

View file

@ -1,20 +1,29 @@
package com.tangem.features.addressbook.editcontact.contract
package com.tangem.features.addressbook.editcontact.ui.state
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.account.CryptoPortfolioIcon
import kotlinx.collections.immutable.ImmutableList
@Immutable
internal data class EditContactUM(
val title: TextReference,
val name: String,
val namePlaceholder: TextReference,
val nameError: TextReference?,
val portfolioIcon: AccountIconUM.CryptoPortfolio,
val colors: Colors,
val addresses: ImmutableList<ValidatedAddress>,
val walletBlock: WalletBlockUM,
val isAddAddressEnabled: Boolean,
val saveButton: TangemButtonUM,
val onNameChange: (String) -> Unit,
val onCloseClick: () -> Unit,
val onAddAddressClick: () -> Unit,
val onAddressClick: (ValidatedAddress) -> Unit,
val onDeleteClick: (() -> Unit)?,
) {
data class Colors(
@ -22,4 +31,10 @@ internal data class EditContactUM(
val list: ImmutableList<CryptoPortfolioIcon.Color>,
val onColorSelect: (CryptoPortfolioIcon.Color) -> Unit,
)
data class WalletBlockUM(
val walletName: String,
val isChangeable: Boolean,
val onClick: () -> Unit,
)
}

View file

@ -0,0 +1,20 @@
package com.tangem.features.addressbook.editcontact.ui.state
import androidx.compose.runtime.Immutable
import kotlinx.collections.immutable.ImmutableList
/**
* A recipient address validated on the AddAddress screen, together with the networks it resolves to.
*
* 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,
)

View file

@ -1,14 +0,0 @@
package com.tangem.features.addressbook.list
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
internal interface AddressBookListComponent : ComposableContentComponent {
interface Factory : ComponentFactory<Params, AddressBookListComponent>
data class Params(
val onContactClick: (String) -> Unit,
val onAddContactClick: () -> Unit,
)
}

View file

@ -1,43 +1,82 @@
package com.tangem.features.addressbook.list
import androidx.compose.foundation.background
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.addressbook.list.contract.AddressBookListUM
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.addressbook.AddressSelectorComponent
import com.tangem.features.addressbook.list.model.AddressBookListModel
import com.tangem.features.addressbook.list.ui.AddressBookEmptyScreen
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import com.tangem.features.addressbook.list.ui.AddressBookListScreen
import com.tangem.features.addressbook.list.ui.AddressBookListShimmer
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
import com.tangem.features.addressbook.route.AddressBookRoute
internal class DefaultAddressBookListComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted val params: AddressBookListComponent.Params,
) : AddressBookListComponent, AppComponentContext by context {
internal class DefaultAddressBookListComponent(
appComponentContext: AppComponentContext,
params: Params,
addressSelectorFactory: AddressSelectorComponent.Factory,
) : ComposableContentComponent, AppComponentContext by appComponentContext {
private val model: AddressBookListModel = getOrCreateModel(params)
private val selectorSlot = childSlot(
source = model.selectorNavigation,
serializer = null,
key = "address_selector_slot",
handleBackButton = true,
childFactory = { contact, componentContext ->
addressSelectorFactory.create(
context = childByContext(componentContext),
params = AddressSelectorComponent.Params(
contact = contact,
onAddressSelected = model::deliverSelection,
onDismiss = { model.selectorNavigation.dismiss() },
),
)
},
)
@Composable
override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle()
val selector by selectorSlot.subscribeAsState()
when (val addressBookListUM = state) {
is AddressBookListUM.Empty -> AddressBookEmptyScreen(
tangemButtonUM = addressBookListUM.tangemButtonUM,
is AddressBookListUM.Loading -> AddressBookListShimmer(
onBackClick = router::pop,
modifier = modifier,
modifier = modifier.background(TangemTheme.colors3.bg.primary),
)
is AddressBookListUM.Empty -> AddressBookEmptyScreen(
onAddContactClick = addressBookListUM.onAddClick,
onBackClick = router::pop,
modifier = modifier.background(TangemTheme.colors3.bg.primary),
)
is AddressBookListUM.Content -> AddressBookListScreen(
state = addressBookListUM,
onBackClick = router::pop,
modifier = Modifier.background(TangemTheme.colors3.bg.primary),
)
is AddressBookListUM.AddressList -> TODO("[REDACTED_TASK_KEY]")
}
selector.child?.instance?.BottomSheet()
}
@AssistedFactory
interface Factory : AddressBookListComponent.Factory {
override fun create(
context: AppComponentContext,
params: AddressBookListComponent.Params,
): DefaultAddressBookListComponent
}
/**
* @property mode Default (management) or Selector (pick a contact for a network)
* @property onContactClick management mode opens the contact editor
* @property onAddContactClick opens the new-contact editor
*/
data class Params(
val mode: AddressBookRoute.ListMode,
val onContactClick: (String) -> Unit,
val onAddContactClick: () -> Unit,
)
}

View file

@ -1,15 +0,0 @@
package com.tangem.features.addressbook.list.contract
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.domain.addressbook.model.Contact
import kotlinx.collections.immutable.ImmutableList
@Immutable
internal sealed class AddressBookListUM {
data class Empty(
val tangemButtonUM: TangemButtonUM,
) : AddressBookListUM()
data class AddressList(val contacts: ImmutableList<Contact>) : AddressBookListUM()
}

View file

@ -1,43 +1,188 @@
package com.tangem.features.addressbook.list.model
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss
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.ui.R
import com.tangem.core.ui.R.drawable.ic_plus_24
import com.tangem.core.ui.ds.button.TangemButtonIconPosition
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.extensions.TextReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.addressbook.list.AddressBookListComponent
import com.tangem.features.addressbook.list.contract.AddressBookListUM
import com.tangem.core.decompose.navigation.Router
import com.tangem.domain.addressbook.interactor.GetVerifiedContactsInteractor
import com.tangem.domain.addressbook.model.VerifiedContact
import com.tangem.domain.addressbook.usecase.SyncAddressBooksUseCase
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.addressbook.ContactSelectionTrigger
import com.tangem.features.addressbook.MatchedContact
import com.tangem.features.addressbook.SelectedContact
import com.tangem.features.addressbook.analytics.AddressBookEvents.ContactListScreenOpened.Source
import com.tangem.features.addressbook.common.AddressBookAnalyticsSender
import com.tangem.features.addressbook.list.DefaultAddressBookListComponent
import com.tangem.features.addressbook.list.state.AddressBookListStateController
import com.tangem.features.addressbook.list.state.transformers.UpdateAddressBookListContentTransformer
import com.tangem.features.addressbook.list.state.transformers.UpdateAddressBookListQueryTransformer
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
import com.tangem.features.addressbook.route.AddressBookRoute
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Backs the contacts list. The list content is the same however the address book was opened the open
* [AddressBookRoute.ListMode] only decides what tapping a contact does:
* - [AddressBookRoute.ListMode.Default]: browse / manage contacts
* - [AddressBookRoute.ListMode.Selector]: pick a recipient for the given network a single matching address is
* returned right away, several open the address selector first.
*/
@Suppress("LongParameterList", "NamedArguments")
@OptIn(ExperimentalCoroutinesApi::class)
@ModelScoped
internal class AddressBookListModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val stateController: AddressBookListStateController,
private val router: Router,
private val contactSelectionTrigger: ContactSelectionTrigger,
private val analyticsSender: AddressBookAnalyticsSender,
private val syncAddressBooksUseCase: SyncAddressBooksUseCase,
private val getVerifiedContactsInteractor: GetVerifiedContactsInteractor,
private val getWalletsUseCase: GetWalletsUseCase,
) : Model() {
private val params = paramsContainer.require<AddressBookListComponent.Params>()
private val params = paramsContainer.require<DefaultAddressBookListComponent.Params>()
val state: StateFlow<AddressBookListUM> = MutableStateFlow(
AddressBookListUM.Empty(
tangemButtonUM = TangemButtonUM(
text = TextReference.Res(R.string.address_book_new_contact),
tangemIconUM = TangemIconUM.Icon(
iconRes = ic_plus_24,
tintReference = { TangemTheme.colors3.text.inverse.primary },
),
iconPosition = TangemButtonIconPosition.End,
type = TangemButtonType.Primary,
onClick = params.onAddContactClick,
val state: StateFlow<AddressBookListUM> get() = stateController.uiState
/** Address-selector bottom sheet, shown when a picked contact has more than one address in the target network. */
val selectorNavigation = SlotNavigation<MatchedContact>()
private val searchQuery = MutableStateFlow(value = "")
private val searchActive = MutableStateFlow(value = false)
private val selectedWalletId = MutableStateFlow<String?>(value = null)
private val allContacts: SharedFlow<List<VerifiedContact>> =
getVerifiedContactsInteractor.getVerifiedContacts(query = "", userWalletId = null)
.shareIn(modelScope, SharingStarted.Lazily, replay = 1)
init {
modelScope.launch {
syncAddressBooksUseCase()
sendContactListScreenOpenedEvent()
observeContacts()
}
}
private suspend fun observeContacts() {
val matchedContacts = searchQuery.flatMapLatest { query ->
if (query.isBlank()) {
allContacts
} else {
getVerifiedContactsInteractor.getVerifiedContacts(query = query, userWalletId = null)
}
}
combine(
allContacts,
matchedContacts,
searchQuery,
selectedWalletId,
getWalletsUseCase.invokeAsMap(isOnlyMultiCurrency = false, filterLocked = true),
) { all, matched, query, selected, wallets ->
ListInputs(
allContacts = all,
matchedContacts = matched,
query = query,
selectedWalletId = selected,
wallets = wallets,
)
}
.onEach(::updateState)
.flowOn(dispatchers.default)
.collect()
}
fun deliverSelection(contact: SelectedContact) {
contactSelectionTrigger.trigger(contact)
selectorNavigation.dismiss()
router.pop()
}
private fun updateState(inputs: ListInputs) {
stateController.update(
UpdateAddressBookListContentTransformer(
allContacts = inputs.allContacts,
matchedContacts = inputs.matchedContacts,
mode = params.mode,
wallets = inputs.wallets,
selectedWalletId = inputs.selectedWalletId,
query = inputs.query,
onContactClick = params.onContactClick,
onPickContact = ::onPickContact,
onQueryChange = ::onQueryChange,
onActiveChange = ::onActiveChange,
onClearQuery = ::onClearQuery,
onChipSelected = ::onChipSelected,
onAddContactClick = params.onAddContactClick,
),
),
)
}
private fun onQueryChange(query: String) {
searchQuery.value = query
updateSearchBar(query = query, isActive = searchActive.value)
}
private fun onActiveChange(active: Boolean) {
searchActive.value = active
updateSearchBar(query = searchQuery.value, isActive = active)
}
private fun onClearQuery() {
searchQuery.value = ""
updateSearchBar(query = "", isActive = searchActive.value)
}
private fun updateSearchBar(query: String, isActive: Boolean) {
stateController.update(UpdateAddressBookListQueryTransformer(query = query, isActive = isActive))
}
private fun onChipSelected(walletId: String?) {
if (selectedWalletId.value == walletId) return
selectedWalletId.value = walletId
}
private fun onPickContact(contact: MatchedContact) {
// Reported on tap, before the address is substituted; onPickContact is only invoked in selector (Send) mode.
analyticsSender.sendContactSelectedInSend(contactId = contact.contactId, scope = modelScope)
val singleEntry = contact.entries.singleOrNull()
if (singleEntry != null) {
deliverSelection(contact.toSelectedContact(singleEntry))
} else {
selectorNavigation.activate(contact)
}
}
private suspend fun sendContactListScreenOpenedEvent() {
val contacts = allContacts.first()
analyticsSender.sendContactListScreenOpened(
source = params.mode.toAnalyticsSource(),
contactsCount = contacts.size,
scope = modelScope,
)
}
private fun AddressBookRoute.ListMode.toAnalyticsSource(): Source = when (this) {
AddressBookRoute.ListMode.Default -> Source.Settings
is AddressBookRoute.ListMode.Selector -> Source.SendFlow
}
private data class ListInputs(
val allContacts: List<VerifiedContact>,
val matchedContacts: List<VerifiedContact>,
val query: String,
val selectedWalletId: String?,
val wallets: Map<UserWalletId, UserWallet>,
)
}

View file

@ -0,0 +1,22 @@
package com.tangem.features.addressbook.list.state
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
import com.tangem.utils.transformer.Transformer
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import javax.inject.Inject
@ModelScoped
internal class AddressBookListStateController @Inject constructor() {
val uiState: StateFlow<AddressBookListUM>
field = MutableStateFlow(value = getInitialState())
fun update(transformer: Transformer<AddressBookListUM>) {
uiState.update(function = transformer::transform)
}
private fun getInitialState(): AddressBookListUM = AddressBookListUM.Loading
}

View file

@ -0,0 +1,131 @@
package com.tangem.features.addressbook.list.state.transformers
import com.tangem.core.ui.R
import com.tangem.core.ui.ds2.search.TangemSearch
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.addressbook.model.VerifiedContact
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.addressbook.MatchedContact
import com.tangem.features.addressbook.common.ContactMatcher
import com.tangem.features.addressbook.list.state.transformers.converter.DefaultContactConverter
import com.tangem.features.addressbook.list.state.transformers.converter.SelectorContactConverter
import com.tangem.features.addressbook.list.ui.state.AddressBookChipUM
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
import com.tangem.features.addressbook.list.ui.state.ContactUM
import com.tangem.features.addressbook.list.ui.state.ContentMode
import com.tangem.features.addressbook.route.AddressBookRoute
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@Suppress("LongParameterList")
internal class UpdateAddressBookListContentTransformer(
wallets: Map<UserWalletId, UserWallet>,
private val allContacts: List<VerifiedContact>,
private val matchedContacts: List<VerifiedContact>,
private val mode: AddressBookRoute.ListMode,
private val selectedWalletId: String?,
private val query: String,
private val onContactClick: (String) -> Unit,
private val onPickContact: (MatchedContact) -> Unit,
private val onQueryChange: (String) -> Unit,
private val onActiveChange: (Boolean) -> Unit,
private val onClearQuery: () -> Unit,
private val onChipSelected: (String?) -> Unit,
private val onAddContactClick: () -> Unit,
) : Transformer<AddressBookListUM> {
private val orderedWalletIds: List<String> = wallets.values.map { it.walletId.stringValue }
private val walletNamesById: Map<String, String> = wallets.values.associate { it.walletId.stringValue to it.name }
override fun transform(prevState: AddressBookListUM): AddressBookListUM {
val matchedItems = matchedItems()
if (matchedItems.isEmpty() && query.isBlank()) {
return AddressBookListUM.Empty(onAddClick = onAddContactClick)
}
val matchingWalletIds = matchedItems.map { it.walletId }.distinct()
val effectiveSelected = selectedWalletId.takeIf { it in matchingWalletIds }
val areChipsVisible = totalWalletIds().size >= 2 && matchedItems.isNotEmpty()
// On the "All" chip of a multi-wallet book each contact shows which wallet it belongs to.
val shouldShowWalletName = areChipsVisible && effectiveSelected == null
val displayContacts = matchedItems
.filter { effectiveSelected == null || it.walletId == effectiveSelected }
.map { if (shouldShowWalletName) it.copy(walletName = walletNamesById[it.walletId]) else it }
.toImmutableList()
return AddressBookListUM.Content(
searchBar = (prevState as? AddressBookListUM.Content)?.searchBar ?: buildSearchBar(),
chips = if (areChipsVisible) buildChips(matchingWalletIds, effectiveSelected) else persistentListOf(),
contacts = displayContacts,
isNothingFound = matchedItems.isEmpty(),
contentMode = contentMode(),
)
}
private fun matchedItems(): List<ContactUM> = when (val mode = mode) {
AddressBookRoute.ListMode.Default ->
DefaultContactConverter(onContactClick).convertList(matchedContacts)
is AddressBookRoute.ListMode.Selector ->
SelectorContactConverter(onPickContact)
.convertList(ContactMatcher.match(matchedContacts.map { it.contact }, mode.networkId))
}
/** Wallets that own at least one contact (respecting the network filter in selector mode) — drives chip visibility. */
private fun totalWalletIds(): Set<String> = when (val mode = mode) {
AddressBookRoute.ListMode.Default -> allContacts.mapTo(mutableSetOf()) { it.contact.walletId.stringValue }
is AddressBookRoute.ListMode.Selector ->
ContactMatcher.match(allContacts.map { it.contact }, mode.networkId).mapTo(mutableSetOf()) { it.walletId }
}
private fun contentMode(): ContentMode = when (mode) {
AddressBookRoute.ListMode.Default -> ContentMode.Default(onAddClick = onAddContactClick)
is AddressBookRoute.ListMode.Selector -> ContentMode.Select
}
private fun buildSearchBar(): TangemSearch.State = TangemSearch.State(
placeholderText = resourceReference(R.string.common_search),
query = query,
onQueryChange = onQueryChange,
isActive = false,
onActiveChange = onActiveChange,
onClearClick = onClearQuery,
onCloseClick = {
onClearQuery()
onActiveChange(false)
},
)
private fun buildChips(matchingWalletIds: List<String>, effectiveSelected: String?) = buildList {
add(
AddressBookChipUM(
id = ALL_CHIP_ID,
text = resourceReference(R.string.common_all),
isSelected = effectiveSelected == null,
onClick = { onChipSelected(null) },
),
)
orderedWalletIds
.filter { it in matchingWalletIds }
.forEach { walletId ->
add(
AddressBookChipUM(
id = walletId,
text = stringReference(walletNamesById[walletId] ?: walletId),
isSelected = walletId == effectiveSelected,
onClick = { onChipSelected(walletId) },
iconRes = R.drawable.ic_key_card_20,
),
)
}
}.toImmutableList()
private companion object {
const val ALL_CHIP_ID = "all"
}
}

View file

@ -0,0 +1,19 @@
package com.tangem.features.addressbook.list.state.transformers
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
import com.tangem.utils.transformer.Transformer
internal class UpdateAddressBookListQueryTransformer(
private val query: String,
private val isActive: Boolean,
) : Transformer<AddressBookListUM> {
override fun transform(prevState: AddressBookListUM): AddressBookListUM = when (prevState) {
is AddressBookListUM.Content -> prevState.copy(
searchBar = prevState.searchBar.copy(query = query, isActive = isActive),
)
is AddressBookListUM.Empty,
AddressBookListUM.Loading,
-> prevState
}
}

View file

@ -0,0 +1,30 @@
package com.tangem.features.addressbook.list.state.transformers.converter
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.domain.addressbook.model.VerifiedContact
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.features.addressbook.list.ui.state.ContactUM
import com.tangem.utils.converter.Converter
internal class DefaultContactConverter(
private val onContactClick: (String) -> Unit,
) : Converter<VerifiedContact, ContactUM> {
override fun convert(value: VerifiedContact): ContactUM {
val contact = value.contact
val name = contact.name.value
return ContactUM(
id = contact.id.value,
walletId = contact.walletId.stringValue,
name = name,
icon = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.entries.firstOrNull { it.name == contact.icon }
?: CryptoPortfolioIcon.Icon.Letter,
color = CryptoPortfolioIcon.Color.entries.firstOrNull { it.name == contact.iconColor }
?: CryptoPortfolioIcon.Color.Azure,
),
networkAddressCount = contact.addresses.size,
onClick = { onContactClick(contact.id.value) },
)
}
}

View file

@ -0,0 +1,19 @@
package com.tangem.features.addressbook.list.state.transformers.converter
import com.tangem.features.addressbook.MatchedContact
import com.tangem.features.addressbook.list.ui.state.ContactUM
import com.tangem.utils.converter.Converter
internal class SelectorContactConverter(
private val onPickContact: (MatchedContact) -> Unit,
) : Converter<MatchedContact, ContactUM> {
override fun convert(value: MatchedContact): ContactUM = ContactUM(
id = value.contactId,
walletId = value.walletId,
name = value.name,
icon = value.icon,
networkAddressCount = value.entries.size,
onClick = { onPickContact(value) },
)
}

View file

@ -0,0 +1,68 @@
package com.tangem.features.addressbook.list.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
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.semantics.Role
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.addressbook.list.ui.state.AddressBookChipUM
@Composable
internal fun AddressBookChip(state: AddressBookChipUM, modifier: Modifier = Modifier) {
val backgroundColor = if (state.isSelected) {
TangemTheme.colors2.tabs.backgroundPrimary
} else {
TangemTheme.colors2.tabs.backgroundSecondary
}
val textColor = if (state.isSelected) {
TangemTheme.colors2.tabs.textPrimary
} else {
TangemTheme.colors2.tabs.textSecondary
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.clip(shape = CircleShape)
.background(color = backgroundColor)
.selectable(
selected = state.isSelected,
onClick = state.onClick,
role = Role.Tab,
interactionSource = remember { MutableInteractionSource() },
indication = ripple(),
)
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
Text(
text = state.text.resolveReference(),
style = TangemTheme.typography3.body.medium,
color = textColor,
maxLines = 1,
)
if (state.iconRes != null) {
Icon(
modifier = Modifier
.padding(start = 4.dp)
.size(20.dp),
painter = painterResource(id = state.iconRes),
contentDescription = null,
tint = TangemTheme.colors2.graphic.neutral.tertiaryConstant,
)
}
}
}

View file

@ -1,6 +1,5 @@
package com.tangem.features.addressbook.list.ui
import android.content.res.Configuration
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
@ -9,26 +8,25 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.ds.button.PrimaryTangemButton
import com.tangem.core.ui.ds.button.TangemButtonIconPosition
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.extensions.TextReference
import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation
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.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_sign_plus_20
@Composable
internal fun AddressBookEmptyScreen(
tangemButtonUM: TangemButtonUM,
onAddContactClick: () -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
@ -36,31 +34,18 @@ internal fun AddressBookEmptyScreen(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
) {
TangemTopBar(
modifier = Modifier.statusBarsPadding(),
TangemTopNavigation(
title = resourceReference(R.string.address_book_title),
startContent = {
TangemButton(
iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_back_24),
onClick = onBackClick,
size = TangemButton.Size.X11,
variant = TangemButton.Variant.Material,
)
},
)
NoContactInfo()
PrimaryTangemButton(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(start = 16.dp, end = 16.dp, bottom = 12.dp),
buttonUM = tangemButtonUM,
contentAlign = TangemTopNavigation.ContentAlign.Center,
blurBackground = false,
onBack = onBackClick,
)
NoContactInfo(onAddClick = onAddContactClick)
}
}
@Composable
private fun ColumnScope.NoContactInfo() {
private fun ColumnScope.NoContactInfo(onAddClick: () -> Unit) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.Center,
@ -68,18 +53,24 @@ private fun ColumnScope.NoContactInfo() {
) {
ContactImage()
Text(
modifier = Modifier.padding(top = TangemTheme.dimens.spacing24),
modifier = Modifier.padding(top = 32.dp),
text = stringResourceSafe(R.string.address_book_no_contacts),
color = TangemTheme.colors3.text.primary,
style = TangemTheme.typography3.heading.medium,
style = TangemTheme.typography3.heading.small,
)
Text(
modifier = Modifier.padding(top = TangemTheme.dimens.spacing8),
modifier = Modifier.padding(top = 8.dp),
text = stringResourceSafe(R.string.address_book_no_contacts_description),
color = TangemTheme.colors3.text.secondary,
style = TangemTheme.typography3.body.medium,
style = TangemTheme.typography3.subheading.medium,
textAlign = TextAlign.Center,
)
TangemButton(
modifier = Modifier.padding(top = 40.dp),
text = resourceReference(R.string.address_book_add_address),
onClick = onAddClick,
iconEnd = TangemIconUM.Icon(imageVector = Icons.ic_sign_plus_20),
)
}
}
@ -95,7 +86,7 @@ private fun ContactImage() {
contentAlignment = Alignment.Center,
) {
Image(
painter = painterResource(R.drawable.ic_contact_20),
imageVector = ImageVector.vectorResource(R.drawable.ic_address_book_24),
contentDescription = stringResourceSafe(R.string.address_book_no_contacts),
modifier = Modifier.size(28.dp),
)
@ -104,16 +95,11 @@ private fun ContactImage() {
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun Preview_AddressBookEmptyScreen() {
AddressBookEmptyScreen(
tangemButtonUM = TangemButtonUM(
text = TextReference.Res(R.string.address_book_new_contact),
tangemIconUM = TangemIconUM.Icon(iconRes = R.drawable.ic_plus_24),
iconPosition = TangemButtonIconPosition.End,
type = TangemButtonType.Secondary,
onClick = {},
),
onBackClick = {},
)
TangemThemePreviewRedesign {
AddressBookEmptyScreen(
onAddContactClick = {},
onBackClick = {},
)
}
}

View file

@ -0,0 +1,160 @@
package com.tangem.features.addressbook.list.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation
import com.tangem.core.ui.ds2.search.TangemSearch
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.core.ui.res.generated.icons.*
import com.tangem.features.addressbook.common.ui.ContactRow
import com.tangem.features.addressbook.list.ui.preview.AddressBookListPreviewParameterProvider
import com.tangem.features.addressbook.list.ui.preview.AddressBookListPreviewScenario
import com.tangem.features.addressbook.list.ui.state.AddressBookChipUM
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
import com.tangem.features.addressbook.list.ui.state.ContentMode
import kotlinx.collections.immutable.ImmutableList
@Composable
internal fun AddressBookListScreen(
state: AddressBookListUM.Content,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val density = LocalDensity.current
val bottomBarHeight = with(density) { WindowInsets.systemBars.getBottom(this).toDp() }
Column(modifier = modifier.fillMaxSize()) {
TangemTopNavigation(
title = resourceReference(R.string.address_book_title),
contentAlign = TangemTopNavigation.ContentAlign.Center,
blurBackground = false,
onBack = when (state.contentMode) {
is ContentMode.Default -> onBackClick
ContentMode.Select -> null
},
endButton = {
TangemButton(
iconStart = TangemIconUM.Icon(
imageVector = when (state.contentMode) {
is ContentMode.Default -> Icons.ic_sign_plus_20
ContentMode.Select -> Icons.ic_cross_20
},
),
onClick = when (state.contentMode) {
is ContentMode.Default -> state.contentMode.onAddClick
ContentMode.Select -> onBackClick
},
size = TangemButton.Size.X11,
variant = TangemButton.Variant.Material,
)
},
)
TangemSearch(
state = state.searchBar,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
)
if (state.chips.isNotEmpty()) {
WalletChips(chips = state.chips)
}
if (state.isNothingFound) {
NothingFoundContent()
} else {
LazyColumn(
modifier = Modifier.imePadding(),
contentPadding = PaddingValues(bottom = 12.dp + bottomBarHeight),
) {
itemsIndexed(items = state.contacts, key = { _, contact -> contact.id }) { index, contact ->
ContactRow(
contact = contact,
modifier = Modifier.roundedShapeItemDecoration(
currentIndex = index,
lastIndex = state.contacts.lastIndex,
radius = 24.dp,
backgroundColor = TangemTheme.colors3.bg.secondary,
),
)
}
}
}
}
}
@Composable
private fun WalletChips(chips: ImmutableList<AddressBookChipUM>) {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
items(items = chips, key = AddressBookChipUM::id) { chip ->
AddressBookChip(state = chip)
}
}
}
@Composable
private fun ColumnScope.NothingFoundContent() {
Column(
modifier = Modifier
.fillMaxWidth()
.imePadding()
.weight(1f),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
modifier = Modifier
.size(48.dp)
.background(color = TangemTheme.colors3.bg.opaque.primary, shape = CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.ic_search_24,
contentDescription = null,
tint = TangemTheme.colors3.icon.secondary,
modifier = Modifier.size(24.dp),
)
}
Text(
modifier = Modifier.padding(top = 32.dp),
text = stringResourceSafe(R.string.address_book_search_no_results),
color = TangemTheme.colors3.text.secondary,
style = TangemTheme.typography3.caption.medium,
)
}
}
@Composable
@Preview(showBackground = true, widthDp = 360, heightDp = 640)
private fun Preview_AddressBookListScreen(
@PreviewParameter(AddressBookListPreviewParameterProvider::class) scenario: AddressBookListPreviewScenario,
) {
TangemThemePreviewRedesign {
AddressBookListScreen(
state = scenario.state,
onBackClick = {},
)
}
}

View file

@ -0,0 +1,79 @@
package com.tangem.features.addressbook.list.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.RectangleShimmer
import com.tangem.core.ui.ds2.shimmers.ProvideTangemShimmer
import com.tangem.core.ui.ds2.topnavigation.TangemTopNavigation
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
@Composable
internal fun AddressBookListShimmer(onBackClick: () -> Unit, modifier: Modifier = Modifier) {
Column(modifier = modifier.fillMaxSize()) {
TangemTopNavigation(
title = resourceReference(R.string.address_book_title),
contentAlign = TangemTopNavigation.ContentAlign.Center,
blurBackground = false,
onBack = onBackClick,
)
ProvideTangemShimmer {
Column(
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(top = 12.dp)
.background(
color = TangemTheme.colors3.bg.secondary,
shape = RoundedCornerShape(24.dp),
),
) {
repeat(SHIMMER_ROW_COUNT) { ContactRowShimmer() }
}
}
}
}
@Composable
private fun ContactRowShimmer() {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RectangleShimmer(
modifier = Modifier.size(40.dp),
radius = 32.dp,
)
Column(
modifier = Modifier.padding(start = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
RectangleShimmer(modifier = Modifier.size(width = 140.dp, height = 16.dp))
RectangleShimmer(modifier = Modifier.size(width = 90.dp, height = 12.dp))
}
}
}
private const val SHIMMER_ROW_COUNT = 3
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_AddressBookListShimmer() {
TangemThemePreviewRedesign {
AddressBookListShimmer(
onBackClick = {},
modifier = Modifier.background(TangemTheme.colors3.bg.primary),
)
}
}

View file

@ -0,0 +1,106 @@
package com.tangem.features.addressbook.list.ui.preview
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.core.ui.R
import com.tangem.core.ui.ds2.search.TangemSearch
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.features.addressbook.list.ui.state.AddressBookChipUM
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
import com.tangem.features.addressbook.list.ui.state.ContactUM
import com.tangem.features.addressbook.list.ui.state.ContentMode
import kotlinx.collections.immutable.persistentListOf
internal data class AddressBookListPreviewScenario(
val title: String,
val state: AddressBookListUM.Content,
)
internal object AddressBookListPreviewFixtures {
private val scenarioDefaultWithContacts = AddressBookListPreviewScenario(
title = "Default chips and contacts",
state = AddressBookListUM.Content(
searchBar = searchBar(query = ""),
chips = persistentListOf(
AddressBookChipUM(
id = "all",
text = resourceReference(R.string.common_all),
isSelected = true,
onClick = {},
),
AddressBookChipUM(
id = "00",
text = stringReference("Wallet 1"),
isSelected = false,
onClick = {},
iconRes = R.drawable.ic_key_card_20,
),
AddressBookChipUM(
id = "01",
text = stringReference("Wallet 2"),
isSelected = false,
onClick = {},
iconRes = R.drawable.ic_key_card_20,
),
),
contacts = persistentListOf(
contact(
walletId = "00",
name = "Binance",
color = CryptoPortfolioIcon.Color.Azure,
count = 1,
),
contact(
walletId = "01",
name = "Alice",
color = CryptoPortfolioIcon.Color.UFOGreen,
count = 3,
),
),
isNothingFound = false,
contentMode = ContentMode.Default(onAddClick = {}),
),
)
val scenarioSelectNothingFound = AddressBookListPreviewScenario(
title = "Select nothing found",
state = AddressBookListUM.Content(
searchBar = searchBar(query = "Antonio"),
chips = persistentListOf(),
contacts = persistentListOf(),
isNothingFound = true,
contentMode = ContentMode.Select,
),
)
fun allScenarios(): List<AddressBookListPreviewScenario> = listOf(
scenarioDefaultWithContacts,
scenarioSelectNothingFound,
)
private fun searchBar(query: String) = TangemSearch.State(
placeholderText = resourceReference(R.string.common_search),
query = query,
onQueryChange = {},
isActive = false,
onActiveChange = {},
)
private fun contact(walletId: String, name: String, color: CryptoPortfolioIcon.Color, count: Int) = ContactUM(
id = name + walletId,
walletId = walletId,
name = name,
icon = AccountIconUM.CryptoPortfolio(value = CryptoPortfolioIcon.Icon.Letter, color = color),
networkAddressCount = count,
onClick = {},
)
}
/** All [AddressBookListPreviewScenario] values for the Preview Parameter dropdown in Android Studio. */
internal class AddressBookListPreviewParameterProvider : PreviewParameterProvider<AddressBookListPreviewScenario> {
override val values: Sequence<AddressBookListPreviewScenario>
get() = AddressBookListPreviewFixtures.allScenarios().asSequence()
}

View file

@ -0,0 +1,14 @@
package com.tangem.features.addressbook.list.ui.state
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.extensions.TextReference
@Immutable
internal data class AddressBookChipUM(
val id: String,
val text: TextReference,
val isSelected: Boolean,
val onClick: () -> Unit,
@DrawableRes val iconRes: Int? = null,
)

View file

@ -0,0 +1,42 @@
package com.tangem.features.addressbook.list.ui.state
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.ds2.search.TangemSearch
import kotlinx.collections.immutable.ImmutableList
/**
* UI state of the contacts list. The list itself is the same however the address book was opened it is either
* [Empty] (no contacts at all) or shows [Content]. How the address book was opened (browse vs. pick a recipient) only
* changes what a contact tap does, which is captured by [ContactUM.onClick] and [Content.contentMode].
*/
@Immutable
internal sealed interface AddressBookListUM {
/** Initial state while the address books are being (re-)synced on open — rendered as shimmer placeholders. */
data object Loading : AddressBookListUM
data class Empty(val onAddClick: () -> Unit) : AddressBookListUM
/**
* @property searchBar always shown so the user can filter contacts across all wallets.
* @property chips wallet filter chips (`All` + a chip per matching wallet); empty means the row is hidden.
* @property contacts contacts for the currently selected chip; empty together with [isNothingFound] = true.
* @property isNothingFound true when the active search matched nothing show the "no results" stub instead of the
* list (the search bar stays visible so the query can be edited).
*/
data class Content(
val searchBar: TangemSearch.State,
val chips: ImmutableList<AddressBookChipUM>,
val contacts: ImmutableList<ContactUM>,
val isNothingFound: Boolean,
val contentMode: ContentMode,
) : AddressBookListUM
}
@Immutable
internal sealed interface ContentMode {
data class Default(val onAddClick: () -> Unit) : ContentMode
data object Select : ContentMode
}

View file

@ -0,0 +1,15 @@
package com.tangem.features.addressbook.list.ui.state
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.account.AccountIconUM
@Immutable
internal data class ContactUM(
val id: String,
val walletId: String,
val name: String,
val icon: AccountIconUM.CryptoPortfolio,
val networkAddressCount: Int,
val walletName: String? = null,
val onClick: () -> Unit,
)

View file

@ -0,0 +1,66 @@
package com.tangem.features.addressbook.route
import kotlinx.serialization.Serializable
@Serializable
internal sealed class AddressBookRoute {
/**
* The contacts list. [mode] mirrors the entry point: [ListMode.Default] for plain browsing/management, and
* [ListMode.Selector] when the list is opened to pick a contact for a given network a tap then returns the
* chosen address instead of opening the editor.
*/
@Serializable
data class List(val mode: ListMode = ListMode.Default) : AddressBookRoute()
/**
* if [contactId] is not null we should fetch existing contact.
*
* [predefinedAddress] and [predefinedNetworkId] are set only when the feature is opened in
* [com.tangem.common.routing.entity.AddressBookOpenMode.WithContactCreation] mode the address and its
* network are already known, so the new contact is opened with that address already attached.
*/
@Serializable
data class EditContact(
val contactId: String? = null,
val predefinedAddress: String? = null,
val predefinedNetworkId: String? = null,
) : AddressBookRoute()
/**
* Address entry screen. [walletId] / [excludeContactId] scope the `network + address` duplicate check to the target
* wallet (excluding the contact being edited). When [prefillAddress] is set the screen opens pre-filled (edit-address
* flow): [prefillNetworkIds] restores the previously chosen networks and [prefillMemo] the memo.
*/
@Serializable
data class AddAddress(
val walletId: String? = null,
val excludeContactId: String? = null,
val prefillAddress: String? = null,
val prefillNetworkIds: kotlin.collections.List<String> = emptyList(),
val prefillMemo: String? = null,
) : AddressBookRoute()
/**
* Network-selection screen. [matchedNetworkIds] are the networks the entered address already resolved to (computed
* once on the AddAddress screen and passed in, so this screen never re-validates the address against every chain).
* [selectedNetworkIds] carries the current selection so it can be restored; empty means nothing is pre-selected.
*/
@Serializable
data class SelectNetworks(
val matchedNetworkIds: kotlin.collections.List<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 {
@Serializable
data object Default : ListMode
/** Pick a contact that has an address in [networkId]. */
@Serializable
data class Selector(val networkId: String) : ListMode
}
}

View file

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

View file

@ -0,0 +1,117 @@
package com.tangem.features.addressbook.selectnetworks.model
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchainsdk.utils.fromNetworkId
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.analytics.AddressBookEvents
import com.tangem.features.addressbook.common.AddressBookAnalyticsSender
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,
override val dispatchers: CoroutineDispatcherProvider,
private val stateController: SelectNetworksStateController,
private val analyticsSender: AddressBookAnalyticsSender,
) : Model() {
private val params: DefaultSelectNetworksComponent.Params = paramsContainer.require()
private val query = MutableStateFlow("")
private val isSearchActive = MutableStateFlow(false)
private val matchedBlockchains: List<Blockchain> = params.matchedNetworkIds.mapNotNull(Blockchain::fromNetworkId)
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,
onSelectAllClick = ::onSelectAllClick,
),
)
}
private fun subscribeToContent() {
combine(query, selectedNetworks, isSearchActive) { query, selection, isSearchActive ->
UpdateNetworksContentTransformer(
matchedBlockchains = matchedBlockchains,
query = query,
selectedNetworkIds = selection,
isSearchActive = isSearchActive,
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 onSelectAllClick() {
val allNetworkIds = matchedBlockchains.map { blockchain -> blockchain.toNetworkId() }.toSet()
val isSelectingAll = allNetworkIds.isEmpty() || !selectedNetworks.value.containsAll(allNetworkIds)
analyticsSender.sendSelectAllNetworksTapped(
action = if (isSelectingAll) {
AddressBookEvents.SelectAllNetworksTapped.Action.SelectAll
} else {
AddressBookEvents.SelectAllNetworksTapped.Action.ClearAll
},
scope = modelScope,
)
selectedNetworks.value = if (isSelectingAll) allNetworkIds else emptySet()
}
private fun onDoneClick() {
val selected = selectedNetworks.value
if (selected.isEmpty()) return
params.onDone(selected)
}
}

View file

@ -0,0 +1,49 @@
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(),
selectAllButton = SelectNetworksUM.SelectAllButtonUM.Empty,
doneButton = TangemButtonUM(
text = TextReference.Res(R.string.common_done),
type = TangemButtonType.Primary,
isEnabled = false,
onClick = {},
),
onBackClick = {},
onSelectAllClick = {},
)
}

View file

@ -0,0 +1,55 @@
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 isSearchActive: Boolean,
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()
val isAllSelected = matchedBlockchains.isNotEmpty() &&
selectedNetworkIds.size == matchedBlockchains.size
val selectAllButton = when {
isSearchActive -> SelectNetworksUM.SelectAllButtonUM.Empty
isAllSelected -> SelectNetworksUM.SelectAllButtonUM.ClearAll
else -> SelectNetworksUM.SelectAllButtonUM.SelectAll
}
// Search field is owned by UpdateSelectNetworksSearchBarTransformer and intentionally left untouched here.
return prevState.copy(
networks = networks,
selectAllButton = selectAllButton,
doneButton = prevState.doneButton.copy(isEnabled = selectedNetworkIds.isNotEmpty()),
)
}
}

View file

@ -0,0 +1,30 @@
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,
private val onSelectAllClick: () -> Unit,
) : Transformer<SelectNetworksUM> {
override fun transform(prevState: SelectNetworksUM): SelectNetworksUM {
return prevState.copy(
searchBar = prevState.searchBar.copy(
onQueryChange = onQueryChange,
onActiveChange = onActiveChange,
onCloseClick = {
onQueryChange("")
onActiveChange(false)
},
onClearClick = { onQueryChange("") },
),
doneButton = prevState.doneButton.copy(onClick = onDoneClick),
onBackClick = onBackClick,
onSelectAllClick = onSelectAllClick,
)
}
}

View file

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

Some files were not shown because too many files have changed in this diff Show more