Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-11 10:05:44 +01:00
parent 52d1ed4312
commit e29792fa97
22 changed files with 1198 additions and 5 deletions

View file

@ -112,6 +112,7 @@ dependencies {
implementation(projects.libs.blockchainSdk) implementation(projects.libs.blockchainSdk)
implementation(projects.domain.account) implementation(projects.domain.account)
implementation(projects.domain.account.status) implementation(projects.domain.account.status)
implementation(projects.domain.addressBook)
implementation(projects.domain.models) implementation(projects.domain.models)
implementation(projects.domain.core) implementation(projects.domain.core)
api(projects.domain.common) api(projects.domain.common)

View file

@ -0,0 +1,27 @@
package com.tangem.tap.di.domain
import com.tangem.domain.addressbook.usecase.ValidateContactAddressUseCase
import com.tangem.domain.tokens.GetNetworkAddressesUseCase
import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object AddressBookDomainModule {
@Provides
@Singleton
fun provideValidateContactAddressUseCase(
validateWalletAddressUseCase: ValidateWalletAddressUseCase,
getNetworkAddressesUseCase: GetNetworkAddressesUseCase,
): ValidateContactAddressUseCase {
return ValidateContactAddressUseCase(
validateWalletAddressUseCase = validateWalletAddressUseCase,
getNetworkAddressesUseCase = getNetworkAddressesUseCase,
)
}
}

View file

@ -16,8 +16,9 @@ dependencies {
implementation(projects.features.addressBook.api) implementation(projects.features.addressBook.api)
/** Domain */ /** Domain */
implementation(projects.domain.models) implementation(projects.domain.account)
implementation(projects.domain.addressBook) implementation(projects.domain.addressBook)
implementation(projects.domain.models)
/** Common */ /** Common */
implementation(projects.common.ui) implementation(projects.common.ui)
@ -45,6 +46,12 @@ dependencies {
/** Other */ /** Other */
implementation(deps.kotlin.immutable.collections) implementation(deps.kotlin.immutable.collections)
/** Utils */
implementation(projects.libs.blockchainSdk)
implementation(tangemDeps.blockchain)
/** Tests */ /** Tests */
testImplementation(projects.test.core) testImplementation(projects.test.core)
testImplementation(projects.test.mock)
testImplementation(projects.common.test)
} }

View file

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

View file

@ -0,0 +1,40 @@
package com.tangem.features.addressbook.addaddress
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.features.addressbook.addaddress.model.AddAddressModel
import com.tangem.features.addressbook.addaddress.ui.AddAddressContent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultAddAddressComponent @AssistedInject constructor(
@Assisted context: AppComponentContext,
@Assisted params: AddAddressComponent.Params,
) : AddAddressComponent, AppComponentContext by context {
private val model: AddAddressModel = getOrCreateModel(params)
@Composable
override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle()
AddAddressContent(
state = state,
modifier = modifier,
)
BackHandler(onBack = state.onBackClick)
}
@AssistedFactory
interface Factory : AddAddressComponent.Factory {
override fun create(
context: AppComponentContext,
params: AddAddressComponent.Params,
): DefaultAddAddressComponent
}
}

View file

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

View file

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

View file

@ -0,0 +1,152 @@
package com.tangem.features.addressbook.addaddress.model
import com.tangem.blockchainsdk.utils.toBlockchain
import com.tangem.common.ui.extensions.iconResId
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.R
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.core.ui.ds.button.TangemButtonType
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.account.supplier.MultiAccountListSupplier
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.features.addressbook.addaddress.AddAddressComponent
import com.tangem.features.addressbook.addaddress.contract.AddAddressUM
import com.tangem.features.addressbook.addaddress.contract.AddressFieldUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.*
import javax.inject.Inject
import kotlin.collections.map
@OptIn(FlowPreview::class)
@ModelScoped
internal class AddAddressModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
multiAccountListSupplier: MultiAccountListSupplier,
private val clipboardManager: ClipboardManager,
) : Model() {
private val params: AddAddressComponent.Params = paramsContainer.require()
val state: StateFlow<AddAddressUM>
field = MutableStateFlow(getInitialState())
private val availableCoins: StateFlow<List<CryptoCurrency.Coin>> = multiAccountListSupplier()
.map { accountLists ->
accountLists
.flatMap { it.accounts }
.filterIsInstance<Account.CryptoPortfolio>()
.flatMap { it.cryptoCurrencies }
.filterIsInstance<CryptoCurrency.Coin>()
.distinctBy { it.network.id }
}
.stateIn(modelScope, SharingStarted.Eagerly, emptyList())
private val addressInput = state
.map { it.addressField.value }
.distinctUntilChanged()
.debounce(ADD_ADDRESS_DEBOUNCE)
init {
subscribeToAddressInput()
}
private fun onAddressChange(value: String, isPasted: Boolean = false) {
state.update { oldState ->
oldState.copy(
addressField = oldState.addressField.copy(
value = value,
isValuePasted = isPasted,
isError = false,
error = null,
),
chosenNetworkState = AddAddressUM.ChosenNetworkState.Loading,
)
}
}
private fun subscribeToAddressInput() {
combine(addressInput, availableCoins) { input, coins ->
getUniqueNetworks(input, coins)
}
.onEach { availableNetworks ->
state.update { oldState ->
oldState.copy(
availableNetworks = availableNetworks,
chosenNetworkState = createChosenNetworkState(availableNetworks),
)
}
}
.launchIn(modelScope)
}
private fun createChosenNetworkState(availableNetworks: ImmutableList<Network>): AddAddressUM.ChosenNetworkState {
return if (availableNetworks.isEmpty()) {
AddAddressUM.ChosenNetworkState.Empty
} else {
AddAddressUM.ChosenNetworkState.Result(
networkUMList = availableNetworks
.map { network ->
AddAddressUM.ChosenNetworkState.Result.NetworkUM(
networkName = network.name,
iconResId = network.iconResId,
)
}
.toImmutableList(),
)
}
}
private fun getUniqueNetworks(input: String, coins: List<CryptoCurrency.Coin>): ImmutableList<Network> {
return coins
.filter { it.network.toBlockchain().validateAddress(input) }
.map { it.network }
.toImmutableList()
}
private fun onPaste() {
onAddressChange(value = clipboardManager.getText().orEmpty(), isPasted = true)
}
private fun validateAndConfirm() {
// TODO([REDACTED_TASK_KEY]): validate the address and invoke params.onConfirm
}
private fun getInitialState(): AddAddressUM = AddAddressUM(
addressField = AddressFieldUM(
value = "",
placeholder = resourceReference(R.string.common_address),
label = resourceReference(R.string.address_book_enter_address),
isError = false,
error = null,
isValuePasted = false,
),
availableNetworks = persistentListOf(),
buttonUM = TangemButtonUM(
text = TextReference.Res(R.string.address_book_add_address),
type = TangemButtonType.Primary,
isEnabled = false,
onClick = ::validateAndConfirm,
),
chosenNetworkState = AddAddressUM.ChosenNetworkState.Empty,
onAddressChange = { onAddressChange(value = it) },
onAddressClear = { onAddressChange("") },
onPasteClick = ::onPaste,
onQrClick = { /* [REDACTED_TODO_COMMENT] */ },
onBackClick = params.onBackClick,
)
companion object {
private const val ADD_ADDRESS_DEBOUNCE = 500L
}
}

View file

@ -0,0 +1,103 @@
package com.tangem.features.addressbook.addaddress.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerH12
import com.tangem.core.ui.ds.button.PrimaryTangemButton
import com.tangem.core.ui.ds.button.TangemButtonType
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.addressbook.addaddress.contract.AddAddressUM
import com.tangem.features.addressbook.addaddress.contract.AddressFieldUM
import kotlinx.collections.immutable.persistentListOf
@Composable
internal fun AddAddressContent(state: AddAddressUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxSize()
.background(color = TangemTheme.colors3.bg.primary)
.systemBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
TangemTopBar(
modifier = Modifier.statusBarsPadding(),
title = resourceReference(R.string.address_book_add_address),
startContent = {
TangemButton(
iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_back_24),
onClick = state.onBackClick,
size = TangemButton.Size.X11,
variant = TangemButton.Variant.Material,
)
},
)
RecipientRow(
addressField = state.addressField,
onValueChange = state.onAddressChange,
onAddressClear = state.onAddressClear,
onQrClick = state.onQrClick,
onPasteClick = state.onPasteClick,
)
SpacerH12()
NetworkBlock(state.chosenNetworkState)
PrimaryButton(state.buttonUM)
}
}
@Composable
private fun ColumnScope.PrimaryButton(buttonUM: TangemButtonUM) {
Spacer(modifier = Modifier.weight(1f))
PrimaryTangemButton(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(start = 16.dp, end = 16.dp, bottom = 12.dp),
buttonUM = buttonUM,
)
}
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_AddAddressContent() {
TangemThemePreviewRedesign {
AddAddressContent(
state = AddAddressUM(
addressField = AddressFieldUM(
value = "",
placeholder = resourceReference(R.string.address_book_enter_address),
label = resourceReference(R.string.common_address),
),
availableNetworks = persistentListOf(),
buttonUM = TangemButtonUM(
text = TextReference.Res(R.string.address_book_add_address),
type = TangemButtonType.Primary,
isEnabled = false,
onClick = { },
),
chosenNetworkState = AddAddressUM.ChosenNetworkState.Empty,
onAddressChange = {},
onAddressClear = {},
onPasteClick = {},
onQrClick = {},
onBackClick = {},
),
)
}
}

View file

