Updated on 2026-08-14
This commit is contained in:
parent
066ab573b3
commit
3fefb7bf52
21 changed files with 944 additions and 56 deletions
|
|
@ -14,6 +14,7 @@ android {
|
|||
dependencies {
|
||||
/** Api */
|
||||
implementation(projects.features.addressBook.api)
|
||||
implementation(projects.features.commonFeatures.api)
|
||||
|
||||
/** Domain */
|
||||
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.route.AddressBookRoute
|
||||
import com.tangem.features.addressbook.selectnetworks.DefaultSelectNetworksComponent
|
||||
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import javax.inject.Inject
|
||||
|
||||
|
|
@ -19,6 +20,7 @@ import javax.inject.Inject
|
|||
*/
|
||||
internal class AddressBookChildFactory @Inject constructor(
|
||||
private val addressSelectorFactory: AddressSelectorComponent.Factory,
|
||||
private val portfolioSelectorComponentFactory: PortfolioSelectorComponent.Factory,
|
||||
) {
|
||||
|
||||
fun createChild(
|
||||
|
|
@ -43,6 +45,7 @@ internal class AddressBookChildFactory @Inject constructor(
|
|||
onBackClick = clickIntents::onEditContactBack,
|
||||
onAddAddressClick = clickIntents::onAddAddressClick,
|
||||
),
|
||||
portfolioSelectorComponentFactory = portfolioSelectorComponentFactory,
|
||||
)
|
||||
AddressBookRoute.AddAddress -> DefaultAddAddressComponent(
|
||||
appComponentContext = context,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.tangem.features.addressbook.common.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.tangem.common.ui.account.AccountIcon
|
||||
import com.tangem.common.ui.account.AccountIconUM
|
||||
|
|
@ -15,8 +16,9 @@ import com.tangem.features.addressbook.list.ui.state.ContactUM
|
|||
import com.tangem.utils.StringsSigns
|
||||
|
||||
@Composable
|
||||
internal fun ContactRow(contact: ContactUM) {
|
||||
internal fun ContactRow(contact: ContactUM, modifier: Modifier = Modifier) {
|
||||
TangemRow(
|
||||
modifier = modifier,
|
||||
onClick = contact.onClick,
|
||||
verticalAlignment = TangemRowVerticalAlignment.Center,
|
||||
contentLead = TangemRowContentLead.Start,
|
||||
|
|
|
|||
|
|
@ -5,29 +5,63 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.arkivanov.decompose.ComponentContext
|
||||
import com.arkivanov.decompose.extensions.compose.subscribeAsState
|
||||
import com.arkivanov.decompose.router.slot.childSlot
|
||||
import com.tangem.core.decompose.context.AppComponentContext
|
||||
import com.tangem.core.decompose.context.childByContext
|
||||
import com.tangem.core.decompose.model.getOrCreateModel
|
||||
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
|
||||
import com.tangem.core.ui.decompose.ComposableContentComponent
|
||||
import com.tangem.domain.addressbook.model.ContactId
|
||||
import com.tangem.features.addressbook.editcontact.model.EditContactModel
|
||||
import com.tangem.features.addressbook.editcontact.ui.EditContactContent
|
||||
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(
|
||||
appComponentContext: AppComponentContext,
|
||||
params: Params,
|
||||
private val portfolioSelectorComponentFactory: PortfolioSelectorComponent.Factory,
|
||||
) : ComposableContentComponent, AppComponentContext by appComponentContext {
|
||||
|
||||
private val model: EditContactModel = getOrCreateModel(params)
|
||||
|
||||
private val portfolioSelectorSlot = childSlot(
|
||||
source = model.portfolioSelectorNavigation,
|
||||
serializer = Unit.serializer(),
|
||||
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
|
||||
override fun Content(modifier: Modifier) {
|
||||
val state by model.state.collectAsStateWithLifecycle()
|
||||
val selectorSlot by portfolioSelectorSlot.subscribeAsState()
|
||||
BackHandler {
|
||||
if (selectorSlot.child != null) {
|
||||
model.portfolioSelectorCallback.onBack()
|
||||
} else {
|
||||
state.onCloseClick()
|
||||
}
|
||||
}
|
||||
EditContactContent(
|
||||
state = state,
|
||||
modifier = modifier,
|
||||
)
|
||||
BackHandler(onBack = state.onCloseClick)
|
||||
selectorSlot.child?.instance?.BottomSheet()
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
import com.arkivanov.decompose.router.slot.SlotNavigation
|
||||
import com.arkivanov.decompose.router.slot.activate
|
||||
import com.arkivanov.decompose.router.slot.dismiss
|
||||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.core.decompose.model.ParamsContainer
|
||||
import com.tangem.core.decompose.ui.UiMessageSender
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.extensions.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.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.editcontact.DefaultEditContactComponent
|
||||
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.SelectContactColorTransformer
|
||||
import com.tangem.features.addressbook.editcontact.state.transformers.UpdateContactNameTransformer
|
||||
import com.tangem.features.addressbook.editcontact.state.transformers.UpdateEditContactInitialStateTransformer
|
||||
import com.tangem.features.addressbook.editcontact.state.transformers.*
|
||||
import com.tangem.features.addressbook.editcontact.state.transformers.converter.ContactNameErrorConverter
|
||||
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
|
||||
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
|
||||
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher
|
||||
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent
|
||||
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorController
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@ModelScoped
|
||||
internal class EditContactModel @Inject constructor(
|
||||
paramsContainer: ParamsContainer,
|
||||
override val dispatchers: CoroutineDispatcherProvider,
|
||||
private val stateController: EditContactStateController,
|
||||
private val resultHolder: AddressBookResultHolder,
|
||||
private val messageSender: UiMessageSender,
|
||||
private val userWalletsListRepository: UserWalletsListRepository,
|
||||
private val validateContactNameUseCase: ValidateContactNameUseCase,
|
||||
private val saveContactInteractor: SaveContactInteractor,
|
||||
val portfolioSelectorController: PortfolioSelectorController,
|
||||
portfolioFetcherFactory: PortfolioFetcher.Factory,
|
||||
) : Model() {
|
||||
|
||||
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 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 {
|
||||
updateInitialState()
|
||||
prefillPredefinedAddress()
|
||||
subscribeToConfirmedAddresses()
|
||||
initSelectedWallet()
|
||||
observeWalletSelection()
|
||||
observeWalletBlock()
|
||||
observeNameValidation()
|
||||
observeSaveButton()
|
||||
}
|
||||
|
||||
/** In WithContactCreation mode the contact opens with the already-known address attached. */
|
||||
|
|
@ -50,11 +100,157 @@ internal class EditContactModel @Inject constructor(
|
|||
onNameChange = ::onNameChange,
|
||||
onColorSelect = ::onColorSelect,
|
||||
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() {
|
||||
resultHolder.confirmedAddress
|
||||
.filterNotNull()
|
||||
|
|
@ -74,6 +270,17 @@ internal class EditContactModel @Inject constructor(
|
|||
}
|
||||
|
||||
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.core.decompose.di.ModelScoped
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.ds.button.TangemButtonType
|
||||
import com.tangem.core.ui.ds.button.TangemButtonUM
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||
|
|
@ -32,6 +34,7 @@ internal class EditContactStateController @Inject constructor() {
|
|||
title = TextReference.EMPTY,
|
||||
name = "",
|
||||
namePlaceholder = resourceReference(R.string.address_book_new_contact),
|
||||
nameError = null,
|
||||
portfolioIcon = AccountIconUM.CryptoPortfolio(
|
||||
value = CryptoPortfolioIcon.Icon.Letter,
|
||||
color = selectedColor,
|
||||
|
|
@ -42,6 +45,18 @@ internal class EditContactStateController @Inject constructor() {
|
|||
onColorSelect = {},
|
||||
),
|
||||
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 = {},
|
||||
onCloseClick = {},
|
||||
onAddAddressClick = {},
|
||||
|
|
|
|||
|
|
@ -7,13 +7,16 @@ import kotlinx.collections.immutable.toImmutableList
|
|||
|
||||
internal class AddValidatedAddressTransformer(
|
||||
private val address: ValidatedAddress,
|
||||
private val maxAddresses: Int,
|
||||
) : Transformer<EditContactUM> {
|
||||
|
||||
override fun transform(prevState: EditContactUM): EditContactUM {
|
||||
// Skip duplicates: an address is identified by its string value (it already carries all its networks).
|
||||
if (prevState.addresses.any { it.address == address.address }) return prevState
|
||||
val addresses = (prevState.addresses + address).toImmutableList()
|
||||
return prevState.copy(
|
||||
addresses = (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 onCloseClick: () -> Unit,
|
||||
private val onAddAddressClick: () -> Unit,
|
||||
private val onSaveClick: () -> Unit,
|
||||
) : Transformer<EditContactUM> {
|
||||
|
||||
override fun transform(prevState: EditContactUM): EditContactUM {
|
||||
|
|
@ -27,6 +28,7 @@ internal class UpdateEditContactInitialStateTransformer(
|
|||
return prevState.copy(
|
||||
title = resourceReference(titleResId),
|
||||
colors = prevState.colors.copy(onColorSelect = onColorSelect),
|
||||
saveButton = prevState.saveButton.copy(onClick = onSaveClick),
|
||||
onNameChange = onNameChange,
|
||||
onCloseClick = onCloseClick,
|
||||
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.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.TextAutoSize
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.tooling.preview.Preview
|
||||
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.TangemBlockCardColors
|
||||
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.TangemIconUM
|
||||
import com.tangem.core.ui.ds.topbar.TangemTopBar
|
||||
|
|
@ -66,26 +73,56 @@ internal fun EditContactContent(state: EditContactUM, modifier: Modifier = Modif
|
|||
},
|
||||
)
|
||||
|
||||
Column(
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
.weight(1f),
|
||||
) {
|
||||
ContactSummary(state = state)
|
||||
ContactColor(colors = state.colors)
|
||||
BlockCard(
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors3.bg.secondary),
|
||||
val minContentHeight = maxHeight
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
ContactAddresses(addresses = state.addresses)
|
||||
AddAddressRow(onClick = state.onAddAddressClick)
|
||||
Column(
|
||||
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
|
||||
private fun ContactAddresses(addresses: ImmutableList<ValidatedAddress>) {
|
||||
addresses.fastForEach { entry ->
|
||||
|
|
@ -126,7 +163,7 @@ private fun AddressRow(entry: ValidatedAddress) {
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun AddAddressRow(onClick: () -> Unit) {
|
||||
private fun AddAddressRow(isEnabled: Boolean, onClick: () -> Unit) {
|
||||
TangemRow(
|
||||
verticalAlignment = TangemRowVerticalAlignment.Center,
|
||||
onClick = onClick,
|
||||
|
|
@ -134,32 +171,113 @@ private fun AddAddressRow(onClick: () -> Unit) {
|
|||
TangemIcon(
|
||||
tangemIconUM = TangemIconUM.Icon(
|
||||
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
|
||||
.size(40.dp)
|
||||
.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),
|
||||
)
|
||||
.padding(8.dp),
|
||||
)
|
||||
},
|
||||
titleSlot = {
|
||||
TangemRowText(
|
||||
text = TextReference.Res(R.string.address_book_add_address),
|
||||
role = TangemRowTextRole.Title,
|
||||
)
|
||||
if (isEnabled) {
|
||||
TangemRowText(
|
||||
text = TextReference.Res(R.string.address_book_add_address),
|
||||
role = TangemRowTextRole.Title,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.address_book_add_address),
|
||||
color = TangemTheme.colors3.text.tertiary,
|
||||
style = TangemTheme.typography3.body.medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
},
|
||||
subtitleSlot = {
|
||||
TangemRowText(
|
||||
text = TextReference.Res(R.string.address_book_add_address_description),
|
||||
role = TangemRowTextRole.Subtitle,
|
||||
)
|
||||
if (isEnabled) {
|
||||
TangemRowText(
|
||||
text = TextReference.Res(R.string.address_book_add_address_description),
|
||||
role = TangemRowTextRole.Subtitle,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = stringResourceSafe(R.string.address_book_add_address_description),
|
||||
color = TangemTheme.colors3.text.tertiary,
|
||||
style = TangemTheme.typography3.caption.medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WalletBlock(walletBlock: EditContactUM.WalletBlockUM, modifier: Modifier = Modifier) {
|
||||
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
|
||||
private fun ContactSummary(state: EditContactUM) {
|
||||
val avatarName = state.name.ifBlank { state.namePlaceholder.resolveReference() }
|
||||
|
|
@ -199,6 +317,17 @@ private fun ContactSummary(state: EditContactUM) {
|
|||
color = TangemTheme.colors3.text.primary,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -264,6 +393,7 @@ private fun Preview_EditContactContent() {
|
|||
title = stringReference("New contact"),
|
||||
name = "",
|
||||
namePlaceholder = stringReference("New contact"),
|
||||
nameError = null,
|
||||
portfolioIcon = AccountIconUM.CryptoPortfolio(
|
||||
value = CryptoPortfolioIcon.Icon.Letter,
|
||||
color = colors.first(),
|
||||
|
|
@ -279,6 +409,18 @@ private fun Preview_EditContactContent() {
|
|||
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 = {},
|
||||
onCloseClick = {},
|
||||
onAddAddressClick = {},
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.tangem.features.addressbook.editcontact.ui.state
|
|||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.tangem.common.ui.account.AccountIconUM
|
||||
import com.tangem.core.ui.ds.button.TangemButtonUM
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.domain.models.account.CryptoPortfolioIcon
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
|
@ -11,9 +12,13 @@ internal data class EditContactUM(
|
|||
val title: TextReference,
|
||||
val name: String,
|
||||
val namePlaceholder: TextReference,
|
||||
val nameError: TextReference?,
|
||||
val portfolioIcon: AccountIconUM.CryptoPortfolio,
|
||||
val colors: Colors,
|
||||
val addresses: ImmutableList<ValidatedAddress>,
|
||||
val walletBlock: WalletBlockUM,
|
||||
val isAddAddressEnabled: Boolean,
|
||||
val saveButton: TangemButtonUM,
|
||||
val onNameChange: (String) -> Unit,
|
||||
val onCloseClick: () -> Unit,
|
||||
val onAddAddressClick: () -> Unit,
|
||||
|
|
@ -24,4 +29,10 @@ internal data class EditContactUM(
|
|||
val list: ImmutableList<CryptoPortfolioIcon.Color>,
|
||||
val onColorSelect: (CryptoPortfolioIcon.Color) -> Unit,
|
||||
)
|
||||
|
||||
data class WalletBlockUM(
|
||||
val walletName: String,
|
||||
val isChangeable: Boolean,
|
||||
val onClick: () -> Unit,
|
||||
)
|
||||
}
|
||||
|
|
@ -5,8 +5,8 @@ import androidx.compose.foundation.layout.*
|
|||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
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.unit.dp
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
|
||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||
import com.tangem.core.ui.ds.topbar.TangemTopBar
|
||||
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.state.AddressBookChipUM
|
||||
import com.tangem.features.addressbook.list.ui.state.AddressBookListUM
|
||||
import com.tangem.features.addressbook.list.ui.state.ContactUM
|
||||
import com.tangem.features.addressbook.list.ui.state.ContentMode
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
|
|
@ -91,18 +91,19 @@ internal fun AddressBookListScreen(
|
|||
NothingFoundContent()
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.imePadding()
|
||||
.padding(top = 16.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
.background(
|
||||
color = TangemTheme.colors3.bg.secondary,
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
),
|
||||
modifier = Modifier.imePadding(),
|
||||
contentPadding = PaddingValues(bottom = 12.dp + bottomBarHeight),
|
||||
) {
|
||||
items(items = state.contacts, key = ContactUM::id) { contact ->
|
||||
ContactRow(contact = contact)
|
||||
itemsIndexed(items = state.contacts, key = { _, contact -> contact.id }) { index, contact ->
|
||||
ContactRow(
|
||||
contact = contact,
|
||||
modifier = Modifier.roundedShapeItemDecoration(
|
||||
currentIndex = index,
|
||||
lastIndex = state.contacts.lastIndex,
|
||||
radius = 24.dp,
|
||||
backgroundColor = TangemTheme.colors3.bg.secondary,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,36 +1,81 @@
|
|||
package com.tangem.features.addressbook.editcontact.model
|
||||
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
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.core.decompose.model.MutableParamsContainer
|
||||
import com.tangem.core.decompose.model.ParamsContainer
|
||||
import com.tangem.core.decompose.ui.UiMessageSender
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.message.DialogMessage
|
||||
import com.tangem.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.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.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.features.addressbook.common.AddressBookResultHolder
|
||||
import com.tangem.features.addressbook.editcontact.DefaultEditContactComponent
|
||||
import com.tangem.features.addressbook.editcontact.state.EditContactStateController
|
||||
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
|
||||
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
|
||||
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioFetcher
|
||||
import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorController
|
||||
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.toImmutableList
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
internal class EditContactModelTest {
|
||||
|
||||
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
|
||||
|
||||
@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
|
||||
fun tearDown() {
|
||||
// Cancels modelScope, stopping the confirmed-addresses collector.
|
||||
|
|
@ -44,12 +89,14 @@ internal class EditContactModelTest {
|
|||
val expectedSelectedColor = expectedColors.first()
|
||||
|
||||
val model = createModel(testScope = this)
|
||||
advanceUntilIdle()
|
||||
val state = model.state.value
|
||||
|
||||
val expected = EditContactUM(
|
||||
title = resourceReference(R.string.address_book_new_contact),
|
||||
name = "",
|
||||
namePlaceholder = resourceReference(R.string.address_book_new_contact),
|
||||
nameError = null,
|
||||
portfolioIcon = AccountIconUM.CryptoPortfolio(
|
||||
value = CryptoPortfolioIcon.Icon.Letter,
|
||||
color = expectedSelectedColor,
|
||||
|
|
@ -60,6 +107,13 @@ internal class EditContactModelTest {
|
|||
onColorSelect = state.colors.onColorSelect,
|
||||
),
|
||||
addresses = persistentListOf(),
|
||||
walletBlock = EditContactUM.WalletBlockUM(
|
||||
walletName = "",
|
||||
isChangeable = false,
|
||||
onClick = state.walletBlock.onClick,
|
||||
),
|
||||
isAddAddressEnabled = true,
|
||||
saveButton = state.saveButton,
|
||||
onNameChange = state.onNameChange,
|
||||
onCloseClick = state.onCloseClick,
|
||||
onAddAddressClick = state.onAddAddressClick,
|
||||
|
|
@ -149,14 +203,249 @@ internal class EditContactModelTest {
|
|||
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(
|
||||
contactId: ContactId? = null,
|
||||
predefinedAddress: ValidatedAddress? = null,
|
||||
onAddAddressClick: () -> Unit = {},
|
||||
onBackClick: () -> Unit = {},
|
||||
): DefaultEditContactComponent.Params = DefaultEditContactComponent.Params(
|
||||
contactId = contactId,
|
||||
predefinedAddress = predefinedAddress,
|
||||
onBackClick = {},
|
||||
onAddAddressClick = {},
|
||||
onBackClick = onBackClick,
|
||||
onAddAddressClick = onAddAddressClick,
|
||||
)
|
||||
|
||||
private fun createModel(
|
||||
|
|
@ -169,9 +458,23 @@ internal class EditContactModelTest {
|
|||
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
|
||||
stateController = EditContactStateController(),
|
||||
resultHolder = resultHolder,
|
||||
messageSender = messageSender,
|
||||
userWalletsListRepository = userWalletsListRepository,
|
||||
validateContactNameUseCase = validateContactNameUseCase,
|
||||
saveContactInteractor = saveContactInteractor,
|
||||
portfolioSelectorController = portfolioSelectorController,
|
||||
portfolioFetcherFactory = portfolioFetcherFactory,
|
||||
).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 {
|
||||
val testDispatcher = StandardTestDispatcher(testScheduler)
|
||||
return TestingCoroutineDispatcherProvider(
|
||||
|
|
@ -182,4 +485,8 @@ internal class EditContactModelTest {
|
|||
single = testDispatcher,
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MAX_ADDRESSES = 20
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue