From 12cebc413d2b54ea72a3da43e74741b4fc40ee9a Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 3 Jul 2026 11:37:25 +0200 Subject: [PATCH] Updated on 2026-08-14 --- .../tap/di/domain/AddressBookDomainModule.kt | 14 ++ core/res/src/main/res/values/strings.xml | 9 + .../src/main/res/drawable/ic_success_20.xml | 9 + .../DefaultAddressBookRepository.kt | 16 +- .../repository/AddressBookRepository.kt | 2 + .../usecase/CheckAddressDuplicateUseCase.kt | 38 ++++ .../usecase/GetContactByIdUseCase.kt | 15 ++ .../CheckAddressDuplicateUseCaseTest.kt | 102 +++++++++++ .../usecase/GetContactByIdUseCaseTest.kt | 65 +++++++ .../DefaultAddressInfoComponent.kt | 34 ++++ .../addressinfo/model/AddressInfoModel.kt | 43 +++++ .../addressinfo/ui/AddressInfoBottomSheet.kt | 171 ++++++++++++++++++ .../addressinfo/ui/state/AddressInfoUM.kt | 12 ++ .../model/ContactAddressEntriesConverter.kt | 13 ++ .../RemoveValidatedAddressTransformer.kt | 19 ++ .../SetValidatedAddressesTransformer.kt | 19 ++ .../addressinfo/model/AddressInfoModelTest.kt | 112 ++++++++++++ 17 files changed, 686 insertions(+), 7 deletions(-) create mode 100644 core/ui/src/main/res/drawable/ic_success_20.xml create mode 100644 domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/CheckAddressDuplicateUseCase.kt create mode 100644 domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/GetContactByIdUseCase.kt create mode 100644 domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/CheckAddressDuplicateUseCaseTest.kt create mode 100644 domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetContactByIdUseCaseTest.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addressinfo/DefaultAddressInfoComponent.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addressinfo/model/AddressInfoModel.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addressinfo/ui/AddressInfoBottomSheet.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addressinfo/ui/state/AddressInfoUM.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/RemoveValidatedAddressTransformer.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/SetValidatedAddressesTransformer.kt create mode 100644 features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/addressinfo/model/AddressInfoModelTest.kt 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