@ -0,0 +1,207 @@
package com.tangem.features.addressbook.addaddress.ui
import android.content.res.Configuration
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
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.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerH12
import com.tangem.core.ui.ds2.loader.TangemLoader
import com.tangem.core.ui.ds2.row.TangemRow
import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.addressbook.addaddress.contract.AddAddressUM
import com.tangem.features.addressbook.addaddress.contract.AddAddressUM.ChosenNetworkState.Result.NetworkUM
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
private const val MAX_VISIBLE_NETWORKS = 3
private val NetworkIconSize = 24.dp
// Horizontal advance per icon. Smaller than the icon size so icons overlap; the bg-colored ring on
// the icon drawn on top carves the crescent cut-out from the icon below.
private val NetworkIconStep = 18.dp
@Composable
internal fun NetworkBlock(chosenNetworkState: AddAddressUM.ChosenNetworkState) {
TangemRow(
verticalAlignment = TangemRowVerticalAlignment.Center,
modifier = Modifier
.padding(horizontal = 16.dp)
.clip(RoundedCornerShape(16.dp))
.fillMaxWidth()
.background(color = TangemTheme.colors3.bg.secondary)
.padding(horizontal = 4.dp),
titleSlot = {
Text(
text = stringResourceSafe(R.string.common_network),
style = TangemTheme.typography.body2,
color = TangemTheme.colors3.text.primary,
)
},
endSlot = {
SelectNetworkButton(chosenNetworkState)
},
)
}
@Composable
private fun SelectNetworkButton(chosenNetworkState: AddAddressUM.ChosenNetworkState) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
when (chosenNetworkState) {
is AddAddressUM.ChosenNetworkState.Result -> NetworkIconsResolver(chosenNetworkState.networkUMList)
AddAddressUM.ChosenNetworkState.Loading -> TangemLoader()
AddAddressUM.ChosenNetworkState.Empty -> {
Text(
modifier = Modifier.padding(start = 8.dp),
text = stringResourceSafe(R.string.address_book_select_network),
style = TangemTheme.typography.body2,
color = TangemTheme.colors3.text.secondary,
)
ChevronIcon()
}
}
}
}
@Composable
private fun NetworkIconsResolver(networks: ImmutableList<NetworkUM>) {
when (networks.size) {
0 -> Unit
1 -> {
val network = networks.first()
Image(
painter = painterResource(id = network.iconResId),
contentDescription = null,
)
Text(
modifier = Modifier.padding(start = 8.dp),
text = network.networkName,
style = TangemTheme.typography.body2,
color = TangemTheme.colors3.text.secondary,
)
ChevronIcon()
}
// 3 and any larger count share the same rendering: up to MAX_VISIBLE_NETWORKS overlapping
// icons, plus a "+N" badge that appears only when there are more than that.
else -> {
OverlappingNetworkIcons(networks)
ChevronIcon()
}
}
}
@Composable
private fun OverlappingNetworkIcons(networks: ImmutableList<NetworkUM>) {
val visible = networks.take(MAX_VISIBLE_NETWORKS)
val remaining = networks.size - visible.size
Box(modifier = Modifier.wrapContentWidth()) {
visible.forEachIndexed { index, network ->
Image(
painter = painterResource(id = network.iconResId),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.padding(start = NetworkIconStep * index)
.networkIconRing()
.size(NetworkIconSize),
)
}
if (remaining > 0) {
Box(
modifier = Modifier
.padding(start = NetworkIconStep * visible.size)
.networkIconRing()
.background(color = TangemTheme.colors3.bg.tertiary)
.size(NetworkIconSize),
contentAlignment = Alignment.Center,
) {
Text(
text = "+$remaining",
style = TangemTheme.typography.caption1,
color = TangemTheme.colors3.text.secondary,
)
}
}
}
}
// bg-colored ring + clip applied to every overlapping element so the one drawn on top carves a
// crescent out of the one below it. The ring color must match the surface the icons sit on.
@Composable
private fun Modifier.networkIconRing(): Modifier = this
.border(width = 2.dp, color = TangemTheme.colors3.bg.secondary, shape = CircleShape)
.padding(2.dp)
.clip(CircleShape)
@Composable
private fun ChevronIcon() {
Image(
modifier = Modifier.padding(start = 8.dp),
painter = painterResource(id = R.drawable.ic_select_18_24),
contentDescription = null,
)
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_NetworkBlock() {
TangemThemePreviewRedesign {
Column {
NetworkBlock(
chosenNetworkState = AddAddressUM.ChosenNetworkState.Result(
networkUMList = persistentListOf(
NetworkUM(networkName = "Ethereum", iconResId = R.drawable.img_eth_22),
),
),
)
SpacerH12()
NetworkBlock(
chosenNetworkState = AddAddressUM.ChosenNetworkState.Result(
networkUMList = persistentListOf(
NetworkUM(networkName = "Ethereum", iconResId = R.drawable.img_eth_22),
NetworkUM(networkName = "BSC", iconResId = R.drawable.img_bsc_22),
NetworkUM(networkName = "Polygon", iconResId = R.drawable.img_polygon_22),
),
),
)
SpacerH12()
NetworkBlock(
chosenNetworkState = AddAddressUM.ChosenNetworkState.Result(
networkUMList = List(15) {
NetworkUM(networkName = "Network", iconResId = R.drawable.img_eth_22)
}.toImmutableList(),
),
)
SpacerH12()
NetworkBlock(chosenNetworkState = AddAddressUM.ChosenNetworkState.Loading)
SpacerH12()
NetworkBlock(chosenNetworkState = AddAddressUM.ChosenNetworkState.Empty)
}
}
}

View file

@ -0,0 +1,143 @@
package com.tangem.features.addressbook.addaddress.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerH12
import com.tangem.core.ui.components.SpacerW8
import com.tangem.core.ui.components.fields.SimpleTextField
import com.tangem.core.ui.ds.image.TangemIcon
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.ds2.row.TangemRow
import com.tangem.core.ui.ds2.row.TangemRowContentLead
import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_cross_circle_20_filled
import com.tangem.features.addressbook.addaddress.contract.AddressFieldUM
@Composable
internal fun RecipientRow(
addressField: AddressFieldUM,
onValueChange: (String) -> Unit,
onAddressClear: () -> Unit,
onQrClick: () -> Unit,
onPasteClick: () -> Unit,
) {
Column(
modifier = Modifier
.padding(horizontal = 16.dp)
.clip(RoundedCornerShape(16.dp))
.fillMaxWidth()
.background(TangemTheme.colors3.bg.secondary),
) {
Text(
modifier = Modifier.padding(start = 16.dp, top = 16.dp),
text = stringResourceSafe(R.string.common_address),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors3.text.secondary,
)
TangemRow(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = TangemRowVerticalAlignment.Center,
contentLead = TangemRowContentLead.Start,
startSlot = {
TangemIcon(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(TangemTheme.colors3.bg.tertiary),
tangemIconUM = TangemIconUM.Ident(text = addressField.value),
)
},
titleSlot = {
SimpleTextField(
modifier = Modifier
.weight(1f)
.padding(start = 12.dp),
value = addressField.value,
onValueChange = onValueChange,
placeholder = TextReference.Res(R.string.address_book_enter_address),
singleLine = false,
)
},
endSlot = {
if (addressField.value.isNotEmpty()) {
Icon(
modifier = Modifier.clickable(onClick = onAddressClear),
imageVector = Icons.ic_cross_circle_20_filled,
tint = TangemTheme.colors3.icon.tertiary,
contentDescription = null,
)
} else {
Row {
TangemButton(
variant = TangemButton.Variant.Secondary,
iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_qrcode_scaner_24),
onClick = onQrClick,
)
SpacerW8()
TangemButton(
variant = TangemButton.Variant.Primary,
text = TextReference.Res(id = R.string.common_paste),
onClick = onPasteClick,
)
}
}
},
)
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun Preview_RecipientRow() {
TangemThemePreviewRedesign {
Column {
RecipientRow(
addressField = AddressFieldUM(
value = "0xfb6916095ca1df60bb79ce92ce3ea74c37c5d359",
placeholder = resourceReference(R.string.address_book_enter_address),
label = resourceReference(R.string.common_address),
),
onValueChange = {},
onAddressClear = {},
onQrClick = {},
onPasteClick = {},
)
SpacerH12()
RecipientRow(
addressField = AddressFieldUM(
value = "",
placeholder = resourceReference(R.string.address_book_enter_address),
label = resourceReference(R.string.common_address),
),
onValueChange = {},
onAddressClear = {},
onQrClick = {},
onPasteClick = {},
)
}
}
}

View file

@ -15,4 +15,7 @@ internal sealed class AddressBookRoute {
data class EditContact( data class EditContact(
val contactId: String? = null, val contactId: String? = null,
) : AddressBookRoute() ) : AddressBookRoute()
@Serializable
data object AddAddress : AddressBookRoute()
} }

View file

@ -16,8 +16,10 @@ import com.tangem.core.decompose.context.childByContext
import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.addressbook.model.ContactId import com.tangem.domain.addressbook.model.ContactId
import com.tangem.features.addressbook.AddressBookComponent import com.tangem.features.addressbook.AddressBookComponent
import com.tangem.features.addressbook.list.AddressBookListComponent import com.tangem.features.addressbook.addaddress.AddAddressComponent
import com.tangem.features.addressbook.editcontact.EditContactComponent import com.tangem.features.addressbook.editcontact.EditContactComponent
import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress
import com.tangem.features.addressbook.list.AddressBookListComponent
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
@ -27,10 +29,18 @@ internal class DefaultAddressBookComponent @AssistedInject constructor(
@Assisted private val params: AddressBookComponent.Params, @Assisted private val params: AddressBookComponent.Params,
private val addressBookListComponentFactory: AddressBookListComponent.Factory, private val addressBookListComponentFactory: AddressBookListComponent.Factory,
private val editContactComponentFactory: EditContactComponent.Factory, private val editContactComponentFactory: EditContactComponent.Factory,
private val addAddressComponentFactory: AddAddressComponent.Factory,
) : AddressBookComponent, AppComponentContext by context { ) : AddressBookComponent, AppComponentContext by context {
private val navigation = StackNavigation<AddressBookRoute>() private val navigation = StackNavigation<AddressBookRoute>()
/**
* Consumer for the address entered on the [AddressBookRoute.AddAddress] screen, registered by the EditContact
* screen when it requests adding an address and invoked when AddAddress confirms. Transient by design the
* entered addresses live only in EditContact's in-memory state until the contact is saved.
*/
private var pendingAddressSink: ((ValidatedAddress) -> Unit)? = null
private val contentStack = childStack( private val contentStack = childStack(
key = "address_book_stack", key = "address_book_stack",
source = navigation, source = navigation,
@ -66,6 +76,24 @@ internal class DefaultAddressBookComponent @AssistedInject constructor(
params = EditContactComponent.Params( params = EditContactComponent.Params(
contactId = config.contactId?.let(::ContactId), contactId = config.contactId?.let(::ContactId),
onBackClick = { navigation.pop() }, onBackClick = { navigation.pop() },
onAddAddressClick = { onResult ->
pendingAddressSink = onResult
navigation.pushNew(AddressBookRoute.AddAddress)
},
),
)
AddressBookRoute.AddAddress -> addAddressComponentFactory.create(
context = childByContext(componentContext),
params = AddAddressComponent.Params(
onBackClick = {
pendingAddressSink = null
navigation.pop()
},
onConfirm = { address ->
pendingAddressSink?.invoke(address)
pendingAddressSink = null
navigation.pop()
},
), ),
) )
} }

View file

@ -1,6 +1,8 @@
package com.tangem.features.addressbook.di package com.tangem.features.addressbook.di
import com.tangem.features.addressbook.AddressBookComponent import com.tangem.features.addressbook.AddressBookComponent
import com.tangem.features.addressbook.addaddress.AddAddressComponent
import com.tangem.features.addressbook.addaddress.DefaultAddAddressComponent
import com.tangem.features.addressbook.component.DefaultAddressBookComponent import com.tangem.features.addressbook.component.DefaultAddressBookComponent
import com.tangem.features.addressbook.list.AddressBookListComponent import com.tangem.features.addressbook.list.AddressBookListComponent
import com.tangem.features.addressbook.list.DefaultAddressBookListComponent import com.tangem.features.addressbook.list.DefaultAddressBookListComponent
@ -29,4 +31,8 @@ internal interface AddressBookComponentModule {
@Binds @Binds
@Singleton @Singleton
fun bindEditContactComponentFactory(factory: DefaultEditContactComponent.Factory): EditContactComponent.Factory fun bindEditContactComponentFactory(factory: DefaultEditContactComponent.Factory): EditContactComponent.Factory
@Binds
@Singleton
fun bindAddAddressComponentFactory(factory: DefaultAddAddressComponent.Factory): AddAddressComponent.Factory
} }

