Updated on 2026-08-14
This commit is contained in:
parent
066ab573b3
commit
3fefb7bf52
21 changed files with 944 additions and 56 deletions
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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 = "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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 = {},
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 = {},
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue