diff --git a/app/src/main/java/com/tangem/tap/di/domain/AddressBookDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/AddressBookDomainModule.kt
index 3c7d4cab2c..c7c52c4c44 100644
--- a/app/src/main/java/com/tangem/tap/di/domain/AddressBookDomainModule.kt
+++ b/app/src/main/java/com/tangem/tap/di/domain/AddressBookDomainModule.kt
@@ -6,7 +6,9 @@ import com.tangem.domain.addressbook.interactor.SaveContactInteractor
import com.tangem.domain.addressbook.repository.AddressBookRepository
import com.tangem.domain.addressbook.time.DefaultIsoTimestampProvider
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.GetContactByIdUseCase
import com.tangem.domain.addressbook.usecase.GetContactsUseCase
import com.tangem.domain.addressbook.usecase.SyncAddressBooksUseCase
import com.tangem.domain.addressbook.usecase.ValidateContactAddressUseCase
@@ -86,6 +88,18 @@ object AddressBookDomainModule {
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
@Singleton
fun provideSyncAddressBooksUseCase(repository: AddressBookRepository): SyncAddressBooksUseCase {
diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml
index 31787d3bb7..7775e7abac 100644
--- a/core/res/src/main/res/values/strings.xml
+++ b/core/res/src/main/res/values/strings.xml
@@ -110,7 +110,9 @@
Copy address
Contact added
Couldn\'t create contact. Please try again later.
+ Delete contact
This contact will be deleted from all your address books
+ \"%1$s\" has only one address. Deleting it will also delete the contact. Continue?
Couldn\'t delete contact. Please try again later.
Manage contacts & addresses
Discard
@@ -1988,6 +1990,13 @@
Remove account
Tangem Pay will be removed from the main screen and won\'t appear again, even after reinstalling the app.
Remove account?
+ Cancel
+ Downgrade plan
+ Select
+ Upgrade plan
+ Compare plans
+ Confirm selection
+ Select plan
We’re fixing a technical issue. Please try again later.
Service temporarily unavailable
Service unreachable. However, card payments are still working.
diff --git a/core/ui/src/main/res/drawable/ic_success_20.xml b/core/ui/src/main/res/drawable/ic_success_20.xml
new file mode 100644
index 0000000000..90e5ac3815
--- /dev/null
+++ b/core/ui/src/main/res/drawable/ic_success_20.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/data/address-book/src/main/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepository.kt b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepository.kt
index ae71fcc84f..8fcd5862f8 100644
--- a/data/address-book/src/main/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepository.kt
+++ b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepository.kt
@@ -27,13 +27,7 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.flow.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.flow.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
@@ -86,6 +80,14 @@ internal class DefaultAddressBookRepository(
}
}
+ override suspend fun getContactsSync(userWalletId: UserWalletId): List {
+ 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? =
withContext(dispatchers.default) {
val blob = blobStore.getBlobSync(userWalletId) ?: return@withContext null
diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/repository/AddressBookRepository.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/repository/AddressBookRepository.kt
index 27a47e4596..4e2cb2935f 100644
--- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/repository/AddressBookRepository.kt
+++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/repository/AddressBookRepository.kt
@@ -16,6 +16,8 @@ interface AddressBookRepository {
/** Contacts across all wallets (flattened). Each [Contact] keeps its own [Contact.walletId]. */
fun getAllContacts(): Flow>
+ suspend fun getContactsSync(userWalletId: UserWalletId): List
+
suspend fun getContact(userWalletId: UserWalletId, name: String): Contact?
suspend fun saveContact(contact: Contact): Either
diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/CheckAddressDuplicateUseCase.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/CheckAddressDuplicateUseCase.kt
new file mode 100644
index 0000000000..2ef945fcfc
--- /dev/null
+++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/CheckAddressDuplicateUseCase.kt
@@ -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
+ }
+}
\ No newline at end of file
diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/GetContactByIdUseCase.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/GetContactByIdUseCase.kt
new file mode 100644
index 0000000000..c204434299
--- /dev/null
+++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/GetContactByIdUseCase.kt
@@ -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 =
+ repository.getAllContacts().map { contacts -> contacts.find { it.id == id } }
+}
\ No newline at end of file
diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/CheckAddressDuplicateUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/CheckAddressDuplicateUseCaseTest.kt
new file mode 100644
index 0000000000..8357f40415
--- /dev/null
+++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/CheckAddressDuplicateUseCaseTest.kt
@@ -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"
+ }
+}
\ No newline at end of file
diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetContactByIdUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetContactByIdUseCaseTest.kt
new file mode 100644
index 0000000000..9ee2b31931
--- /dev/null
+++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetContactByIdUseCaseTest.kt
@@ -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(),
+ )
+}
\ No newline at end of file
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addressinfo/DefaultAddressInfoComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addressinfo/DefaultAddressInfoComponent.kt
new file mode 100644
index 0000000000..f1829f93b5
--- /dev/null
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addressinfo/DefaultAddressInfoComponent.kt
@@ -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,
+ )
+}
\ No newline at end of file
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addressinfo/model/AddressInfoModel.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addressinfo/model/AddressInfoModel.kt
new file mode 100644
index 0000000000..6cfd652ec0
--- /dev/null
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addressinfo/model/AddressInfoModel.kt
@@ -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 = 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()
+ }
+}
\ No newline at end of file
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addressinfo/ui/AddressInfoBottomSheet.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addressinfo/ui/AddressInfoBottomSheet.kt
new file mode 100644
index 0000000000..6cd5ad4408
--- /dev/null
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addressinfo/ui/AddressInfoBottomSheet.kt
@@ -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(
+ 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 = {},
+ )
+ }
+}
\ No newline at end of file
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addressinfo/ui/state/AddressInfoUM.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addressinfo/ui/state/AddressInfoUM.kt
new file mode 100644
index 0000000000..da65f53cf9
--- /dev/null
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addressinfo/ui/state/AddressInfoUM.kt
@@ -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,
+)
\ No newline at end of file
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/ContactAddressEntriesConverter.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/ContactAddressEntriesConverter.kt
index a039134f1d..403ef3e8a4 100644
--- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/ContactAddressEntriesConverter.kt
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/ContactAddressEntriesConverter.kt
@@ -7,6 +7,7 @@ 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 kotlinx.collections.immutable.toImmutableList
import java.util.UUID
internal class ContactAddressEntriesConverter {
@@ -17,6 +18,18 @@ internal class ContactAddressEntriesConverter {
}
}
+ fun toValidatedAddresses(entries: List): List {
+ 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 {
val blockchain = Blockchain.fromNetworkId(rawId)
val hasExtrasSupport = blockchain?.getSupportedTransactionExtras()?.isTxExtrasSupported() == true
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/RemoveValidatedAddressTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/RemoveValidatedAddressTransformer.kt
new file mode 100644
index 0000000000..8c0be24e8b
--- /dev/null
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/RemoveValidatedAddressTransformer.kt
@@ -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 {
+
+ override fun transform(prevState: EditContactUM): EditContactUM {
+ val addresses = prevState.addresses.filterNot { it.address == address }.toImmutableList()
+ return prevState.copy(
+ addresses = addresses,
+ isAddAddressEnabled = addresses.size < maxAddresses,
+ )
+ }
+}
\ No newline at end of file
diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/SetValidatedAddressesTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/SetValidatedAddressesTransformer.kt
new file mode 100644
index 0000000000..53e386a7ca
--- /dev/null
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/SetValidatedAddressesTransformer.kt
@@ -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,
+ private val maxAddresses: Int,
+) : Transformer {
+
+ override fun transform(prevState: EditContactUM): EditContactUM {
+ return prevState.copy(
+ addresses = addresses.toImmutableList(),
+ isAddAddressEnabled = addresses.size < maxAddresses,
+ )
+ }
+}
\ No newline at end of file
diff --git a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/addressinfo/model/AddressInfoModelTest.kt b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/addressinfo/model/AddressInfoModelTest.kt
new file mode 100644
index 0000000000..fbedb5f5d3
--- /dev/null
+++ b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/addressinfo/model/AddressInfoModelTest.kt
@@ -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()) }
+ 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,
+ )
+ }
+}
\ No newline at end of file