View file

@ -2,6 +2,7 @@ package com.tangem.features.addressbook.di
import com.tangem.core.decompose.di.ModelComponent import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.Model
import com.tangem.features.addressbook.addaddress.model.AddAddressModel
import com.tangem.features.addressbook.list.model.AddressBookListModel import com.tangem.features.addressbook.list.model.AddressBookListModel
import com.tangem.features.addressbook.editcontact.model.EditContactModel import com.tangem.features.addressbook.editcontact.model.EditContactModel
import dagger.Binds import dagger.Binds
@ -23,4 +24,9 @@ internal interface AddressBookModelModule {
@IntoMap @IntoMap
@ClassKey(EditContactModel::class) @ClassKey(EditContactModel::class)
fun bindEditContactModel(model: EditContactModel): Model fun bindEditContactModel(model: EditContactModel): Model
@Binds
@IntoMap
@ClassKey(AddAddressModel::class)
fun bindAddAddressModel(model: AddAddressModel): Model
} }

View file

@ -3,6 +3,7 @@ package com.tangem.features.addressbook.editcontact
import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.addressbook.model.ContactId import com.tangem.domain.addressbook.model.ContactId
import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress
internal interface EditContactComponent : ComposableContentComponent { internal interface EditContactComponent : ComposableContentComponent {
@ -11,5 +12,6 @@ internal interface EditContactComponent : ComposableContentComponent {
data class Params( data class Params(
val contactId: ContactId?, val contactId: ContactId?,
val onBackClick: () -> Unit, val onBackClick: () -> Unit,
val onAddAddressClick: (onResult: (ValidatedAddress) -> Unit) -> Unit,
) )
} }

View file

@ -1,23 +1,22 @@
package com.tangem.features.addressbook.editcontact.contract package com.tangem.features.addressbook.editcontact.contract
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.account.AccountIconUM import com.tangem.common.ui.account.AccountIconUM
import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.account.CryptoPortfolioIcon import com.tangem.domain.models.account.CryptoPortfolioIcon
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@Immutable
internal data class EditContactUM( internal data class EditContactUM(
val title: TextReference, val title: TextReference,
val name: String, val name: String,
val namePlaceholder: TextReference, val namePlaceholder: TextReference,
val portfolioIcon: AccountIconUM.CryptoPortfolio, val portfolioIcon: AccountIconUM.CryptoPortfolio,
val colors: Colors, val colors: Colors,
val addresses: ImmutableList<ValidatedAddress>,
val onNameChange: (String) -> Unit, val onNameChange: (String) -> Unit,
val onCloseClick: () -> Unit, val onCloseClick: () -> Unit,
val onAddAddressClick: () -> Unit,
) { ) {
@Immutable
data class Colors( data class Colors(
val selected: CryptoPortfolioIcon.Color, val selected: CryptoPortfolioIcon.Color,
val list: ImmutableList<CryptoPortfolioIcon.Color>, val list: ImmutableList<CryptoPortfolioIcon.Color>,

View file

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

View file

@ -9,7 +9,9 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.models.account.CryptoPortfolioIcon import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.features.addressbook.editcontact.EditContactComponent import com.tangem.features.addressbook.editcontact.EditContactComponent
import com.tangem.features.addressbook.editcontact.contract.EditContactUM import com.tangem.features.addressbook.editcontact.contract.EditContactUM
import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -40,6 +42,14 @@ internal class EditContactModel @Inject constructor(
} }
} }
private fun requestAddAddress() {
params.onAddAddressClick(::addAddress)
}
private fun addAddress(address: ValidatedAddress) {
state.update { it.copy(addresses = (it.addresses + address).toImmutableList()) }
}
private fun getInitialState(): EditContactUM { private fun getInitialState(): EditContactUM {
val colors = CryptoPortfolioIcon.Color.entries.toImmutableList() val colors = CryptoPortfolioIcon.Color.entries.toImmutableList()
val selectedColor = colors.first() val selectedColor = colors.first()
@ -61,8 +71,10 @@ internal class EditContactModel @Inject constructor(
list = colors, list = colors,
onColorSelect = ::onColorSelect, onColorSelect = ::onColorSelect,
), ),
addresses = persistentListOf(),
onNameChange = ::onNameChange, onNameChange = ::onNameChange,
onCloseClick = params.onBackClick, onCloseClick = params.onBackClick,
onAddAddressClick = ::requestAddAddress,
) )
} }
} }

