Updated on 2026-08-14

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

View file

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

View file

@ -10,6 +10,7 @@ import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
import com.tangem.features.addressbook.list.DefaultAddressBookListComponent
import com.tangem.features.addressbook.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,

View file

@ -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,

View file

@ -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(

View file

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

View file

@ -1,41 +1,91 @@
package com.tangem.features.addressbook.editcontact.model
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
}
}

View file

@ -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 = {},

View file

@ -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,
)
}
}

View file

@ -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,

View file

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

View file

@ -0,0 +1,16 @@
package com.tangem.features.addressbook.editcontact.state.transformers
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
import com.tangem.utils.transformer.Transformer
internal class UpdateSaveButtonTransformer(
private val isEnabled: Boolean,
private val isLoading: Boolean,
) : Transformer<EditContactUM> {
override fun transform(prevState: EditContactUM): EditContactUM {
return prevState.copy(
saveButton = prevState.saveButton.copy(isEnabled = isEnabled, isLoading = isLoading),
)
}
}

View file

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

View file

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

View file

@ -5,11 +5,16 @@ import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.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 = {},

View file

@ -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,
)
}

View file

@ -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,
),
)
}
}
}

View file

@ -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
}
}

View file

@ -32,6 +32,15 @@ interface PortfolioSelectorComponent : ComposableBottomSheetComponent, Composabl
val portfolioFetcher: PortfolioFetcher,
val controller: PortfolioSelectorController,
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 {

View file

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