Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-03 11:37:25 +02:00
parent 391e0562cc
commit 12cebc413d
17 changed files with 686 additions and 7 deletions

View file

@ -6,7 +6,9 @@ import com.tangem.domain.addressbook.interactor.SaveContactInteractor
import com.tangem.domain.addressbook.repository.AddressBookRepository import com.tangem.domain.addressbook.repository.AddressBookRepository
import com.tangem.domain.addressbook.time.DefaultIsoTimestampProvider import com.tangem.domain.addressbook.time.DefaultIsoTimestampProvider
import com.tangem.domain.addressbook.time.IsoTimestampProvider import com.tangem.domain.addressbook.time.IsoTimestampProvider
import com.tangem.domain.addressbook.usecase.CheckAddressDuplicateUseCase
import com.tangem.domain.addressbook.usecase.DeleteContactUseCase import com.tangem.domain.addressbook.usecase.DeleteContactUseCase
import com.tangem.domain.addressbook.usecase.GetContactByIdUseCase
import com.tangem.domain.addressbook.usecase.GetContactsUseCase import com.tangem.domain.addressbook.usecase.GetContactsUseCase
import com.tangem.domain.addressbook.usecase.SyncAddressBooksUseCase import com.tangem.domain.addressbook.usecase.SyncAddressBooksUseCase
import com.tangem.domain.addressbook.usecase.ValidateContactAddressUseCase import com.tangem.domain.addressbook.usecase.ValidateContactAddressUseCase
@ -86,6 +88,18 @@ object AddressBookDomainModule {
return DeleteContactUseCase(repository = repository) return DeleteContactUseCase(repository = repository)
} }
@Provides
@Singleton
fun provideGetContactByIdUseCase(repository: AddressBookRepository): GetContactByIdUseCase {
return GetContactByIdUseCase(repository = repository)
}
@Provides
@Singleton
fun provideCheckAddressDuplicateUseCase(repository: AddressBookRepository): CheckAddressDuplicateUseCase {
return CheckAddressDuplicateUseCase(repository = repository)
}
@Provides @Provides
@Singleton @Singleton
fun provideSyncAddressBooksUseCase(repository: AddressBookRepository): SyncAddressBooksUseCase { fun provideSyncAddressBooksUseCase(repository: AddressBookRepository): SyncAddressBooksUseCase {

View file

@ -110,7 +110,9 @@
<string name="address_book_copy_address">Copy address</string> <string name="address_book_copy_address">Copy address</string>
<string name="address_book_create_success_message">Contact added</string> <string name="address_book_create_success_message">Contact added</string>
<string name="address_book_creating_error">Couldn\'t create contact. Please try again later.</string> <string name="address_book_creating_error">Couldn\'t create contact. Please try again later.</string>
<string name="address_book_delete_contact">Delete contact</string>
<string name="address_book_delete_contact_description">This contact will be deleted from all your address books</string> <string name="address_book_delete_contact_description">This contact will be deleted from all your address books</string>
<string name="address_book_delete_last_address">\"%1$s\" has only one address. Deleting it will also delete the contact. Continue?</string>
<string name="address_book_deleting_error">Couldn\'t delete contact. Please try again later.</string> <string name="address_book_deleting_error">Couldn\'t delete contact. Please try again later.</string>
<string name="address_book_description">Manage contacts &amp; addresses</string> <string name="address_book_description">Manage contacts &amp; addresses</string>
<string name="address_book_discard">Discard</string> <string name="address_book_discard">Discard</string>
@ -1988,6 +1990,13 @@
<string name="tangempay_remove_account">Remove account</string> <string name="tangempay_remove_account">Remove account</string>
<string name="tangempay_remove_account_alert_description">Tangem Pay will be removed from the main screen and won\'t appear again, even after reinstalling the app.</string> <string name="tangempay_remove_account_alert_description">Tangem Pay will be removed from the main screen and won\'t appear again, even after reinstalling the app.</string>
<string name="tangempay_remove_account_alert_title">Remove account?</string> <string name="tangempay_remove_account_alert_title">Remove account?</string>
<string name="tangempay_select_plan_btn_cancel">Cancel</string>
<string name="tangempay_select_plan_btn_downgrade">Downgrade plan</string>
<string name="tangempay_select_plan_btn_select">Select</string>
<string name="tangempay_select_plan_btn_upgrade">Upgrade plan</string>
<string name="tangempay_select_plan_compare">Compare plans</string>
<string name="tangempay_select_plan_confirm_title">Confirm selection</string>
<string name="tangempay_select_plan_title">Select plan</string>
<string name="tangempay_service_unavailable_description">Were fixing a technical issue. Please try again later.</string> <string name="tangempay_service_unavailable_description">Were fixing a technical issue. Please try again later.</string>
<string name="tangempay_service_unavailable_title">Service temporarily unavailable</string> <string name="tangempay_service_unavailable_title">Service temporarily unavailable</string>
<string name="tangempay_service_unreachable_try_later">Service unreachable. However, card payments are still working.</string> <string name="tangempay_service_unreachable_try_later">Service unreachable. However, card payments are still working.</string>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<path
android:pathData="M9.992,1.667C14.551,1.667 18.333,5.441 18.333,10C18.333,14.551 14.559,18.333 10,18.333C5.449,18.333 1.667,14.551 1.667,10C1.667,5.441 5.442,1.667 9.992,1.667ZM13.121,6.381C12.909,6.381 12.696,6.512 12.541,6.757L9.053,12.361L7.394,10.221C7.189,9.951 7.009,9.878 6.772,9.878C6.405,9.878 6.119,10.18 6.119,10.548C6.119,10.736 6.193,10.915 6.315,11.078L8.366,13.595C8.579,13.881 8.807,13.995 9.085,13.995C9.363,13.995 9.6,13.864 9.771,13.595L13.611,7.549C13.709,7.377 13.815,7.189 13.815,7.01C13.815,6.626 13.481,6.381 13.121,6.381Z"
android:fillColor="#0099FF"/>
</vector>

View file

@ -27,13 +27,7 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -86,6 +80,14 @@ internal class DefaultAddressBookRepository(
} }
} }
override suspend fun getContactsSync(userWalletId: UserWalletId): List<Contact> {
return withContext(dispatchers.io) {
val blob = blobStore.getBlobSync(userWalletId) ?: return@withContext emptyList()
val userWallet = findUserWallet(blob.walletId) ?: return@withContext emptyList()
decryptContacts(blob, userWallet)
}
}
override suspend fun getContact(userWalletId: UserWalletId, name: String): Contact? = override suspend fun getContact(userWalletId: UserWalletId, name: String): Contact? =
withContext(dispatchers.default) { withContext(dispatchers.default) {
val blob = blobStore.getBlobSync(userWalletId) ?: return@withContext null val blob = blobStore.getBlobSync(userWalletId) ?: return@withContext null

View file

@ -16,6 +16,8 @@ interface AddressBookRepository {
/** Contacts across all wallets (flattened). Each [Contact] keeps its own [Contact.walletId]. */ /** Contacts across all wallets (flattened). Each [Contact] keeps its own [Contact.walletId]. */
fun getAllContacts(): Flow<List<Contact>> fun getAllContacts(): Flow<List<Contact>>
suspend fun getContactsSync(userWalletId: UserWalletId): List<Contact>
suspend fun getContact(userWalletId: UserWalletId, name: String): Contact? suspend fun getContact(userWalletId: UserWalletId, name: String): Contact?
suspend fun saveContact(contact: Contact): Either<AddressBookSyncError, Unit> suspend fun saveContact(contact: Contact): Either<AddressBookSyncError, Unit>

View file

@ -0,0 +1,38 @@
package com.tangem.domain.addressbook.usecase
import com.tangem.domain.addressbook.model.ContactId
import com.tangem.domain.addressbook.repository.AddressBookRepository
import com.tangem.domain.models.wallet.UserWalletId
/**
* Enforces the `network + address` uniqueness rule within a wallet's address book: checks whether the
* ([networkId], [address]) pair is already saved and, if so, returns the name of the contact that holds it
* so the UI can tell the user under which name it is stored. Returns `null` when the pair is free.
*
* The same address in a different network is allowed. [excludeContactId] lets an in-place edit skip the
* contact currently being edited. Address comparison is exact (matching the in-editor dedup in
* `AddValidatedAddressTransformer`), so case-sensitive chains are not falsely flagged.
*
* Reads the local snapshot ([AddressBookRepository.getContactsSync]) rather than the syncing flow, so validating on
* every keystroke/selection change never triggers a backend sync.
*/
class CheckAddressDuplicateUseCase(
private val repository: AddressBookRepository,
) {
suspend operator fun invoke(
userWalletId: UserWalletId,
networkId: String,
address: String,
excludeContactId: ContactId? = null,
): String? {
val contacts = repository.getContactsSync(userWalletId)
return contacts
.firstOrNull { contact ->
contact.id != excludeContactId && contact.addressEntries.any { entry ->
entry.networkId.value == networkId && entry.address == address
}
}
?.name?.value
}
}

View file

@ -0,0 +1,15 @@
package com.tangem.domain.addressbook.usecase
import com.tangem.domain.addressbook.model.Contact
import com.tangem.domain.addressbook.model.ContactId
import com.tangem.domain.addressbook.repository.AddressBookRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class GetContactByIdUseCase(
private val repository: AddressBookRepository,
) {
operator fun invoke(id: ContactId): Flow<Contact?> =
repository.getAllContacts().map { contacts -> contacts.find { it.id == id } }
}

View file

@ -0,0 +1,102 @@
package com.tangem.domain.addressbook.usecase
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.addressbook.model.*
import com.tangem.domain.addressbook.repository.AddressBookRepository
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class CheckAddressDuplicateUseCaseTest {
private val repository: AddressBookRepository = mockk()
private val useCase = CheckAddressDuplicateUseCase(repository)
private val walletId = UserWalletId("0001")
@BeforeEach
fun resetMocks() {
clearMocks(repository)
}
@Test
fun `GIVEN network and address already saved WHEN invoke THEN returns owning contact name`() = runTest {
// Arrange
coEvery { repository.getContactsSync(walletId) } returns listOf(contact("Binance", "0xAAA", ETHEREUM))
// Act
val result = useCase(walletId, networkId = ETHEREUM, address = "0xAAA")
// Assert
assertThat(result).isEqualTo("Binance")
}
@Test
fun `GIVEN same address in a different network WHEN invoke THEN returns null`() = runTest {
// Arrange
coEvery { repository.getContactsSync(walletId) } returns listOf(contact("Binance", "0xAAA", ETHEREUM))
// Act
val result = useCase(walletId, networkId = TRON, address = "0xAAA")
// Assert
assertThat(result).isNull()
}
@Test
fun `GIVEN the pair belongs to the excluded contact WHEN invoke THEN returns null`() = runTest {
// Arrange
val contact = contact("Binance", "0xAAA", ETHEREUM, id = "id-1")
coEvery { repository.getContactsSync(walletId) } returns listOf(contact)
// Act
val result = useCase(walletId, networkId = ETHEREUM, address = "0xAAA", excludeContactId = ContactId("id-1"))
// Assert
assertThat(result).isNull()
}
@Test
fun `GIVEN free pair WHEN invoke THEN returns null`() = runTest {
// Arrange
coEvery { repository.getContactsSync(walletId) } returns listOf(contact("Binance", "0xAAA", ETHEREUM))
// Act
val result = useCase(walletId, networkId = ETHEREUM, address = "0xBBB")
// Assert
assertThat(result).isNull()
}
private fun contact(name: String, address: String, networkId: String, id: String = "id-$name"): Contact = Contact(
id = ContactId(id),
walletId = walletId,
name = requireNotNull(ContactName(name).getOrNull()),
icon = "",
iconColor = "Azure",
createdAt = "2026-01-01T00:00:00.000Z",
updatedAt = "2026-01-01T00:00:00.000Z",
addressEntries = listOf(
AddressEntry(
id = AddressEntryId("addr-$name"),
address = address,
networkId = Network.RawID(networkId),
memo = null,
signature = "sig",
networkName = "Ethereum",
),
),
)
private companion object {
const val ETHEREUM = "ethereum"
const val TRON = "tron"
}
}

View file

@ -0,0 +1,65 @@
package com.tangem.domain.addressbook.usecase
import com.google.common.truth.Truth.assertThat
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.repository.AddressBookRepository
import com.tangem.domain.models.wallet.UserWalletId
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class GetContactByIdUseCaseTest {
private val repository: AddressBookRepository = mockk()
private val useCase = GetContactByIdUseCase(repository)
@BeforeEach
fun resetMocks() {
clearMocks(repository)
}
@Test
fun `GIVEN matching id WHEN invoke THEN emits that contact`() = runTest {
// Arrange
val target = contact("id-2", "Bob")
every { repository.getAllContacts() } returns flowOf(listOf(contact("id-1", "Alice"), target))
// Act
val result = useCase(ContactId("id-2")).first()
// Assert
assertThat(result).isEqualTo(target)
}
@Test
fun `GIVEN no matching id WHEN invoke THEN emits null`() = runTest {
// Arrange
every { repository.getAllContacts() } returns flowOf(listOf(contact("id-1", "Alice")))
// Act
val result = useCase(ContactId("missing")).first()
// Assert
assertThat(result).isNull()
}
private fun contact(id: String, name: String): Contact = Contact(
id = ContactId(id),
walletId = UserWalletId("0001"),
name = requireNotNull(ContactName(name).getOrNull()),
icon = "",
iconColor = "Azure",
createdAt = "2026-01-01T00:00:00.000Z",
updatedAt = "2026-01-01T00:00:00.000Z",
addressEntries = emptyList(),
)
}

View file

@ -0,0 +1,34 @@
package com.tangem.features.addressbook.addressinfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.features.addressbook.addressinfo.model.AddressInfoModel
import com.tangem.features.addressbook.addressinfo.ui.AddressInfoBottomSheet
internal class DefaultAddressInfoComponent(
appComponentContext: AppComponentContext,
private val params: Params,
) : ComposableBottomSheetComponent, AppComponentContext by appComponentContext {
private val model: AddressInfoModel = getOrCreateModel(params)
override fun dismiss() = params.onDismiss()
@Composable
override fun BottomSheet() {
val state by model.state.collectAsStateWithLifecycle()
AddressInfoBottomSheet(state = state, onDismiss = ::dismiss)
}
data class Params(
val address: String,
val networkCount: Int,
val onEditAddress: () -> Unit,
val onDeleteAddress: () -> Unit,
val onDismiss: () -> Unit,
)
}

View file

@ -0,0 +1,43 @@
package com.tangem.features.addressbook.addressinfo.model
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.clipboard.ClipboardManager
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.features.addressbook.addressinfo.DefaultAddressInfoComponent
import com.tangem.features.addressbook.addressinfo.ui.state.AddressInfoUM
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
@ModelScoped
internal class AddressInfoModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val clipboardManager: ClipboardManager,
private val messageSender: UiMessageSender,
) : Model() {
private val params: DefaultAddressInfoComponent.Params = paramsContainer.require()
val state: StateFlow<AddressInfoUM> = MutableStateFlow(
AddressInfoUM(
address = params.address,
networkCount = params.networkCount,
onCopy = ::onCopy,
onEditAddress = params.onEditAddress,
onDeleteAddress = params.onDeleteAddress,
),
)
private fun onCopy() {
clipboardManager.setText(text = params.address, isSensitive = false)
messageSender.send(SnackbarMessage(resourceReference(R.string.address_book_address_copied)))
params.onDismiss()
}
}

View file

@ -0,0 +1,171 @@
package com.tangem.features.addressbook.addressinfo.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
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.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheet
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetType
import com.tangem.core.ui.ds.image.TangemIcon
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds.topbar.TangemTopBarType
import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.extensions.*
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_copy_20
import com.tangem.core.ui.res.generated.icons.ic_cross_24
import com.tangem.features.addressbook.addressinfo.ui.state.AddressInfoUM
import com.tangem.features.addressbook.impl.R
@Composable
internal fun AddressInfoBottomSheet(state: AddressInfoUM, onDismiss: () -> Unit) {
TangemBottomSheet<TangemBottomSheetConfigContent.Empty>(
config = TangemBottomSheetConfig(
isShown = true,
onDismissRequest = onDismiss,
content = TangemBottomSheetConfigContent.Empty,
),
type = TangemBottomSheetType.Modal,
containerColor = TangemTheme.colors3.bg.secondary,
title = {
TangemTopBar(
type = TangemTopBarType.BottomSheet,
endContent = {
TangemButton(
iconStart = TangemIconUM.Icon(imageVector = Icons.ic_cross_24),
onClick = onDismiss,
size = TangemButton.Size.X11,
variant = TangemButton.Variant.Material,
)
},
)
},
content = {
AddressInfoContent(
address = state.address,
networkCount = state.networkCount,
onCopy = state.onCopy,
onEditAddress = state.onEditAddress,
onDeleteAddress = state.onDeleteAddress,
)
},
)
}
@Suppress("LongParameterList")
@Composable
private fun AddressInfoContent(
address: String,
networkCount: Int,
onCopy: () -> Unit,
onEditAddress: () -> Unit,
onDeleteAddress: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AddressHeader(address = address, networkCount = networkCount)
SpacerH(8.dp)
TangemButton(
onClick = onCopy,
iconEnd = TangemIconUM.Icon(imageVector = Icons.ic_copy_20),
text = resourceReference(R.string.common_copy_address),
variant = TangemButton.Variant.Secondary,
size = TangemButton.Size.X9,
)
SpacerH(48.dp)
Row(
modifier = Modifier
.fillMaxWidth()
.clip(CircleShape)
.background(TangemTheme.colors3.bg.opaque.primary)
.clickableSingle(onClick = onDeleteAddress),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
Text(
modifier = Modifier.padding(vertical = 14.dp),
text = stringResourceSafe(R.string.address_book_remove_address),
style = TangemTheme.typography3.body.medium,
color = TangemTheme.colors3.text.status.error,
)
}
SpacerH(8.dp)
TangemButton(
modifier = Modifier.fillMaxWidth(),
onClick = onEditAddress,
text = resourceReference(R.string.address_book_edit_address),
variant = TangemButton.Variant.Secondary,
size = TangemButton.Size.X12,
)
}
}
@Composable
private fun AddressHeader(address: String, networkCount: Int) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
TangemIcon(
tangemIconUM = TangemIconUM.Ident(text = address),
modifier = Modifier
.size(64.dp)
.clip(CircleShape),
)
SpacerH(32.dp)
Text(
text = address,
style = TangemTheme.typography3.heading.small,
color = TangemTheme.colors3.text.primary,
textAlign = TextAlign.Center,
overflow = TextOverflow.MiddleEllipsis,
maxLines = 1,
)
SpacerH(8.dp)
Text(
text = pluralReference(
id = R.plurals.common_networks_count,
count = networkCount,
formatArgs = wrappedList(networkCount),
).resolveReference(),
style = TangemTheme.typography3.subheading.medium,
color = TangemTheme.colors3.text.secondary,
)
}
}
@Preview(showBackground = true, widthDp = 360)
@Composable
private fun Preview_AddressInfoContent() {
TangemThemePreviewRedesign {
AddressInfoContent(
address = "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D",
networkCount = 3,
onCopy = {},
onEditAddress = {},
onDeleteAddress = {},
)
}
}