View file

@ -7,11 +7,14 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach import androidx.compose.ui.util.fastForEach
@ -31,6 +34,9 @@ import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.domain.models.account.CryptoPortfolioIcon import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.features.addressbook.editcontact.contract.EditContactUM import com.tangem.features.addressbook.editcontact.contract.EditContactUM
import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@Composable @Composable
@ -63,6 +69,82 @@ internal fun EditContactContent(state: EditContactUM, modifier: Modifier = Modif
) { ) {
ContactSummary(state = state) ContactSummary(state = state)
ContactColor(colors = state.colors) ContactColor(colors = state.colors)
ContactAddresses(addresses = state.addresses)
AddAddressRow(onClick = state.onAddAddressClick)
}
}
}
@Composable
private fun ContactAddresses(addresses: ImmutableList<ValidatedAddress>) {
if (addresses.isEmpty()) return
Column(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.fillMaxWidth()
.background(TangemTheme.colors3.bg.secondary),
) {
addresses.fastForEach { entry ->
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = entry.network.name,
style = TangemTheme.typography3.caption.medium,
color = TangemTheme.colors3.text.tertiary,
)
Text(
text = entry.address,
style = TangemTheme.typography3.body.medium,
color = TangemTheme.colors3.text.primary,
)
}
}
}
}
@Composable
private fun AddAddressRow(onClick: () -> Unit) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.fillMaxWidth()
.background(TangemTheme.colors3.bg.secondary)
.clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 15.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(TangemTheme.colors3.bg.status.infoSubtle),
) {
Icon(
modifier = Modifier.size(18.dp),
imageVector = ImageVector.vectorResource(R.drawable.ic_plus_24),
tint = TangemTheme.colors3.text.status.info,
contentDescription = null,
)
}
Column(
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = stringResourceSafe(R.string.address_book_add_address),
style = TangemTheme.typography3.body.medium,
color = TangemTheme.colors3.text.primary,
)
Text(
text = stringResourceSafe(R.string.address_book_add_address_description),
style = TangemTheme.typography3.caption.medium,
color = TangemTheme.colors3.text.tertiary,
)
} }
} }
} }
@ -177,8 +259,10 @@ private fun Preview_EditContactContent() {
list = colors, list = colors,
onColorSelect = {}, onColorSelect = {},
), ),
addresses = persistentListOf(),
onNameChange = {}, onNameChange = {},
onCloseClick = {}, onCloseClick = {},
onAddAddressClick = {},
), ),
) )
} }

