Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-30 15:16:50 +02:00
parent 066ab573b3
commit 3fefb7bf52
21 changed files with 944 additions and 56 deletions

View file

@ -3,6 +3,7 @@ package com.tangem.domain.addressbook.model
import arrow.core.Either import arrow.core.Either
import arrow.core.raise.either import arrow.core.raise.either
import arrow.core.raise.ensure import arrow.core.raise.ensure
import com.tangem.domain.addressbook.model.ContactName.Companion.invoke
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
/** /**
@ -31,11 +32,24 @@ data class ContactName private constructor(val value: String) {
companion object { companion object {
const val MIN_LENGTH = 1 private const val MIN_LENGTH = 1
const val MAX_LENGTH = 50 const val MAX_LENGTH = 50
/** Letters, numbers and spaces only — forbids emoji, new lines, tabs, special symbols and html/scripts. */ /**
private val allowedPattern = Regex("^[\\p{L}\\p{N} ]+$") * Allows letters of any locale (`\p{L}`) and their combining marks (`\p{M}`, which also covers emoji
* variation selectors and keycap marks), digits (`\p{N}`), a regular space, and emoji symbols (`\p{So}`,
* including flags / regional indicators), emoji skin-tone modifiers (`\p{Sk}`) and the zero-width joiner
* (U+200D) used in emoji sequences.
*
* Everything else is rejected, which covers the forbidden set: line breaks, tabs and other control
* characters, invisible/format unicode (zero-width spaces, BOM, ), exotic spaces, and HTML/script symbols.
*
* The leading lookahead requires at least one visible "base" character (letter / digit / emoji symbol), so a
* name made up only of zero-width joiners, combining marks, modifiers or spaces (i.e. effectively invisible)
* is rejected.
*/
private val allowedPattern =
Regex("^(?=.*[\\p{L}\\p{N}\\p{So}])[\\p{L}\\p{M}\\p{N}\\p{So}\\p{Sk}\\u0020\\u200D]+$")
operator fun invoke(value: String): Either<Error, ContactName> = either { operator fun invoke(value: String): Either<Error, ContactName> = either {
val trimmed = value.trim() val trimmed = value.trim()

View file

@ -39,8 +39,33 @@ class ContactNameTest {
} }
@Test @Test
fun `emoji is rejected`() { fun `simple emoji is accepted`() {
assertThat(ContactName("Alice 😀").leftOrNull()).isEqualTo(ContactName.Error.InvalidCharacters) assertThat(ContactName("Alice 😀").isRight()).isTrue()
}
@Test
fun `emoji-only name is accepted`() {
assertThat(ContactName("😀").isRight()).isTrue()
}
@Test
fun `flag emoji is accepted`() {
assertThat(ContactName("Team 🇺🇸").isRight()).isTrue()
}
@Test
fun `zwj emoji sequence is accepted`() {
assertThat(ContactName("Family 👨‍👩‍👧").isRight()).isTrue()
}
@Test
fun `emoji with variation selector is accepted`() {
assertThat(ContactName("Love ❤️").isRight()).isTrue()
}
@Test
fun `non-latin letters are accepted`() {
assertThat(ContactName("Алёша 大阪").isRight()).isTrue()
} }
@Test @Test
@ -53,6 +78,16 @@ class ContactNameTest {
assertThat(ContactName("Ali\tce").leftOrNull()).isEqualTo(ContactName.Error.InvalidCharacters) assertThat(ContactName("Ali\tce").leftOrNull()).isEqualTo(ContactName.Error.InvalidCharacters)
} }
@Test
fun `zero-width space is rejected`() {
assertThat(ContactName("Ali\u200Bce").leftOrNull()).isEqualTo(ContactName.Error.InvalidCharacters)
}
@Test
fun `non-breaking space is rejected`() {
assertThat(ContactName("Ali\u00A0ce").leftOrNull()).isEqualTo(ContactName.Error.InvalidCharacters)
}
@Test @Test
fun `html script is rejected`() { fun `html script is rejected`() {
assertThat(ContactName("<script>alert(1)</script>").leftOrNull()) assertThat(ContactName("<script>alert(1)</script>").leftOrNull())

View file

@ -14,6 +14,7 @@ android {
dependencies { dependencies {
/** Api */ /** Api */
implementation(projects.features.addressBook.api) implementation(projects.features.addressBook.api)
implementation(projects.features.commonFeatures.api)
/** Domain */ /** Domain */
implementation(projects.domain.account) implementation(projects.domain.account)

View file

@ -10,6 +10,7 @@ import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
import com.tangem.features.addressbook.list.DefaultAddressBookListComponent import com.tangem.features.addressbook.list.DefaultAddressBookListComponent
import com.tangem.features.addressbook.route.AddressBookRoute import com.tangem.features.addressbook.route.AddressBookRoute
import com.tangem.features.addressbook.selectnetworks.DefaultSelectNetworksComponent import com.tangem.features.addressbook.selectnetworks.DefaultSelectNetworksComponent
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import javax.inject.Inject import javax.inject.Inject
@ -19,6 +20,7 @@ import javax.inject.Inject
*/ */
internal class AddressBookChildFactory @Inject constructor( internal class AddressBookChildFactory @Inject constructor(
private val addressSelectorFactory: AddressSelectorComponent.Factory, private val addressSelectorFactory: AddressSelectorComponent.Factory,
private val portfolioSelectorComponentFactory: PortfolioSelectorComponent.Factory,
) { ) {
fun createChild( fun createChild(
@ -43,6 +45,7 @@ internal class AddressBookChildFactory @Inject constructor(
onBackClick = clickIntents::onEditContactBack, onBackClick = clickIntents::onEditContactBack,
onAddAddressClick = clickIntents::onAddAddressClick, onAddAddressClick = clickIntents::onAddAddressClick,
), ),
portfolioSelectorComponentFactory = portfolioSelectorComponentFactory,
) )
AddressBookRoute.AddAddress -> DefaultAddAddressComponent( AddressBookRoute.AddAddress -> DefaultAddAddressComponent(
appComponentContext = context, appComponentContext = context,

View file

@ -1,6 +1,7 @@
package com.tangem.features.addressbook.common.ui package com.tangem.features.addressbook.common.ui
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import com.tangem.common.ui.account.AccountIcon import com.tangem.common.ui.account.AccountIcon
import com.tangem.common.ui.account.AccountIconUM import com.tangem.common.ui.account.AccountIconUM
@ -15,8 +16,9 @@ import com.tangem.features.addressbook.list.ui.state.ContactUM
import com.tangem.utils.StringsSigns import com.tangem.utils.StringsSigns
@Composable @Composable
internal fun ContactRow(contact: ContactUM) { internal fun ContactRow(contact: ContactUM, modifier: Modifier = Modifier) {
TangemRow( TangemRow(
modifier = modifier,
onClick = contact.onClick, onClick = contact.onClick,
verticalAlignment = TangemRowVerticalAlignment.Center, verticalAlignment = TangemRowVerticalAlignment.Center,
contentLead = TangemRowContentLead.Start, contentLead = TangemRowContentLead.Start,

View file

@ -5,29 +5,63 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.tangem.core.decompose.context.AppComponentContext 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.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.addressbook.model.ContactId import com.tangem.domain.addressbook.model.ContactId
import com.tangem.features.addressbook.editcontact.model.EditContactModel import com.tangem.features.addressbook.editcontact.model.EditContactModel
import com.tangem.features.addressbook.editcontact.ui.EditContactContent import com.tangem.features.addressbook.editcontact.ui.EditContactContent
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress 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( internal class DefaultEditContactComponent(
appComponentContext: AppComponentContext, appComponentContext: AppComponentContext,
params: Params, params: Params,
private val portfolioSelectorComponentFactory: PortfolioSelectorComponent.Factory,
) : ComposableContentComponent, AppComponentContext by appComponentContext { ) : ComposableContentComponent, AppComponentContext by appComponentContext {
private val model: EditContactModel = getOrCreateModel(params) private val model: EditContactModel = getOrCreateModel(params)
private val portfolioSelectorSlot = childSlot(
source = model.portfolioSelectorNavigation,
serializer = Unit.serializer(),
handleBackButton = false,
childFactory = { _, componentContext -> portfolioSelectorChild(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),
),
)
@Composable @Composable
override fun Content(modifier: Modifier) { override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle() val state by model.state.collectAsStateWithLifecycle()
val selectorSlot by portfolioSelectorSlot.subscribeAsState()
BackHandler {
if (selectorSlot.child != null) {
model.portfolioSelectorCallback.onBack()
} else {
state.onCloseClick()
}
}
EditContactContent( EditContactContent(
state = state, state = state,
modifier = modifier, modifier = modifier,
) )
BackHandler(onBack = state.onCloseClick) selectorSlot.child?.instance?.BottomSheet()
} }
data class Params( data class Params(

View file

@ -0,0 +1,32 @@
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 java.util.UUID
internal class ContactAddressEntriesConverter {
fun convert(addresses: List<ValidatedAddress>): List<AddressEntry> {
return addresses.flatMap { address ->
address.networkIds.map { rawId -> address.toAddressEntry(rawId) }
}
}
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),
networkName = blockchain?.fullName ?: rawId,
memo = memo?.takeIf { hasExtrasSupport },
signature = "",
)
}
}

View file

@ -1,41 +1,91 @@
package com.tangem.features.addressbook.editcontact.model package com.tangem.features.addressbook.editcontact.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.di.ModelScoped
import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
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.ContactName
import com.tangem.domain.addressbook.usecase.ValidateContactNameUseCase
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.account.CryptoPortfolioIcon import com.tangem.domain.models.account.CryptoPortfolioIcon
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.common.AddressBookResultHolder import com.tangem.features.addressbook.common.AddressBookResultHolder
import com.tangem.features.addressbook.editcontact.DefaultEditContactComponent import com.tangem.features.addressbook.editcontact.DefaultEditContactComponent
import com.tangem.features.addressbook.editcontact.state.EditContactStateController import com.tangem.features.addressbook.editcontact.state.EditContactStateController
import com.tangem.features.addressbook.editcontact.state.transformers.AddValidatedAddressTransformer import com.tangem.features.addressbook.editcontact.state.transformers.*
import com.tangem.features.addressbook.editcontact.state.transformers.SelectContactColorTransformer import com.tangem.features.addressbook.editcontact.state.transformers.converter.ContactNameErrorConverter
import com.tangem.features.addressbook.editcontact.state.transformers.UpdateContactNameTransformer
import com.tangem.features.addressbook.editcontact.state.transformers.UpdateEditContactInitialStateTransformer
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress 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 com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@Suppress("LongParameterList")
@ModelScoped @ModelScoped
internal class EditContactModel @Inject constructor( internal class EditContactModel @Inject constructor(
paramsContainer: ParamsContainer, paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider, override val dispatchers: CoroutineDispatcherProvider,
private val stateController: EditContactStateController, private val stateController: EditContactStateController,
private val resultHolder: AddressBookResultHolder, private val resultHolder: AddressBookResultHolder,
private val messageSender: UiMessageSender,
private val userWalletsListRepository: UserWalletsListRepository,
private val validateContactNameUseCase: ValidateContactNameUseCase,
private val saveContactInteractor: SaveContactInteractor,
val portfolioSelectorController: PortfolioSelectorController,
portfolioFetcherFactory: PortfolioFetcher.Factory,
) : Model() { ) : Model() {
private val params: DefaultEditContactComponent.Params = paramsContainer.require() private val params: DefaultEditContactComponent.Params = paramsContainer.require()
private val selectedWalletId = MutableStateFlow<UserWalletId?>(null)
/** 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 state: StateFlow<EditContactUM> get() = stateController.uiState
val portfolioSelectorNavigation = SlotNavigation<Unit>()
val portfolioFetcher: PortfolioFetcher by lazy {
portfolioFetcherFactory.create(
mode = PortfolioFetcher.Mode.All(isOnlyMultiCurrency = false),
scope = modelScope,
)
}
val portfolioSelectorCallback = object : PortfolioSelectorComponent.BottomSheetCallback {
override val onDismiss: () -> Unit = { portfolioSelectorNavigation.dismiss() }
override val onBack: () -> Unit = { portfolioSelectorNavigation.dismiss() }
}
init { init {
updateInitialState() updateInitialState()
prefillPredefinedAddress() prefillPredefinedAddress()
subscribeToConfirmedAddresses() subscribeToConfirmedAddresses()
initSelectedWallet()
observeWalletSelection()
observeWalletBlock()
observeNameValidation()
observeSaveButton()
} }
/** In WithContactCreation mode the contact opens with the already-known address attached. */ /** In WithContactCreation mode the contact opens with the already-known address attached. */
@ -50,11 +100,157 @@ internal class EditContactModel @Inject constructor(
onNameChange = ::onNameChange, onNameChange = ::onNameChange,
onColorSelect = ::onColorSelect, onColorSelect = ::onColorSelect,
onCloseClick = params.onBackClick, onCloseClick = params.onBackClick,
onAddAddressClick = params.onAddAddressClick, onAddAddressClick = ::onAddAddressClick,
onSaveClick = ::onSaveClick,
), ),
) )
} }
private fun initSelectedWallet() {
// TODO: For an existing contact the contact's own wallet should be used here once existing-contact
// loading is implemented. For now both new and existing contacts default to the selected wallet.
userWalletsListRepository.selectedUserWallet
.filterNotNull()
.onEach { wallet ->
if (selectedWalletId.value == null) selectedWalletId.value = wallet.walletId
}
.launchIn(modelScope)
}
/** Maps the account picked in the selector back to its wallet (wallet-only mode picks the main account). */
private fun observeWalletSelection() {
portfolioSelectorController.selectedAccountWithData(portfolioFetcher)
.mapNotNull { it?.first?.walletId }
.onEach { walletId ->
selectedWalletId.value = walletId
portfolioSelectorNavigation.dismiss()
}
.launchIn(modelScope)
}
private fun observeWalletBlock() {
combine(
selectedWalletId,
userWalletsListRepository.userWallets,
) { walletId, wallets ->
UpdateWalletBlockTransformer(
walletName = wallets?.firstOrNull { it.walletId == walletId }?.name.orEmpty(),
isChangeable = isWalletChangeable(wallets),
onClick = ::onWalletBlockClick,
)
}
.onEach(stateController::update)
.flowOn(dispatchers.default)
.launchIn(modelScope)
}
private fun isWalletChangeable(wallets: List<UserWallet>?): Boolean {
val unlockedWalletsCount = wallets.orEmpty().count { !it.isLocked }
return params.contactId == null && unlockedWalletsCount > 1
}
private fun onWalletBlockClick() {
if (isWalletChangeable(userWalletsListRepository.userWallets.value)) {
portfolioSelectorNavigation.activate(Unit)
}
}
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
private fun observeNameValidation() {
combine(
stateController.uiState.map { it.name }.distinctUntilChanged().debounce(NAME_DEBOUNCE_MS),
selectedWalletId.filterNotNull(),
) { name, walletId -> name to walletId }
.mapLatest { (name, walletId) -> validateName(name, walletId) }
.onEach { error -> stateController.update(UpdateNameErrorTransformer(error)) }
.launchIn(modelScope)
}
private suspend fun validateName(name: String, walletId: UserWalletId): TextReference? {
if (name.isBlank()) return null
val error = validateContactNameUseCase(walletId, name).leftOrNull() ?: return null
// A blank name must not surface an inline error; the Empty case is treated as "no error".
if (error is ContactNameValidationError.Format && error.error is ContactName.Error.Empty) return null
return ContactNameErrorConverter().convert(error)
}
private fun observeSaveButton() {
stateController.uiState
.map { state ->
SaveButtonInputs(
name = state.name,
hasNameError = state.nameError != null,
hasAddresses = state.addresses.isNotEmpty(),
)
}
.distinctUntilChanged()
.onEach { refreshSaveButton() }
.launchIn(modelScope)
}
/** Recomputes the button from the current inputs and whether a save is running ([saveJob] is active). */
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))
}
private fun onSaveClick() {
if (saveJob?.isActive == true) return
val userWallet = userWalletsListRepository.userWallets.value
?.firstOrNull { it.walletId == selectedWalletId.value }
?: return
val ui = stateController.uiState.value
val addressEntries = ContactAddressEntriesConverter().convert(ui.addresses)
saveJob = modelScope.launch {
try {
// TODO: existing-contact update needs the loaded Contact; existing-contact loading is not implemented.
val result = saveContactInteractor.createContact(
userWallet = userWallet,
name = ui.name,
iconColor = ui.colors.selected.name,
addressEntries = addressEntries,
)
result.fold(
ifLeft = ::handleSaveError,
ifRight = { params.onBackClick() },
)
} finally {
refreshSaveButton()
}
}
refreshSaveButton()
}
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),
),
)
}
}
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),
),
)
} else {
params.onAddAddressClick()
}
}
private fun subscribeToConfirmedAddresses() { private fun subscribeToConfirmedAddresses() {
resultHolder.confirmedAddress resultHolder.confirmedAddress
.filterNotNull() .filterNotNull()
@ -74,6 +270,17 @@ internal class EditContactModel @Inject constructor(
} }
private fun addAddress(address: ValidatedAddress) { private fun addAddress(address: ValidatedAddress) {
stateController.update(AddValidatedAddressTransformer(address = address)) stateController.update(AddValidatedAddressTransformer(address = address, maxAddresses = MAX_ADDRESSES))
}
private data class SaveButtonInputs(
val name: String,
val hasNameError: Boolean,
val hasAddresses: Boolean,
)
private companion object {
const val MAX_ADDRESSES = 20
const val NAME_DEBOUNCE_MS = 300L
} }
} }

View file

@ -3,6 +3,8 @@ package com.tangem.features.addressbook.editcontact.state
import com.tangem.common.ui.account.AccountIconUM import com.tangem.common.ui.account.AccountIconUM
import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.ui.R 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.TextReference
import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.models.account.CryptoPortfolioIcon import com.tangem.domain.models.account.CryptoPortfolioIcon
@ -32,6 +34,7 @@ internal class EditContactStateController @Inject constructor() {
title = TextReference.EMPTY, title = TextReference.EMPTY,
name = "", name = "",
namePlaceholder = resourceReference(R.string.address_book_new_contact), namePlaceholder = resourceReference(R.string.address_book_new_contact),
nameError = null,
portfolioIcon = AccountIconUM.CryptoPortfolio( portfolioIcon = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Letter, value = CryptoPortfolioIcon.Icon.Letter,
color = selectedColor, color = selectedColor,
@ -42,6 +45,18 @@ internal class EditContactStateController @Inject constructor() {
onColorSelect = {}, onColorSelect = {},
), ),
addresses = persistentListOf(), addresses = persistentListOf(),
walletBlock = EditContactUM.WalletBlockUM(
walletName = "",
isChangeable = false,
onClick = {},
),
isAddAddressEnabled = true,
saveButton = TangemButtonUM(
text = TextReference.Res(R.string.common_save),
type = TangemButtonType.Primary,
isEnabled = false,
onClick = {},
),
onNameChange = {}, onNameChange = {},
onCloseClick = {}, onCloseClick = {},
onAddAddressClick = {}, onAddAddressClick = {},

View file

@ -7,13 +7,16 @@ import kotlinx.collections.immutable.toImmutableList
internal class AddValidatedAddressTransformer( internal class AddValidatedAddressTransformer(
private val address: ValidatedAddress, private val address: ValidatedAddress,
private val maxAddresses: Int,
) : Transformer<EditContactUM> { ) : Transformer<EditContactUM> {
override fun transform(prevState: EditContactUM): EditContactUM { override fun transform(prevState: EditContactUM): EditContactUM {
// Skip duplicates: an address is identified by its string value (it already carries all its networks). // 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 if (prevState.addresses.any { it.address == address.address }) return prevState
val addresses = (prevState.addresses + address).toImmutableList()
return prevState.copy( return prevState.copy(
addresses = (prevState.addresses + address).toImmutableList(), addresses = addresses,
isAddAddressEnabled = addresses.size < maxAddresses,
) )
} }
} }

View file

@ -16,6 +16,7 @@ internal class UpdateEditContactInitialStateTransformer(
private val onColorSelect: (CryptoPortfolioIcon.Color) -> Unit, private val onColorSelect: (CryptoPortfolioIcon.Color) -> Unit,
private val onCloseClick: () -> Unit, private val onCloseClick: () -> Unit,
private val onAddAddressClick: () -> Unit, private val onAddAddressClick: () -> Unit,
private val onSaveClick: () -> Unit,
) : Transformer<EditContactUM> { ) : Transformer<EditContactUM> {
override fun transform(prevState: EditContactUM): EditContactUM { override fun transform(prevState: EditContactUM): EditContactUM {
@ -27,6 +28,7 @@ internal class UpdateEditContactInitialStateTransformer(
return prevState.copy( return prevState.copy(
title = resourceReference(titleResId), title = resourceReference(titleResId),
colors = prevState.colors.copy(onColorSelect = onColorSelect), colors = prevState.colors.copy(onColorSelect = onColorSelect),
saveButton = prevState.saveButton.copy(onClick = onSaveClick),
onNameChange = onNameChange, onNameChange = onNameChange,
onCloseClick = onCloseClick, onCloseClick = onCloseClick,
onAddAddressClick = onAddAddressClick, onAddAddressClick = onAddAddressClick,

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,16 @@
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 UpdateSaveButtonTransformer(
private val isEnabled: Boolean,
private val isLoading: Boolean,
) : Transformer<EditContactUM> {
override fun transform(prevState: EditContactUM): EditContactUM {
return prevState.copy(
saveButton = prevState.saveButton.copy(isEnabled = isEnabled, isLoading = isLoading),
)
}
}

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

@ -5,11 +5,16 @@ import androidx.compose.foundation.*
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape 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.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -23,6 +28,8 @@ import com.tangem.core.ui.components.account.AccountIconSize
import com.tangem.core.ui.components.block.BlockCard import com.tangem.core.ui.components.block.BlockCard
import com.tangem.core.ui.components.block.TangemBlockCardColors import com.tangem.core.ui.components.block.TangemBlockCardColors
import com.tangem.core.ui.components.fields.AutoSizeTextField import com.tangem.core.ui.components.fields.AutoSizeTextField
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.TangemIcon
import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.topbar.TangemTopBar import com.tangem.core.ui.ds.topbar.TangemTopBar
@ -66,26 +73,56 @@ internal fun EditContactContent(state: EditContactUM, modifier: Modifier = Modif
}, },
) )
Column( BoxWithConstraints(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.verticalScroll(rememberScrollState()) .weight(1f),
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
ContactSummary(state = state) val minContentHeight = maxHeight
ContactColor(colors = state.colors) Column(
BlockCard( modifier = Modifier
shape = RoundedCornerShape(24.dp), .fillMaxWidth()
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors3.bg.secondary), .verticalScroll(rememberScrollState()),
) { ) {
ContactAddresses(addresses = state.addresses) Column(
AddAddressRow(onClick = state.onAddAddressClick) modifier = Modifier
.heightIn(min = minContentHeight)
.padding(horizontal = 16.dp, vertical = 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)
AddAddressRow(isEnabled = state.isAddAddressEnabled, onClick = state.onAddAddressClick)
}
WalletBlock(walletBlock = state.walletBlock)
}
Spacer(modifier = Modifier.weight(1f))
SaveButton(saveButton = state.saveButton)
}
} }
} }
} }
} }
@Composable
private fun SaveButton(saveButton: TangemButtonUM) {
TangemButton(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp),
text = saveButton.text,
onClick = saveButton.onClick,
isEnabled = saveButton.isEnabled,
isLoading = saveButton.isLoading,
size = TangemButton.Size.X12,
)
}
@Composable @Composable
private fun ContactAddresses(addresses: ImmutableList<ValidatedAddress>) { private fun ContactAddresses(addresses: ImmutableList<ValidatedAddress>) {
addresses.fastForEach { entry -> addresses.fastForEach { entry ->
@ -126,7 +163,7 @@ private fun AddressRow(entry: ValidatedAddress) {
} }
@Composable @Composable
private fun AddAddressRow(onClick: () -> Unit) { private fun AddAddressRow(isEnabled: Boolean, onClick: () -> Unit) {
TangemRow( TangemRow(
verticalAlignment = TangemRowVerticalAlignment.Center, verticalAlignment = TangemRowVerticalAlignment.Center,
onClick = onClick, onClick = onClick,
@ -134,32 +171,113 @@ private fun AddAddressRow(onClick: () -> Unit) {
TangemIcon( TangemIcon(
tangemIconUM = TangemIconUM.Icon( tangemIconUM = TangemIconUM.Icon(
imageVector = Icons.ic_sign_plus_20, imageVector = Icons.ic_sign_plus_20,
tintReference = { TangemTheme.colors3.icon.brand }, tintReference = {
if (isEnabled) {
TangemTheme.colors3.icon.brand
} else {
TangemTheme.colors3.icon.tertiary
}
},
), ),
modifier = Modifier modifier = Modifier
.size(40.dp) .size(40.dp)
.background( .background(
color = TangemTheme.colors3.bg.status.infoSubtle, color = if (isEnabled) {
TangemTheme.colors3.bg.status.infoSubtle
} else {
TangemTheme.colors3.bg.opaque.secondary
},
shape = RoundedCornerShape(10.dp), shape = RoundedCornerShape(10.dp),
) )
.padding(8.dp), .padding(8.dp),
) )
}, },
titleSlot = { titleSlot = {
TangemRowText( if (isEnabled) {
text = TextReference.Res(R.string.address_book_add_address), TangemRowText(
role = TangemRowTextRole.Title, 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 = { subtitleSlot = {
TangemRowText( if (isEnabled) {
text = TextReference.Res(R.string.address_book_add_address_description), TangemRowText(
role = TangemRowTextRole.Subtitle, 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) {
Box(
modifier = modifier
.clip(RoundedCornerShape(24.dp))
.fillMaxWidth()
.background(TangemTheme.colors3.bg.secondary),
) {
TangemRow(
onClick = if (walletBlock.isChangeable) walletBlock.onClick else null,
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()
}
}
},
)
}
}
@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 @Composable
private fun ContactSummary(state: EditContactUM) { private fun ContactSummary(state: EditContactUM) {
val avatarName = state.name.ifBlank { state.namePlaceholder.resolveReference() } val avatarName = state.name.ifBlank { state.namePlaceholder.resolveReference() }
@ -199,6 +317,17 @@ private fun ContactSummary(state: EditContactUM) {
color = TangemTheme.colors3.text.primary, color = TangemTheme.colors3.text.primary,
placeholderColor = TangemTheme.colors3.text.tertiary, placeholderColor = TangemTheme.colors3.text.tertiary,
) )
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) SpacerH(8.dp)
} }
} }
@ -264,6 +393,7 @@ private fun Preview_EditContactContent() {
title = stringReference("New contact"), title = stringReference("New contact"),
name = "", name = "",
namePlaceholder = stringReference("New contact"), namePlaceholder = stringReference("New contact"),
nameError = null,
portfolioIcon = AccountIconUM.CryptoPortfolio( portfolioIcon = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Letter, value = CryptoPortfolioIcon.Icon.Letter,
color = colors.first(), color = colors.first(),
@ -279,6 +409,18 @@ private fun Preview_EditContactContent() {
networkIds = persistentListOf("ethereum", "bsc", "polygon"), 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,
onClick = {},
),
onNameChange = {}, onNameChange = {},
onCloseClick = {}, onCloseClick = {},
onAddAddressClick = {}, onAddAddressClick = {},

View file

@ -2,6 +2,7 @@ package com.tangem.features.addressbook.editcontact.ui.state
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import com.tangem.common.ui.account.AccountIconUM 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.core.ui.extensions.TextReference
import com.tangem.domain.models.account.CryptoPortfolioIcon import com.tangem.domain.models.account.CryptoPortfolioIcon
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -11,9 +12,13 @@ internal data class EditContactUM(
val title: TextReference, val title: TextReference,
val name: String, val name: String,
val namePlaceholder: TextReference, val namePlaceholder: TextReference,
val nameError: TextReference?,
val portfolioIcon: AccountIconUM.CryptoPortfolio, val portfolioIcon: AccountIconUM.CryptoPortfolio,
val colors: Colors, val colors: Colors,
val addresses: ImmutableList<ValidatedAddress>, val addresses: ImmutableList<ValidatedAddress>,
val walletBlock: WalletBlockUM,
val isAddAddressEnabled: Boolean,
val saveButton: TangemButtonUM,
val onNameChange: (String) -> Unit, val onNameChange: (String) -> Unit,
val onCloseClick: () -> Unit, val onCloseClick: () -> Unit,
val onAddAddressClick: () -> Unit, val onAddAddressClick: () -> Unit,
@ -24,4 +29,10 @@ internal data class EditContactUM(
val list: ImmutableList<CryptoPortfolioIcon.Color>, val list: ImmutableList<CryptoPortfolioIcon.Color>,
val onColorSelect: (CryptoPortfolioIcon.Color) -> Unit, val onColorSelect: (CryptoPortfolioIcon.Color) -> Unit,
) )
data class WalletBlockUM(
val walletName: String,
val isChangeable: Boolean,
val onClick: () -> Unit,
)
} }

View file

@ -5,8 +5,8 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -17,6 +17,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R 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.ds.image.TangemIconUM
import com.tangem.core.ui.ds.topbar.TangemTopBar import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds2.button.TangemButton import com.tangem.core.ui.ds2.button.TangemButton
@ -31,7 +32,6 @@ import com.tangem.features.addressbook.list.ui.preview.AddressBookListPreviewPar
import com.tangem.features.addressbook.list.ui.preview.AddressBookListPreviewScenario 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.AddressBookChipUM
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM 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.list.ui.state.ContentMode
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -91,18 +91,19 @@ internal fun AddressBookListScreen(
NothingFoundContent() NothingFoundContent()
} else { } else {
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier.imePadding(),
.imePadding()
.padding(top = 16.dp)
.padding(horizontal = 16.dp)
.background(
color = TangemTheme.colors3.bg.secondary,
shape = RoundedCornerShape(24.dp),
),
contentPadding = PaddingValues(bottom = 12.dp + bottomBarHeight), contentPadding = PaddingValues(bottom = 12.dp + bottomBarHeight),
) { ) {
items(items = state.contacts, key = ContactUM::id) { contact -> itemsIndexed(items = state.contacts, key = { _, contact -> contact.id }) { index, contact ->
ContactRow(contact = contact) ContactRow(
contact = contact,
modifier = Modifier.roundedShapeItemDecoration(
currentIndex = index,
lastIndex = state.contacts.lastIndex,
radius = 24.dp,
backgroundColor = TangemTheme.colors3.bg.secondary,
),
)
} }
} }
} }

View file

@ -1,36 +1,81 @@
package com.tangem.features.addressbook.editcontact.model package com.tangem.features.addressbook.editcontact.model
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import com.tangem.common.test.domain.wallet.MockUserWalletFactory
import com.tangem.common.ui.account.AccountIconUM import com.tangem.common.ui.account.AccountIconUM
import com.tangem.core.decompose.model.MutableParamsContainer import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.R import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.DialogMessage
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.ContactId import com.tangem.domain.addressbook.model.ContactId
import com.tangem.domain.addressbook.model.ContactName
import com.tangem.domain.addressbook.usecase.ValidateContactNameUseCase
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.CryptoPortfolioIcon import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.addressbook.common.AddressBookResultHolder import com.tangem.features.addressbook.common.AddressBookResultHolder
import com.tangem.features.addressbook.editcontact.DefaultEditContactComponent import com.tangem.features.addressbook.editcontact.DefaultEditContactComponent
import com.tangem.features.addressbook.editcontact.state.EditContactStateController import com.tangem.features.addressbook.editcontact.state.EditContactStateController
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorController
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
internal class EditContactModelTest { internal class EditContactModelTest {
private val resultHolder = AddressBookResultHolder() private val resultHolder = AddressBookResultHolder()
private val messageSender: UiMessageSender = mockk(relaxed = true)
private val userWalletsListRepository: UserWalletsListRepository = mockk(relaxed = true)
private val validateContactNameUseCase: ValidateContactNameUseCase = mockk()
private val saveContactInteractor: SaveContactInteractor = mockk()
private val portfolioSelectorController: PortfolioSelectorController = mockk()
private val portfolioFetcher: PortfolioFetcher = mockk(relaxed = true)
private val portfolioFetcherFactory: PortfolioFetcher.Factory = mockk()
// Drives the wallet picked in the reused portfolio selector; `first` of the pair is the chosen wallet.
private val selectedWalletData =
MutableSharedFlow<Pair<UserWallet, AccountStatus.CryptoPortfolio>?>(extraBufferCapacity = 1)
private var model: EditContactModel? = null private var model: EditContactModel? = null
@BeforeEach
fun setUp() {
// Default: no wallets loaded, name always valid. Individual tests override as needed.
setupWallets(wallets = emptyList(), selected = null)
coEvery { validateContactNameUseCase(any(), any()) } returns ContactName("Satoshi").getOrNull()!!.right()
every { portfolioFetcherFactory.create(any(), any()) } returns portfolioFetcher
every { portfolioSelectorController.selectedAccountWithData(any()) } returns selectedWalletData
}
@AfterEach @AfterEach
fun tearDown() { fun tearDown() {
// Cancels modelScope, stopping the confirmed-addresses collector. // Cancels modelScope, stopping the confirmed-addresses collector.
@ -44,12 +89,14 @@ internal class EditContactModelTest {
val expectedSelectedColor = expectedColors.first() val expectedSelectedColor = expectedColors.first()
val model = createModel(testScope = this) val model = createModel(testScope = this)
advanceUntilIdle()
val state = model.state.value val state = model.state.value
val expected = EditContactUM( val expected = EditContactUM(
title = resourceReference(R.string.address_book_new_contact), title = resourceReference(R.string.address_book_new_contact),
name = "", name = "",
namePlaceholder = resourceReference(R.string.address_book_new_contact), namePlaceholder = resourceReference(R.string.address_book_new_contact),
nameError = null,
portfolioIcon = AccountIconUM.CryptoPortfolio( portfolioIcon = AccountIconUM.CryptoPortfolio(
value = CryptoPortfolioIcon.Icon.Letter, value = CryptoPortfolioIcon.Icon.Letter,
color = expectedSelectedColor, color = expectedSelectedColor,
@ -60,6 +107,13 @@ internal class EditContactModelTest {
onColorSelect = state.colors.onColorSelect, onColorSelect = state.colors.onColorSelect,
), ),
addresses = persistentListOf(), addresses = persistentListOf(),
walletBlock = EditContactUM.WalletBlockUM(
walletName = "",
isChangeable = false,
onClick = state.walletBlock.onClick,
),
isAddAddressEnabled = true,
saveButton = state.saveButton,
onNameChange = state.onNameChange, onNameChange = state.onNameChange,
onCloseClick = state.onCloseClick, onCloseClick = state.onCloseClick,
onAddAddressClick = state.onAddAddressClick, onAddAddressClick = state.onAddAddressClick,
@ -149,14 +203,249 @@ internal class EditContactModelTest {
assertThat(model.state.value.addresses).containsExactly(predefined) assertThat(model.state.value.addresses).containsExactly(predefined)
} }
@Test
fun `GIVEN below address limit WHEN onAddAddressClick THEN click propagated AND no dialog`() = runTest {
// Arrange
var addClicked = false
val model = createModel(testScope = this, params = createParams(onAddAddressClick = { addClicked = true }))
advanceUntilIdle()
// Act
model.state.value.onAddAddressClick()
// Assert
assertThat(addClicked).isTrue()
verify(exactly = 0) { messageSender.send(any<DialogMessage>()) }
}
@Test
fun `GIVEN max addresses reached WHEN onAddAddressClick THEN limit dialog shown AND click not propagated`() =
runTest {
// Arrange
var addClicked = false
val model = createModel(testScope = this, params = createParams(onAddAddressClick = { addClicked = true }))
advanceUntilIdle()
repeat(MAX_ADDRESSES) { index ->
resultHolder.setConfirmedAddress(
ValidatedAddress(address = "0x$index", networkIds = persistentListOf("ethereum")),
)
advanceUntilIdle()
}
// Act
model.state.value.onAddAddressClick()
// Assert
assertThat(model.state.value.addresses).hasSize(MAX_ADDRESSES)
assertThat(model.state.value.isAddAddressEnabled).isFalse()
assertThat(addClicked).isFalse()
verify { messageSender.send(any<DialogMessage>()) }
}
@Test
fun `GIVEN new contact AND multiple unlocked wallets WHEN created THEN wallet block changeable`() = runTest {
// Arrange
val walletA = createWallet(id = "aa", name = "Wallet A")
val walletB = createWallet(id = "bb", name = "Wallet B")
setupWallets(wallets = listOf(walletA, walletB), selected = walletA)
// Act
val model = createModel(testScope = this)
advanceUntilIdle()
// Assert
val block = model.state.value.walletBlock
assertThat(block.isChangeable).isTrue()
assertThat(block.walletName).isEqualTo("Wallet A")
}
@Test
fun `GIVEN new contact AND single unlocked wallet WHEN created THEN wallet block not changeable`() = runTest {
// Arrange
val walletA = createWallet(id = "aa", name = "Wallet A")
setupWallets(wallets = listOf(walletA), selected = walletA)
// Act
val model = createModel(testScope = this)
advanceUntilIdle()
// Assert
assertThat(model.state.value.walletBlock.isChangeable).isFalse()
}
@Test
fun `GIVEN duplicate name in selected wallet WHEN name entered THEN name error shown`() = runTest {
// Arrange
val walletA = createWallet(id = "aa", name = "Wallet A")
setupWallets(wallets = listOf(walletA), selected = walletA)
coEvery {
validateContactNameUseCase(any(), any())
} returns ContactNameValidationError.Duplicate.left()
val model = createModel(testScope = this)
advanceUntilIdle()
// Act
model.state.value.onNameChange("Satoshi")
advanceUntilIdle()
// Assert
assertThat(model.state.value.nameError)
.isEqualTo(resourceReference(R.string.address_book_name_taken_error))
}
@Test
fun `GIVEN unique name in selected wallet WHEN name entered THEN no name error`() = runTest {
// Arrange
val walletA = createWallet(id = "aa", name = "Wallet A")
setupWallets(wallets = listOf(walletA), selected = walletA)
val model = createModel(testScope = this)
advanceUntilIdle()
// Act
model.state.value.onNameChange("Satoshi")
advanceUntilIdle()
// Assert
assertThat(model.state.value.nameError).isNull()
}
@Test
fun `GIVEN multiple wallets WHEN wallet picked in selector THEN block reflects chosen wallet AND name re-validated`() =
runTest {
// Arrange
val walletA = createWallet(id = "aa", name = "Wallet A")
val walletB = createWallet(id = "bb", name = "Wallet B")
setupWallets(wallets = listOf(walletA, walletB), selected = walletA)
coEvery {
validateContactNameUseCase(walletB.walletId, "Satoshi")
} returns ContactNameValidationError.Duplicate.left()
val model = createModel(testScope = this)
advanceUntilIdle()
model.state.value.onNameChange("Satoshi")
advanceUntilIdle()
// Act — the reused portfolio selector reports wallet B (wallet-only mode maps to its main account).
selectedWalletData.tryEmit(walletB to mockk())
advanceUntilIdle()
// Assert
assertThat(model.state.value.walletBlock.walletName).isEqualTo("Wallet B")
assertThat(model.state.value.nameError)
.isEqualTo(resourceReference(R.string.address_book_name_taken_error))
}
@Test
fun `GIVEN valid name address and wallet WHEN observed THEN save button enabled`() = runTest {
// Arrange
val walletA = createWallet(id = "aa", name = "Wallet A")
setupWallets(wallets = listOf(walletA), selected = walletA)
val model = createModel(testScope = this)
advanceUntilIdle()
// Act
model.state.value.onNameChange("Satoshi")
resultHolder.setConfirmedAddress(ValidatedAddress(address = "0xABC", networkIds = persistentListOf("ethereum")))
advanceUntilIdle()
// Assert
assertThat(model.state.value.saveButton.isEnabled).isTrue()
}
@Test
fun `GIVEN name but no address WHEN observed THEN save button disabled`() = runTest {
// Arrange
val walletA = createWallet(id = "aa", name = "Wallet A")
setupWallets(wallets = listOf(walletA), selected = walletA)
val model = createModel(testScope = this)
advanceUntilIdle()
// Act
model.state.value.onNameChange("Satoshi")
advanceUntilIdle()
// Assert
assertThat(model.state.value.saveButton.isEnabled).isFalse()
}
@Test
fun `GIVEN name error WHEN observed THEN save button disabled`() = runTest {
// Arrange
val walletA = createWallet(id = "aa", name = "Wallet A")
setupWallets(wallets = listOf(walletA), selected = walletA)
coEvery { validateContactNameUseCase(any(), any()) } returns ContactNameValidationError.Duplicate.left()
val model = createModel(testScope = this)
advanceUntilIdle()
// Act
model.state.value.onNameChange("Satoshi")
resultHolder.setConfirmedAddress(ValidatedAddress(address = "0xABC", networkIds = persistentListOf("ethereum")))
advanceUntilIdle()
// Assert
assertThat(model.state.value.saveButton.isEnabled).isFalse()
}
@Test
fun `GIVEN valid contact WHEN save clicked THEN createContact called AND navigates back`() = runTest {
// Arrange
var navigatedBack = false
val walletA = createWallet(id = "aa", name = "Wallet A")
setupWallets(wallets = listOf(walletA), selected = walletA)
coEvery { saveContactInteractor.createContact(any(), any(), any(), any()) } returns mockk<Contact>().right()
val model = createModel(testScope = this, params = createParams(onBackClick = { navigatedBack = true }))
advanceUntilIdle()
model.state.value.onNameChange("Satoshi")
resultHolder.setConfirmedAddress(ValidatedAddress(address = "0xABC", networkIds = persistentListOf("ethereum")))
advanceUntilIdle()
// Act
model.state.value.saveButton.onClick()
advanceUntilIdle()
// Assert
coVerify {
saveContactInteractor.createContact(
userWallet = walletA,
name = "Satoshi",
iconColor = any(),
addressEntries = any(),
)
}
assertThat(navigatedBack).isTrue()
}
@Test
fun `GIVEN save returns name error WHEN save clicked THEN inline name error shown`() = runTest {
// Arrange
val walletA = createWallet(id = "aa", name = "Wallet A")
setupWallets(wallets = listOf(walletA), selected = walletA)
coEvery { saveContactInteractor.createContact(any(), any(), any(), any()) } returns
SaveContactError.Name(ContactNameValidationError.Duplicate).left()
val model = createModel(testScope = this)
advanceUntilIdle()
model.state.value.onNameChange("Satoshi")
resultHolder.setConfirmedAddress(ValidatedAddress(address = "0xABC", networkIds = persistentListOf("ethereum")))
advanceUntilIdle()
// Act
model.state.value.saveButton.onClick()
advanceUntilIdle()
// Assert
assertThat(model.state.value.nameError)
.isEqualTo(resourceReference(R.string.address_book_name_taken_error))
}
private fun createParams( private fun createParams(
contactId: ContactId? = null, contactId: ContactId? = null,
predefinedAddress: ValidatedAddress? = null, predefinedAddress: ValidatedAddress? = null,
onAddAddressClick: () -> Unit = {},
onBackClick: () -> Unit = {},
): DefaultEditContactComponent.Params = DefaultEditContactComponent.Params( ): DefaultEditContactComponent.Params = DefaultEditContactComponent.Params(
contactId = contactId, contactId = contactId,
predefinedAddress = predefinedAddress, predefinedAddress = predefinedAddress,
onBackClick = {}, onBackClick = onBackClick,
onAddAddressClick = {}, onAddAddressClick = onAddAddressClick,
) )
private fun createModel( private fun createModel(
@ -169,9 +458,23 @@ internal class EditContactModelTest {
dispatchers = testScope.createTestingCoroutineDispatcherProvider(), dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
stateController = EditContactStateController(), stateController = EditContactStateController(),
resultHolder = resultHolder, resultHolder = resultHolder,
messageSender = messageSender,
userWalletsListRepository = userWalletsListRepository,
validateContactNameUseCase = validateContactNameUseCase,
saveContactInteractor = saveContactInteractor,
portfolioSelectorController = portfolioSelectorController,
portfolioFetcherFactory = portfolioFetcherFactory,
).also { model = it } ).also { model = it }
} }
private fun setupWallets(wallets: List<UserWallet>, selected: UserWallet?) {
every { userWalletsListRepository.userWallets } returns MutableStateFlow(wallets)
every { userWalletsListRepository.selectedUserWallet } returns MutableStateFlow(selected)
}
private fun createWallet(id: String, name: String): UserWallet =
MockUserWalletFactory.create().copy(walletId = UserWalletId(id), name = name)
private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider { private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
val testDispatcher = StandardTestDispatcher(testScheduler) val testDispatcher = StandardTestDispatcher(testScheduler)
return TestingCoroutineDispatcherProvider( return TestingCoroutineDispatcherProvider(
@ -182,4 +485,8 @@ internal class EditContactModelTest {
single = testDispatcher, single = testDispatcher,
) )
} }
private companion object {
const val MAX_ADDRESSES = 20
}
} }

View file

@ -32,6 +32,15 @@ interface PortfolioSelectorComponent : ComposableBottomSheetComponent, Composabl
val portfolioFetcher: PortfolioFetcher, val portfolioFetcher: PortfolioFetcher,
val controller: PortfolioSelectorController, val controller: PortfolioSelectorController,
val bsCallback: BottomSheetCallback? = null, val bsCallback: BottomSheetCallback? = null,
val settings: Settings = Settings(),
)
/**
* @param isWalletSelectionOnly when `true`, the selector always shows a flat wallet list and ignores the global
* accounts mode (no account grouping).
*/
data class Settings(
val isWalletSelectionOnly: Boolean = false,
) )
interface BottomSheetCallback { interface BottomSheetCallback {

View file

@ -59,14 +59,15 @@ internal class PortfolioSelectorModel @Inject constructor(
flow4 = selectorController.isEnabled, flow4 = selectorController.isEnabled,
flow5 = selectedAccountState, flow5 = selectedAccountState,
transform = { isAccountsMode, portfolioData, artworks, isEnabled, selectedAccount -> transform = { isAccountsMode, portfolioData, artworks, isEnabled, selectedAccount ->
val isAccountsModeEffective = isAccountsMode && !params.settings.isWalletSelectionOnly
val uiList = buildUiList( val uiList = buildUiList(
isAccountsMode = isAccountsMode, isAccountsMode = isAccountsModeEffective,
portfolioData = portfolioData, portfolioData = portfolioData,
artworks = artworks, artworks = artworks,
isEnabled = isEnabled, isEnabled = isEnabled,
selectedAccount = selectedAccount, selectedAccount = selectedAccount,
) )
val title = when (isAccountsMode) { val title = when (isAccountsModeEffective) {
true -> resourceReference(R.string.common_choose_account) true -> resourceReference(R.string.common_choose_account)
false -> resourceReference(R.string.common_choose_wallet) false -> resourceReference(R.string.common_choose_wallet)
} }