View file

@ -0,0 +1,12 @@
package com.tangem.features.addressbook.addressinfo.ui.state
import androidx.compose.runtime.Immutable
@Immutable
internal data class AddressInfoUM(
val address: String,
val networkCount: Int,
val onCopy: () -> Unit,
val onEditAddress: () -> Unit,
val onDeleteAddress: () -> Unit,
)

View file

@ -7,6 +7,7 @@ import com.tangem.domain.addressbook.model.AddressEntry
import com.tangem.domain.addressbook.model.AddressEntryId import com.tangem.domain.addressbook.model.AddressEntryId
import com.tangem.domain.models.network.Network import com.tangem.domain.models.network.Network
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
import kotlinx.collections.immutable.toImmutableList
import java.util.UUID import java.util.UUID
internal class ContactAddressEntriesConverter { internal class ContactAddressEntriesConverter {
@ -17,6 +18,18 @@ internal class ContactAddressEntriesConverter {
} }
} }
fun toValidatedAddresses(entries: List<AddressEntry>): List<ValidatedAddress> {
return entries
.groupBy { it.address }
.map { (address, group) ->
ValidatedAddress(
address = address,
networkIds = group.map { it.networkId.value }.toImmutableList(),
memo = group.firstNotNullOfOrNull { it.memo },
)
}
}
private fun ValidatedAddress.toAddressEntry(rawId: String): AddressEntry { private fun ValidatedAddress.toAddressEntry(rawId: String): AddressEntry {
val blockchain = Blockchain.fromNetworkId(rawId) val blockchain = Blockchain.fromNetworkId(rawId)
val hasExtrasSupport = blockchain?.getSupportedTransactionExtras()?.isTxExtrasSupported() == true val hasExtrasSupport = blockchain?.getSupportedTransactionExtras()?.isTxExtrasSupported() == true

View file

@ -0,0 +1,19 @@
package com.tangem.features.addressbook.editcontact.state.transformers
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.toImmutableList
internal class RemoveValidatedAddressTransformer(
private val address: String,
private val maxAddresses: Int,
) : Transformer<EditContactUM> {
override fun transform(prevState: EditContactUM): EditContactUM {
val addresses = prevState.addresses.filterNot { it.address == address }.toImmutableList()
return prevState.copy(
addresses = addresses,
isAddAddressEnabled = addresses.size < maxAddresses,
)
}
}

View file

@ -0,0 +1,19 @@
package com.tangem.features.addressbook.editcontact.state.transformers
import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM
import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.toImmutableList
internal class SetValidatedAddressesTransformer(
private val addresses: List<ValidatedAddress>,
private val maxAddresses: Int,
) : Transformer<EditContactUM> {
override fun transform(prevState: EditContactUM): EditContactUM {
return prevState.copy(
addresses = addresses.toImmutableList(),
isAddAddressEnabled = addresses.size < maxAddresses,
)
}
}

View file

@ -0,0 +1,112 @@
package com.tangem.features.addressbook.addressinfo.model
import com.google.common.truth.Truth.assertThat
import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.clipboard.ClipboardManager
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.features.addressbook.addressinfo.DefaultAddressInfoComponent
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
@OptIn(ExperimentalCoroutinesApi::class)
internal class AddressInfoModelTest {
private val clipboardManager: ClipboardManager = mockk(relaxed = true)
private val messageSender: UiMessageSender = mockk(relaxed = true)
private var model: AddressInfoModel? = null
@AfterEach
fun tearDown() {
model?.onDestroy()
model = null
}
@Test
fun `WHEN created THEN state reflects params`() = runTest {
// Act
val model = createModel(testScope = this, address = "0xABC", networkCount = 3)
// Assert
assertThat(model.state.value.address).isEqualTo("0xABC")
assertThat(model.state.value.networkCount).isEqualTo(3)
}
@Test
fun `WHEN onCopy THEN address copied AND snackbar shown AND sheet dismissed`() = runTest {
// Arrange
var dismissed = false
val model = createModel(testScope = this, address = "0xABC", onDismiss = { dismissed = true })
// Act
model.state.value.onCopy()
// Assert
verify { clipboardManager.setText(text = "0xABC", isSensitive = false) }
verify { messageSender.send(any<SnackbarMessage>()) }
assertThat(dismissed).isTrue()
}
@Test
fun `WHEN onEditAddress or onDeleteAddress THEN delegated to the editor callbacks`() = runTest {
// Arrange
var edited = false
var deleted = false
val model = createModel(
testScope = this,
onEditAddress = { edited = true },
onDeleteAddress = { deleted = true },
)
// Act
model.state.value.onEditAddress()
model.state.value.onDeleteAddress()
// Assert
assertThat(edited).isTrue()
assertThat(deleted).isTrue()
}
@Suppress("LongParameterList")
private fun createModel(
testScope: TestScope,
address: String = "0xABC",
networkCount: Int = 1,
onEditAddress: () -> Unit = {},
onDeleteAddress: () -> Unit = {},
onDismiss: () -> Unit = {},
): AddressInfoModel {
val params = DefaultAddressInfoComponent.Params(
address = address,
networkCount = networkCount,
onEditAddress = onEditAddress,
onDeleteAddress = onDeleteAddress,
onDismiss = onDismiss,
)
return AddressInfoModel(
paramsContainer = MutableParamsContainer(value = params),
dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
clipboardManager = clipboardManager,
messageSender = messageSender,
).also { model = it }
}
private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
val testDispatcher = StandardTestDispatcher(testScheduler)
return TestingCoroutineDispatcherProvider(
main = testDispatcher,
mainImmediate = testDispatcher,
io = testDispatcher,
default = testDispatcher,
single = testDispatcher,
)
}
}