View file

@ -0,0 +1,271 @@
package com.tangem.features.addressbook.addaddress.model
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.common.Blockchain
import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory
import com.tangem.common.ui.extensions.iconResId
import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.supplier.MultiAccountListSupplier
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.features.addressbook.addaddress.AddAddressComponent
import com.tangem.features.addressbook.addaddress.contract.AddAddressUM
import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress
import com.tangem.test.mock.MockAccounts
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
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.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@OptIn(ExperimentalCoroutinesApi::class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class AddAddressModelTest {
private val multiAccountListSupplier: MultiAccountListSupplier = mockk()
private val clipboardManager: ClipboardManager = mockk()
private val cryptoCurrencyFactory = MockCryptoCurrencyFactory()
private val ethereum = cryptoCurrencyFactory.createCoin(Blockchain.Ethereum)
private val bitcoin = cryptoCurrencyFactory.createCoin(Blockchain.Bitcoin)
private var model: AddAddressModel? = null
@BeforeEach
fun resetMocks() {
clearMocks(multiAccountListSupplier, clipboardManager)
// Default: no accounts, so no coins are available unless a test overrides it.
every { multiAccountListSupplier.invoke() } returns flowOf(emptyList())
}
@AfterEach
fun tearDown() {
// Cancels modelScope, stopping the long-lived availableCoins / address-input collectors.
model?.onDestroy()
model = null
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class AddressField {
@Test
fun `WHEN model created THEN field is empty AND button disabled`() = runTest {
// Act
val model = createModel(testScope = this)
val state = model.state.value
// Assert
assertThat(state.addressField.value).isEmpty()
assertThat(state.addressField.isValuePasted).isFalse()
assertThat(state.buttonUM.isEnabled).isFalse()
}
@Test
fun `GIVEN empty field WHEN onAddressChange THEN value updated`() = runTest {
// Arrange
val model = createModel(testScope = this)
val address = "0xABC"
// Act
model.state.value.onAddressChange(address)
// Assert
val field = model.state.value.addressField
assertThat(field.value).isEqualTo(address)
assertThat(field.isValuePasted).isFalse()
}
@Test
fun `GIVEN empty field WHEN onPasteClick THEN value marked as pasted`() = runTest {
// Arrange
val model = createModel(testScope = this)
val address = "0xABC"
every { clipboardManager.getText() } returns address
// Act
model.state.value.onPasteClick()
// Assert
val field = model.state.value.addressField
assertThat(field.value).isEqualTo(address)
assertThat(field.isValuePasted).isTrue()
}
// validateAndConfirm() is an unimplemented seam — the button click must NOT emit a result yet.
// This guards the foundation and will fail (prompting an update) once validation is wired in.
@Test
fun `GIVEN typed address WHEN button clicked THEN onConfirm not called yet`() = runTest {
// Arrange
var confirmed: ValidatedAddress? = null
val model = createModel(testScope = this, onConfirm = { confirmed = it })
model.state.value.onAddressChange("0xABC")
// Act
model.state.value.buttonUM.onClick()
// Assert
assertThat(confirmed).isNull()
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class AddressInput {
@Test
fun `GIVEN coins available WHEN valid address typed THEN matching network chosen`() = runTest {
// Arrange
every { multiAccountListSupplier.invoke() } returns
flowOf(listOf(accountListWith(ethereum, bitcoin)))
val model = createModel(testScope = this)
advanceUntilIdle()
// Act
model.state.value.onAddressChange(VALID_ETH_ADDRESS)
advanceUntilIdle()
// Assert
val state = model.state.value
assertThat(state.availableNetworks).containsExactly(ethereum.network)
assertThat(state.chosenNetworkState)
.isEqualTo(resultOf(ethereum.network))
}
@Test
fun `GIVEN coins available WHEN address matches no network THEN empty state`() = runTest {
// Arrange
every { multiAccountListSupplier.invoke() } returns
flowOf(listOf(accountListWith(ethereum, bitcoin)))
val model = createModel(testScope = this)
advanceUntilIdle()
// Act
model.state.value.onAddressChange("not-an-address")
advanceUntilIdle()
// Assert
val state = model.state.value
assertThat(state.availableNetworks).isEmpty()
assertThat(state.chosenNetworkState).isEqualTo(AddAddressUM.ChosenNetworkState.Empty)
}
@Test
fun `GIVEN no coins available WHEN valid address typed THEN empty state`() = runTest {
// Arrange — supplier emits no accounts.
every { multiAccountListSupplier.invoke() } returns flowOf(emptyList())
val model = createModel(testScope = this)
advanceUntilIdle()
// Act
model.state.value.onAddressChange(VALID_ETH_ADDRESS)
advanceUntilIdle()
// Assert
val state = model.state.value
assertThat(state.availableNetworks).isEmpty()
assertThat(state.chosenNetworkState).isEqualTo(AddAddressUM.ChosenNetworkState.Empty)
}
// Covers the "not initialized yet" case: the address is typed before coins load, and the
// chosen network must resolve reactively once the supplier emits them.
@Test
fun `GIVEN address typed before coins load WHEN coins emitted THEN network resolved reactively`() = runTest {
// Arrange
val accountsFlow = MutableStateFlow<List<AccountList>>(emptyList())
every { multiAccountListSupplier.invoke() } returns accountsFlow
val model = createModel(testScope = this)
advanceUntilIdle()
// Act — type while coins are still empty
model.state.value.onAddressChange(VALID_ETH_ADDRESS)
advanceUntilIdle()
// Assert intermediate: nothing to match yet
assertThat(model.state.value.chosenNetworkState).isEqualTo(AddAddressUM.ChosenNetworkState.Empty)
// Act — coins arrive later
accountsFlow.value = listOf(accountListWith(ethereum, bitcoin))
advanceUntilIdle()
// Assert
assertThat(model.state.value.chosenNetworkState)
.isEqualTo(resultOf(ethereum.network))
}
}
private fun resultOf(vararg networks: Network) = AddAddressUM.ChosenNetworkState.Result(
networkUMList = networks
.map { network ->
AddAddressUM.ChosenNetworkState.Result.NetworkUM(
networkName = network.name,
iconResId = network.iconResId,
)
}
.toImmutableList(),
)
private fun accountListWith(vararg currencies: CryptoCurrency): AccountList {
val walletId = MockAccounts.userWalletId
val accounts = listOf(
Account.CryptoPortfolio.createMainAccount(
userWalletId = walletId,
cryptoCurrencies = currencies.toList(),
),
)
return AccountList(
userWalletId = walletId,
accounts = accounts,
totalAccounts = accounts.size,
totalArchivedAccounts = 0,
).getOrNull()!!
}
private fun createModel(
testScope: TestScope,
onConfirm: (ValidatedAddress) -> Unit = {},
params: AddAddressComponent.Params = AddAddressComponent.Params(
onBackClick = {},
onConfirm = onConfirm,
),
paramsContainer: ParamsContainer = MutableParamsContainer(value = params),
): AddAddressModel {
return AddAddressModel(
paramsContainer = paramsContainer,
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
multiAccountListSupplier = multiAccountListSupplier,
clipboardManager = clipboardManager,
).also { model = it }
}
private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
val testDispatcher = StandardTestDispatcher(testScheduler)
return TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
)
}
private companion object {
// EIP-55 checksummed address from the spec — guaranteed to pass Ethereum validation.
const val VALID_ETH_ADDRESS = "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed"
}
}

View file

@ -8,9 +8,13 @@ import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.addressbook.model.ContactId import com.tangem.domain.addressbook.model.ContactId
import com.tangem.domain.models.account.CryptoPortfolioIcon import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.domain.models.network.Network
import com.tangem.features.addressbook.editcontact.EditContactComponent import com.tangem.features.addressbook.editcontact.EditContactComponent
import com.tangem.features.addressbook.editcontact.contract.EditContactUM import com.tangem.features.addressbook.editcontact.contract.EditContactUM
import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.StandardTestDispatcher
@ -42,8 +46,10 @@ internal class EditContactModelTest {
list = expectedColors, list = expectedColors,
onColorSelect = state.colors.onColorSelect, onColorSelect = state.colors.onColorSelect,
), ),
addresses = persistentListOf(),
onNameChange = state.onNameChange, onNameChange = state.onNameChange,
onCloseClick = state.onCloseClick, onCloseClick = state.onCloseClick,
onAddAddressClick = state.onAddAddressClick,
) )
assertThat(state).isEqualTo(expected) assertThat(state).isEqualTo(expected)
} }
@ -54,6 +60,7 @@ internal class EditContactModelTest {
val params = EditContactComponent.Params( val params = EditContactComponent.Params(
contactId = ContactId(value = "contact-id"), contactId = ContactId(value = "contact-id"),
onBackClick = {}, onBackClick = {},
onAddAddressClick = {},
) )
// Act // Act
@ -86,11 +93,32 @@ internal class EditContactModelTest {
assertThat(state.portfolioIcon.color).isEqualTo(newColor) assertThat(state.portfolioIcon.color).isEqualTo(newColor)
} }
@Test
fun `GIVEN add address requested WHEN result delivered THEN address appended to state`() = runTest {
// Arrange
var capturedSink: ((ValidatedAddress) -> Unit)? = null
val params = EditContactComponent.Params(
contactId = null,
onBackClick = {},
onAddAddressClick = { onResult -> capturedSink = onResult },
)
val model = createModel(testScope = this, params = params)
val validatedAddress = ValidatedAddress(address = "0xABC", network = mockk())
// Act
model.state.value.onAddAddressClick()
capturedSink?.invoke(validatedAddress)
// Assert
assertThat(model.state.value.addresses).containsExactly(validatedAddress)
}
private fun createModel( private fun createModel(
testScope: TestScope, testScope: TestScope,
params: EditContactComponent.Params = EditContactComponent.Params( params: EditContactComponent.Params = EditContactComponent.Params(
contactId = null, contactId = null,
onBackClick = {}, onBackClick = {},
onAddAddressClick = {},
), ),
paramsContainer: ParamsContainer = MutableParamsContainer(value = params), paramsContainer: ParamsContainer = MutableParamsContainer(value = params),
): EditContactModel { ): EditContactModel {