Updated on 2026-08-14
This commit is contained in:
parent
391e0562cc
commit
12cebc413d
17 changed files with 686 additions and 7 deletions
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 & addresses</string>
|
<string name="address_book_description">Manage contacts & 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">We’re fixing a technical issue. Please try again later.</string>
|
<string name="tangempay_service_unavailable_description">We’re 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>
|
||||||
|
|
|
||||||
9
core/ui/src/main/res/drawable/ic_success_20.xml
Normal file
9
core/ui/src/main/res/drawable/ic_success_20.xml
Normal 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>
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 } }
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue