From 99b517455ad9e1392cacfcea1a6f3e225434072a Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 19 Jun 2026 10:07:44 +0100 Subject: [PATCH 001/210] Updated on 2026-08-14 --- app/build.gradle.kts | 1 + data/address-book/build.gradle.kts | 42 ++++ .../DefaultAddressBookRepository.kt | 116 ++++++++++ .../addressbook/di/AddressBookDataModule.kt | 68 ++++++ .../addressbook/store/AddressBookBlobStore.kt | 25 ++ .../store/DefaultAddressBookBlobStore.kt | 58 +++++ .../store/StoredAddressBookBlob.kt | 15 ++ .../DefaultAddressBookRepositoryTest.kt | 219 ++++++++++++++++++ .../store/DefaultAddressBookBlobStoreTest.kt | 112 +++++++++ .../repository/AddressBookRepository.kt | 5 +- .../addressbook/usecase/GetContactsUseCase.kt | 21 +- .../usecase/GetContactsUseCaseTest.kt | 105 +++++++++ settings.gradle.kts | 1 + 13 files changed, 785 insertions(+), 3 deletions(-) create mode 100644 data/address-book/build.gradle.kts create mode 100644 data/address-book/src/main/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepository.kt create mode 100644 data/address-book/src/main/kotlin/com/tangem/data/addressbook/di/AddressBookDataModule.kt create mode 100644 data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/AddressBookBlobStore.kt create mode 100644 data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/DefaultAddressBookBlobStore.kt create mode 100644 data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/StoredAddressBookBlob.kt create mode 100644 data/address-book/src/test/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepositoryTest.kt create mode 100644 data/address-book/src/test/kotlin/com/tangem/data/addressbook/store/DefaultAddressBookBlobStoreTest.kt create mode 100644 domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCaseTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c307bb3f9b..33ba9e4946 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -191,6 +191,7 @@ dependencies { implementation(projects.libs.tangemSdkApi) implementation(projects.data.account) + implementation(projects.data.addressBook) implementation(projects.data.appCurrency) implementation(projects.data.appTheme) implementation(projects.data.balanceHiding) diff --git a/data/address-book/build.gradle.kts b/data/address-book/build.gradle.kts new file mode 100644 index 0000000000..734a559f8c --- /dev/null +++ b/data/address-book/build.gradle.kts @@ -0,0 +1,42 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.kapt) + alias(deps.plugins.kotlin.serialization) + id("configuration") +} + +android { + namespace = "com.tangem.data.addressbook" +} + +dependencies { + // region Project - Core + implementation(projects.core.datasource) + implementation(projects.core.utils) + // endregion + + // region Project - Domain + implementation(projects.domain.addressBook) + implementation(projects.domain.common) + implementation(projects.domain.models) + // endregion + + // region SDK + implementation(deps.androidx.datastore) + implementation(deps.arrow.core) + implementation(deps.jodatime) + implementation(deps.kotlin.coroutines) + implementation(deps.kotlin.serialization) + // endregion + + // region DI + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) + // endregion + + // region Testing + testImplementation(projects.test.core) + testImplementation(deps.moshi.kotlin) + // endregion +} \ No newline at end of file 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 new file mode 100644 index 0000000000..8ce2414259 --- /dev/null +++ b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepository.kt @@ -0,0 +1,116 @@ +package com.tangem.data.addressbook + +import com.tangem.data.addressbook.store.AddressBookBlobStore +import com.tangem.domain.addressbook.crypto.AddressBookCipher +import com.tangem.domain.addressbook.model.AddressBook +import com.tangem.domain.addressbook.model.AddressBookBlob +import com.tangem.domain.addressbook.model.Contact +import com.tangem.domain.addressbook.model.ContactId +import com.tangem.domain.addressbook.repository.AddressBookRepository +import com.tangem.domain.addressbook.time.IsoTimestampProvider +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +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.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.joda.time.DateTime + +internal class DefaultAddressBookRepository( + private val blobStore: AddressBookBlobStore, + private val cipher: AddressBookCipher, + private val userWalletsListRepository: UserWalletsListRepository, + private val timestampProvider: IsoTimestampProvider, + private val dispatchers: CoroutineDispatcherProvider, +) : AddressBookRepository { + + private val writeMutex = Mutex() + + override fun getContacts(userWalletId: UserWalletId): Flow> { + return getContactsForWallet(userWalletId) + .distinctUntilChanged() + .flowOn(dispatchers.default) + } + + @OptIn(ExperimentalCoroutinesApi::class) + override fun getAllContacts(): Flow> { + return userWalletsListRepository.userWallets + .filterNotNull() + .flatMapLatest { wallets -> + val walletsById = wallets.associateBy { it.walletId.stringValue } + val ids = wallets.mapTo(mutableSetOf()) { it.walletId } + blobStore.getBlobs(ids).map { blobs -> + blobs.flatMap { blob -> + walletsById[blob.walletId]?.let { userWallet -> + decryptContacts(blob, userWallet) + }.orEmpty() + } + } + } + .distinctUntilChanged() + .flowOn(dispatchers.default) + } + + private fun getContactsForWallet(userWalletId: UserWalletId): Flow> { + return blobStore.getBlob(userWalletId).map { blob -> + val userWallet = blob?.let { findUserWallet(it.walletId) } ?: return@map emptyList() + decryptContacts(blob, userWallet) + } + } + + override suspend fun getContact(userWalletId: UserWalletId, name: String): Contact? = + withContext(dispatchers.default) { + val blob = blobStore.getBlobSync(userWalletId) ?: return@withContext null + val userWallet = findUserWallet(blob.walletId) ?: return@withContext null + decryptContacts(blob, userWallet).find { it.name.value == name } + } + + override suspend fun saveContact(contact: Contact) = withContext(dispatchers.default) { + writeMutex.withLock { + val userWallet = findUserWallet(contact.walletId.stringValue) ?: return@withLock + val current = currentContacts(contact.walletId, userWallet) + val merged = current.filterNot { it.id == contact.id } + contact + persist(userWallet, AddressBook(walletId = contact.walletId, contacts = merged)) + } + } + + override suspend fun deleteContact(id: ContactId) = withContext(dispatchers.default) { + writeMutex.withLock { + userWalletsListRepository.userWalletsSync().forEach { userWallet -> + val blob = blobStore.getBlobSync(userWallet.walletId) ?: return@forEach + val addressBook = cipher.decrypt(blob, userWallet).getOrNull() ?: return@forEach + if (addressBook.contacts.none { it.id == id }) return@forEach + + val remaining = addressBook.contacts.filterNot { it.id == id } + persist(userWallet, addressBook.copy(contacts = remaining)) + return@withLock + } + } + } + + private fun decryptContacts(blob: AddressBookBlob, userWallet: UserWallet): List { + return cipher.decrypt(blob, userWallet).getOrNull()?.contacts.orEmpty() + } + + private suspend fun currentContacts(userWalletId: UserWalletId, userWallet: UserWallet): List { + val blob = blobStore.getBlobSync(userWalletId) ?: return emptyList() + return decryptContacts(blob, userWallet) + } + + private suspend fun persist(userWallet: UserWallet, addressBook: AddressBook) { + val updatedAt = DateTime.parse(timestampProvider.now()) + cipher.encrypt(addressBook, userWallet, updatedAt) + .onRight { blobStore.storeBlob(it) } + } + + private suspend fun findUserWallet(walletId: String): UserWallet? = + userWalletsListRepository.userWalletsSync().find { it.walletId.stringValue == walletId } +} \ No newline at end of file diff --git a/data/address-book/src/main/kotlin/com/tangem/data/addressbook/di/AddressBookDataModule.kt b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/di/AddressBookDataModule.kt new file mode 100644 index 0000000000..24ffcd6120 --- /dev/null +++ b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/di/AddressBookDataModule.kt @@ -0,0 +1,68 @@ +package com.tangem.data.addressbook.di + +import android.content.Context +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.dataStoreFile +import com.tangem.data.addressbook.DefaultAddressBookRepository +import com.tangem.data.addressbook.store.AddressBookBlobStore +import com.tangem.data.addressbook.store.DefaultAddressBookBlobStore +import com.tangem.data.addressbook.store.StoredAddressBookBlob +import com.tangem.datasource.utils.KotlinxDataStoreSerializer +import com.tangem.domain.addressbook.crypto.AddressBookCipher +import com.tangem.domain.addressbook.repository.AddressBookRepository +import com.tangem.domain.addressbook.time.IsoTimestampProvider +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.utils.coroutines.AppCoroutineScope +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer + +@Module +@InstallIn(SingletonComponent::class) +internal object AddressBookDataModule { + + @Provides + @Singleton + fun provideAddressBookBlobStore( + @ApplicationContext context: Context, + appScope: AppCoroutineScope, + ): AddressBookBlobStore { + return DefaultAddressBookBlobStore( + dataStore = DataStoreFactory.create( + serializer = KotlinxDataStoreSerializer( + defaultValue = emptyMap(), + serializer = MapSerializer( + keySerializer = String.serializer(), + valueSerializer = StoredAddressBookBlob.serializer(), + ), + ), + produceFile = { context.dataStoreFile(fileName = "address_book_blobs") }, + scope = appScope, + ), + ) + } + + @Provides + @Singleton + fun provideAddressBookRepository( + blobStore: AddressBookBlobStore, + cipher: AddressBookCipher, + userWalletsListRepository: UserWalletsListRepository, + timestampProvider: IsoTimestampProvider, + dispatchers: CoroutineDispatcherProvider, + ): AddressBookRepository { + return DefaultAddressBookRepository( + blobStore = blobStore, + cipher = cipher, + userWalletsListRepository = userWalletsListRepository, + timestampProvider = timestampProvider, + dispatchers = dispatchers, + ) + } +} \ No newline at end of file diff --git a/data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/AddressBookBlobStore.kt b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/AddressBookBlobStore.kt new file mode 100644 index 0000000000..de3e180d81 --- /dev/null +++ b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/AddressBookBlobStore.kt @@ -0,0 +1,25 @@ +package com.tangem.data.addressbook.store + +import com.tangem.domain.addressbook.model.AddressBookBlob +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.coroutines.flow.Flow + +interface AddressBookBlobStore { + + fun getBlob(userWalletId: UserWalletId): Flow + + fun getBlobs(userWalletIds: Set): Flow> + + suspend fun getBlobSync(userWalletId: UserWalletId): AddressBookBlob? + + /** Persists [blob] optimistically with `isBESynchronized = false`. Keyed by [AddressBookBlob.walletId]. */ + suspend fun storeBlob(blob: AddressBookBlob) + + /** Flips the BE-sync flag to `true` once the backend confirms the push. No-op if the blob is absent. */ + suspend fun markAsSynchronized(userWalletId: UserWalletId) + + /** Blobs still pending a backend push — the entry point for the future sync service. */ + suspend fun getUnsynchronizedBlobs(): List + + suspend fun deleteBlob(userWalletId: UserWalletId) +} \ No newline at end of file diff --git a/data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/DefaultAddressBookBlobStore.kt b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/DefaultAddressBookBlobStore.kt new file mode 100644 index 0000000000..8aa6c8558e --- /dev/null +++ b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/DefaultAddressBookBlobStore.kt @@ -0,0 +1,58 @@ +package com.tangem.data.addressbook.store + +import androidx.datastore.core.DataStore +import com.tangem.domain.addressbook.model.AddressBookBlob +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +internal typealias AddressBookBlobs = Map + +internal class DefaultAddressBookBlobStore( + private val dataStore: DataStore, +) : AddressBookBlobStore { + + override fun getBlob(userWalletId: UserWalletId): Flow { + return dataStore.data + .map { it[userWalletId.stringValue]?.blob } + .distinctUntilChanged() + } + + override fun getBlobs(userWalletIds: Set): Flow> { + val ids = userWalletIds.mapTo(mutableSetOf()) { it.stringValue } + return dataStore.data + .map { stored -> stored.filterKeys { it in ids }.values.map { it.blob } } + .distinctUntilChanged() + } + + override suspend fun getBlobSync(userWalletId: UserWalletId): AddressBookBlob? { + return getStoredBlobs()[userWalletId.stringValue]?.blob + } + + override suspend fun storeBlob(blob: AddressBookBlob) { + dataStore.updateData { stored -> + stored + (blob.walletId to StoredAddressBookBlob(blob = blob, isBESynchronized = false)) + } + } + + override suspend fun markAsSynchronized(userWalletId: UserWalletId) { + dataStore.updateData { stored -> + val current = stored[userWalletId.stringValue] ?: return@updateData stored + stored + (userWalletId.stringValue to current.copy(isBESynchronized = true)) + } + } + + override suspend fun getUnsynchronizedBlobs(): List { + return getStoredBlobs().values + .filterNot { it.isBESynchronized } + .map { it.blob } + } + + override suspend fun deleteBlob(userWalletId: UserWalletId) { + dataStore.updateData { stored -> stored - userWalletId.stringValue } + } + + private suspend fun getStoredBlobs(): AddressBookBlobs = dataStore.data.first() +} \ No newline at end of file diff --git a/data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/StoredAddressBookBlob.kt b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/StoredAddressBookBlob.kt new file mode 100644 index 0000000000..9e82e17205 --- /dev/null +++ b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/StoredAddressBookBlob.kt @@ -0,0 +1,15 @@ +package com.tangem.data.addressbook.store + +import com.tangem.domain.addressbook.model.AddressBookBlob +import kotlinx.serialization.Serializable + +/** + * [isBESynchronized] tracks whether the blob has already been pushed to the backend. A freshly + * stored blob is written optimistically with `false`; a future BE-sync service flips it to `true` + * once the push is confirmed. + */ +@Serializable +internal data class StoredAddressBookBlob( + val blob: AddressBookBlob, + val isBESynchronized: Boolean, +) \ No newline at end of file diff --git a/data/address-book/src/test/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepositoryTest.kt b/data/address-book/src/test/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepositoryTest.kt new file mode 100644 index 0000000000..e796387263 --- /dev/null +++ b/data/address-book/src/test/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepositoryTest.kt @@ -0,0 +1,219 @@ +package com.tangem.data.addressbook + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.data.addressbook.store.AddressBookBlobStore +import com.tangem.domain.addressbook.crypto.AddressBookCipher +import com.tangem.domain.addressbook.error.AddressBookCryptoError +import com.tangem.domain.addressbook.model.AddressBook +import com.tangem.domain.addressbook.model.AddressBookBlob +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.time.IsoTimestampProvider +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.flow.MutableStateFlow +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) +internal class DefaultAddressBookRepositoryTest { + + private val blobStore: AddressBookBlobStore = mockk() + private val cipher: AddressBookCipher = mockk() + private val userWalletsListRepository: UserWalletsListRepository = mockk() + private val timestampProvider: IsoTimestampProvider = mockk() + + private val userWallet: UserWallet = mockk { + every { walletId } returns UserWalletId(WALLET_A) + } + + private val repository = DefaultAddressBookRepository( + blobStore = blobStore, + cipher = cipher, + userWalletsListRepository = userWalletsListRepository, + timestampProvider = timestampProvider, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + @BeforeEach + fun setup() { + clearMocks(blobStore, cipher, userWalletsListRepository, timestampProvider) + every { timestampProvider.now() } returns TIMESTAMP + coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet) + } + + @Test + fun `GIVEN decryptable blob WHEN getContacts THEN emits decrypted contacts`() = runTest { + // Arrange + val contact = createContact(id = "c1", name = "Alice") + val blob = createBlob() + every { blobStore.getBlob(UserWalletId(WALLET_A)) } returns flowOf(blob) + every { cipher.decrypt(blob, userWallet) } returns AddressBook(UserWalletId(WALLET_A), listOf(contact)).right() + + // Act + val result = repository.getContacts(UserWalletId(WALLET_A)).first() + + // Assert + assertThat(result).containsExactly(contact) + } + + @Test + fun `GIVEN multiple wallets WHEN getAllContacts THEN emits contacts from all wallets`() = runTest { + // Arrange + val contact = createContact(id = "c1", name = "Alice") + val blob = createBlob() + every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet)) + every { blobStore.getBlobs(setOf(UserWalletId(WALLET_A))) } returns flowOf(listOf(blob)) + every { cipher.decrypt(blob, userWallet) } returns AddressBook(UserWalletId(WALLET_A), listOf(contact)).right() + + // Act + val result = repository.getAllContacts().first() + + // Assert + assertThat(result).containsExactly(contact) + } + + @Test + fun `GIVEN no blob WHEN getContacts THEN emits empty`() = runTest { + // Arrange + every { blobStore.getBlob(UserWalletId(WALLET_A)) } returns flowOf(null) + + // Act + val result = repository.getContacts(UserWalletId(WALLET_A)).first() + + // Assert + assertThat(result).isEmpty() + } + + @Test + fun `GIVEN decryption fails WHEN getContacts THEN emits empty`() = runTest { + // Arrange + val blob = createBlob() + every { blobStore.getBlob(UserWalletId(WALLET_A)) } returns flowOf(blob) + every { cipher.decrypt(blob, userWallet) } returns AddressBookCryptoError.DecryptionFailed.left() + + // Act + val result = repository.getContacts(UserWalletId(WALLET_A)).first() + + // Assert + assertThat(result).isEmpty() + } + + @Test + fun `GIVEN new contact WHEN saveContact THEN encrypts merged book and stores blob`() = runTest { + // Arrange + val existing = createContact(id = "c1", name = "Alice") + val added = createContact(id = "c2", name = "Bob") + val storedBlob = createBlob() + coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns storedBlob + every { cipher.decrypt(storedBlob, userWallet) } returns + AddressBook(UserWalletId(WALLET_A), listOf(existing)).right() + val bookSlot = slot() + val newBlob = createBlob() + every { cipher.encrypt(capture(bookSlot), userWallet, any()) } returns newBlob.right() + coEvery { blobStore.storeBlob(newBlob) } returns Unit + + // Act + repository.saveContact(added) + + // Assert + assertThat(bookSlot.captured.contacts).containsExactly(existing, added) + coVerify(exactly = 1) { blobStore.storeBlob(newBlob) } + } + + @Test + fun `GIVEN existing contact id WHEN saveContact THEN replaces it`() = runTest { + // Arrange + val original = createContact(id = "c1", name = "Alice") + val updated = createContact(id = "c1", name = "Alice Updated") + val storedBlob = createBlob() + coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns storedBlob + every { cipher.decrypt(storedBlob, userWallet) } returns + AddressBook(UserWalletId(WALLET_A), listOf(original)).right() + val bookSlot = slot() + every { cipher.encrypt(capture(bookSlot), userWallet, any()) } returns createBlob().right() + coEvery { blobStore.storeBlob(any()) } returns Unit + + // Act + repository.saveContact(updated) + + // Assert + assertThat(bookSlot.captured.contacts).containsExactly(updated) + } + + @Test + fun `GIVEN contact in wallet WHEN deleteContact THEN re-stores book without it`() = runTest { + // Arrange + val kept = createContact(id = "c1", name = "Alice") + val removed = createContact(id = "c2", name = "Bob") + val storedBlob = createBlob() + coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns storedBlob + every { cipher.decrypt(storedBlob, userWallet) } returns + AddressBook(UserWalletId(WALLET_A), listOf(kept, removed)).right() + val bookSlot = slot() + val newBlob = createBlob() + every { cipher.encrypt(capture(bookSlot), userWallet, any()) } returns newBlob.right() + coEvery { blobStore.storeBlob(newBlob) } returns Unit + + // Act + repository.deleteContact(ContactId("c2")) + + // Assert + assertThat(bookSlot.captured.contacts).containsExactly(kept) + coVerify(exactly = 1) { blobStore.storeBlob(newBlob) } + } + + @Test + fun `GIVEN matching name WHEN getContact THEN returns it`() = runTest { + // Arrange + val alice = createContact(id = "c1", name = "Alice") + val bob = createContact(id = "c2", name = "Bob") + val blob = createBlob() + coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns blob + every { cipher.decrypt(blob, userWallet) } returns + AddressBook(UserWalletId(WALLET_A), listOf(alice, bob)).right() + + // Act + val result = repository.getContact(UserWalletId(WALLET_A), name = "Bob") + + // Assert + assertThat(result).isEqualTo(bob) + } + + private fun createContact(id: String, name: String): Contact = Contact( + id = ContactId(id), + walletId = UserWalletId(WALLET_A), + name = ContactName(name).getOrNull()!!, + createdAt = TIMESTAMP, + updatedAt = TIMESTAMP, + addressEntries = emptyList(), + ) + + private fun createBlob(): AddressBookBlob = AddressBookBlob( + walletId = WALLET_A, + updatedAt = TIMESTAMP, + nonce = "00112233445566778899aabb", + ciphertext = "deadbeef", + authTag = "cafebabecafebabecafebabecafebabe", + ) + + private companion object { + const val WALLET_A = "0a0a0a" + const val TIMESTAMP = "2026-05-22T09:00:00.000Z" + } +} \ No newline at end of file diff --git a/data/address-book/src/test/kotlin/com/tangem/data/addressbook/store/DefaultAddressBookBlobStoreTest.kt b/data/address-book/src/test/kotlin/com/tangem/data/addressbook/store/DefaultAddressBookBlobStoreTest.kt new file mode 100644 index 0000000000..14f271eaa2 --- /dev/null +++ b/data/address-book/src/test/kotlin/com/tangem/data/addressbook/store/DefaultAddressBookBlobStoreTest.kt @@ -0,0 +1,112 @@ +package com.tangem.data.addressbook.store + +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.addressbook.model.AddressBookBlob +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.test.core.datastore.MockStateDataStore +import kotlinx.coroutines.flow.first +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) +internal class DefaultAddressBookBlobStoreTest { + + private lateinit var store: DefaultAddressBookBlobStore + + @BeforeEach + fun setup() { + store = DefaultAddressBookBlobStore( + dataStore = MockStateDataStore(default = emptyMap()), + ) + } + + @Test + fun `GIVEN blob WHEN storeBlob THEN getBlob emits it AND it is unsynchronized`() = runTest { + // Arrange + val blob = createBlob(walletId = WALLET_A) + + // Act + store.storeBlob(blob) + + // Assert + assertThat(store.getBlob(UserWalletId(WALLET_A)).first()).isEqualTo(blob) + assertThat(store.getBlobSync(UserWalletId(WALLET_A))).isEqualTo(blob) + assertThat(store.getUnsynchronizedBlobs()).containsExactly(blob) + } + + @Test + fun `GIVEN stored blob WHEN markAsSynchronized THEN getUnsynchronizedBlobs excludes it`() = runTest { + // Arrange + val blob = createBlob(walletId = WALLET_A) + store.storeBlob(blob) + + // Act + store.markAsSynchronized(UserWalletId(WALLET_A)) + + // Assert + assertThat(store.getUnsynchronizedBlobs()).isEmpty() + assertThat(store.getBlob(UserWalletId(WALLET_A)).first()).isEqualTo(blob) + } + + @Test + fun `GIVEN blobs for two wallets WHEN getBlob walletA THEN only walletA blob emitted`() = runTest { + // Arrange + val blobA = createBlob(walletId = WALLET_A) + val blobB = createBlob(walletId = WALLET_B) + store.storeBlob(blobA) + store.storeBlob(blobB) + + // Act + val result = store.getBlob(UserWalletId(WALLET_A)).first() + + // Assert + assertThat(result).isEqualTo(blobA) + assertThat(store.getUnsynchronizedBlobs()).containsExactly(blobA, blobB) + } + + @Test + fun `GIVEN blobs for two wallets WHEN getBlobs THEN only requested wallets returned`() = runTest { + // Arrange + val blobA = createBlob(walletId = WALLET_A) + val blobB = createBlob(walletId = WALLET_B) + store.storeBlob(blobA) + store.storeBlob(blobB) + + // Act + val result = store.getBlobs(setOf(UserWalletId(WALLET_A), UserWalletId(WALLET_B))).first() + val onlyA = store.getBlobs(setOf(UserWalletId(WALLET_A))).first() + + // Assert + assertThat(result).containsExactly(blobA, blobB) + assertThat(onlyA).containsExactly(blobA) + } + + @Test + fun `GIVEN stored blob WHEN deleteBlob THEN getBlob emits null`() = runTest { + // Arrange + val blob = createBlob(walletId = WALLET_A) + store.storeBlob(blob) + + // Act + store.deleteBlob(UserWalletId(WALLET_A)) + + // Assert + assertThat(store.getBlob(UserWalletId(WALLET_A)).first()).isNull() + assertThat(store.getBlobSync(UserWalletId(WALLET_A))).isNull() + } + + private fun createBlob(walletId: String): AddressBookBlob = AddressBookBlob( + walletId = walletId, + updatedAt = "2026-05-22T09:00:00.000Z", + nonce = "00112233445566778899aabb", + ciphertext = "deadbeef", + authTag = "cafebabecafebabecafebabecafebabe", + ) + + private companion object { + const val WALLET_A = "0a0a0a" + const val WALLET_B = "0b0b0b" + } +} \ No newline at end of file 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 71858fae3e..41fd877268 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 @@ -8,10 +8,11 @@ import kotlinx.coroutines.flow.Flow /** Persistence port for the address book. The implementation is provided by the data layer. */ interface AddressBookRepository { + /** Contacts for a single wallet. Each [Contact] keeps its own [Contact.walletId]. */ fun getContacts(userWalletId: UserWalletId): Flow> - /** Contacts across several wallets, flattened. Each [Contact] keeps its own [Contact.walletId]. */ - fun getContacts(userWalletIds: Set): Flow> + /** Contacts across all wallets (flattened). Each [Contact] keeps its own [Contact.walletId]. */ + fun getAllContacts(): Flow> suspend fun getContact(userWalletId: UserWalletId, name: String): Contact? diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCase.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCase.kt index 71f56c7d7c..af19f1f2dc 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCase.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCase.kt @@ -4,10 +4,29 @@ import com.tangem.domain.addressbook.model.Contact import com.tangem.domain.addressbook.repository.AddressBookRepository import com.tangem.domain.models.wallet.UserWalletId import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map class GetContactsUseCase( private val repository: AddressBookRepository, ) { - operator fun invoke(userWalletIds: Set): Flow> = repository.getContacts(userWalletIds) + operator fun invoke(query: String, userWalletId: UserWalletId? = null): Flow> { + val source = if (userWalletId == null) { + repository.getAllContacts() + } else { + repository.getContacts(userWalletId) + } + val normalizedQuery = query.trim() + if (normalizedQuery.isEmpty()) return source + return source + .map { contacts -> + contacts.filter { contact -> + val isNameContaining = contact.name.value.contains(other = normalizedQuery, ignoreCase = true) + val isAddressContaining = contact.addressEntries.any { addressEntry -> + addressEntry.address.contains(other = normalizedQuery, ignoreCase = true) + } + isNameContaining || isAddressContaining + } + } + } } \ No newline at end of file diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCaseTest.kt new file mode 100644 index 0000000000..bb0253a442 --- /dev/null +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCaseTest.kt @@ -0,0 +1,105 @@ +package com.tangem.domain.addressbook.usecase + +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.addressbook.model.AddressEntry +import com.tangem.domain.addressbook.model.AddressEntryId +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.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +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 GetContactsUseCaseTest { + + private val repository: AddressBookRepository = mockk() + private val useCase = GetContactsUseCase(repository) + + private val alice = contact(name = "Alice", address = "0xaaa") + private val bob = contact(name = "Bob", address = "0xbbb") + + @BeforeEach + fun resetMocks() { + clearMocks(repository) + every { repository.getAllContacts() } returns flowOf(listOf(alice, bob)) + } + + @Test + fun `GIVEN query matches a name WHEN invoke THEN returns only matching contacts`() = runTest { + // Act + val result = useCase(query = "ali").first() + + // Assert + assertThat(result).containsExactly(alice) + } + + @Test + fun `GIVEN query matches an address WHEN invoke THEN returns only matching contacts`() = runTest { + // Act + val result = useCase(query = "0xbbb").first() + + // Assert + assertThat(result).containsExactly(bob) + } + + @Test + fun `GIVEN blank query WHEN invoke THEN returns all contacts unfiltered`() = runTest { + // Act + val result = useCase(query = " ").first() + + // Assert + assertThat(result).containsExactly(alice, bob) + } + + @Test + fun `GIVEN query matches nothing WHEN invoke THEN returns empty list`() = runTest { + // Act + val result = useCase(query = "charlie").first() + + // Assert + assertThat(result).isEmpty() + } + + @Test + fun `GIVEN userWalletId WHEN invoke THEN reads single wallet contacts AND not all contacts`() = runTest { + // Arrange + val walletId = UserWalletId("011") + every { repository.getContacts(walletId) } returns flowOf(listOf(alice)) + + // Act + val result = useCase(query = "", userWalletId = walletId).first() + + // Assert + assertThat(result).containsExactly(alice) + verify(exactly = 1) { repository.getContacts(walletId) } + verify(exactly = 0) { repository.getAllContacts() } + } + + private fun contact(name: String, address: String): Contact = Contact( + id = ContactId("id-$name"), + walletId = UserWalletId("011"), + name = requireNotNull(ContactName(name).getOrNull()), + 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("ethereum"), + memo = null, + signature = "sig", + ), + ), + ) +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index b61afb33b1..b1fabd1412 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -439,6 +439,7 @@ include(":domain:search") // region Data modules include(":data:account") +include(":data:address-book") include(":data:app-currency") include(":data:app-theme") include(":data:balance-hiding") From 09db4a7d03b4e630e25652ed91c82fda86f56879 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 19 Jun 2026 11:18:58 +0200 Subject: [PATCH 002/210] Updated on 2026-08-14 --- .../tangem/tap/routing/utils/ChildFactory.kt | 6 +- .../com/tangem/common/routing/AppRoute.kt | 11 +- .../routing/entity/AddressBookOpenMode.kt | 17 ++ .../core/ui/components/account/AccountIcon.kt | 9 +- .../main/res/drawable/ic_address_book_24.xml | 25 ++ .../src/main/res/drawable/ic_contact_20.xml | 9 - features/address-book/api/build.gradle.kts | 3 + .../addressbook/AddressBookComponent.kt | 3 +- .../addaddress/AddAddressComponent.kt | 15 -- .../addaddress/DefaultAddAddressComponent.kt | 24 +- .../addaddress/model/AddAddressModel.kt | 124 +++------- .../state/AddAddressStateController.kt | 50 ++++ ...UpdateAddAddressInitialStateTransformer.kt | 29 +++ .../UpdateAddressInputTransformer.kt | 25 ++ .../UpdateAddressValidationTransformer.kt | 36 +++ .../addaddress/ui/AddAddressContent.kt | 38 +-- .../addressbook/addaddress/ui/NetworkBlock.kt | 89 ++++--- .../addressbook/addaddress/ui/RecipientRow.kt | 98 ++++---- .../{contract => ui/state}/AddAddressUM.kt | 16 +- .../{contract => ui/state}/AddressFieldUM.kt | 5 +- .../common/AddressBookChildFactory.kt | 56 +++++ .../common/AddressBookClickIntents.kt | 26 +++ .../common/AddressBookResultHolder.kt | 29 +++ .../common/DefaultAddressBookComponent.kt | 117 ++++++++++ .../DefaultAddressBookFeatureToggles.kt | 3 +- .../addressbook/component/AddressBookRoute.kt | 21 -- .../component/DefaultAddressBookComponent.kt | 108 --------- .../di/AddressBookComponentModule.kt | 22 +- .../addressbook/di/AddressBookModule.kt | 2 +- .../DefaultEditContactComponent.kt | 27 ++- .../editcontact/EditContactComponent.kt | 17 -- .../editcontact/contract/ValidatedAddress.kt | 14 -- .../editcontact/model/EditContactModel.kt | 105 +++++---- .../state/EditContactStateController.kt | 52 +++++ .../AddValidatedAddressTransformer.kt | 19 ++ .../SelectContactColorTransformer.kt | 17 ++ .../UpdateContactNameTransformer.kt | 13 ++ ...pdateEditContactInitialStateTransformer.kt | 35 +++ .../editcontact/ui/EditContactContent.kt | 221 ++++++++++-------- .../{contract => ui/state}/EditContactUM.kt | 4 +- .../editcontact/ui/state/ValidatedAddress.kt | 17 ++ .../list/AddressBookListComponent.kt | 14 -- .../list/DefaultAddressBookListComponent.kt | 31 ++- .../list/contract/AddressBookListUM.kt | 15 -- .../list/model/AddressBookListModel.kt | 39 ++-- .../state/AddressBookListStateController.kt | 25 ++ .../state/converter/ContactUMConverter.kt | 21 ++ ...eAddressBookListInitialStateTransformer.kt | 19 ++ .../list/ui/AddressBookEmptyScreen.kt | 61 +++-- .../list/ui/state/AddressBookListUM.kt | 12 + .../addressbook/list/ui/state/ContactUM.kt | 11 + .../addressbook/route/AddressBookRoute.kt | 27 +++ .../addaddress/model/AddAddressModelTest.kt | 90 +++---- .../editcontact/model/EditContactModelTest.kt | 98 +++++--- .../features/details/entity/DetailsItemUM.kt | 3 +- .../features/details/ui/DetailsScreen.kt | 118 ++++++++-- .../features/details/utils/ItemsBuilder.kt | 20 +- .../details/utils/ItemsBuilderTest.kt | 18 +- 58 files changed, 1353 insertions(+), 826 deletions(-) create mode 100644 common/routing/src/main/kotlin/com/tangem/common/routing/entity/AddressBookOpenMode.kt create mode 100644 core/ui/src/main/res/drawable/ic_address_book_24.xml delete mode 100644 core/ui/src/main/res/drawable/ic_contact_20.xml delete mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/AddAddressComponent.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/AddAddressStateController.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddAddressInitialStateTransformer.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddressInputTransformer.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddressValidationTransformer.kt rename features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/{contract => ui/state}/AddAddressUM.kt (62%) rename features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/{contract => ui/state}/AddressFieldUM.kt (54%) create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookChildFactory.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookClickIntents.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookResultHolder.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/DefaultAddressBookComponent.kt rename features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/{ => common}/DefaultAddressBookFeatureToggles.kt (78%) delete mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/component/AddressBookRoute.kt delete mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/component/DefaultAddressBookComponent.kt delete mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/EditContactComponent.kt delete mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/contract/ValidatedAddress.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/EditContactStateController.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/AddValidatedAddressTransformer.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/SelectContactColorTransformer.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/UpdateContactNameTransformer.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/UpdateEditContactInitialStateTransformer.kt rename features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/{contract => ui/state}/EditContactUM.kt (87%) create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/state/ValidatedAddress.kt delete mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/AddressBookListComponent.kt delete mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/contract/AddressBookListUM.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/AddressBookListStateController.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/converter/ContactUMConverter.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListInitialStateTransformer.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/AddressBookListUM.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/ContactUM.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/route/AddressBookRoute.kt diff --git a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index 1e1942f867..8f1f852eb9 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt @@ -21,7 +21,6 @@ import com.tangem.features.feed.entry.components.FeedEntryRoute import com.tangem.features.home.api.HomeComponent import com.tangem.features.hotwallet.* import com.tangem.features.kyc.KycComponent -import com.tangem.features.survey.SurveyComponent import com.tangem.features.managetokens.component.ChooseManagedTokensComponent import com.tangem.features.managetokens.component.ManageTokensComponent import com.tangem.features.managetokens.component.ManageTokensMode @@ -37,9 +36,10 @@ import com.tangem.features.send.api.NFTSendComponent import com.tangem.features.send.api.SendComponent import com.tangem.features.send.api.SendEntryPointComponent import com.tangem.features.staking.api.StakingComponent +import com.tangem.features.survey.SurveyComponent import com.tangem.features.swap.SwapComponent -import com.tangem.features.tangempay.components.TangemPayHotWalletOnboardingComponent import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent +import com.tangem.features.tangempay.components.TangemPayHotWalletOnboardingComponent import com.tangem.features.tangempay.components.TangemPayOnboardingComponent import com.tangem.features.tangempay.components.TangemPayOnboardingComponent.Params.* import com.tangem.features.tokendetails.TokenDetailsComponent @@ -758,7 +758,7 @@ internal class ChildFactory @Inject constructor( is AppRoute.AddressBook -> { createComponentChild( context = context, - params = AddressBookComponent.Params(route.predefinedAddress), + params = AddressBookComponent.Params(addressBookOpenMode = route.addressBookOpenMode), componentFactory = addressBookComponentFactory, ) } diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt index 12df52d117..1f04371c14 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt @@ -6,6 +6,7 @@ import android.annotation.SuppressLint import android.os.Bundle import com.tangem.common.routing.bundle.RouteBundleParams import com.tangem.common.routing.bundle.bundle +import com.tangem.common.routing.entity.AddressBookOpenMode import com.tangem.common.routing.entity.InitScreenLaunchMode import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.decompose.navigation.Route @@ -174,8 +175,14 @@ sealed class AppRoute(val path: String) : Route { @Serializable data class AddressBook( - val predefinedAddress: String? = null, - ) : AppRoute(path = "/address_book/predefinedAddress/$predefinedAddress") + val addressBookOpenMode: AddressBookOpenMode = AddressBookOpenMode.Default, + ) : AppRoute( + path = when (addressBookOpenMode) { + is AddressBookOpenMode.WithContactCreation -> + "/address_book/${addressBookOpenMode.address}-${addressBookOpenMode.networkId}" + AddressBookOpenMode.Default -> "/address_book" + }, + ) @Serializable data class QrScanning(val source: Source) : AppRoute(path = "/$source/qr_scanning${source.path}") { diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/entity/AddressBookOpenMode.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/entity/AddressBookOpenMode.kt new file mode 100644 index 0000000000..7c6b6206c4 --- /dev/null +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/entity/AddressBookOpenMode.kt @@ -0,0 +1,17 @@ +package com.tangem.common.routing.entity + +import kotlinx.serialization.Serializable + +/** How the address book is opened — part of the navigation contract, carried by [com.tangem.common.routing.AppRoute.AddressBook]. */ +@Serializable +sealed interface AddressBookOpenMode { + + @Serializable + data object Default : AddressBookOpenMode + + @Serializable + data class WithContactCreation( + val address: String, + val networkId: String, + ) : AddressBookOpenMode +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/account/AccountIcon.kt b/core/ui/src/main/java/com/tangem/core/ui/components/account/AccountIcon.kt index 35901d7a10..b9ffbb60e7 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/account/AccountIcon.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/account/AccountIcon.kt @@ -32,7 +32,7 @@ import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.res.TangemThemePreviewRedesign enum class AccountIconSize { - Default, Large, Medium, Small, ExtraSmall, RedesignedDefault, RedesignExtraSmall + Default, Large, Medium, Small, ExtraSmall, RedesignedDefault, RedesignExtraSmall, RedesignLarge } /** @@ -132,6 +132,7 @@ fun AccountCharIcon(char: Char, color: Color, size: AccountIconSize, modifier: M AccountIconSize.ExtraSmall -> TangemTheme.typography.caption1 AccountIconSize.RedesignedDefault -> TangemTheme.typography2.headingSemibold28 AccountIconSize.RedesignExtraSmall -> TangemTheme.typography2.captionMedium11 + AccountIconSize.RedesignLarge -> TangemTheme.typography3.heading.medium } val textSize by animateFloatAsState( @@ -166,6 +167,7 @@ private fun AccountIconSize.iconSizeInDp(): Dp = when (this) { AccountIconSize.ExtraSmall -> 8.dp AccountIconSize.RedesignedDefault -> 20.dp AccountIconSize.RedesignExtraSmall -> 8.dp + AccountIconSize.RedesignLarge -> 32.dp } fun AccountIconSize.toBoxSize(): Dp = when (this) { @@ -176,6 +178,7 @@ fun AccountIconSize.toBoxSize(): Dp = when (this) { AccountIconSize.ExtraSmall -> 14.dp AccountIconSize.RedesignedDefault -> 40.dp AccountIconSize.RedesignExtraSmall -> 16.dp + AccountIconSize.RedesignLarge -> 80.dp } private fun AccountIconSize.boxShapeSizeInDp(): Dp = when (this) { @@ -186,6 +189,7 @@ private fun AccountIconSize.boxShapeSizeInDp(): Dp = when (this) { AccountIconSize.ExtraSmall -> 4.dp AccountIconSize.RedesignedDefault -> 12.dp AccountIconSize.RedesignExtraSmall -> 6.dp + AccountIconSize.RedesignLarge -> 80.dp } @Preview(showBackground = true) @@ -228,7 +232,8 @@ private fun Sample() { AccountIconSize.Small -> AccountIconSize.ExtraSmall AccountIconSize.ExtraSmall -> AccountIconSize.RedesignedDefault AccountIconSize.RedesignedDefault -> AccountIconSize.RedesignExtraSmall - AccountIconSize.RedesignExtraSmall -> AccountIconSize.Default + AccountIconSize.RedesignExtraSmall -> AccountIconSize.RedesignLarge + AccountIconSize.RedesignLarge -> AccountIconSize.Default } }) { Text("Change") } diff --git a/core/ui/src/main/res/drawable/ic_address_book_24.xml b/core/ui/src/main/res/drawable/ic_address_book_24.xml new file mode 100644 index 0000000000..ce54ca1a63 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_address_book_24.xml @@ -0,0 +1,25 @@ + + + + diff --git a/core/ui/src/main/res/drawable/ic_contact_20.xml b/core/ui/src/main/res/drawable/ic_contact_20.xml deleted file mode 100644 index b6d762b21b..0000000000 --- a/core/ui/src/main/res/drawable/ic_contact_20.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/features/address-book/api/build.gradle.kts b/features/address-book/api/build.gradle.kts index 425593dae2..50ede12ea4 100644 --- a/features/address-book/api/build.gradle.kts +++ b/features/address-book/api/build.gradle.kts @@ -10,6 +10,9 @@ android { dependencies { + /* Project - Common */ + api(projects.common.routing) + /* Project - Domain */ implementation(projects.domain.models) diff --git a/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/AddressBookComponent.kt b/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/AddressBookComponent.kt index e9fe9ddb22..ff7f36e511 100644 --- a/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/AddressBookComponent.kt +++ b/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/AddressBookComponent.kt @@ -1,5 +1,6 @@ package com.tangem.features.addressbook +import com.tangem.common.routing.entity.AddressBookOpenMode import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableContentComponent @@ -7,5 +8,5 @@ interface AddressBookComponent : ComposableContentComponent { interface Factory : ComponentFactory - data class Params(val predefinedAddress: String?) + data class Params(val addressBookOpenMode: AddressBookOpenMode) } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/AddAddressComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/AddAddressComponent.kt deleted file mode 100644 index 445dfa6c4a..0000000000 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/AddAddressComponent.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.tangem.features.addressbook.addaddress - -import com.tangem.core.decompose.factory.ComponentFactory -import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress - -internal interface AddAddressComponent : ComposableContentComponent { - - interface Factory : ComponentFactory - - data class Params( - val onBackClick: () -> Unit, - val onConfirm: (ValidatedAddress) -> Unit, - ) -} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/DefaultAddAddressComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/DefaultAddAddressComponent.kt index 374e522918..62d3e586a8 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/DefaultAddAddressComponent.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/DefaultAddAddressComponent.kt @@ -7,16 +7,15 @@ import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.features.addressbook.addaddress.model.AddAddressModel import com.tangem.features.addressbook.addaddress.ui.AddAddressContent -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject +import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress -internal class DefaultAddAddressComponent @AssistedInject constructor( - @Assisted context: AppComponentContext, - @Assisted params: AddAddressComponent.Params, -) : AddAddressComponent, AppComponentContext by context { +internal class DefaultAddAddressComponent( + appComponentContext: AppComponentContext, + params: Params, +) : ComposableContentComponent, AppComponentContext by appComponentContext { private val model: AddAddressModel = getOrCreateModel(params) @@ -30,11 +29,8 @@ internal class DefaultAddAddressComponent @AssistedInject constructor( BackHandler(onBack = state.onBackClick) } - @AssistedFactory - interface Factory : AddAddressComponent.Factory { - override fun create( - context: AppComponentContext, - params: AddAddressComponent.Params, - ): DefaultAddAddressComponent - } + data class Params( + val onBackClick: () -> Unit, + val onConfirm: (ValidatedAddress) -> Unit, + ) } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModel.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModel.kt index c5ca0ebc21..925354b0d5 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModel.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModel.kt @@ -1,30 +1,21 @@ package com.tangem.features.addressbook.addaddress.model -import com.tangem.blockchainsdk.utils.toBlockchain -import com.tangem.common.ui.extensions.iconResId import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer -import com.tangem.core.ui.R import com.tangem.core.ui.clipboard.ClipboardManager -import com.tangem.core.ui.ds.button.TangemButtonType -import com.tangem.core.ui.ds.button.TangemButtonUM -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.account.supplier.MultiAccountListSupplier import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.network.Network -import com.tangem.features.addressbook.addaddress.AddAddressComponent -import com.tangem.features.addressbook.addaddress.contract.AddAddressUM -import com.tangem.features.addressbook.addaddress.contract.AddressFieldUM +import com.tangem.features.addressbook.addaddress.DefaultAddAddressComponent +import com.tangem.features.addressbook.addaddress.state.AddAddressStateController +import com.tangem.features.addressbook.addaddress.state.transformers.UpdateAddAddressInitialStateTransformer +import com.tangem.features.addressbook.addaddress.state.transformers.UpdateAddressInputTransformer +import com.tangem.features.addressbook.addaddress.state.transformers.UpdateAddressValidationTransformer +import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.* import javax.inject.Inject -import kotlin.collections.map @OptIn(FlowPreview::class) @ModelScoped @@ -33,12 +24,12 @@ internal class AddAddressModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, multiAccountListSupplier: MultiAccountListSupplier, private val clipboardManager: ClipboardManager, + private val stateController: AddAddressStateController, ) : Model() { - private val params: AddAddressComponent.Params = paramsContainer.require() + private val params: DefaultAddAddressComponent.Params = paramsContainer.require() - val state: StateFlow - field = MutableStateFlow(getInitialState()) + val state: StateFlow get() = stateController.uiState private val availableCoins: StateFlow> = multiAccountListSupplier() .map { accountLists -> @@ -47,6 +38,7 @@ internal class AddAddressModel @Inject constructor( .filterIsInstance() .distinctBy { it.network.id } } + .flowOn(dispatchers.default) .stateIn(modelScope, SharingStarted.Eagerly, emptyList()) private val addressInput = state @@ -55,94 +47,44 @@ internal class AddAddressModel @Inject constructor( .debounce(ADD_ADDRESS_DEBOUNCE) init { - subscribeToAddressInput() + updateInitialState() + subscribeToAddressValidation() } - private fun onAddressChange(value: String, isPasted: Boolean = false) { - state.update { oldState -> - oldState.copy( - addressField = oldState.addressField.copy( - value = value, - isValuePasted = isPasted, - isError = false, - error = null, - ), - chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Loading, - ) - } + private fun updateInitialState() { + stateController.update( + UpdateAddAddressInitialStateTransformer( + onAddressChange = { onAddressChange(value = it) }, + onAddressClear = { onAddressChange("") }, + onPasteClick = ::onPaste, + onQrClick = { /* [REDACTED_TODO_COMMENT] */ }, + onBackClick = params.onBackClick, + onConfirmClick = ::validateAndConfirm, + ), + ) } - private fun subscribeToAddressInput() { + private fun onAddressChange(value: String) { + stateController.update(UpdateAddressInputTransformer(value = value)) + } + + private fun subscribeToAddressValidation() { combine(addressInput, availableCoins) { input, coins -> - getUniqueNetworks(input, coins) + UpdateAddressValidationTransformer(address = input, coins = coins) } - .onEach { availableNetworks -> - state.update { oldState -> - oldState.copy( - availableNetworks = availableNetworks, - chosenNetworkStateUM = createChosenNetworkState(availableNetworks), - ) - } - } + .onEach(stateController::update) + .flowOn(dispatchers.default) .launchIn(modelScope) } - private fun createChosenNetworkState(availableNetworks: ImmutableList): AddAddressUM.ChosenNetworkStateUM { - return if (availableNetworks.isEmpty()) { - AddAddressUM.ChosenNetworkStateUM.Empty - } else { - AddAddressUM.ChosenNetworkStateUM.Result( - networkUMList = availableNetworks - .map { network -> - AddAddressUM.ChosenNetworkStateUM.Result.NetworkUM( - networkName = network.name, - iconResId = network.iconResId, - ) - } - .toImmutableList(), - ) - } - } - - private fun getUniqueNetworks(input: String, coins: List): ImmutableList { - return coins - .filter { it.network.toBlockchain().validateAddress(input) } - .map { it.network } - .toImmutableList() - } - private fun onPaste() { - onAddressChange(value = clipboardManager.getText().orEmpty(), isPasted = true) + onAddressChange(value = clipboardManager.getText().orEmpty()) } private fun validateAndConfirm() { - // TODO([REDACTED_TASK_KEY]): validate the address and invoke params.onConfirm + // TODO Address book ([REDACTED_TASK_KEY]): navigate to the network-selection with the address and its matching networks. } - private fun getInitialState(): AddAddressUM = AddAddressUM( - addressField = AddressFieldUM( - value = "", - placeholder = resourceReference(R.string.common_address), - label = resourceReference(R.string.address_book_enter_address), - isError = false, - error = null, - isValuePasted = false, - ), - availableNetworks = persistentListOf(), - buttonUM = TangemButtonUM( - text = TextReference.Res(R.string.address_book_add_address), - type = TangemButtonType.Primary, - isEnabled = false, - onClick = ::validateAndConfirm, - ), - chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Empty, - onAddressChange = { onAddressChange(value = it) }, - onAddressClear = { onAddressChange("") }, - onPasteClick = ::onPaste, - onQrClick = { /* [REDACTED_TODO_COMMENT] */ }, - onBackClick = params.onBackClick, - ) - companion object { private const val ADD_ADDRESS_DEBOUNCE = 500L } diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/AddAddressStateController.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/AddAddressStateController.kt new file mode 100644 index 0000000000..d5ecedc0de --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/AddAddressStateController.kt @@ -0,0 +1,50 @@ +package com.tangem.features.addressbook.addaddress.state + +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.ui.R +import com.tangem.core.ui.ds.button.TangemButtonType +import com.tangem.core.ui.ds.button.TangemButtonUM +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM +import com.tangem.features.addressbook.addaddress.ui.state.AddressFieldUM +import com.tangem.utils.transformer.Transformer +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject + +@ModelScoped +internal class AddAddressStateController @Inject constructor() { + + private val mutableUiState: MutableStateFlow = MutableStateFlow(value = getInitialState()) + + val uiState: StateFlow get() = mutableUiState.asStateFlow() + + fun update(transformer: Transformer) { + mutableUiState.update(function = transformer::transform) + } + + private fun getInitialState(): AddAddressUM = AddAddressUM( + addressField = AddressFieldUM( + value = "", + placeholder = resourceReference(R.string.address_book_enter_address), + label = resourceReference(R.string.common_address), + isError = false, + ), + buttonUM = TangemButtonUM( + text = TextReference.Res(R.string.address_book_add_address), + type = TangemButtonType.Primary, + isEnabled = false, + onClick = {}, + ), + chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Empty, + onAddressChange = {}, + onAddressClear = {}, + onPasteClick = {}, + onQrClick = {}, + onBackClick = {}, + onNetworkClick = {}, + ) +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddAddressInitialStateTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddAddressInitialStateTransformer.kt new file mode 100644 index 0000000000..15007b6655 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddAddressInitialStateTransformer.kt @@ -0,0 +1,29 @@ +package com.tangem.features.addressbook.addaddress.state.transformers + +import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM +import com.tangem.utils.transformer.Transformer + +/** + * Wires the callbacks owned by [com.tangem.features.addressbook.addaddress.model.AddAddressModel] into the initial + * state produced by [com.tangem.features.addressbook.addaddress.state.AddAddressStateController]. + */ +internal class UpdateAddAddressInitialStateTransformer( + private val onAddressChange: (String) -> Unit, + private val onAddressClear: () -> Unit, + private val onPasteClick: () -> Unit, + private val onQrClick: () -> Unit, + private val onBackClick: () -> Unit, + private val onConfirmClick: () -> Unit, +) : Transformer { + + override fun transform(prevState: AddAddressUM): AddAddressUM { + return prevState.copy( + onAddressChange = onAddressChange, + onAddressClear = onAddressClear, + onPasteClick = onPasteClick, + onQrClick = onQrClick, + onBackClick = onBackClick, + buttonUM = prevState.buttonUM.copy(onClick = onConfirmClick), + ) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddressInputTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddressInputTransformer.kt new file mode 100644 index 0000000000..f9b84f065e --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddressInputTransformer.kt @@ -0,0 +1,25 @@ +package com.tangem.features.addressbook.addaddress.state.transformers + +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM +import com.tangem.utils.transformer.Transformer + +/** + * Updates the address field with a freshly entered/pasted [value] and clears any previous error, restoring the default + * label. The actual (re)validation runs after a debounce — see [UpdateAddressValidationTransformer]. + */ +internal class UpdateAddressInputTransformer( + private val value: String, +) : Transformer { + + override fun transform(prevState: AddAddressUM): AddAddressUM { + return prevState.copy( + addressField = prevState.addressField.copy( + value = value, + isError = false, + label = resourceReference(R.string.common_address), + ), + ) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddressValidationTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddressValidationTransformer.kt new file mode 100644 index 0000000000..6d7c469965 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddressValidationTransformer.kt @@ -0,0 +1,36 @@ +package com.tangem.features.addressbook.addaddress.state.transformers + +import com.tangem.blockchainsdk.utils.toBlockchain +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM +import com.tangem.utils.transformer.Transformer + +/** + * Validates [address] against the wallet's [coins] and reflects the result in the UI. + * + * The network is not chosen on this screen (it is selected on the next screen), so the address is valid when it matches + * at least one of the available networks — the same blockchain check the Send flow uses. An invalid (non-empty, + * matching nothing) address surfaces the error in the field label and disables the confirm button. + */ +internal class UpdateAddressValidationTransformer( + private val address: String, + private val coins: List, +) : Transformer { + + override fun transform(prevState: AddAddressUM): AddAddressUM { + val hasMatchedAnyNetwork = address.isNotBlank() && + coins.any { it.network.toBlockchain().validateAddress(address) } + val isError = address.isNotBlank() && !hasMatchedAnyNetwork + val label = if (isError) { + resourceReference(R.string.address_book_invalid_address_error) + } else { + resourceReference(R.string.common_address) + } + return prevState.copy( + addressField = prevState.addressField.copy(isError = isError, label = label), + buttonUM = prevState.buttonUM.copy(isEnabled = hasMatchedAnyNetwork), + ) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/AddAddressContent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/AddAddressContent.kt index 9cf8321120..6a8d9f5cfe 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/AddAddressContent.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/AddAddressContent.kt @@ -3,14 +3,15 @@ package com.tangem.features.addressbook.addaddress.ui import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape 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.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.tangem.core.ui.R -import com.tangem.core.ui.components.SpacerH12 -import com.tangem.core.ui.ds.button.PrimaryTangemButton +import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.ds.button.TangemButtonType import com.tangem.core.ui.ds.button.TangemButtonUM import com.tangem.core.ui.ds.image.TangemIconUM @@ -20,9 +21,8 @@ import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign -import com.tangem.features.addressbook.addaddress.contract.AddAddressUM -import com.tangem.features.addressbook.addaddress.contract.AddressFieldUM -import kotlinx.collections.immutable.persistentListOf +import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM +import com.tangem.features.addressbook.addaddress.ui.state.AddressFieldUM @Composable internal fun AddAddressContent(state: AddAddressUM, modifier: Modifier = Modifier) { @@ -34,7 +34,6 @@ internal fun AddAddressContent(state: AddAddressUM, modifier: Modifier = Modifie horizontalAlignment = Alignment.CenterHorizontally, ) { TangemTopBar( - modifier = Modifier.statusBarsPadding(), title = resourceReference(R.string.address_book_add_address), startContent = { TangemButton( @@ -47,14 +46,23 @@ internal fun AddAddressContent(state: AddAddressUM, modifier: Modifier = Modifie ) RecipientRow( + modifier = Modifier.padding(horizontal = 16.dp), addressField = state.addressField, onValueChange = state.onAddressChange, onAddressClear = state.onAddressClear, onQrClick = state.onQrClick, onPasteClick = state.onPasteClick, ) - SpacerH12() - NetworkBlock(state.chosenNetworkStateUM) + SpacerH(20.dp) + NetworkBlock( + modifier = Modifier + .padding(horizontal = 16.dp) + .clip(RoundedCornerShape(16.dp)) + .fillMaxWidth() + .background(color = TangemTheme.colors3.bg.secondary), + chosenNetworkStateUM = state.chosenNetworkStateUM, + onNetworkSelectClick = state.onNetworkClick, + ) PrimaryButton(state.buttonUM) } } @@ -62,13 +70,15 @@ internal fun AddAddressContent(state: AddAddressUM, modifier: Modifier = Modifie @Composable private fun ColumnScope.PrimaryButton(buttonUM: TangemButtonUM) { Spacer(modifier = Modifier.weight(1f)) - - PrimaryTangemButton( + TangemButton( modifier = Modifier .fillMaxWidth() - .navigationBarsPadding() - .padding(start = 16.dp, end = 16.dp, bottom = 12.dp), - buttonUM = buttonUM, + .padding(horizontal = 16.dp, vertical = 12.dp), + onClick = buttonUM.onClick, + isEnabled = buttonUM.isEnabled, + isLoading = buttonUM.isLoading, + size = TangemButton.Size.X12, + text = buttonUM.text, ) } @@ -84,7 +94,6 @@ private fun Preview_AddAddressContent() { placeholder = resourceReference(R.string.address_book_enter_address), label = resourceReference(R.string.common_address), ), - availableNetworks = persistentListOf(), buttonUM = TangemButtonUM( text = TextReference.Res(R.string.address_book_add_address), type = TangemButtonType.Primary, @@ -97,6 +106,7 @@ private fun Preview_AddAddressContent() { onPasteClick = {}, onQrClick = {}, onBackClick = {}, + onNetworkClick = {}, ), ) } diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/NetworkBlock.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/NetworkBlock.kt index dc91b1d8b7..e2b3e1b6a5 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/NetworkBlock.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/NetworkBlock.kt @@ -1,86 +1,95 @@ package com.tangem.features.addressbook.addaddress.ui -import android.content.res.Configuration import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEachIndexed import com.tangem.core.ui.R import com.tangem.core.ui.components.SpacerH12 +import com.tangem.core.ui.components.SpacerW import com.tangem.core.ui.ds2.loader.TangemLoader +import com.tangem.core.ui.ds2.loader.TangemLoaderSize import com.tangem.core.ui.ds2.row.TangemRow import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment +import com.tangem.core.ui.extensions.clickableSingle import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign -import com.tangem.features.addressbook.addaddress.contract.AddAddressUM -import com.tangem.features.addressbook.addaddress.contract.AddAddressUM.ChosenNetworkStateUM.Result.NetworkUM +import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM +import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM.ChosenNetworkStateUM.Result.NetworkUM +import com.tangem.utils.StringsSigns import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList private const val MAX_VISIBLE_NETWORKS = 3 -private val NetworkIconSize = 24.dp // Horizontal advance per icon. Smaller than the icon size so icons overlap; the bg-colored ring on // the icon drawn on top carves the crescent cut-out from the icon below. private val NetworkIconStep = 18.dp @Composable -internal fun NetworkBlock(chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM) { +internal fun NetworkBlock( + onNetworkSelectClick: () -> Unit, + chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM, + modifier: Modifier = Modifier, +) { TangemRow( verticalAlignment = TangemRowVerticalAlignment.Center, - modifier = Modifier - .padding(horizontal = 16.dp) - .clip(RoundedCornerShape(16.dp)) - .fillMaxWidth() - .background(color = TangemTheme.colors3.bg.secondary) - .padding(horizontal = 4.dp), + modifier = modifier, titleSlot = { Text( text = stringResourceSafe(R.string.common_network), - style = TangemTheme.typography.body2, + style = TangemTheme.typography3.body.medium, color = TangemTheme.colors3.text.primary, ) }, endSlot = { - SelectNetworkButton(chosenNetworkStateUM) + SelectNetworkButton( + onNetworkSelectClick = onNetworkSelectClick, + chosenNetworkStateUM = chosenNetworkStateUM, + ) }, ) } @Composable -private fun SelectNetworkButton(chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM) { +private fun SelectNetworkButton( + onNetworkSelectClick: () -> Unit, + chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM, +) { Row( + modifier = Modifier.clickableSingle( + onClick = onNetworkSelectClick, + enabled = chosenNetworkStateUM !is AddAddressUM.ChosenNetworkStateUM.Loading, + ), verticalAlignment = Alignment.CenterVertically, ) { when (chosenNetworkStateUM) { is AddAddressUM.ChosenNetworkStateUM.Result -> NetworkIconsResolver(chosenNetworkStateUM.networkUMList) - AddAddressUM.ChosenNetworkStateUM.Loading -> TangemLoader() + AddAddressUM.ChosenNetworkStateUM.Loading -> TangemLoader(size = TangemLoaderSize.X20) AddAddressUM.ChosenNetworkStateUM.Empty -> { Text( modifier = Modifier.padding(start = 8.dp), text = stringResourceSafe(R.string.address_book_select_network), - style = TangemTheme.typography.body2, + style = TangemTheme.typography3.body.medium, color = TangemTheme.colors3.text.secondary, ) + SpacerW(4.dp) ChevronIcon() } } @@ -100,7 +109,7 @@ private fun NetworkIconsResolver(networks: ImmutableList) { Text( modifier = Modifier.padding(start = 8.dp), text = network.networkName, - style = TangemTheme.typography.body2, + style = TangemTheme.typography3.body.medium, color = TangemTheme.colors3.text.secondary, ) ChevronIcon() @@ -120,7 +129,7 @@ private fun OverlappingNetworkIcons(networks: ImmutableList) { val remaining = networks.size - visible.size Box(modifier = Modifier.wrapContentWidth()) { - visible.forEachIndexed { index, network -> + visible.fastForEachIndexed { index, network -> Image( painter = painterResource(id = network.iconResId), contentDescription = null, @@ -128,7 +137,7 @@ private fun OverlappingNetworkIcons(networks: ImmutableList) { modifier = Modifier .padding(start = NetworkIconStep * index) .networkIconRing() - .size(NetworkIconSize), + .size(24.dp), ) } if (remaining > 0) { @@ -137,12 +146,13 @@ private fun OverlappingNetworkIcons(networks: ImmutableList) { .padding(start = NetworkIconStep * visible.size) .networkIconRing() .background(color = TangemTheme.colors3.bg.tertiary) - .size(NetworkIconSize), + .heightIn(min = 24.dp) + .padding(vertical = 2.dp, horizontal = 4.dp), contentAlignment = Alignment.Center, ) { Text( - text = "+$remaining", - style = TangemTheme.typography.caption1, + text = "${StringsSigns.PLUS}$remaining", + style = TangemTheme.typography3.caption.medium, color = TangemTheme.colors3.text.secondary, ) } @@ -160,20 +170,23 @@ private fun Modifier.networkIconRing(): Modifier = this @Composable private fun ChevronIcon() { - Image( - modifier = Modifier.padding(start = 8.dp), - painter = painterResource(id = R.drawable.ic_select_18_24), + Icon( + modifier = Modifier + .padding(start = 8.dp) + .size(20.dp), + tint = TangemTheme.colors3.icon.secondary, + imageVector = ImageVector.vectorResource(id = R.drawable.ic_select_18_24), contentDescription = null, ) } -@Preview -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(showBackground = true) @Composable private fun Preview_NetworkBlock() { TangemThemePreviewRedesign { Column { NetworkBlock( + onNetworkSelectClick = {}, chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Result( networkUMList = persistentListOf( NetworkUM(networkName = "Ethereum", iconResId = R.drawable.img_eth_22), @@ -182,6 +195,7 @@ private fun Preview_NetworkBlock() { ) SpacerH12() NetworkBlock( + onNetworkSelectClick = {}, chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Result( networkUMList = persistentListOf( NetworkUM(networkName = "Ethereum", iconResId = R.drawable.img_eth_22), @@ -192,6 +206,7 @@ private fun Preview_NetworkBlock() { ) SpacerH12() NetworkBlock( + onNetworkSelectClick = {}, chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Result( networkUMList = List(15) { NetworkUM(networkName = "Network", iconResId = R.drawable.img_eth_22) @@ -199,9 +214,9 @@ private fun Preview_NetworkBlock() { ), ) SpacerH12() - NetworkBlock(chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Loading) + NetworkBlock(chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Loading, onNetworkSelectClick = {}) SpacerH12() - NetworkBlock(chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Empty) + NetworkBlock(chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Empty, onNetworkSelectClick = {}) } } } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/RecipientRow.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/RecipientRow.kt index 8ebe82cc0f..aadf4f91ca 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/RecipientRow.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/RecipientRow.kt @@ -3,11 +3,7 @@ package com.tangem.features.addressbook.addaddress.ui import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon @@ -19,7 +15,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.tangem.core.ui.R import com.tangem.core.ui.components.SpacerH12 -import com.tangem.core.ui.components.SpacerW8 import com.tangem.core.ui.components.fields.SimpleTextField import com.tangem.core.ui.ds.image.TangemIcon import com.tangem.core.ui.ds.image.TangemIconUM @@ -28,13 +23,14 @@ import com.tangem.core.ui.ds2.row.TangemRow import com.tangem.core.ui.ds2.row.TangemRowContentLead import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.res.generated.icons.Icons import com.tangem.core.ui.res.generated.icons.ic_cross_circle_20_filled -import com.tangem.features.addressbook.addaddress.contract.AddressFieldUM +import com.tangem.core.ui.res.generated.icons.ic_scan_20 +import com.tangem.features.addressbook.addaddress.ui.state.AddressFieldUM @Composable internal fun RecipientRow( @@ -43,19 +39,23 @@ internal fun RecipientRow( onAddressClear: () -> Unit, onQrClick: () -> Unit, onPasteClick: () -> Unit, + modifier: Modifier = Modifier, ) { Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .clip(RoundedCornerShape(16.dp)) + modifier = modifier + .clip(RoundedCornerShape(24.dp)) .fillMaxWidth() .background(TangemTheme.colors3.bg.secondary), ) { Text( - modifier = Modifier.padding(start = 16.dp, top = 16.dp), - text = stringResourceSafe(R.string.common_address), - style = TangemTheme.typography.caption1, - color = TangemTheme.colors3.text.secondary, + modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 4.dp), + text = addressField.label.resolveReference(), + style = TangemTheme.typography3.caption.medium, + color = if (addressField.isError) { + TangemTheme.colors3.text.status.error + } else { + TangemTheme.colors3.text.secondary + }, ) TangemRow( modifier = Modifier.fillMaxWidth(), @@ -64,7 +64,7 @@ internal fun RecipientRow( startSlot = { TangemIcon( modifier = Modifier - .size(36.dp) + .size(40.dp) .clip(CircleShape) .background(TangemTheme.colors3.bg.tertiary), tangemIconUM = TangemIconUM.Ident(text = addressField.value), @@ -72,43 +72,55 @@ internal fun RecipientRow( }, titleSlot = { SimpleTextField( - modifier = Modifier - .weight(1f) - .padding(start = 12.dp), + modifier = Modifier.weight(1f), value = addressField.value, onValueChange = onValueChange, - placeholder = TextReference.Res(R.string.address_book_enter_address), - singleLine = false, + placeholder = addressField.placeholder, ) }, endSlot = { - if (addressField.value.isNotEmpty()) { - Icon( - modifier = Modifier.clickable(onClick = onAddressClear), - imageVector = Icons.ic_cross_circle_20_filled, - tint = TangemTheme.colors3.icon.tertiary, - contentDescription = null, - ) - } else { - Row { - TangemButton( - variant = TangemButton.Variant.Secondary, - iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_qrcode_scaner_24), - onClick = onQrClick, - ) - SpacerW8() - TangemButton( - variant = TangemButton.Variant.Primary, - text = TextReference.Res(id = R.string.common_paste), - onClick = onPasteClick, - ) - } - } + RecipientEndSlot( + hasValue = addressField.value.isNotEmpty(), + onAddressClear = onAddressClear, + onQrClick = onQrClick, + onPasteClick = onPasteClick, + ) }, ) } } +@Composable +private fun RecipientEndSlot( + hasValue: Boolean, + onAddressClear: () -> Unit, + onQrClick: () -> Unit, + onPasteClick: () -> Unit, +) { + if (hasValue) { + Icon( + modifier = Modifier + .clip(CircleShape) + .clickable(onClick = onAddressClear), + imageVector = Icons.ic_cross_circle_20_filled, + tint = TangemTheme.colors3.icon.tertiary, + contentDescription = null, + ) + } else { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TangemButton( + variant = TangemButton.Variant.Secondary, + iconStart = TangemIconUM.Icon(imageVector = Icons.ic_scan_20), + onClick = onQrClick, + ) + TangemButton( + text = TextReference.Res(id = R.string.common_paste), + onClick = onPasteClick, + ) + } + } +} + @Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/contract/AddAddressUM.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/state/AddAddressUM.kt similarity index 62% rename from features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/contract/AddAddressUM.kt rename to features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/state/AddAddressUM.kt index d54c5e1716..45704c2355 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/contract/AddAddressUM.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/state/AddAddressUM.kt @@ -1,14 +1,13 @@ -package com.tangem.features.addressbook.addaddress.contract +package com.tangem.features.addressbook.addaddress.ui.state import androidx.annotation.DrawableRes import androidx.compose.runtime.Immutable import com.tangem.core.ui.ds.button.TangemButtonUM -import com.tangem.domain.models.network.Network import kotlinx.collections.immutable.ImmutableList +@Immutable internal data class AddAddressUM( val addressField: AddressFieldUM, - val availableNetworks: ImmutableList, val buttonUM: TangemButtonUM, val chosenNetworkStateUM: ChosenNetworkStateUM, val onAddressChange: (String) -> Unit, @@ -16,15 +15,14 @@ internal data class AddAddressUM( val onPasteClick: () -> Unit, val onQrClick: () -> Unit, val onBackClick: () -> Unit, + val onNetworkClick: () -> Unit, ) { @Immutable - sealed class ChosenNetworkStateUM { - data object Loading : ChosenNetworkStateUM() - data object Empty : ChosenNetworkStateUM() - data class Result( - val networkUMList: ImmutableList, - ) : ChosenNetworkStateUM() { + sealed interface ChosenNetworkStateUM { + data object Loading : ChosenNetworkStateUM + data object Empty : ChosenNetworkStateUM + data class Result(val networkUMList: ImmutableList) : ChosenNetworkStateUM { data class NetworkUM( val networkName: String, @DrawableRes val iconResId: Int, diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/contract/AddressFieldUM.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/state/AddressFieldUM.kt similarity index 54% rename from features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/contract/AddressFieldUM.kt rename to features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/state/AddressFieldUM.kt index ea8e324bd7..2e65e1b381 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/contract/AddressFieldUM.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/state/AddressFieldUM.kt @@ -1,4 +1,4 @@ -package com.tangem.features.addressbook.addaddress.contract +package com.tangem.features.addressbook.addaddress.ui.state import com.tangem.core.ui.extensions.TextReference @@ -7,7 +7,4 @@ internal data class AddressFieldUM( val placeholder: TextReference, val label: TextReference, val isError: Boolean = false, - val error: TextReference? = null, - val isValuePasted: Boolean = false, - val blockchainAddress: String? = null, ) \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookChildFactory.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookChildFactory.kt new file mode 100644 index 0000000000..3283d11e76 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookChildFactory.kt @@ -0,0 +1,56 @@ +package com.tangem.features.addressbook.common + +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.addressbook.model.ContactId +import com.tangem.features.addressbook.addaddress.DefaultAddAddressComponent +import com.tangem.features.addressbook.editcontact.DefaultEditContactComponent +import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress +import com.tangem.features.addressbook.list.DefaultAddressBookListComponent +import com.tangem.features.addressbook.route.AddressBookRoute +import kotlinx.collections.immutable.persistentListOf +import javax.inject.Inject + +/** + * Builds the child screens of the address book feature for a given [AddressBookRoute], wiring their callbacks to the + * container's [AddressBookClickIntents]. Mirrors the `FeedEntryChildFactory` pattern used by the feed feature. + */ +internal class AddressBookChildFactory @Inject constructor() { + + fun createChild( + route: AddressBookRoute, + context: AppComponentContext, + clickIntents: AddressBookClickIntents, + ): ComposableContentComponent = when (route) { + AddressBookRoute.List -> DefaultAddressBookListComponent( + appComponentContext = context, + params = DefaultAddressBookListComponent.Params( + onContactClick = { clickIntents.onContactClick(ContactId(it)) }, + onAddContactClick = clickIntents::onAddContactClick, + ), + ) + is AddressBookRoute.EditContact -> DefaultEditContactComponent( + appComponentContext = context, + params = DefaultEditContactComponent.Params( + contactId = route.contactId?.let(::ContactId), + predefinedAddress = buildPredefinedAddress(route), + onBackClick = clickIntents::onEditContactBack, + onAddAddressClick = clickIntents::onAddAddressClick, + ), + ) + AddressBookRoute.AddAddress -> DefaultAddAddressComponent( + appComponentContext = context, + params = DefaultAddAddressComponent.Params( + onBackClick = clickIntents::onAddAddressBack, + onConfirm = clickIntents::onAddressConfirmed, + ), + ) + } + + /** Builds the address attached up-front in WithContactCreation mode, when both the address and network are known. */ + private fun buildPredefinedAddress(route: AddressBookRoute.EditContact): ValidatedAddress? { + val address = route.predefinedAddress ?: return null + val networkId = route.predefinedNetworkId ?: return null + return ValidatedAddress(address = address, networkIds = persistentListOf(networkId)) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookClickIntents.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookClickIntents.kt new file mode 100644 index 0000000000..5b13a5204f --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookClickIntents.kt @@ -0,0 +1,26 @@ +package com.tangem.features.addressbook.common + +import com.tangem.domain.addressbook.model.ContactId +import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress + +/** + * Navigation/click contract that the container ([DefaultAddressBookComponent]) implements and passes down to its + * children through [AddressBookChildFactory]. Keeping all cross-screen intents in one place removes the need for the + * children to know about each other or about navigation. + * + * Result delivery (the confirmed address) is handled out-of-band by [AddressBookResultHolder], not by this contract. + */ +internal interface AddressBookClickIntents { + + fun onContactClick(contactId: ContactId) + + fun onAddContactClick() + + fun onEditContactBack() + + fun onAddAddressClick() + + fun onAddAddressBack() + + fun onAddressConfirmed(address: ValidatedAddress) +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookResultHolder.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookResultHolder.kt new file mode 100644 index 0000000000..1ba1423a22 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookResultHolder.kt @@ -0,0 +1,29 @@ +package com.tangem.features.addressbook.common + +import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Carries a [ValidatedAddress] confirmed on the AddAddress screen over to the EditContact screen. + * + * The two screens live in independent model scopes, so a shared singleton holder is used to hand the result over + * instead of routing it through navigation/click intents. The producer calls [setConfirmedAddress]; the consumer + * observes [confirmedAddress] and calls [clear] after applying the value so it is not re-applied on resubscription. + */ +@Singleton +internal class AddressBookResultHolder @Inject constructor() { + + val confirmedAddress: StateFlow + field = MutableStateFlow(null) + + fun setConfirmedAddress(address: ValidatedAddress) { + confirmedAddress.value = address + } + + fun clear() { + confirmedAddress.value = null + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/DefaultAddressBookComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/DefaultAddressBookComponent.kt new file mode 100644 index 0000000000..462aec3631 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/DefaultAddressBookComponent.kt @@ -0,0 +1,117 @@ +package com.tangem.features.addressbook.common + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.extensions.compose.stack.Children +import com.arkivanov.decompose.extensions.compose.stack.animation.slide +import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.stack.StackNavigation +import com.arkivanov.decompose.router.stack.childStack +import com.arkivanov.decompose.router.stack.pop +import com.arkivanov.decompose.router.stack.pushNew +import com.tangem.common.routing.entity.AddressBookOpenMode +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.childByContext +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.addressbook.model.ContactId +import com.tangem.features.addressbook.AddressBookComponent +import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress +import com.tangem.features.addressbook.route.AddressBookRoute +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultAddressBookComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Assisted private val params: AddressBookComponent.Params, + private val childFactory: AddressBookChildFactory, + private val resultHolder: AddressBookResultHolder, +) : AddressBookComponent, AppComponentContext by context { + + private val navigation = StackNavigation() + + init { + // Drop any address left over from a previous session before the (possibly preloaded) stack starts collecting. + resultHolder.clear() + } + + private val clickIntents = object : AddressBookClickIntents { + + override fun onContactClick(contactId: ContactId) { + navigation.pushNew(AddressBookRoute.EditContact(contactId = contactId.value)) + } + + override fun onAddContactClick() { + navigation.pushNew(AddressBookRoute.EditContact()) + } + + override fun onEditContactBack() { + navigation.pop() + } + + override fun onAddAddressClick() { + navigation.pushNew(AddressBookRoute.AddAddress) + } + + override fun onAddAddressBack() { + navigation.pop() + } + + override fun onAddressConfirmed(address: ValidatedAddress) { + resultHolder.setConfirmedAddress(address) + navigation.pop() + } + } + + private val contentStack = childStack( + key = "address_book_stack", + source = navigation, + serializer = AddressBookRoute.serializer(), + initialStack = ::initialStack, + handleBackButton = false, + childFactory = ::screenChild, + ) + + @Composable + override fun Content(modifier: Modifier) { + val childStack by contentStack.subscribeAsState() + Children( + modifier = modifier, + stack = childStack, + animation = stackAnimation(slide()), + ) { child -> + child.instance.Content(Modifier) + } + } + + private fun screenChild(config: AddressBookRoute, componentContext: ComponentContext): ComposableContentComponent { + return childFactory.createChild( + route = config, + context = childByContext(componentContext), + clickIntents = clickIntents, + ) + } + + private fun initialStack(): List = when (val mode = params.addressBookOpenMode) { + AddressBookOpenMode.Default -> listOf(AddressBookRoute.List) + is AddressBookOpenMode.WithContactCreation -> listOf( + AddressBookRoute.List, + // Address + network are already known, so open the new contact with that address attached — no AddAddress. + AddressBookRoute.EditContact( + predefinedAddress = mode.address, + predefinedNetworkId = mode.networkId, + ), + ) + } + + @AssistedFactory + interface Factory : AddressBookComponent.Factory { + override fun create( + context: AppComponentContext, + params: AddressBookComponent.Params, + ): DefaultAddressBookComponent + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/DefaultAddressBookFeatureToggles.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/DefaultAddressBookFeatureToggles.kt similarity index 78% rename from features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/DefaultAddressBookFeatureToggles.kt rename to features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/DefaultAddressBookFeatureToggles.kt index 08247d086c..c3ddb2f1b9 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/DefaultAddressBookFeatureToggles.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/DefaultAddressBookFeatureToggles.kt @@ -1,7 +1,8 @@ -package com.tangem.features.addressbook +package com.tangem.features.addressbook.common import com.tangem.core.configtoggle.FeatureToggles import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.features.addressbook.AddressBookFeatureToggles internal class DefaultAddressBookFeatureToggles( private val featureTogglesManager: FeatureTogglesManager, diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/component/AddressBookRoute.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/component/AddressBookRoute.kt deleted file mode 100644 index 815ebd2498..0000000000 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/component/AddressBookRoute.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.tangem.features.addressbook.component - -import kotlinx.serialization.Serializable - -@Serializable -internal sealed class AddressBookRoute { - - @Serializable - data object List : AddressBookRoute() - - /** - * if [contactId] is not null we should fetch existing contact - */ - @Serializable - data class EditContact( - val contactId: String? = null, - ) : AddressBookRoute() - - @Serializable - data object AddAddress : AddressBookRoute() -} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/component/DefaultAddressBookComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/component/DefaultAddressBookComponent.kt deleted file mode 100644 index e843ffd769..0000000000 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/component/DefaultAddressBookComponent.kt +++ /dev/null @@ -1,108 +0,0 @@ -package com.tangem.features.addressbook.component - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import com.arkivanov.decompose.ComponentContext -import com.arkivanov.decompose.extensions.compose.stack.Children -import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation -import com.arkivanov.decompose.extensions.compose.subscribeAsState -import com.arkivanov.decompose.router.stack.StackNavigation -import com.arkivanov.decompose.router.stack.childStack -import com.arkivanov.decompose.router.stack.pop -import com.arkivanov.decompose.router.stack.pushNew -import com.tangem.core.decompose.context.AppComponentContext -import com.tangem.core.decompose.context.childByContext -import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.domain.addressbook.model.ContactId -import com.tangem.features.addressbook.AddressBookComponent -import com.tangem.features.addressbook.addaddress.AddAddressComponent -import com.tangem.features.addressbook.editcontact.EditContactComponent -import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress -import com.tangem.features.addressbook.list.AddressBookListComponent -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject - -internal class DefaultAddressBookComponent @AssistedInject constructor( - @Assisted context: AppComponentContext, - @Assisted private val params: AddressBookComponent.Params, - private val addressBookListComponentFactory: AddressBookListComponent.Factory, - private val editContactComponentFactory: EditContactComponent.Factory, - private val addAddressComponentFactory: AddAddressComponent.Factory, -) : AddressBookComponent, AppComponentContext by context { - - private val navigation = StackNavigation() - - /** - * Consumer for the address entered on the [AddressBookRoute.AddAddress] screen, registered by the EditContact - * screen when it requests adding an address and invoked when AddAddress confirms. Transient by design — the - * entered addresses live only in EditContact's in-memory state until the contact is saved. - */ - private var pendingAddressSink: ((ValidatedAddress) -> Unit)? = null - - private val contentStack = childStack( - key = "address_book_stack", - source = navigation, - serializer = AddressBookRoute.serializer(), - initialConfiguration = AddressBookRoute.List, - handleBackButton = false, - childFactory = ::screenChild, - ) - - @Suppress("ReusedModifierInstance") - @Composable - override fun Content(modifier: Modifier) { - val childStack by contentStack.subscribeAsState() - - Children(stack = childStack, animation = stackAnimation()) { child -> - child.instance.Content(modifier = modifier) - } - } - - private fun screenChild(config: AddressBookRoute, componentContext: ComponentContext): ComposableContentComponent = - when (config) { - AddressBookRoute.List -> addressBookListComponentFactory.create( - context = childByContext(componentContext), - params = AddressBookListComponent.Params( - onContactClick = { contactId -> - navigation.pushNew(AddressBookRoute.EditContact(contactId)) - }, - onAddContactClick = { navigation.pushNew(AddressBookRoute.EditContact()) }, - ), - ) - is AddressBookRoute.EditContact -> editContactComponentFactory.create( - context = childByContext(componentContext), - params = EditContactComponent.Params( - contactId = config.contactId?.let(::ContactId), - onBackClick = { navigation.pop() }, - onAddAddressClick = { onResult -> - pendingAddressSink = onResult - navigation.pushNew(AddressBookRoute.AddAddress) - }, - ), - ) - AddressBookRoute.AddAddress -> addAddressComponentFactory.create( - context = childByContext(componentContext), - params = AddAddressComponent.Params( - onBackClick = { - pendingAddressSink = null - navigation.pop() - }, - onConfirm = { address -> - pendingAddressSink?.invoke(address) - pendingAddressSink = null - navigation.pop() - }, - ), - ) - } - - @AssistedFactory - interface Factory : AddressBookComponent.Factory { - override fun create( - context: AppComponentContext, - params: AddressBookComponent.Params, - ): DefaultAddressBookComponent - } -} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookComponentModule.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookComponentModule.kt index 493422e7c2..868ed604e7 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookComponentModule.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookComponentModule.kt @@ -1,13 +1,7 @@ package com.tangem.features.addressbook.di import com.tangem.features.addressbook.AddressBookComponent -import com.tangem.features.addressbook.addaddress.AddAddressComponent -import com.tangem.features.addressbook.addaddress.DefaultAddAddressComponent -import com.tangem.features.addressbook.component.DefaultAddressBookComponent -import com.tangem.features.addressbook.list.AddressBookListComponent -import com.tangem.features.addressbook.list.DefaultAddressBookListComponent -import com.tangem.features.addressbook.editcontact.DefaultEditContactComponent -import com.tangem.features.addressbook.editcontact.EditContactComponent +import com.tangem.features.addressbook.common.DefaultAddressBookComponent import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -21,18 +15,4 @@ internal interface AddressBookComponentModule { @Binds @Singleton fun bindAddressBookComponentFactory(factory: DefaultAddressBookComponent.Factory): AddressBookComponent.Factory - - @Binds - @Singleton - fun bindAddressBookListComponentFactory( - factory: DefaultAddressBookListComponent.Factory, - ): AddressBookListComponent.Factory - - @Binds - @Singleton - fun bindEditContactComponentFactory(factory: DefaultEditContactComponent.Factory): EditContactComponent.Factory - - @Binds - @Singleton - fun bindAddAddressComponentFactory(factory: DefaultAddAddressComponent.Factory): AddAddressComponent.Factory } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookModule.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookModule.kt index 4594968b09..3ca882b9ec 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookModule.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookModule.kt @@ -2,7 +2,7 @@ package com.tangem.features.addressbook.di import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.features.addressbook.AddressBookFeatureToggles -import com.tangem.features.addressbook.DefaultAddressBookFeatureToggles +import com.tangem.features.addressbook.common.DefaultAddressBookFeatureToggles import dagger.Module import dagger.Provides import dagger.hilt.InstallIn diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/DefaultEditContactComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/DefaultEditContactComponent.kt index 8f83105d52..7fa44a861e 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/DefaultEditContactComponent.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/DefaultEditContactComponent.kt @@ -7,16 +7,16 @@ import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.addressbook.model.ContactId import com.tangem.features.addressbook.editcontact.model.EditContactModel import com.tangem.features.addressbook.editcontact.ui.EditContactContent -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject +import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress -internal class DefaultEditContactComponent @AssistedInject constructor( - @Assisted context: AppComponentContext, - @Assisted params: EditContactComponent.Params, -) : EditContactComponent, AppComponentContext by context { +internal class DefaultEditContactComponent( + appComponentContext: AppComponentContext, + params: Params, +) : ComposableContentComponent, AppComponentContext by appComponentContext { private val model: EditContactModel = getOrCreateModel(params) @@ -30,11 +30,10 @@ internal class DefaultEditContactComponent @AssistedInject constructor( BackHandler(onBack = state.onCloseClick) } - @AssistedFactory - interface Factory : EditContactComponent.Factory { - override fun create( - context: AppComponentContext, - params: EditContactComponent.Params, - ): DefaultEditContactComponent - } + data class Params( + val contactId: ContactId?, + val predefinedAddress: ValidatedAddress? = null, + val onBackClick: () -> Unit, + val onAddAddressClick: () -> Unit, + ) } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/EditContactComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/EditContactComponent.kt deleted file mode 100644 index 2e93f5ff24..0000000000 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/EditContactComponent.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.tangem.features.addressbook.editcontact - -import com.tangem.core.decompose.factory.ComponentFactory -import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.domain.addressbook.model.ContactId -import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress - -internal interface EditContactComponent : ComposableContentComponent { - - interface Factory : ComponentFactory - - data class Params( - val contactId: ContactId?, - val onBackClick: () -> Unit, - val onAddAddressClick: (onResult: (ValidatedAddress) -> Unit) -> Unit, - ) -} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/contract/ValidatedAddress.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/contract/ValidatedAddress.kt deleted file mode 100644 index 87a094ee60..0000000000 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/contract/ValidatedAddress.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.tangem.features.addressbook.editcontact.contract - -import com.tangem.domain.models.network.Network - -/** - * A recipient address that has been validated and resolved to a [Network] on the AddAddress screen. - * - * This is the in-progress (pre-save) representation accumulated in [EditContactUM]. It is converted to a domain - * `AddressEntry` only when the contact is persisted, since the entry's id and signature are produced at save time. - */ -data class ValidatedAddress( - val address: String, - val network: Network, -) \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModel.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModel.kt index d4bcdf27ab..90f414fabf 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModel.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModel.kt @@ -1,80 +1,79 @@ package com.tangem.features.addressbook.editcontact.model -import com.tangem.common.ui.account.AccountIconUM import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer -import com.tangem.core.ui.R -import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.models.account.CryptoPortfolioIcon -import com.tangem.features.addressbook.editcontact.EditContactComponent -import com.tangem.features.addressbook.editcontact.contract.EditContactUM -import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress +import com.tangem.features.addressbook.common.AddressBookResultHolder +import com.tangem.features.addressbook.editcontact.DefaultEditContactComponent +import com.tangem.features.addressbook.editcontact.state.EditContactStateController +import com.tangem.features.addressbook.editcontact.state.transformers.AddValidatedAddressTransformer +import com.tangem.features.addressbook.editcontact.state.transformers.SelectContactColorTransformer +import com.tangem.features.addressbook.editcontact.state.transformers.UpdateContactNameTransformer +import com.tangem.features.addressbook.editcontact.state.transformers.UpdateEditContactInitialStateTransformer +import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM +import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import javax.inject.Inject @ModelScoped internal class EditContactModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, + private val stateController: EditContactStateController, + private val resultHolder: AddressBookResultHolder, ) : Model() { - private val params: EditContactComponent.Params = paramsContainer.require() + private val params: DefaultEditContactComponent.Params = paramsContainer.require() - val state: StateFlow - field = MutableStateFlow(getInitialState()) + val state: StateFlow get() = stateController.uiState + + init { + updateInitialState() + prefillPredefinedAddress() + subscribeToConfirmedAddresses() + } + + /** In WithContactCreation mode the contact opens with the already-known address attached. */ + private fun prefillPredefinedAddress() { + params.predefinedAddress?.let(::addAddress) + } + + private fun updateInitialState() { + stateController.update( + UpdateEditContactInitialStateTransformer( + isExistingContact = params.contactId != null, + onNameChange = ::onNameChange, + onColorSelect = ::onColorSelect, + onCloseClick = params.onBackClick, + onAddAddressClick = params.onAddAddressClick, + ), + ) + } + + private fun subscribeToConfirmedAddresses() { + resultHolder.confirmedAddress + .filterNotNull() + .onEach { address -> + addAddress(address) + resultHolder.clear() + } + .launchIn(modelScope) + } private fun onNameChange(name: String) { - state.update { it.copy(name = name) } + stateController.update(UpdateContactNameTransformer(name = name)) } private fun onColorSelect(color: CryptoPortfolioIcon.Color) { - state.update { oldState -> - oldState.copy( - colors = oldState.colors.copy(selected = color), - portfolioIcon = oldState.portfolioIcon.copy(color = color), - ) - } - } - - private fun requestAddAddress() { - params.onAddAddressClick(::addAddress) + stateController.update(SelectContactColorTransformer(color = color)) } private fun addAddress(address: ValidatedAddress) { - state.update { it.copy(addresses = (it.addresses + address).toImmutableList()) } - } - - private fun getInitialState(): EditContactUM { - val colors = CryptoPortfolioIcon.Color.entries.toImmutableList() - val selectedColor = colors.first() - val titleResId = if (params.contactId == null) { - R.string.address_book_new_contact - } else { - R.string.address_book_contact - } - return EditContactUM( - title = resourceReference(titleResId), - name = "", - namePlaceholder = resourceReference(R.string.address_book_new_contact), - portfolioIcon = AccountIconUM.CryptoPortfolio( - value = CryptoPortfolioIcon.Icon.Letter, - color = selectedColor, - ), - colors = EditContactUM.Colors( - selected = selectedColor, - list = colors, - onColorSelect = ::onColorSelect, - ), - addresses = persistentListOf(), - onNameChange = ::onNameChange, - onCloseClick = params.onBackClick, - onAddAddressClick = ::requestAddAddress, - ) + stateController.update(AddValidatedAddressTransformer(address = address)) } } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/EditContactStateController.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/EditContactStateController.kt new file mode 100644 index 0000000000..566148a41d --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/EditContactStateController.kt @@ -0,0 +1,52 @@ +package com.tangem.features.addressbook.editcontact.state + +import com.tangem.common.ui.account.AccountIconUM +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject + +@ModelScoped +internal class EditContactStateController @Inject constructor() { + + private val mutableUiState: MutableStateFlow = MutableStateFlow(value = getInitialState()) + + val uiState: StateFlow get() = mutableUiState.asStateFlow() + + fun update(transformer: Transformer) { + mutableUiState.update(function = transformer::transform) + } + + private fun getInitialState(): EditContactUM { + val colors = CryptoPortfolioIcon.Color.entries.toImmutableList() + val selectedColor = colors.first() + return EditContactUM( + title = TextReference.EMPTY, + name = "", + namePlaceholder = resourceReference(R.string.address_book_new_contact), + portfolioIcon = AccountIconUM.CryptoPortfolio( + value = CryptoPortfolioIcon.Icon.Letter, + color = selectedColor, + ), + colors = EditContactUM.Colors( + selected = selectedColor, + list = colors, + onColorSelect = {}, + ), + addresses = persistentListOf(), + onNameChange = {}, + onCloseClick = {}, + onAddAddressClick = {}, + ) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/AddValidatedAddressTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/AddValidatedAddressTransformer.kt new file mode 100644 index 0000000000..232b07210f --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/AddValidatedAddressTransformer.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 AddValidatedAddressTransformer( + private val address: ValidatedAddress, +) : Transformer { + + override fun transform(prevState: EditContactUM): EditContactUM { + // Skip duplicates: an address is identified by its string value (it already carries all its networks). + if (prevState.addresses.any { it.address == address.address }) return prevState + return prevState.copy( + addresses = (prevState.addresses + address).toImmutableList(), + ) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/SelectContactColorTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/SelectContactColorTransformer.kt new file mode 100644 index 0000000000..fa2e4a0572 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/SelectContactColorTransformer.kt @@ -0,0 +1,17 @@ +package com.tangem.features.addressbook.editcontact.state.transformers + +import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM +import com.tangem.utils.transformer.Transformer + +internal class SelectContactColorTransformer( + private val color: CryptoPortfolioIcon.Color, +) : Transformer { + + override fun transform(prevState: EditContactUM): EditContactUM { + return prevState.copy( + colors = prevState.colors.copy(selected = color), + portfolioIcon = prevState.portfolioIcon.copy(color = color), + ) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/UpdateContactNameTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/UpdateContactNameTransformer.kt new file mode 100644 index 0000000000..8b61afce92 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/UpdateContactNameTransformer.kt @@ -0,0 +1,13 @@ +package com.tangem.features.addressbook.editcontact.state.transformers + +import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM +import com.tangem.utils.transformer.Transformer + +internal class UpdateContactNameTransformer( + private val name: String, +) : Transformer { + + override fun transform(prevState: EditContactUM): EditContactUM { + return prevState.copy(name = name) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/UpdateEditContactInitialStateTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/UpdateEditContactInitialStateTransformer.kt new file mode 100644 index 0000000000..26d8bdd3c8 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/transformers/UpdateEditContactInitialStateTransformer.kt @@ -0,0 +1,35 @@ +package com.tangem.features.addressbook.editcontact.state.transformers + +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM +import com.tangem.utils.transformer.Transformer + +/** + * Wires the title (derived from whether an existing contact is being edited) and the callbacks owned by + * [com.tangem.features.addressbook.editcontact.model.EditContactModel] into the initial state. + */ +internal class UpdateEditContactInitialStateTransformer( + private val isExistingContact: Boolean, + private val onNameChange: (String) -> Unit, + private val onColorSelect: (CryptoPortfolioIcon.Color) -> Unit, + private val onCloseClick: () -> Unit, + private val onAddAddressClick: () -> Unit, +) : Transformer { + + override fun transform(prevState: EditContactUM): EditContactUM { + val titleResId = if (isExistingContact) { + R.string.address_book_contact + } else { + R.string.address_book_new_contact + } + return prevState.copy( + title = resourceReference(titleResId), + colors = prevState.colors.copy(onColorSelect = onColorSelect), + onNameChange = onNameChange, + onCloseClick = onCloseClick, + onAddAddressClick = onAddAddressClick, + ) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/EditContactContent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/EditContactContent.kt index e6d2f4a0ab..c45223210c 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/EditContactContent.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/EditContactContent.kt @@ -1,20 +1,16 @@ package com.tangem.features.addressbook.editcontact.ui import android.content.res.Configuration -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable +import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEach @@ -22,19 +18,27 @@ import com.tangem.common.ui.account.AccountIcon import com.tangem.common.ui.account.AccountIconUM import com.tangem.common.ui.account.getUiColor import com.tangem.core.ui.R +import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.account.AccountIconSize +import com.tangem.core.ui.components.block.BlockCard +import com.tangem.core.ui.components.block.TangemBlockCardColors import com.tangem.core.ui.components.fields.AutoSizeTextField +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.ds2.button.TangemButton -import com.tangem.core.ui.extensions.resolveReference -import com.tangem.core.ui.extensions.stringReference -import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.ds2.row.TangemRow +import com.tangem.core.ui.ds2.row.TangemRowText +import com.tangem.core.ui.ds2.row.TangemRowTextRole +import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment +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_sign_plus_20 import com.tangem.domain.models.account.CryptoPortfolioIcon -import com.tangem.features.addressbook.editcontact.contract.EditContactUM -import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress +import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM +import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -63,118 +67,126 @@ internal fun EditContactContent(state: EditContactUM, modifier: Modifier = Modif Column( modifier = Modifier - .padding(horizontal = 16.dp) - .weight(1f), - verticalArrangement = Arrangement.spacedBy(12.dp), + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { ContactSummary(state = state) ContactColor(colors = state.colors) - ContactAddresses(addresses = state.addresses) - AddAddressRow(onClick = state.onAddAddressClick) - } - } -} - -@Composable -private fun ContactAddresses(addresses: ImmutableList) { - if (addresses.isEmpty()) return - Column( - modifier = Modifier - .clip(RoundedCornerShape(16.dp)) - .fillMaxWidth() - .background(TangemTheme.colors3.bg.secondary), - ) { - addresses.fastForEach { entry -> - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), + BlockCard( + shape = RoundedCornerShape(24.dp), + colors = TangemBlockCardColors.copy(containerColor = TangemTheme.colors3.bg.secondary), ) { - Text( - text = entry.network.name, - style = TangemTheme.typography3.caption.medium, - color = TangemTheme.colors3.text.tertiary, - ) - Text( - text = entry.address, - style = TangemTheme.typography3.body.medium, - color = TangemTheme.colors3.text.primary, - maxLines = 1, - ) + ContactAddresses(addresses = state.addresses) + AddAddressRow(onClick = state.onAddAddressClick) } } } } @Composable -private fun AddAddressRow(onClick: () -> Unit) { - Row( - modifier = Modifier - .clip(RoundedCornerShape(16.dp)) - .fillMaxWidth() - .background(TangemTheme.colors3.bg.secondary) - .clickable(onClick = onClick) - .padding(horizontal = 12.dp, vertical = 15.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .size(36.dp) - .clip(CircleShape) - .background(TangemTheme.colors3.bg.status.infoSubtle), - ) { - Icon( - modifier = Modifier.size(18.dp), - imageVector = ImageVector.vectorResource(R.drawable.ic_plus_24), - tint = TangemTheme.colors3.text.status.info, - contentDescription = null, - ) - } - Column( - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - Text( - text = stringResourceSafe(R.string.address_book_add_address), - style = TangemTheme.typography3.body.medium, - color = TangemTheme.colors3.text.primary, - ) - Text( - text = stringResourceSafe(R.string.address_book_add_address_description), - style = TangemTheme.typography3.caption.medium, - color = TangemTheme.colors3.text.tertiary, - ) - } +private fun ContactAddresses(addresses: ImmutableList) { + addresses.fastForEach { entry -> + AddressRow(entry = entry) } } +@Composable +private fun AddressRow(entry: ValidatedAddress) { + TangemRow( + verticalAlignment = TangemRowVerticalAlignment.Center, + startSlot = { + TangemIcon( + tangemIconUM = TangemIconUM.Ident(text = entry.address), + modifier = Modifier + .size(40.dp) + .clip(CircleShape), + ) + }, + titleSlot = { + TangemRowText( + text = stringReference(entry.address), + role = TangemRowTextRole.Title, + overflow = TextOverflow.MiddleEllipsis, + ) + }, + subtitleSlot = { + TangemRowText( + text = pluralReference( + id = R.plurals.common_networks_count, + count = entry.networkIds.size, + formatArgs = wrappedList(entry.networkIds.size), + ), + role = TangemRowTextRole.Subtitle, + ) + }, + ) +} + +@Composable +private fun AddAddressRow(onClick: () -> Unit) { + TangemRow( + verticalAlignment = TangemRowVerticalAlignment.Center, + onClick = onClick, + startSlot = { + TangemIcon( + tangemIconUM = TangemIconUM.Icon( + imageVector = Icons.ic_sign_plus_20, + tintReference = { TangemTheme.colors3.icon.brand }, + ), + modifier = Modifier + .size(40.dp) + .background( + color = TangemTheme.colors3.bg.status.infoSubtle, + shape = RoundedCornerShape(10.dp), + ) + .padding(8.dp), + ) + }, + titleSlot = { + TangemRowText( + text = TextReference.Res(R.string.address_book_add_address), + role = TangemRowTextRole.Title, + ) + }, + subtitleSlot = { + TangemRowText( + text = TextReference.Res(R.string.address_book_add_address_description), + role = TangemRowTextRole.Subtitle, + ) + }, + ) +} + @Composable private fun ContactSummary(state: EditContactUM) { val avatarName = state.name.ifBlank { state.namePlaceholder.resolveReference() } Column( modifier = Modifier - .clip(RoundedCornerShape(16.dp)) + .clip(RoundedCornerShape(24.dp)) .fillMaxWidth() - .background(TangemTheme.colors3.bg.secondary), + .background(TangemTheme.colors3.bg.secondary) + .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - Spacer(modifier = Modifier.height(24.dp)) + SpacerH(20.dp) AccountIcon( name = stringReference(avatarName), icon = state.portfolioIcon, - size = AccountIconSize.Large, + size = AccountIconSize.RedesignLarge, ) - Spacer(modifier = Modifier.height(24.dp)) + + SpacerH(28.dp) Text( text = stringResourceSafe(R.string.address_book_contact_name), style = TangemTheme.typography3.caption.medium, - color = TangemTheme.colors3.text.tertiary, + color = TangemTheme.colors3.text.secondary, ) - Spacer(modifier = Modifier.height(2.dp)) + + SpacerH(4.dp) AutoSizeTextField( value = state.name, @@ -186,7 +198,7 @@ private fun ContactSummary(state: EditContactUM) { color = TangemTheme.colors3.text.primary, placeholderColor = TangemTheme.colors3.text.tertiary, ) - Spacer(modifier = Modifier.height(20.dp)) + SpacerH(8.dp) } } @@ -196,16 +208,16 @@ private fun ContactSummary(state: EditContactUM) { private fun ContactColor(colors: EditContactUM.Colors) { Box( modifier = Modifier - .clip(RoundedCornerShape(16.dp)) + .clip(RoundedCornerShape(24.dp)) .fillMaxWidth() - .background(TangemTheme.colors3.bg.secondary), + .background(TangemTheme.colors3.bg.secondary) + .padding(16.dp), ) { FlowRow( maxItemsInEachRow = 6, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally), + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalArrangement = Arrangement.spacedBy(18.dp), ) { colors.list.fastForEach { color -> val isSelected = color == colors.selected @@ -219,12 +231,12 @@ private fun ContactColor(colors: EditContactUM.Colors) { if (isSelected) { Box( modifier = Modifier - .size(47.dp) + .size(48.dp) .border(2.dp, color.getUiColor(), shape = CircleShape), ) Box( modifier = Modifier - .size(36.dp) + .size(38.dp) .background(color = color.getUiColor(), shape = CircleShape), ) } else { @@ -260,7 +272,12 @@ private fun Preview_EditContactContent() { list = colors, onColorSelect = {}, ), - addresses = persistentListOf(), + addresses = persistentListOf( + ValidatedAddress( + address = "0x1234567890abcdef1234567890abcdef12345678", + networkIds = persistentListOf("ethereum", "bsc", "polygon"), + ), + ), onNameChange = {}, onCloseClick = {}, onAddAddressClick = {}, diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/contract/EditContactUM.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/state/EditContactUM.kt similarity index 87% rename from features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/contract/EditContactUM.kt rename to features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/state/EditContactUM.kt index 9efd8db0e2..55793600ad 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/contract/EditContactUM.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/state/EditContactUM.kt @@ -1,10 +1,12 @@ -package com.tangem.features.addressbook.editcontact.contract +package com.tangem.features.addressbook.editcontact.ui.state +import androidx.compose.runtime.Immutable import com.tangem.common.ui.account.AccountIconUM import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.models.account.CryptoPortfolioIcon import kotlinx.collections.immutable.ImmutableList +@Immutable internal data class EditContactUM( val title: TextReference, val name: String, diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/state/ValidatedAddress.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/state/ValidatedAddress.kt new file mode 100644 index 0000000000..8d36e4c6a8 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/state/ValidatedAddress.kt @@ -0,0 +1,17 @@ +package com.tangem.features.addressbook.editcontact.ui.state + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList + +/** + * A recipient address validated on the AddAddress screen, together with the networks it resolves to. + * + * A single address can belong to several networks (e.g. the same address across EVM chains), so it carries a list of + * [networkIds]. This is the in-progress (pre-save) representation accumulated in [EditContactUM]; the [networkIds] are + * used to rebuild the domain `AddressEntry`s when the contact is persisted. + */ +@Immutable +data class ValidatedAddress( + val address: String, + val networkIds: ImmutableList, +) \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/AddressBookListComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/AddressBookListComponent.kt deleted file mode 100644 index 0072a79d9f..0000000000 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/AddressBookListComponent.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.tangem.features.addressbook.list - -import com.tangem.core.decompose.factory.ComponentFactory -import com.tangem.core.ui.decompose.ComposableContentComponent - -internal interface AddressBookListComponent : ComposableContentComponent { - - interface Factory : ComponentFactory - - data class Params( - val onContactClick: (String) -> Unit, - val onAddContactClick: () -> Unit, - ) -} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/DefaultAddressBookListComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/DefaultAddressBookListComponent.kt index 63733b90f9..6214fdbc75 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/DefaultAddressBookListComponent.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/DefaultAddressBookListComponent.kt @@ -1,22 +1,22 @@ package com.tangem.features.addressbook.list +import androidx.compose.foundation.background import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.features.addressbook.list.contract.AddressBookListUM +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.res.TangemTheme import com.tangem.features.addressbook.list.model.AddressBookListModel import com.tangem.features.addressbook.list.ui.AddressBookEmptyScreen -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject +import com.tangem.features.addressbook.list.ui.state.AddressBookListUM -internal class DefaultAddressBookListComponent @AssistedInject constructor( - @Assisted context: AppComponentContext, - @Assisted val params: AddressBookListComponent.Params, -) : AddressBookListComponent, AppComponentContext by context { +internal class DefaultAddressBookListComponent( + appComponentContext: AppComponentContext, + params: Params, +) : ComposableContentComponent, AppComponentContext by appComponentContext { private val model: AddressBookListModel = getOrCreateModel(params) @@ -25,19 +25,16 @@ internal class DefaultAddressBookListComponent @AssistedInject constructor( val state by model.state.collectAsStateWithLifecycle() when (val addressBookListUM = state) { is AddressBookListUM.Empty -> AddressBookEmptyScreen( - tangemButtonUM = addressBookListUM.tangemButtonUM, + onAddContactClick = addressBookListUM.onAddClick, onBackClick = router::pop, - modifier = modifier, + modifier = modifier.background(TangemTheme.colors3.bg.primary), ) is AddressBookListUM.AddressList -> TODO("[REDACTED_TASK_KEY]") } } - @AssistedFactory - interface Factory : AddressBookListComponent.Factory { - override fun create( - context: AppComponentContext, - params: AddressBookListComponent.Params, - ): DefaultAddressBookListComponent - } + data class Params( + val onContactClick: (String) -> Unit, + val onAddContactClick: () -> Unit, + ) } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/contract/AddressBookListUM.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/contract/AddressBookListUM.kt deleted file mode 100644 index 4c0c74bab4..0000000000 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/contract/AddressBookListUM.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.tangem.features.addressbook.list.contract - -import androidx.compose.runtime.Immutable -import com.tangem.core.ui.ds.button.TangemButtonUM -import com.tangem.domain.addressbook.model.Contact -import kotlinx.collections.immutable.ImmutableList - -@Immutable -internal sealed class AddressBookListUM { - - data class Empty( - val tangemButtonUM: TangemButtonUM, - ) : AddressBookListUM() - data class AddressList(val contacts: ImmutableList) : AddressBookListUM() -} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt index 27039d82aa..6d269a7616 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt @@ -3,18 +3,11 @@ package com.tangem.features.addressbook.list.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.ui.R -import com.tangem.core.ui.R.drawable.ic_plus_24 -import com.tangem.core.ui.ds.button.TangemButtonIconPosition -import com.tangem.core.ui.ds.button.TangemButtonType -import com.tangem.core.ui.ds.button.TangemButtonUM -import com.tangem.core.ui.ds.image.TangemIconUM -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.res.TangemTheme -import com.tangem.features.addressbook.list.AddressBookListComponent -import com.tangem.features.addressbook.list.contract.AddressBookListUM +import com.tangem.features.addressbook.list.DefaultAddressBookListComponent +import com.tangem.features.addressbook.list.state.AddressBookListStateController +import com.tangem.features.addressbook.list.state.transformers.UpdateAddressBookListInitialStateTransformer +import com.tangem.features.addressbook.list.ui.state.AddressBookListUM import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import javax.inject.Inject @@ -22,22 +15,16 @@ import javax.inject.Inject internal class AddressBookListModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, + private val stateController: AddressBookListStateController, ) : Model() { - private val params = paramsContainer.require() + private val params = paramsContainer.require() - val state: StateFlow = MutableStateFlow( - AddressBookListUM.Empty( - tangemButtonUM = TangemButtonUM( - text = TextReference.Res(R.string.address_book_new_contact), - tangemIconUM = TangemIconUM.Icon( - iconRes = ic_plus_24, - tintReference = { TangemTheme.colors3.text.inverse.primary }, - ), - iconPosition = TangemButtonIconPosition.End, - type = TangemButtonType.Primary, - onClick = params.onAddContactClick, - ), - ), - ) + val state: StateFlow get() = stateController.uiState + + init { + stateController.update( + UpdateAddressBookListInitialStateTransformer(onAddContactClick = params.onAddContactClick), + ) + } } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/AddressBookListStateController.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/AddressBookListStateController.kt new file mode 100644 index 0000000000..f42ef0cd71 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/AddressBookListStateController.kt @@ -0,0 +1,25 @@ +package com.tangem.features.addressbook.list.state + +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.features.addressbook.list.ui.state.AddressBookListUM +import com.tangem.utils.transformer.Transformer +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject + +@ModelScoped +internal class AddressBookListStateController @Inject constructor() { + + private val mutableUiState: MutableStateFlow = + MutableStateFlow(value = getInitialState()) + + val uiState: StateFlow get() = mutableUiState.asStateFlow() + + fun update(transformer: Transformer) { + mutableUiState.update(function = transformer::transform) + } + + private fun getInitialState(): AddressBookListUM = AddressBookListUM.Empty(onAddClick = {}) +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/converter/ContactUMConverter.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/converter/ContactUMConverter.kt new file mode 100644 index 0000000000..e50783cb34 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/converter/ContactUMConverter.kt @@ -0,0 +1,21 @@ +package com.tangem.features.addressbook.list.state.converter + +import com.tangem.domain.addressbook.model.Contact +import com.tangem.features.addressbook.list.ui.state.ContactUM +import com.tangem.utils.converter.Converter + +/** + * Maps a domain [Contact] to its UI representation [ContactUM]. + * + * TODO AddressBook ([REDACTED_TASK_KEY]): wire into [com.tangem.features.addressbook.list.model.AddressBookListModel] when the contacts list + * is loaded from the repository and the [com.tangem.features.addressbook.list.ui.state.AddressBookListUM.AddressList] + * screen is implemented. + */ +internal class ContactUMConverter : Converter { + + override fun convert(value: Contact): ContactUM = ContactUM( + id = value.id.value, + name = value.name.value, + addressCount = value.addressEntries.size, + ) +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListInitialStateTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListInitialStateTransformer.kt new file mode 100644 index 0000000000..8191d0f1b5 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListInitialStateTransformer.kt @@ -0,0 +1,19 @@ +package com.tangem.features.addressbook.list.state.transformers + +import com.tangem.features.addressbook.list.ui.state.AddressBookListUM +import com.tangem.utils.transformer.Transformer + +/** + * Wires the "add contact" callback owned by the container into the initial (empty) list state. + */ +internal class UpdateAddressBookListInitialStateTransformer( + private val onAddContactClick: () -> Unit, +) : Transformer { + + override fun transform(prevState: AddressBookListUM): AddressBookListUM { + return when (prevState) { + is AddressBookListUM.Empty -> prevState.copy(onAddClick = onAddContactClick) + is AddressBookListUM.AddressList -> prevState + } + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookEmptyScreen.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookEmptyScreen.kt index b1045b5cc2..50a6d9a30d 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookEmptyScreen.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookEmptyScreen.kt @@ -1,6 +1,5 @@ package com.tangem.features.addressbook.list.ui -import android.content.res.Configuration import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.* @@ -9,26 +8,26 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.tangem.core.ui.R -import com.tangem.core.ui.ds.button.PrimaryTangemButton -import com.tangem.core.ui.ds.button.TangemButtonIconPosition -import com.tangem.core.ui.ds.button.TangemButtonType -import com.tangem.core.ui.ds.button.TangemButtonUM import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.ds.topbar.TangemTopBar import com.tangem.core.ui.ds2.button.TangemButton -import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.core.ui.res.generated.icons.Icons +import com.tangem.core.ui.res.generated.icons.ic_chevron_left_20 +import com.tangem.core.ui.res.generated.icons.ic_sign_plus_20 @Composable internal fun AddressBookEmptyScreen( - tangemButtonUM: TangemButtonUM, + onAddContactClick: () -> Unit, onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { @@ -41,26 +40,19 @@ internal fun AddressBookEmptyScreen( title = resourceReference(R.string.address_book_title), startContent = { TangemButton( - iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_back_24), + iconStart = TangemIconUM.Icon(imageVector = Icons.ic_chevron_left_20), onClick = onBackClick, size = TangemButton.Size.X11, variant = TangemButton.Variant.Material, ) }, ) - NoContactInfo() - PrimaryTangemButton( - modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding() - .padding(start = 16.dp, end = 16.dp, bottom = 12.dp), - buttonUM = tangemButtonUM, - ) + NoContactInfo(onAddClick = onAddContactClick) } } @Composable -private fun ColumnScope.NoContactInfo() { +private fun ColumnScope.NoContactInfo(onAddClick: () -> Unit) { Column( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.Center, @@ -68,18 +60,24 @@ private fun ColumnScope.NoContactInfo() { ) { ContactImage() Text( - modifier = Modifier.padding(top = TangemTheme.dimens.spacing24), + modifier = Modifier.padding(top = 32.dp), text = stringResourceSafe(R.string.address_book_no_contacts), color = TangemTheme.colors3.text.primary, - style = TangemTheme.typography3.heading.medium, + style = TangemTheme.typography3.heading.small, ) Text( - modifier = Modifier.padding(top = TangemTheme.dimens.spacing8), + modifier = Modifier.padding(top = 8.dp), text = stringResourceSafe(R.string.address_book_no_contacts_description), color = TangemTheme.colors3.text.secondary, - style = TangemTheme.typography3.body.medium, + style = TangemTheme.typography3.subheading.medium, textAlign = TextAlign.Center, ) + TangemButton( + modifier = Modifier.padding(top = 40.dp), + text = resourceReference(R.string.address_book_add_address), + onClick = onAddClick, + iconEnd = TangemIconUM.Icon(imageVector = Icons.ic_sign_plus_20), + ) } } @@ -95,7 +93,7 @@ private fun ContactImage() { contentAlignment = Alignment.Center, ) { Image( - painter = painterResource(R.drawable.ic_contact_20), + imageVector = ImageVector.vectorResource(R.drawable.ic_address_book_24), contentDescription = stringResourceSafe(R.string.address_book_no_contacts), modifier = Modifier.size(28.dp), ) @@ -104,16 +102,11 @@ private fun ContactImage() { @Composable @Preview(showBackground = true, widthDp = 360) -@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) private fun Preview_AddressBookEmptyScreen() { - AddressBookEmptyScreen( - tangemButtonUM = TangemButtonUM( - text = TextReference.Res(R.string.address_book_new_contact), - tangemIconUM = TangemIconUM.Icon(iconRes = R.drawable.ic_plus_24), - iconPosition = TangemButtonIconPosition.End, - type = TangemButtonType.Secondary, - onClick = {}, - ), - onBackClick = {}, - ) + TangemThemePreviewRedesign { + AddressBookEmptyScreen( + onAddContactClick = {}, + onBackClick = {}, + ) + } } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/AddressBookListUM.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/AddressBookListUM.kt new file mode 100644 index 0000000000..e1b9061014 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/AddressBookListUM.kt @@ -0,0 +1,12 @@ +package com.tangem.features.addressbook.list.ui.state + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal sealed interface AddressBookListUM { + + data class Empty(val onAddClick: () -> Unit) : AddressBookListUM + + data class AddressList(val contacts: ImmutableList) : AddressBookListUM +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/ContactUM.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/ContactUM.kt new file mode 100644 index 0000000000..c5f526a6bc --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/ContactUM.kt @@ -0,0 +1,11 @@ +package com.tangem.features.addressbook.list.ui.state + +import androidx.compose.runtime.Immutable + +/** UI model of a single address-book contact row. Holds only what the list needs to render — no domain types. */ +@Immutable +internal data class ContactUM( + val id: String, + val name: String, + val addressCount: Int, +) \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/route/AddressBookRoute.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/route/AddressBookRoute.kt new file mode 100644 index 0000000000..c59c929b70 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/route/AddressBookRoute.kt @@ -0,0 +1,27 @@ +package com.tangem.features.addressbook.route + +import kotlinx.serialization.Serializable + +@Serializable +internal sealed class AddressBookRoute { + + @Serializable + data object List : AddressBookRoute() + + /** + * if [contactId] is not null we should fetch existing contact. + * + * [predefinedAddress] and [predefinedNetworkId] are set only when the feature is opened in + * [com.tangem.features.addressbook.entity.AddressBookOpenMode.WithContactCreation] mode — the address and its + * network are already known, so the new contact is opened with that address already attached. + */ + @Serializable + data class EditContact( + val contactId: String? = null, + val predefinedAddress: String? = null, + val predefinedNetworkId: String? = null, + ) : AddressBookRoute() + + @Serializable + data object AddAddress : AddressBookRoute() +} \ No newline at end of file diff --git a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModelTest.kt b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModelTest.kt index c58bb25f8d..cce48d3bbe 100644 --- a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModelTest.kt +++ b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModelTest.kt @@ -3,24 +3,23 @@ package com.tangem.features.addressbook.addaddress.model import com.google.common.truth.Truth.assertThat import com.tangem.blockchain.common.Blockchain import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory -import com.tangem.common.ui.extensions.iconResId import com.tangem.core.decompose.model.MutableParamsContainer import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.ui.R import com.tangem.core.ui.clipboard.ClipboardManager +import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.account.models.AccountList import com.tangem.domain.account.supplier.MultiAccountListSupplier import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.network.Network -import com.tangem.features.addressbook.addaddress.AddAddressComponent -import com.tangem.features.addressbook.addaddress.contract.AddAddressUM -import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress +import com.tangem.features.addressbook.addaddress.DefaultAddAddressComponent +import com.tangem.features.addressbook.addaddress.state.AddAddressStateController +import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress import com.tangem.test.mock.MockAccounts import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.clearMocks import io.mockk.every import io.mockk.mockk -import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf @@ -28,11 +27,7 @@ import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.* @OptIn(ExperimentalCoroutinesApi::class) @TestInstance(TestInstance.Lifecycle.PER_CLASS) @@ -73,7 +68,6 @@ internal class AddAddressModelTest { // Assert assertThat(state.addressField.value).isEmpty() - assertThat(state.addressField.isValuePasted).isFalse() assertThat(state.buttonUM.isEnabled).isFalse() } @@ -87,13 +81,11 @@ internal class AddAddressModelTest { model.state.value.onAddressChange(address) // Assert - val field = model.state.value.addressField - assertThat(field.value).isEqualTo(address) - assertThat(field.isValuePasted).isFalse() + assertThat(model.state.value.addressField.value).isEqualTo(address) } @Test - fun `GIVEN empty field WHEN onPasteClick THEN value marked as pasted`() = runTest { + fun `GIVEN empty field WHEN onPasteClick THEN value taken from clipboard`() = runTest { // Arrange val model = createModel(testScope = this) val address = "0xABC" @@ -103,13 +95,10 @@ internal class AddAddressModelTest { model.state.value.onPasteClick() // Assert - val field = model.state.value.addressField - assertThat(field.value).isEqualTo(address) - assertThat(field.isValuePasted).isTrue() + assertThat(model.state.value.addressField.value).isEqualTo(address) } // validateAndConfirm() is an unimplemented seam — the button click must NOT emit a result yet. - // This guards the foundation and will fail (prompting an update) once validation is wired in. @Test fun `GIVEN typed address WHEN button clicked THEN onConfirm not called yet`() = runTest { // Arrange @@ -127,13 +116,12 @@ internal class AddAddressModelTest { @Nested @TestInstance(TestInstance.Lifecycle.PER_CLASS) - inner class AddressInput { + inner class Validation { @Test - fun `GIVEN coins available WHEN valid address typed THEN matching network chosen`() = runTest { + fun `GIVEN coins available WHEN valid address typed THEN no error AND button enabled`() = runTest { // Arrange - every { multiAccountListSupplier.invoke() } returns - flowOf(listOf(accountListWith(ethereum, bitcoin))) + every { multiAccountListSupplier.invoke() } returns flowOf(listOf(accountListWith(ethereum, bitcoin))) val model = createModel(testScope = this) advanceUntilIdle() @@ -143,16 +131,14 @@ internal class AddAddressModelTest { // Assert val state = model.state.value - assertThat(state.availableNetworks).containsExactly(ethereum.network) - assertThat(state.chosenNetworkStateUM) - .isEqualTo(resultOf(ethereum.network)) + assertThat(state.addressField.isError).isFalse() + assertThat(state.buttonUM.isEnabled).isTrue() } @Test - fun `GIVEN coins available WHEN address matches no network THEN empty state`() = runTest { + fun `GIVEN coins available WHEN address matches no network THEN error AND button disabled`() = runTest { // Arrange - every { multiAccountListSupplier.invoke() } returns - flowOf(listOf(accountListWith(ethereum, bitcoin))) + every { multiAccountListSupplier.invoke() } returns flowOf(listOf(accountListWith(ethereum, bitcoin))) val model = createModel(testScope = this) advanceUntilIdle() @@ -162,31 +148,32 @@ internal class AddAddressModelTest { // Assert val state = model.state.value - assertThat(state.availableNetworks).isEmpty() - assertThat(state.chosenNetworkStateUM).isEqualTo(AddAddressUM.ChosenNetworkStateUM.Empty) + assertThat(state.addressField.isError).isTrue() + assertThat(state.addressField.label) + .isEqualTo(resourceReference(R.string.address_book_invalid_address_error)) + assertThat(state.buttonUM.isEnabled).isFalse() } @Test - fun `GIVEN no coins available WHEN valid address typed THEN empty state`() = runTest { - // Arrange — supplier emits no accounts. - every { multiAccountListSupplier.invoke() } returns flowOf(emptyList()) + fun `GIVEN empty address WHEN validated THEN no error AND button disabled`() = runTest { + // Arrange + every { multiAccountListSupplier.invoke() } returns flowOf(listOf(accountListWith(ethereum))) val model = createModel(testScope = this) advanceUntilIdle() // Act - model.state.value.onAddressChange(VALID_ETH_ADDRESS) + model.state.value.onAddressChange("") advanceUntilIdle() // Assert val state = model.state.value - assertThat(state.availableNetworks).isEmpty() - assertThat(state.chosenNetworkStateUM).isEqualTo(AddAddressUM.ChosenNetworkStateUM.Empty) + assertThat(state.addressField.isError).isFalse() + assertThat(state.buttonUM.isEnabled).isFalse() } - // Covers the "not initialized yet" case: the address is typed before coins load, and the - // chosen network must resolve reactively once the supplier emits them. + // The address is typed before coins load; validity must resolve reactively once the supplier emits them. @Test - fun `GIVEN address typed before coins load WHEN coins emitted THEN network resolved reactively`() = runTest { + fun `GIVEN address typed before coins load WHEN coins emitted THEN validated reactively`() = runTest { // Arrange val accountsFlow = MutableStateFlow>(emptyList()) every { multiAccountListSupplier.invoke() } returns accountsFlow @@ -197,29 +184,19 @@ internal class AddAddressModelTest { model.state.value.onAddressChange(VALID_ETH_ADDRESS) advanceUntilIdle() // Assert intermediate: nothing to match yet - assertThat(model.state.value.chosenNetworkStateUM).isEqualTo(AddAddressUM.ChosenNetworkStateUM.Empty) + assertThat(model.state.value.buttonUM.isEnabled).isFalse() // Act — coins arrive later accountsFlow.value = listOf(accountListWith(ethereum, bitcoin)) advanceUntilIdle() // Assert - assertThat(model.state.value.chosenNetworkStateUM) - .isEqualTo(resultOf(ethereum.network)) + val state = model.state.value + assertThat(state.buttonUM.isEnabled).isTrue() + assertThat(state.addressField.isError).isFalse() } } - private fun resultOf(vararg networks: Network) = AddAddressUM.ChosenNetworkStateUM.Result( - networkUMList = networks - .map { network -> - AddAddressUM.ChosenNetworkStateUM.Result.NetworkUM( - networkName = network.name, - iconResId = network.iconResId, - ) - } - .toImmutableList(), - ) - private fun accountListWith(vararg currencies: CryptoCurrency): AccountList { val walletId = MockAccounts.userWalletId val accounts = listOf( @@ -239,7 +216,7 @@ internal class AddAddressModelTest { private fun createModel( testScope: TestScope, onConfirm: (ValidatedAddress) -> Unit = {}, - params: AddAddressComponent.Params = AddAddressComponent.Params( + params: DefaultAddAddressComponent.Params = DefaultAddAddressComponent.Params( onBackClick = {}, onConfirm = onConfirm, ), @@ -250,6 +227,7 @@ internal class AddAddressModelTest { dispatchers = testScope.createTestingCoroutineDispatcherProvider(), multiAccountListSupplier = multiAccountListSupplier, clipboardManager = clipboardManager, + stateController = AddAddressStateController(), ).also { model = it } } diff --git a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModelTest.kt b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModelTest.kt index ebf9acafd1..cdf098ec82 100644 --- a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModelTest.kt +++ b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/editcontact/model/EditContactModelTest.kt @@ -8,23 +8,36 @@ import com.tangem.core.ui.R import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.addressbook.model.ContactId import com.tangem.domain.models.account.CryptoPortfolioIcon -import com.tangem.domain.models.network.Network -import com.tangem.features.addressbook.editcontact.EditContactComponent -import com.tangem.features.addressbook.editcontact.contract.EditContactUM -import com.tangem.features.addressbook.editcontact.contract.ValidatedAddress +import com.tangem.features.addressbook.common.AddressBookResultHolder +import com.tangem.features.addressbook.editcontact.DefaultEditContactComponent +import com.tangem.features.addressbook.editcontact.state.EditContactStateController +import com.tangem.features.addressbook.editcontact.ui.state.EditContactUM +import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider -import io.mockk.mockk import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test @OptIn(ExperimentalCoroutinesApi::class) internal class EditContactModelTest { + private val resultHolder = AddressBookResultHolder() + + private var model: EditContactModel? = null + + @AfterEach + fun tearDown() { + // Cancels modelScope, stopping the confirmed-addresses collector. + model?.onDestroy() + model = null + } + @Test fun `WHEN model created THEN initial state is correct`() = runTest { val expectedColors = CryptoPortfolioIcon.Color.entries.toImmutableList() @@ -57,11 +70,7 @@ internal class EditContactModelTest { @Test fun `GIVEN existing contactId WHEN model created THEN title is contact`() = runTest { // Arrange - val params = EditContactComponent.Params( - contactId = ContactId(value = "contact-id"), - onBackClick = {}, - onAddAddressClick = {}, - ) + val params = createParams(contactId = ContactId(value = "contact-id")) // Act val model = createModel(testScope = this, params = params) @@ -94,38 +103,73 @@ internal class EditContactModelTest { } @Test - fun `GIVEN add address requested WHEN result delivered THEN address appended to state`() = runTest { + fun `GIVEN confirmed address set on holder WHEN collected THEN address appended to state`() = runTest { // Arrange - var capturedSink: ((ValidatedAddress) -> Unit)? = null - val params = EditContactComponent.Params( - contactId = null, - onBackClick = {}, - onAddAddressClick = { onResult -> capturedSink = onResult }, - ) - val model = createModel(testScope = this, params = params) - val validatedAddress = ValidatedAddress(address = "0xABC", network = mockk()) + val model = createModel(testScope = this) + advanceUntilIdle() + val validatedAddress = ValidatedAddress(address = "0xABC", networkIds = persistentListOf("ethereum")) // Act - model.state.value.onAddAddressClick() - capturedSink?.invoke(validatedAddress) + resultHolder.setConfirmedAddress(validatedAddress) + advanceUntilIdle() + + // Assert + assertThat(model.state.value.addresses).containsExactly(validatedAddress) + // The value must be consumed so it is not re-applied on resubscription. + assertThat(resultHolder.confirmedAddress.value).isNull() + } + + @Test + fun `GIVEN same address confirmed twice WHEN collected THEN added only once`() = runTest { + // Arrange + val model = createModel(testScope = this) + advanceUntilIdle() + val validatedAddress = ValidatedAddress(address = "0xABC", networkIds = persistentListOf("ethereum")) + + // Act + resultHolder.setConfirmedAddress(validatedAddress) + advanceUntilIdle() + resultHolder.setConfirmedAddress(validatedAddress) + advanceUntilIdle() // Assert assertThat(model.state.value.addresses).containsExactly(validatedAddress) } + @Test + fun `GIVEN predefined address WHEN model created THEN address attached`() = runTest { + // Arrange + val predefined = ValidatedAddress(address = "0xABC", networkIds = persistentListOf("ethereum")) + + // Act + val model = createModel(testScope = this, params = createParams(predefinedAddress = predefined)) + advanceUntilIdle() + + // Assert + assertThat(model.state.value.addresses).containsExactly(predefined) + } + + private fun createParams( + contactId: ContactId? = null, + predefinedAddress: ValidatedAddress? = null, + ): DefaultEditContactComponent.Params = DefaultEditContactComponent.Params( + contactId = contactId, + predefinedAddress = predefinedAddress, + onBackClick = {}, + onAddAddressClick = {}, + ) + private fun createModel( testScope: TestScope, - params: EditContactComponent.Params = EditContactComponent.Params( - contactId = null, - onBackClick = {}, - onAddAddressClick = {}, - ), + params: DefaultEditContactComponent.Params = createParams(), paramsContainer: ParamsContainer = MutableParamsContainer(value = params), ): EditContactModel { return EditContactModel( paramsContainer = paramsContainer, dispatchers = testScope.createTestingCoroutineDispatcherProvider(), - ) + stateController = EditContactStateController(), + resultHolder = resultHolder, + ).also { model = it } } private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider { diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/DetailsItemUM.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/DetailsItemUM.kt index e7849eebed..7082a0c24e 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/DetailsItemUM.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/DetailsItemUM.kt @@ -25,10 +25,11 @@ internal sealed class DetailsItemUM { override val id: String = "wallet_connect" } - data class WalletConnectAddressBookBlock(val items: List) : DetailsItemUM() { + data class WalletActionBlock(val items: ImmutableList) : DetailsItemUM() { override val id: String = "wallet_connect_address_book" sealed class Item(open val onClick: () -> Unit) { + data class WalletConnect(override val onClick: () -> Unit) : Item(onClick) data class AddressBook(override val onClick: () -> Unit) : Item(onClick) } diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/DetailsScreen.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/DetailsScreen.kt index 427c80adce..48d51c0835 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/DetailsScreen.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/DetailsScreen.kt @@ -2,13 +2,13 @@ package com.tangem.features.details.ui import android.content.res.Configuration import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.scrollable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold @@ -17,6 +17,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.key import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview @@ -27,19 +28,27 @@ import com.tangem.core.ui.components.appbar.TangemTopAppBar import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM import com.tangem.core.ui.components.block.BlockCard import com.tangem.core.ui.components.block.BlockItem -import com.tangem.core.ui.components.inputrow.InputRowImageBase import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.ds.image.TangemIcon +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds2.row.TangemRow +import com.tangem.core.ui.ds2.row.TangemRowText +import com.tangem.core.ui.ds2.row.TangemRowTextRole +import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview +import com.tangem.core.ui.res.generated.icons.Icons +import com.tangem.core.ui.res.generated.icons.ic_chevron_right_20 import com.tangem.core.ui.test.DetailsScreenTestTags import com.tangem.features.details.component.preview.PreviewDetailsComponent import com.tangem.features.details.entity.DetailsFooterUM import com.tangem.features.details.entity.DetailsItemUM import com.tangem.features.details.entity.DetailsUM import com.tangem.features.details.impl.R +import kotlinx.collections.immutable.ImmutableList @Composable internal fun DetailsScreen( @@ -159,9 +168,9 @@ private fun Block( onClick = model.onClick, ) } - is DetailsItemUM.WalletConnectAddressBookBlock -> { + is DetailsItemUM.WalletActionBlock -> { BlockCard { - WalletConnectAddressBookBlockItems( + WalletActionsBlock( items = model.items, modifier = itemModifier, ) @@ -170,37 +179,106 @@ private fun Block( is DetailsItemUM.UserWalletList -> { userWalletListBlockContent.Content(modifier = itemModifier) } - is DetailsItemUM.UnderSectionText -> { /* Handled above */ - } + is DetailsItemUM.UnderSectionText -> Unit } } } @Composable -private fun WalletConnectAddressBookBlockItems( - items: List, +private fun WalletActionsBlock( + items: ImmutableList, modifier: Modifier = Modifier, ) { items.fastForEach { item -> when (item) { - is DetailsItemUM.WalletConnectAddressBookBlock.Item.WalletConnect -> InputRowImageBase( - modifier = modifier.clickable(onClick = item.onClick).padding(12.dp), - iconResVector = R.drawable.ic_wallet_connect_24, - iconTint = TangemTheme.colors.icon.primary1, - subtitle = TextReference.Res(R.string.wallet_connect_title), - caption = TextReference.Res(R.string.wallet_connect_subtitle), + is DetailsItemUM.WalletActionBlock.Item.WalletConnect -> WalletConnectActionRow( + onClick = item.onClick, + modifier = modifier, ) - is DetailsItemUM.WalletConnectAddressBookBlock.Item.AddressBook -> InputRowImageBase( - modifier = modifier.clickable(onClick = item.onClick).padding(12.dp), - iconResVector = R.drawable.ic_contact_20, - iconTint = TangemTheme.colors.icon.accent, - subtitle = TextReference.Res(R.string.address_book_title), - caption = TextReference.Res(R.string.address_book_description), + is DetailsItemUM.WalletActionBlock.Item.AddressBook -> AddressBookActionRow( + onClick = item.onClick, + modifier = modifier, ) } } } +@Composable +private fun WalletConnectActionRow(onClick: () -> Unit, modifier: Modifier = Modifier) { + TangemRow( + verticalAlignment = TangemRowVerticalAlignment.Center, + modifier = modifier, + onClick = onClick, + startSlot = { + TangemIcon( + tangemIconUM = TangemIconUM.Image(R.drawable.img_wallet_connect_76), + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(12.dp)), + ) + }, + titleSlot = { + TangemRowText( + text = TextReference.Res(R.string.wallet_connect_title), + role = TangemRowTextRole.Title, + ) + }, + subtitleSlot = { + TangemRowText( + text = TextReference.Res(R.string.wallet_connect_subtitle), + role = TangemRowTextRole.Subtitle, + ) + }, + endSlot = { ActionRowChevron() }, + ) +} + +@Composable +private fun AddressBookActionRow(onClick: () -> Unit, modifier: Modifier = Modifier) { + TangemRow( + verticalAlignment = TangemRowVerticalAlignment.Center, + modifier = modifier, + onClick = onClick, + startSlot = { + TangemIcon( + tangemIconUM = TangemIconUM.Icon( + iconRes = R.drawable.ic_address_book_24, + tintReference = { TangemTheme.colors3.icon.brand }, + ), + modifier = Modifier + .size(40.dp) + .background( + color = TangemTheme.colors3.bg.status.infoSubtle, + shape = RoundedCornerShape(12.dp), + ) + .padding(8.dp), + ) + }, + titleSlot = { + TangemRowText( + text = TextReference.Res(R.string.address_book_title), + role = TangemRowTextRole.Title, + ) + }, + subtitleSlot = { + TangemRowText( + text = TextReference.Res(R.string.address_book_description), + role = TangemRowTextRole.Subtitle, + ) + }, + endSlot = { ActionRowChevron() }, + ) +} + +@Composable +private fun ActionRowChevron() { + Icon( + imageVector = Icons.ic_chevron_right_20, + contentDescription = null, + tint = TangemTheme.colors3.icon.secondary, + ) +} + @Composable private fun UnderSectionTextBlock(text: TextReference, modifier: Modifier = Modifier) { Text( diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/ItemsBuilder.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/ItemsBuilder.kt index df2e653821..99d34d298d 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/ItemsBuilder.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/ItemsBuilder.kt @@ -35,7 +35,7 @@ internal class ItemsBuilder @Inject constructor( onBuyClick: () -> Unit, ): ImmutableList = buildList { if (isAddressBookAvailable) { - buildWalletConnectAddressBookBlock(isWalletConnectAvailable, userWalletId) + buildWalletActionBlock(isWalletConnectAvailable, userWalletId) } else { buildWalletConnectBlock(isWalletConnectAvailable, userWalletId)?.let(::add) } @@ -91,29 +91,29 @@ internal class ItemsBuilder @Inject constructor( } } - private fun MutableList.buildWalletConnectAddressBookBlock( + private fun MutableList.buildWalletActionBlock( isWalletConnectAvailable: Boolean, userWalletId: UserWalletId, ) { - val walletConnectAddressBookItems = buildList { + val walletActionItems = buildList { if (isWalletConnectAvailable) add(buildWalletConnectButton(userWalletId)) add(buildAddressBookButton()) - } - if (walletConnectAddressBookItems.isNotEmpty()) { - add(DetailsItemUM.WalletConnectAddressBookBlock(walletConnectAddressBookItems)) + }.toImmutableList() + if (walletActionItems.isNotEmpty()) { + add(DetailsItemUM.WalletActionBlock(walletActionItems)) } } private fun buildWalletConnectButton( userWalletId: UserWalletId, - ): DetailsItemUM.WalletConnectAddressBookBlock.Item.WalletConnect { - return DetailsItemUM.WalletConnectAddressBookBlock.Item.WalletConnect( + ): DetailsItemUM.WalletActionBlock.Item.WalletConnect { + return DetailsItemUM.WalletActionBlock.Item.WalletConnect( onClick = { router.push(AppRoute.WalletConnectSessions(userWalletId)) }, ) } - private fun buildAddressBookButton(): DetailsItemUM.WalletConnectAddressBookBlock.Item.AddressBook { - return DetailsItemUM.WalletConnectAddressBookBlock.Item.AddressBook( + private fun buildAddressBookButton(): DetailsItemUM.WalletActionBlock.Item.AddressBook { + return DetailsItemUM.WalletActionBlock.Item.AddressBook( onClick = { router.push(AppRoute.AddressBook()) }, ) } diff --git a/features/details/impl/src/test/kotlin/com/tangem/features/details/utils/ItemsBuilderTest.kt b/features/details/impl/src/test/kotlin/com/tangem/features/details/utils/ItemsBuilderTest.kt index d6962201eb..6f4c22d644 100644 --- a/features/details/impl/src/test/kotlin/com/tangem/features/details/utils/ItemsBuilderTest.kt +++ b/features/details/impl/src/test/kotlin/com/tangem/features/details/utils/ItemsBuilderTest.kt @@ -65,10 +65,10 @@ internal class ItemsBuilderTest { "support", ).inOrder() - val block = result.first() as DetailsItemUM.WalletConnectAddressBookBlock + val block = result.first() as DetailsItemUM.WalletActionBlock assertThat(block.items.map { it::class.java }).containsExactly( - DetailsItemUM.WalletConnectAddressBookBlock.Item.WalletConnect::class.java, - DetailsItemUM.WalletConnectAddressBookBlock.Item.AddressBook::class.java, + DetailsItemUM.WalletActionBlock.Item.WalletConnect::class.java, + DetailsItemUM.WalletActionBlock.Item.AddressBook::class.java, ).inOrder() } @@ -86,9 +86,9 @@ internal class ItemsBuilderTest { "support", ).inOrder() - val block = result.first() as DetailsItemUM.WalletConnectAddressBookBlock + val block = result.first() as DetailsItemUM.WalletActionBlock assertThat(block.items.map { it::class.java }).containsExactly( - DetailsItemUM.WalletConnectAddressBookBlock.Item.AddressBook::class.java, + DetailsItemUM.WalletActionBlock.Item.AddressBook::class.java, ) } @@ -109,9 +109,9 @@ internal class ItemsBuilderTest { fun `GIVEN combined block walletConnect item WHEN clicked THEN router pushes WalletConnectSessions`() { // Arrange val result = buildAll(isWalletConnectAvailable = true, isAddressBookAvailable = true) - val block = result.first() as DetailsItemUM.WalletConnectAddressBookBlock + val block = result.first() as DetailsItemUM.WalletActionBlock val walletConnect = block.items - .filterIsInstance() + .filterIsInstance() .single() // Act @@ -125,9 +125,9 @@ internal class ItemsBuilderTest { fun `GIVEN combined block addressBook item WHEN clicked THEN router pushes AddressBook`() { // Arrange val result = buildAll(isWalletConnectAvailable = true, isAddressBookAvailable = true) - val block = result.first() as DetailsItemUM.WalletConnectAddressBookBlock + val block = result.first() as DetailsItemUM.WalletActionBlock val addressBook = block.items - .filterIsInstance() + .filterIsInstance() .single() // Act From e1bf8d2672d6df644bb5c7b92079cc0981602229 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 19 Jun 2026 11:24:02 +0200 Subject: [PATCH 003/210] Updated on 2026-08-14 --- .claude/docs/navigation-graph.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.claude/docs/navigation-graph.md b/.claude/docs/navigation-graph.md index 9e5202e1ca..e869f1fd82 100644 --- a/.claude/docs/navigation-graph.md +++ b/.claude/docs/navigation-graph.md @@ -40,7 +40,6 @@ Complete navigation map of the app based on `AppRoute` sealed class and feature- | 30 | `OnrampSuccess` | `/onramp/success/{txId}` | Onramp success screen | | 31 | `BuyCrypto` | `/buy_crypto/{walletId}` | Buy crypto token selector | | 32 | `SellCrypto` | `/sell_crypto/{walletId}` | Sell crypto token selector | -| 33 | `SwapCrypto` | `/swap_crypto/{walletId}` | Swap crypto token selector | | 34 | `Onboarding` | `/onboarding_v2/{mode}` | Onboarding flow (v2) | | 35 | `Stories` | `/stories$storyId` | Stories / promotional content | | 36 | `NFT` | `/nft/{walletId}` | NFT collection list | @@ -269,11 +268,10 @@ Each entry shows: **Source route** → target routes it can navigate to (via `pu |--------|--------|---------| | `CurrencyDetails` | push | Navigate to fee token | -### SwapCrypto / BuyCrypto / SellCrypto +### BuyCrypto / SellCrypto | Target | Method | Trigger | |--------|--------|---------| -| `Swap` | push | After token selection (SwapCrypto) | | `Onramp` | push | After token selection (BuyCrypto/SellCrypto) | ### Deep Link Handlers (push to AppRoute) @@ -284,7 +282,6 @@ Each entry shows: **Source route** → target routes it can navigate to (via `pu | `SellRedirectDeepLinkHandler` | `Send` (with sell redirect params) | | `BuyDeepLinkHandler` | `BuyCrypto` | | `SellDeepLinkHandler` | `SellCrypto` | -| `SwapDeepLinkHandler` | `SwapCrypto` | | `ReferralDeepLinkHandler` | Referral handling | | `WalletDeepLinkHandler` | Wallet handling | | `TokenDetailsDeepLinkHandler` | `CurrencyDetails` | @@ -447,7 +444,6 @@ Transitions: `ManualBackupStart` → `ManualBackupPhrase` → `ManualBackupCheck | `redirect` | — | Buy redirect (no-op) | | `buy` | `BuyDeepLinkHandler` | `BuyCrypto` | | `sell` | `SellDeepLinkHandler` | `SellCrypto` | -| `swap` | `SwapDeepLinkHandler` | `SwapCrypto` | | `referral` | `ReferralDeepLinkHandler` | Referral flow | | `main` | `WalletDeepLinkHandler` | Wallet screen | | `token` | `TokenDetailsDeepLinkHandler` | `CurrencyDetails` | From 50c35995882c96ccfedbbca55a39ef407b801542 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 19 Jun 2026 10:51:26 +0100 Subject: [PATCH 004/210] Updated on 2026-08-14 --- .../tap/di/domain/AddressBookDomainModule.kt | 78 ++++++++++++ domain/address-book/build.gradle.kts | 1 + .../addressbook/error/SaveContactError.kt | 3 + .../addressbook/model/VerifiedContact.kt | 12 ++ .../usecase/CreateContactUseCase.kt | 16 ++- .../usecase/GetVerifiedContactsUseCase.kt | 28 +++++ .../usecase/UpdateContactUseCase.kt | 15 +-- .../usecase/CreateContactUseCaseTest.kt | 41 ++++++- .../usecase/GetVerifiedContactsUseCaseTest.kt | 116 ++++++++++++++++++ .../usecase/UpdateContactUseCaseTest.kt | 35 +++++- 10 files changed, 324 insertions(+), 21 deletions(-) create mode 100644 domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/VerifiedContact.kt create mode 100644 domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/GetVerifiedContactsUseCase.kt create mode 100644 domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetVerifiedContactsUseCaseTest.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 3fa8efefb9..5535d25f92 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 @@ -1,11 +1,21 @@ package com.tangem.tap.di.domain import com.tangem.domain.addressbook.crypto.AddressBookCipher +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.CreateContactUseCase +import com.tangem.domain.addressbook.usecase.DeleteContactUseCase +import com.tangem.domain.addressbook.usecase.GetContactsUseCase +import com.tangem.domain.addressbook.usecase.GetVerifiedContactsUseCase +import com.tangem.domain.addressbook.usecase.SignAddressEntriesUseCase +import com.tangem.domain.addressbook.usecase.UpdateContactUseCase import com.tangem.domain.addressbook.usecase.ValidateContactAddressUseCase +import com.tangem.domain.addressbook.usecase.ValidateContactNameUseCase import com.tangem.domain.addressbook.usecase.VerifyAddressEntriesUseCase +import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.tokens.GetNetworkAddressesUseCase +import com.tangem.domain.transaction.usecase.SignUseCase import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase import com.tangem.domain.transaction.usecase.VerifySecp256k1MessagesUseCase import dagger.Module @@ -38,6 +48,74 @@ object AddressBookDomainModule { return VerifyAddressEntriesUseCase(verifyMessagesUseCase = verifyMessagesUseCase) } + @Provides + @Singleton + fun provideSignAddressEntriesUseCase(signUseCase: SignUseCase): SignAddressEntriesUseCase { + return SignAddressEntriesUseCase(signUseCase = signUseCase) + } + + @Provides + @Singleton + fun provideValidateContactNameUseCase(repository: AddressBookRepository): ValidateContactNameUseCase { + return ValidateContactNameUseCase(repository = repository) + } + + @Provides + @Singleton + fun provideGetContactsUseCase(repository: AddressBookRepository): GetContactsUseCase { + return GetContactsUseCase(repository = repository) + } + + @Provides + @Singleton + fun provideGetVerifiedContactsUseCase( + getContactsUseCase: GetContactsUseCase, + verifyAddressEntriesUseCase: VerifyAddressEntriesUseCase, + userWalletsListRepository: UserWalletsListRepository, + ): GetVerifiedContactsUseCase { + return GetVerifiedContactsUseCase( + getContacts = getContactsUseCase, + verifyAddressEntries = verifyAddressEntriesUseCase, + userWalletsListRepository = userWalletsListRepository, + ) + } + + @Provides + @Singleton + fun provideCreateContactUseCase( + repository: AddressBookRepository, + validateContactNameUseCase: ValidateContactNameUseCase, + signAddressEntriesUseCase: SignAddressEntriesUseCase, + timestampProvider: IsoTimestampProvider, + ): CreateContactUseCase { + return CreateContactUseCase( + repository = repository, + validateContactName = validateContactNameUseCase, + signAddressEntries = signAddressEntriesUseCase, + timestampProvider = timestampProvider, + ) + } + + @Provides + @Singleton + fun provideUpdateContactUseCase( + repository: AddressBookRepository, + signAddressEntriesUseCase: SignAddressEntriesUseCase, + timestampProvider: IsoTimestampProvider, + ): UpdateContactUseCase { + return UpdateContactUseCase( + repository = repository, + signAddressEntries = signAddressEntriesUseCase, + timestampProvider = timestampProvider, + ) + } + + @Provides + @Singleton + fun provideDeleteContactUseCase(repository: AddressBookRepository): DeleteContactUseCase { + return DeleteContactUseCase(repository = repository) + } + @Provides @Singleton fun provideAddressBookCipher(): AddressBookCipher = AddressBookCipher() diff --git a/domain/address-book/build.gradle.kts b/domain/address-book/build.gradle.kts index 02180ffe1e..9f15edfeb6 100644 --- a/domain/address-book/build.gradle.kts +++ b/domain/address-book/build.gradle.kts @@ -14,6 +14,7 @@ dependencies { api(projects.domain.core) api(projects.domain.models) + implementation(projects.domain.common) implementation(projects.domain.transaction) implementation(projects.domain.tokens) diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/error/SaveContactError.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/error/SaveContactError.kt index f2c3e979ba..22f8b6a8d9 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/error/SaveContactError.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/error/SaveContactError.kt @@ -1,10 +1,13 @@ package com.tangem.domain.addressbook.error import com.tangem.domain.transaction.error.AddressValidation +import com.tangem.domain.transaction.error.SignHashesError sealed interface SaveContactError { data class Name(val error: ContactNameValidationError) : SaveContactError data class Address(val error: AddressValidation.Error) : SaveContactError + + data class Signing(val error: SignHashesError) : SaveContactError } \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/VerifiedContact.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/VerifiedContact.kt new file mode 100644 index 0000000000..4568d5d3d7 --- /dev/null +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/VerifiedContact.kt @@ -0,0 +1,12 @@ +package com.tangem.domain.addressbook.model + +/** + * @property contact the contact carrying only the entries whose signatures verified against the + * wallet — what should be shown to the user. + * @property invalidEntries entries that failed verification (tampered, signed by another wallet, or + * malformed). Hidden from the UI but kept for analytics. + */ +data class VerifiedContact( + val contact: Contact, + val invalidEntries: List, +) \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCase.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCase.kt index f7e861cd6f..45d623fe55 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCase.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCase.kt @@ -9,27 +9,30 @@ import com.tangem.domain.addressbook.model.ContactId import com.tangem.domain.addressbook.repository.AddressBookRepository import com.tangem.domain.addressbook.time.IsoTimestampProvider import com.tangem.domain.models.network.Network -import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.wallet.UserWallet import java.util.UUID /** * Creates a new [Contact] with client-generated UUID v4 ids. The name must be valid and unique - * the current time. + * the current time. Every address entry is signed with [userWallet]'s key before the contact is + * persisted, so only signed contacts are ever stored. */ class CreateContactUseCase( private val repository: AddressBookRepository, private val validateContactName: ValidateContactNameUseCase, + private val signAddressEntries: SignAddressEntriesUseCase, private val timestampProvider: IsoTimestampProvider, ) { @Suppress("LongParameterList") suspend operator fun invoke( - userWalletId: UserWalletId, + userWallet: UserWallet, name: String, network: Network, addressEntries: List, ): Either = either { + val userWalletId = userWallet.walletId val validName = validateContactName(userWalletId, name) .mapLeft(SaveContactError::Name) .bind() @@ -43,7 +46,10 @@ class CreateContactUseCase( updatedAt = now, addressEntries = addressEntries, ) - repository.saveContact(contact) - contact + val signed = signAddressEntries(userWallet, contact) + .mapLeft(SaveContactError::Signing) + .bind() + repository.saveContact(signed) + signed } } \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/GetVerifiedContactsUseCase.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/GetVerifiedContactsUseCase.kt new file mode 100644 index 0000000000..be933b28e7 --- /dev/null +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/GetVerifiedContactsUseCase.kt @@ -0,0 +1,28 @@ +package com.tangem.domain.addressbook.usecase + +import com.tangem.domain.addressbook.model.VerifiedContact +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class GetVerifiedContactsUseCase( + private val getContacts: GetContactsUseCase, + private val verifyAddressEntries: VerifyAddressEntriesUseCase, + private val userWalletsListRepository: UserWalletsListRepository, +) { + + operator fun invoke(query: String, userWalletId: UserWalletId? = null): Flow> { + return getContacts(query, userWalletId).map { contacts -> + val walletsById = userWalletsListRepository.userWalletsSync().associateBy { it.walletId } + contacts.mapNotNull { contact -> + val userWallet = walletsById[contact.walletId] ?: return@mapNotNull null + val verification = verifyAddressEntries(userWallet, contact).getOrNull() ?: return@mapNotNull null + VerifiedContact( + contact = contact.copy(addressEntries = verification.valid), + invalidEntries = verification.invalid, + ) + } + } + } +} \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCase.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCase.kt index 3f6580f0f4..3a6ed87bf1 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCase.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCase.kt @@ -9,18 +9,16 @@ import com.tangem.domain.addressbook.model.Contact import com.tangem.domain.addressbook.model.ContactName import com.tangem.domain.addressbook.repository.AddressBookRepository import com.tangem.domain.addressbook.time.IsoTimestampProvider +import com.tangem.domain.models.wallet.UserWallet -/** - - * format-checked — uniqueness is not re-validated on update. Address entries must be prepared and - * validated before calling this use case. [Contact.updatedAt] is restamped with the current time. - */ class UpdateContactUseCase( private val repository: AddressBookRepository, + private val signAddressEntries: SignAddressEntriesUseCase, private val timestampProvider: IsoTimestampProvider, ) { suspend operator fun invoke( + userWallet: UserWallet, contact: Contact, name: String, addressEntries: List, @@ -34,7 +32,10 @@ class UpdateContactUseCase( addressEntries = addressEntries, updatedAt = timestampProvider.now(), ) - repository.saveContact(updated) - updated + val signed = signAddressEntries(userWallet, updated) + .mapLeft(SaveContactError::Signing) + .bind() + repository.saveContact(signed) + signed } } \ No newline at end of file diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCaseTest.kt index c37c8c7d07..60a42a7729 100644 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCaseTest.kt +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCaseTest.kt @@ -1,5 +1,7 @@ package com.tangem.domain.addressbook.usecase +import arrow.core.left +import arrow.core.right import com.google.common.truth.Truth.assertThat import com.tangem.domain.addressbook.error.ContactNameValidationError import com.tangem.domain.addressbook.error.SaveContactError @@ -11,7 +13,9 @@ import com.tangem.domain.addressbook.model.ContactName import com.tangem.domain.addressbook.repository.AddressBookRepository import com.tangem.domain.addressbook.time.IsoTimestampProvider import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.transaction.error.SignHashesError import io.mockk.clearMocks import io.mockk.coEvery import io.mockk.coVerify @@ -32,13 +36,16 @@ class CreateContactUseCaseTest { private val timestampProvider: IsoTimestampProvider = mockk { every { now() } returns expectedTimestamp } + private val signAddressEntries: SignAddressEntriesUseCase = mockk() private val useCase = CreateContactUseCase( repository = repository, validateContactName = ValidateContactNameUseCase(repository), + signAddressEntries = signAddressEntries, timestampProvider = timestampProvider, ) private val walletId = UserWalletId("011") + private val userWallet: UserWallet = mockk { every { walletId } returns this@CreateContactUseCaseTest.walletId } private val networkRawId = Network.RawID("ethereum") private val networkId = Network.ID(value = "ethereum", derivationPath = Network.DerivationPath.None) private val network: Network = mockk { every { id } returns networkId } @@ -53,19 +60,25 @@ class CreateContactUseCaseTest { ), ) + private val signedEntries = listOf(addressEntries.first().copy(signature = "signed")) + @BeforeEach fun resetMocks() { - clearMocks(repository) + clearMocks(repository, signAddressEntries) + // Sign returns the contact with signed entries; the persisted contact must be the signed one. + coEvery { signAddressEntries(eq(userWallet), any()) } answers { + secondArg().copy(addressEntries = signedEntries).right() + } } @Test - fun `create generates ids and persists the contact`() = runTest { + fun `create generates ids and persists the signed contact`() = runTest { every { repository.getContacts(walletId) } returns flowOf(emptyList()) val saved = slot() coEvery { repository.saveContact(capture(saved)) } returns Unit val result = useCase( - userWalletId = walletId, + userWallet = userWallet, name = "Alice", network = network, addressEntries = addressEntries, @@ -76,17 +89,33 @@ class CreateContactUseCaseTest { assertThat(contact!!.walletId).isEqualTo(walletId) assertThat(contact.name.value).isEqualTo("Alice") assertThat(contact.id.value).isNotEmpty() - assertThat(contact.addressEntries).isEqualTo(addressEntries) + assertThat(contact.addressEntries).isEqualTo(signedEntries) assertThat(contact.createdAt).isEqualTo(expectedTimestamp) assertThat(contact.updatedAt).isEqualTo(expectedTimestamp) } + @Test + fun `signing failure fails without persisting`() = runTest { + every { repository.getContacts(walletId) } returns flowOf(emptyList()) + coEvery { signAddressEntries(eq(userWallet), any()) } returns SignHashesError.NoSigningKey.left() + + val result = useCase( + userWallet = userWallet, + name = "Alice", + network = network, + addressEntries = addressEntries, + ) + + assertThat(result.leftOrNull()).isEqualTo(SaveContactError.Signing(SignHashesError.NoSigningKey)) + coVerify(exactly = 0) { repository.saveContact(any()) } + } + @Test fun `duplicate name fails without persisting`() = runTest { every { repository.getContacts(walletId) } returns flowOf(listOf(contact(name = "Alice"))) val result = useCase( - userWalletId = walletId, + userWallet = userWallet, name = "alice", network = network, addressEntries = addressEntries, @@ -102,7 +131,7 @@ class CreateContactUseCaseTest { every { repository.getContacts(walletId) } returns flowOf(emptyList()) val result = useCase( - userWalletId = walletId, + userWallet = userWallet, name = "", network = network, addressEntries = addressEntries, diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetVerifiedContactsUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetVerifiedContactsUseCaseTest.kt new file mode 100644 index 0000000000..4c4e20d359 --- /dev/null +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetVerifiedContactsUseCaseTest.kt @@ -0,0 +1,116 @@ +package com.tangem.domain.addressbook.usecase + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.addressbook.model.AddressEntriesVerification +import com.tangem.domain.addressbook.model.AddressEntry +import com.tangem.domain.addressbook.model.AddressEntryId +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.model.VerifiedContact +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.transaction.error.VerifyMessagesError +import io.mockk.clearMocks +import io.mockk.coEvery +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 GetVerifiedContactsUseCaseTest { + + private val getContacts: GetContactsUseCase = mockk() + private val verifyAddressEntries: VerifyAddressEntriesUseCase = mockk() + private val userWalletsListRepository: UserWalletsListRepository = mockk() + + private val useCase = GetVerifiedContactsUseCase( + getContacts = getContacts, + verifyAddressEntries = verifyAddressEntries, + userWalletsListRepository = userWalletsListRepository, + ) + + private val walletId = UserWalletId("011") + private val userWallet: UserWallet = mockk { every { walletId } returns this@GetVerifiedContactsUseCaseTest.walletId } + + private val validEntry = entry(id = "valid", address = "0xvalid") + private val invalidEntry = entry(id = "invalid", address = "0xinvalid") + private val contact = contact(name = "Alice", entries = listOf(validEntry, invalidEntry)) + + @BeforeEach + fun resetMocks() { + clearMocks(getContacts, verifyAddressEntries, userWalletsListRepository) + coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet) + } + + @Test + fun `GIVEN mixed entries WHEN invoke THEN displays only valid AND keeps invalid for analytics`() = runTest { + // Arrange + every { getContacts(query = "", userWalletId = null) } returns flowOf(listOf(contact)) + every { verifyAddressEntries(userWallet, contact) } returns + AddressEntriesVerification(valid = listOf(validEntry), invalid = listOf(invalidEntry)).right() + + // Act + val result = useCase(query = "").first() + + // Assert + assertThat(result).containsExactly( + VerifiedContact( + contact = contact.copy(addressEntries = listOf(validEntry)), + invalidEntries = listOf(invalidEntry), + ), + ) + } + + @Test + fun `GIVEN wallet cannot be resolved WHEN invoke THEN contact is dropped`() = runTest { + // Arrange + coEvery { userWalletsListRepository.userWalletsSync() } returns emptyList() + every { getContacts(query = "", userWalletId = null) } returns flowOf(listOf(contact)) + + // Act + val result = useCase(query = "").first() + + // Assert + assertThat(result).isEmpty() + } + + @Test + fun `GIVEN verification fails WHEN invoke THEN contact is dropped`() = runTest { + // Arrange + every { getContacts(query = "", userWalletId = null) } returns flowOf(listOf(contact)) + every { verifyAddressEntries(userWallet, contact) } returns VerifyMessagesError.NoSigningKey.left() + + // Act + val result = useCase(query = "").first() + + // Assert + assertThat(result).isEmpty() + } + + private fun entry(id: String, address: String): AddressEntry = AddressEntry( + id = AddressEntryId(id), + address = address, + networkId = Network.RawID("ethereum"), + memo = null, + signature = "sig-$id", + ) + + private fun contact(name: String, entries: List): Contact = Contact( + id = ContactId("id-$name"), + walletId = walletId, + name = requireNotNull(ContactName(name).getOrNull()), + createdAt = "2026-01-01T00:00:00.000Z", + updatedAt = "2026-01-01T00:00:00.000Z", + addressEntries = entries, + ) +} \ No newline at end of file diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCaseTest.kt index 043697d440..7e3929dbe3 100644 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCaseTest.kt +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCaseTest.kt @@ -1,5 +1,7 @@ package com.tangem.domain.addressbook.usecase +import arrow.core.left +import arrow.core.right import com.google.common.truth.Truth.assertThat import com.tangem.domain.addressbook.error.ContactNameValidationError import com.tangem.domain.addressbook.error.SaveContactError @@ -11,7 +13,9 @@ import com.tangem.domain.addressbook.model.ContactName import com.tangem.domain.addressbook.repository.AddressBookRepository import com.tangem.domain.addressbook.time.IsoTimestampProvider import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.transaction.error.SignHashesError import io.mockk.clearMocks import io.mockk.coEvery import io.mockk.coVerify @@ -32,12 +36,15 @@ class UpdateContactUseCaseTest { private val timestampProvider: IsoTimestampProvider = mockk { every { now() } returns newTimestamp } + private val signAddressEntries: SignAddressEntriesUseCase = mockk() private val useCase = UpdateContactUseCase( repository = repository, + signAddressEntries = signAddressEntries, timestampProvider = timestampProvider, ) private val walletId = UserWalletId("011") + private val userWallet: UserWallet = mockk { every { walletId } returns this@UpdateContactUseCaseTest.walletId } private val networkRawId = Network.RawID("ethereum") private val updatedEntries = listOf( @@ -50,18 +57,24 @@ class UpdateContactUseCaseTest { ), ) + private val signedEntries = listOf(updatedEntries.first().copy(signature = "signed")) + @BeforeEach fun resetMocks() { - clearMocks(repository) + clearMocks(repository, signAddressEntries) + coEvery { signAddressEntries(eq(userWallet), any()) } answers { + secondArg().copy(addressEntries = signedEntries).right() + } } @Test - fun `update preserves id and persists changes without checking uniqueness`() = runTest { + fun `update preserves id and persists signed changes without checking uniqueness`() = runTest { val existing = contact(name = "Alice") val saved = slot() coEvery { repository.saveContact(capture(saved)) } returns Unit val result = useCase( + userWallet = userWallet, contact = existing, name = "Bob", addressEntries = updatedEntries, @@ -71,15 +84,31 @@ class UpdateContactUseCaseTest { assertThat(contact).isEqualTo(saved.captured) assertThat(contact!!.id).isEqualTo(existing.id) assertThat(contact.name.value).isEqualTo("Bob") - assertThat(contact.addressEntries).isEqualTo(updatedEntries) + assertThat(contact.addressEntries).isEqualTo(signedEntries) assertThat(contact.createdAt).isEqualTo(originalTimestamp) // preserved assertThat(contact.updatedAt).isEqualTo(newTimestamp) // restamped coVerify(exactly = 0) { repository.getContacts(any()) } } + @Test + fun `signing failure fails without persisting`() = runTest { + coEvery { signAddressEntries(eq(userWallet), any()) } returns SignHashesError.NoSigningKey.left() + + val result = useCase( + userWallet = userWallet, + contact = contact(name = "Alice"), + name = "Bob", + addressEntries = updatedEntries, + ) + + assertThat(result.leftOrNull()).isEqualTo(SaveContactError.Signing(SignHashesError.NoSigningKey)) + coVerify(exactly = 0) { repository.saveContact(any()) } + } + @Test fun `invalid name fails without persisting`() = runTest { val result = useCase( + userWallet = userWallet, contact = contact(name = "Alice"), name = "", addressEntries = updatedEntries, From 8891721b89d04c08c81ada83edc49563911710ba Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 19 Jun 2026 12:24:23 +0200 Subject: [PATCH 005/210] Updated on 2026-08-14 --- .claude/docs/navigation-graph.md | 56 +++++++++++++++++--------------- 1 file changed, 29 insertions(+), 27 deletions(-) diff --git a/.claude/docs/navigation-graph.md b/.claude/docs/navigation-graph.md index e869f1fd82..c51aa1544a 100644 --- a/.claude/docs/navigation-graph.md +++ b/.claude/docs/navigation-graph.md @@ -40,31 +40,31 @@ Complete navigation map of the app based on `AppRoute` sealed class and feature- | 30 | `OnrampSuccess` | `/onramp/success/{txId}` | Onramp success screen | | 31 | `BuyCrypto` | `/buy_crypto/{walletId}` | Buy crypto token selector | | 32 | `SellCrypto` | `/sell_crypto/{walletId}` | Sell crypto token selector | -| 34 | `Onboarding` | `/onboarding_v2/{mode}` | Onboarding flow (v2) | -| 35 | `Stories` | `/stories$storyId` | Stories / promotional content | -| 36 | `NFT` | `/nft/{walletId}` | NFT collection list | -| 37 | `NFTSend` | `/send/nft/{walletId}/{collection}/{assetId}` | Send NFT | -| 38 | `CreateWalletSelection` | `/create_wallet_selection` | Choose wallet creation type | -| 39 | `CreateWalletStart` | `/create_wallet_start` | Wallet creation intro (cold/hot) | -| 40 | `CreateHardwareWallet` | `/create_hardware_wallet` | Create hardware wallet flow | -| 41 | `CreateMobileWallet` | `/create_mobile_wallet` | Create mobile (hot) wallet | -| 42 | `UpgradeWallet` | `/upgrade_wallet/{walletId}` | Upgrade hot wallet to hardware | -| 43 | `AddExistingWallet` | `/add_existing_wallet` | Import existing wallet | -| 44 | `WalletActivation` | `/wallet_activation/{walletId}` | Activate wallet post-creation | -| 45 | `CreateWalletBackup` | `/create_wallet_backup/{walletId}` | Backup flow for created wallet | -| 46 | `UpdateAccessCode` | `/update_access_code/{walletId}` | Change access code | -| 47 | `ViewPhrase` | `/view_seed_phrase/{walletId}` | View recovery phrase | -| 48 | `ForgetWallet` | `/forget_wallet/{walletId}` | Remove wallet from app | -| 49 | `SendEntryPoint` | `/send_entry_point/{walletId}/{currencyId}` | Send entry with swap option | -| 50 | `CreateAccount` | `/create_account/{walletId}` | Create new account | -| 51 | `EditAccount` | `/edit_account/{accountId}` | Edit account | -| 52 | `AccountDetails` | `/account_details/{accountId}` | Account details screen | -| 53 | `ArchivedAccountList` | `/archived_account/{walletId}` | Archived accounts list | -| 54 | `TangemPayDetails` | `/tangem_pay_details/{walletId}` | Tangem Pay card details | -| 55 | `TangemPayOnboarding` | `/tangem_pay_onboarding/{mode}` | Tangem Pay onboarding | -| 56 | `Kyc` | `/kyc` | KYC verification | -| 57 | `YieldSupplyEntry` | `/yield_supply_entry/{walletId}/{symbol}` | Yield/supply entry point | -| 58 | `NewsDetails` | `/news_details/{newsId}` | News article detail | +| 33 | `Onboarding` | `/onboarding_v2/{mode}` | Onboarding flow (v2) | +| 34 | `Stories` | `/stories$storyId` | Stories / promotional content | +| 35 | `NFT` | `/nft/{walletId}` | NFT collection list | +| 36 | `NFTSend` | `/send/nft/{walletId}/{collection}/{assetId}` | Send NFT | +| 37 | `CreateWalletSelection` | `/create_wallet_selection` | Choose wallet creation type | +| 38 | `CreateWalletStart` | `/create_wallet_start` | Wallet creation intro (cold/hot) | +| 39 | `CreateHardwareWallet` | `/create_hardware_wallet` | Create hardware wallet flow | +| 40 | `CreateMobileWallet` | `/create_mobile_wallet` | Create mobile (hot) wallet | +| 41 | `UpgradeWallet` | `/upgrade_wallet/{walletId}` | Upgrade hot wallet to hardware | +| 42 | `AddExistingWallet` | `/add_existing_wallet` | Import existing wallet | +| 43 | `WalletActivation` | `/wallet_activation/{walletId}` | Activate wallet post-creation | +| 44 | `CreateWalletBackup` | `/create_wallet_backup/{walletId}` | Backup flow for created wallet | +| 45 | `UpdateAccessCode` | `/update_access_code/{walletId}` | Change access code | +| 46 | `ViewPhrase` | `/view_seed_phrase/{walletId}` | View recovery phrase | +| 47 | `ForgetWallet` | `/forget_wallet/{walletId}` | Remove wallet from app | +| 48 | `SendEntryPoint` | `/send_entry_point/{walletId}/{currencyId}` | Send entry with swap option | +| 49 | `CreateAccount` | `/create_account/{walletId}` | Create new account | +| 50 | `EditAccount` | `/edit_account/{accountId}` | Edit account | +| 51 | `AccountDetails` | `/account_details/{accountId}` | Account details screen | +| 52 | `ArchivedAccountList` | `/archived_account/{walletId}` | Archived accounts list | +| 53 | `TangemPayDetails` | `/tangem_pay_details/{walletId}` | Tangem Pay card details | +| 54 | `TangemPayOnboarding` | `/tangem_pay_onboarding/{mode}` | Tangem Pay onboarding | +| 55 | `Kyc` | `/kyc` | KYC verification | +| 56 | `YieldSupplyEntry` | `/yield_supply_entry/{walletId}/{symbol}` | Yield/supply entry point | +| 57 | `NewsDetails` | `/news_details/{newsId}` | News article detail | ## 2. Navigation Edges @@ -268,10 +268,10 @@ Each entry shows: **Source route** → target routes it can navigate to (via `pu |--------|--------|---------| | `CurrencyDetails` | push | Navigate to fee token | -### BuyCrypto / SellCrypto - +### Swap / BuyCrypto / SellCrypto | Target | Method | Trigger | |--------|--------|---------| +| `Swap` | push | After token selection (Swap) | | `Onramp` | push | After token selection (BuyCrypto/SellCrypto) | ### Deep Link Handlers (push to AppRoute) @@ -282,6 +282,7 @@ Each entry shows: **Source route** → target routes it can navigate to (via `pu | `SellRedirectDeepLinkHandler` | `Send` (with sell redirect params) | | `BuyDeepLinkHandler` | `BuyCrypto` | | `SellDeepLinkHandler` | `SellCrypto` | +| `SwapDeepLinkHandler` | `Swap` | | `ReferralDeepLinkHandler` | Referral handling | | `WalletDeepLinkHandler` | Wallet handling | | `TokenDetailsDeepLinkHandler` | `CurrencyDetails` | @@ -444,6 +445,7 @@ Transitions: `ManualBackupStart` → `ManualBackupPhrase` → `ManualBackupCheck | `redirect` | — | Buy redirect (no-op) | | `buy` | `BuyDeepLinkHandler` | `BuyCrypto` | | `sell` | `SellDeepLinkHandler` | `SellCrypto` | +| `swap` | `SwapDeepLinkHandler` | `Swap` | | `referral` | `ReferralDeepLinkHandler` | Referral flow | | `main` | `WalletDeepLinkHandler` | Wallet screen | | `token` | `TokenDetailsDeepLinkHandler` | `CurrencyDetails` | From 0fc2462b96c694e1c94a1dbcfd3b29505af9850a Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 15 Jun 2026 18:37:12 +0300 Subject: [PATCH 006/210] Updated on 2026-08-14 --- .../TxInfoToTxHistoryDetailsUMConverter.kt | 36 +- .../txhistory/entity/TxHistoryDetailsUM.kt | 74 ++++- .../txhistory/ui/TxHistoryDetailsContent.kt | 32 +- .../txhistory/ui/TxHistoryDetailsInfoRows.kt | 8 +- ...TxHistoryDetailsModalBottomSheetContent.kt | 3 +- .../ui/TxHistoryDetailsStatusBanner.kt | 311 ++++++++++++++++++ .../ui/TxHistoryDetailsTwoAssetsBlock.kt | 300 +++++++++++++++++ ...TxInfoToTxHistoryDetailsUMConverterTest.kt | 55 ++++ 8 files changed, 805 insertions(+), 14 deletions(-) create mode 100644 features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsStatusBanner.kt create mode 100644 features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsTwoAssetsBlock.kt diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxInfoToTxHistoryDetailsUMConverter.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxInfoToTxHistoryDetailsUMConverter.kt index 94be545f70..70bf982a6d 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxInfoToTxHistoryDetailsUMConverter.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxInfoToTxHistoryDetailsUMConverter.kt @@ -14,11 +14,13 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.TxInfo import com.tangem.domain.models.network.TxInfo.TransactionType import com.tangem.features.txhistory.entity.TxHistoryDetailsUM +import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.StatusBannerUM.Severity import com.tangem.features.txhistory.impl.R import com.tangem.utils.StringsSigns import com.tangem.utils.converter.Converter import com.tangem.utils.extensions.isZero import com.tangem.utils.toBriefAddressFormat +import kotlinx.collections.immutable.persistentListOf import org.joda.time.DateTime /** @@ -37,13 +39,18 @@ internal class TxInfoToTxHistoryDetailsUMConverter( private val iconStateConverter = CryptoCurrencyToIconStateConverter() override fun convert(value: TxInfo): TxHistoryDetailsUM = when (value.type) { - is TransactionType.Swap -> TxHistoryDetailsUM.TwoAssets(header = value.toHeaderUM()) + // TODO([REDACTED_TASK_KEY]): populate `from` / `to` legs once TxInfo exposes the swap legs (amounts, currencies, fiat). + // Until then the card falls back to the header-only placeholder (the TwoAssetsBlock UI is already wired). + is TransactionType.Swap -> TxHistoryDetailsUM.TwoAssets( + header = value.toHeaderUM(), + statusBanner = value.toStatusBannerUM(), + ) else -> TxHistoryDetailsUM.SingleAsset( header = value.toHeaderUM(), amountBlock = value.toAmountBlockUM(), counterparty = value.toCounterpartyUM(), // TODO: TxInfo has no network fee / rate yet — empty until those fields are added to TxInfo. - rows = emptyList(), + rows = persistentListOf(), ) } @@ -54,6 +61,31 @@ internal class TxInfoToTxHistoryDetailsUMConverter( subtitle = headerSubtitle(), ) + /** + * Express status plaque under the swap block. A stopgap over the three generic [TxInfo.TransactionStatus] values — + * so [Severity.Warning] (verification) is not reachable yet. + * + * [REDACTED_TODO_COMMENT] + */ + private fun TxInfo.toStatusBannerUM(): TxHistoryDetailsUM.StatusBannerUM = when (status) { + is TxInfo.TransactionStatus.Unconfirmed -> TxHistoryDetailsUM.StatusBannerUM( + severity = Severity.Info, + title = resourceReference(R.string.express_exchange_status_receiving_active), + isLoading = true, + ) + is TxInfo.TransactionStatus.Confirmed -> TxHistoryDetailsUM.StatusBannerUM( + severity = Severity.Success, + title = resourceReference(R.string.express_exchange_status_exchanged), + isLoading = false, + ) + is TxInfo.TransactionStatus.Failed -> TxHistoryDetailsUM.StatusBannerUM( + severity = Severity.Error, + title = resourceReference(R.string.express_exchange_status_failed), + subtitle = resourceReference(R.string.express_exchange_notification_failed_text), + isLoading = false, + ) + } + private fun TxInfo.toAmountBlockUM(): TxHistoryDetailsUM.AmountBlockUM = TxHistoryDetailsUM.AmountBlockUM( currencyIcon = iconStateConverter.convert(currency), amount = stringReference(signedAmount(currency)), diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt index 953290ca5d..3b3464e998 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt @@ -8,6 +8,7 @@ import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.transactions.state.TransactionItemUM import com.tangem.core.ui.ds.image.DeviceIconUM import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList /** * UI model for the in-app transaction details ("Operation") card. @@ -28,14 +29,79 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent { override val header: HeaderUM, val amountBlock: AmountBlockUM, val counterparty: CounterpartyUM?, - val rows: List, + val rows: ImmutableList, ) : TxHistoryDetailsUM - /** Two-asset layout: Swap / Onramp */ + /** + * Two-asset layout: Swap / Onramp. + * + * [from] ("You sent") → [to] ("You receive") exchange block. Both are nullable: the converter can't populate the + * legs yet (`TxInfo` exposes no swap amounts/currencies/fiat), so the card falls back to a header-only placeholder + * until that data lands. [statusBanner] is the express status plaque under the block, `null` until status is known. + */ data class TwoAssets( override val header: HeaderUM, + val from: AssetUM? = null, + val to: AssetUM? = null, + val statusBanner: StatusBannerUM? = null, ) : TxHistoryDetailsUM + /** + * Express status plaque under the two-asset block. The UI animates between successive emissions. + * + * @property severity Plaque colors (background tint + text/icon color). + * @property title Status line, e.g. "Awaiting funds" / "Confirmed" / "Failed". + * @property subtitle Optional second line (e.g. the refund hint on a failed terminal). + * @property isLoading `true` → trailing rotating loader (in-progress); `false` → static [severity] glyph. + */ + data class StatusBannerUM( + val severity: Severity, + val title: TextReference, + val subtitle: TextReference? = null, + val isLoading: Boolean, + ) { + + /** Visual severity of the [StatusBannerUM] — selects the background tint and the text/icon color. */ + enum class Severity { Info, Success, Error, Warning } + } + + /** + * One side of the two-asset block: the [label] over the signed [amount], with the [currencyIcon] on the trailing + * side. [owner] `null` → plain label ("You sent"); non-null → "From"/"To" prefix plus the resolved own account / + * wallet decoration. [isFaded] renders the unsettled/failed amount (struck through, recolored to tertiary). + */ + data class AssetUM( + val label: TextReference, + val owner: AssetOwnerUM?, + val amount: TextReference, + val currencyIcon: CurrencyIconState, + val isFaded: Boolean, + ) + + /** + * Counterparty rendered inline in an [AssetUM.label] when a swap leg resolves to one of the user's own portfolios. + * Carries the [name] plus a kind-specific 16dp decoration. Only own account / own wallet are decorated here (no + * address case, unlike the single-asset [CounterpartyAvatar]). + */ + @Immutable + sealed interface AssetOwnerUM { + + val name: TextReference + + /** User's own account — the [iconResId] glyph tinted over [backgroundColor], shown **before** the [name]. */ + data class Account( + override val name: TextReference, + @DrawableRes val iconResId: Int, + val backgroundColor: Color, + ) : AssetOwnerUM + + /** User's own wallet — the wallet card [deviceIconUM], shown **after** the [name]. */ + data class Wallet( + override val name: TextReference, + val deviceIconUM: DeviceIconUM, + ) : AssetOwnerUM + } + /** * Centered amount block of the single-asset card: token avatar (with network badge), the big signed crypto * [amount] and the secondary [fiatAmount]. @@ -43,7 +109,6 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent { * [isFailed] drives the failed visual state — the amount is struck through, recolored to tertiary and carries no * `+`/`−` sign (mirrors the status-driven recolor in the shared header). */ - @Immutable data class AmountBlockUM( val currencyIcon: CurrencyIconState, val amount: TextReference, @@ -55,7 +120,6 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent { * A single info row of the details card: a [label] on the leading side and its [value] on the trailing side * (e.g. `Network fee` → `0.00056 ETH`, `Rate` → `1 POL ≈ 0.36 USDT`). Rendered by [TxHistoryDetailsInfoRows]. */ - @Immutable data class InfoRowUM( val label: TextReference, val value: TextReference, @@ -76,7 +140,6 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent { * @property avatar Leading avatar. * @property onCopyClick Copy action; `null` hides the copy button (e.g. own-wallet has nothing to copy). */ - @Immutable data class CounterpartyUM( val label: TextReference, val title: TextReference, @@ -105,7 +168,6 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent { * Shared bottom-sheet top bar. The icon glyph and [title] text come from the transaction type; [status] drives * the three visual states (in-progress / confirmed / failed) — recoloring the icon circle and the title. */ - @Immutable data class HeaderUM( @DrawableRes val iconRes: Int, val status: TransactionItemUM.Content.Status, diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsContent.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsContent.kt index 168b677494..236d3ffeca 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsContent.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsContent.kt @@ -19,8 +19,7 @@ import com.tangem.features.txhistory.entity.TxHistoryDetailsUM internal fun TxHistoryDetailsContent(state: TxHistoryDetailsUM, modifier: Modifier = Modifier) { when (state) { is TxHistoryDetailsUM.SingleAsset -> SingleAssetContent(state = state, modifier = modifier) - // TODO([REDACTED_TASK_KEY]): two-asset (Swap / Onramp) body — out of scope for the single-asset amount block ticket. - is TxHistoryDetailsUM.TwoAssets -> TwoAssetsPlaceholder(state = state, modifier = modifier) + is TxHistoryDetailsUM.TwoAssets -> TwoAssetsContent(state = state, modifier = modifier) } } @@ -45,6 +44,35 @@ private fun SingleAssetContent(state: TxHistoryDetailsUM.SingleAsset, modifier: } } +@Composable +private fun TwoAssetsContent(state: TxHistoryDetailsUM.TwoAssets, modifier: Modifier = Modifier) { + val from = state.from + val to = state.to + Column(modifier = modifier.fillMaxWidth().padding(bottom = 16.dp)) { + if (from != null && to != null) { + TxHistoryDetailsTwoAssetsBlock( + from = from, + to = to, + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp), + ) + } else { + // TODO([REDACTED_TASK_KEY]): the converter cannot populate the swap legs yet (TxInfo exposes no two-leg / fiat / + // provider data). Until those fields land, fall back to the header-only placeholder. + TwoAssetsPlaceholder(state = state) + } + // Express status plaque under the exchange block. The top gap is owned by the banner (inside its collapsing + // region), so only horizontal padding is applied here. + TxHistoryDetailsStatusBanner( + state = state.statusBanner, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } +} + @Composable private fun TwoAssetsPlaceholder(state: TxHistoryDetailsUM.TwoAssets, modifier: Modifier = Modifier) { Box( diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsInfoRows.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsInfoRows.kt index 832800759a..9a9a742589 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsInfoRows.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsInfoRows.kt @@ -20,6 +20,8 @@ import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.InfoRowUM +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf /** * Info-rows block of the transaction details card: a vertical list of DS3 [TangemRow]s (label on the leading side, @@ -36,7 +38,7 @@ import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.InfoRowUM * @param modifier Modifier applied to the list container. */ @Composable -internal fun TxHistoryDetailsInfoRows(rows: List, modifier: Modifier = Modifier) { +internal fun TxHistoryDetailsInfoRows(rows: ImmutableList, modifier: Modifier = Modifier) { if (rows.isEmpty()) return Column( modifier = modifier, @@ -74,7 +76,7 @@ private fun TxHistoryDetailsInfoRowsPreview() { ) { // Multiple rows — dividers between rows, none after the last TxHistoryDetailsInfoRows( - rows = listOf( + rows = persistentListOf( InfoRowUM(label = stringReference("Network fee"), value = stringReference("0.00056 ETH")), InfoRowUM(label = stringReference("Rate"), value = stringReference("1 POL ≈ 0.36 USDT")), InfoRowUM(label = stringReference("Network fee"), value = stringReference("0.00056 ETH")), @@ -83,7 +85,7 @@ private fun TxHistoryDetailsInfoRowsPreview() { // Single row — no divider TxHistoryDetailsInfoRows( modifier = Modifier.padding(top = 16.dp), - rows = listOf( + rows = persistentListOf( InfoRowUM(label = stringReference("Network fee"), value = stringReference("0.00056 ETH")), ), ) diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsModalBottomSheetContent.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsModalBottomSheetContent.kt index 62309f8e01..bf58d7e39c 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsModalBottomSheetContent.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsModalBottomSheetContent.kt @@ -14,6 +14,7 @@ import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.features.txhistory.entity.TxHistoryDetailsUM +import kotlinx.collections.immutable.persistentListOf /** * The transaction details bottom sheet ("Operation"): the [TangemModalBottomSheet] shell shared by all transaction @@ -79,7 +80,7 @@ private fun previewSingleAsset() = TxHistoryDetailsUM.SingleAsset( ), onCopyClick = {}, ), - rows = listOf( + rows = persistentListOf( TxHistoryDetailsUM.InfoRowUM(label = stringReference("Network fee"), value = stringReference("0.00056 ETH")), ), ) diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsStatusBanner.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsStatusBanner.kt new file mode 100644 index 0000000000..3c8eb60f34 --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsStatusBanner.kt @@ -0,0 +1,311 @@ +package com.tangem.features.txhistory.ui + +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideInVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +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.ds2.loader.TangemLoader +import com.tangem.core.ui.ds2.loader.TangemLoaderSize +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference +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_error_20 +import com.tangem.core.ui.res.generated.icons.ic_info_20 +import com.tangem.core.ui.res.generated.icons.ic_success_20 +import com.tangem.core.ui.res.generated.icons.ic_warning_20 +import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.StatusBannerUM +import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.StatusBannerUM.Severity + +// Animation timings in ms (ProtoPie spec). The status swap is two-phase: the old status fades out, then the new one +// fades/slides in after ENTER_DELAY. Most steps run over the default duration; the trailing loader/glyph fades faster +// (FAST_FADE), and the plaque grows over GROW to make room for a subtitle. +private const val DEFAULT_ANIMATION_MILLIS = 300 +private const val FAST_FADE_MILLIS = 200 +private const val GROW_MILLIS = 400 +private const val ENTER_DELAY_MILLIS = DEFAULT_ANIMATION_MILLIS // phase 2 waits for the phase-1 fade-out to clear +private const val SUBTITLE_DELAY_MILLIS = ENTER_DELAY_MILLIS + 100 // subtitle trails the title + +private const val TITLE_SLIDE_FRACTION = 12 // in-progress/Success title slides in 1/12 width from the right +private const val CONTENT_RISE_FRACTION = 2 // Warning/Error title floats up 1/2 height from below +private const val ICON_ENTER_SCALE = 0.6f + +/** Gap between the exchange block above and the plaque; kept inside the collapsing region so it folds away cleanly. */ +private val BANNER_TOP_GAP = 12.dp + +/** Gap between the title row and the subtitle; lives inside the subtitle slot so it folds away when there's no line. */ +private val SUBTITLE_TOP_GAP = 4.dp + +/** Key for the title [AnimatedContent]: the resolved [text] plus the [severity] that selects the swap motion. */ +private data class StatusBannerTitle(val text: String, val severity: Severity) + +/** + * Title transition picked by the *target* severity: Info/Success slide in from the right ([titleSlide]); Warning/Error + * float up from below ([titleRise]). Both fade the old status out fully before fading the new one in. + */ +private fun titleTransition(target: Severity): ContentTransform = when (target) { + Severity.Warning, Severity.Error -> titleRise() + Severity.Info, Severity.Success -> titleSlide() +} + +/** In-progress / success swap: old status fades out, new one fades in sliding from the right. */ +private fun titleSlide(): ContentTransform = ContentTransform( + targetContentEnter = fadeIn(tween(durationMillis = DEFAULT_ANIMATION_MILLIS, delayMillis = ENTER_DELAY_MILLIS)) + + slideInHorizontally( + animationSpec = tween(durationMillis = DEFAULT_ANIMATION_MILLIS, delayMillis = ENTER_DELAY_MILLIS), + ) { width -> width / TITLE_SLIDE_FRACTION }, + initialContentExit = fadeOut(tween(durationMillis = DEFAULT_ANIMATION_MILLIS)), + sizeTransform = SizeTransform(clip = false) { _, _ -> snap() }, +) + +/** Terminal warning / error swap: old status fades out, new one fades in floating up a touch from below. */ +private fun titleRise(): ContentTransform = ContentTransform( + targetContentEnter = fadeIn(tween(durationMillis = DEFAULT_ANIMATION_MILLIS, delayMillis = ENTER_DELAY_MILLIS)) + + slideInVertically( + animationSpec = tween(durationMillis = DEFAULT_ANIMATION_MILLIS, delayMillis = ENTER_DELAY_MILLIS), + ) { height -> height / CONTENT_RISE_FRACTION }, + initialContentExit = fadeOut(tween(durationMillis = DEFAULT_ANIMATION_MILLIS)), + sizeTransform = SizeTransform(clip = false) { _, _ -> snap() }, +) + +/** Trailing-slot swap (loader → glyph): loader fades out (Phase 1), then the glyph "pops" in (Phase 2). */ +private fun iconSwapTransition(): ContentTransform = ContentTransform( + targetContentEnter = fadeIn(tween(durationMillis = FAST_FADE_MILLIS, delayMillis = ENTER_DELAY_MILLIS)) + + scaleIn( + animationSpec = tween(durationMillis = DEFAULT_ANIMATION_MILLIS, delayMillis = ENTER_DELAY_MILLIS), + initialScale = ICON_ENTER_SCALE, + ), + initialContentExit = fadeOut(tween(durationMillis = FAST_FADE_MILLIS)), + sizeTransform = SizeTransform(clip = false) { _, _ -> snap() }, +) + +/** + * Express status plaque of the Swap / Onramp transaction details, rendered under the two-asset exchange block. + * + * [Figma](https://www.figma.com/design/Qqm0dNTOnqtxLYEcmgc32C/Store?node-id=1370-114172) + * + * Two animation layers: [AnimatedVisibility] grows the plaque in from its top edge / collapses it to the bottom; + * in-place status transitions ([StatusBannerContent]) morph the title, background tint and trailing loader→glyph as + * the model re-emits the latest [state]. + * + * @param state Current status to render, or `null` to hide the plaque (animated out). + * @param modifier Modifier applied to the plaque container. + */ +@Composable +internal fun TxHistoryDetailsStatusBanner(state: StatusBannerUM?, modifier: Modifier = Modifier) { + // Retain the last non-null state so content stays rendered through the exit (collapse+fade). The retained value + // only backfills the exit (when [state] is null); published in a SideEffect, not written during composition. + val lastState = remember { mutableStateOf(null) } + SideEffect { if (state != null) lastState.value = state } + val content = state ?: lastState.value + + AnimatedVisibility( + visible = state != null, + // Fade and size share one tween so alpha and height finish together (mismatched default springs leave a jerk). + enter = fadeIn(tween(DEFAULT_ANIMATION_MILLIS)) + + expandVertically(tween(DEFAULT_ANIMATION_MILLIS), expandFrom = Alignment.Top), + exit = fadeOut(tween(DEFAULT_ANIMATION_MILLIS)) + + shrinkVertically(tween(DEFAULT_ANIMATION_MILLIS), shrinkTowards = Alignment.Bottom), + modifier = modifier, + ) { + // Leading gap lives inside the animated region so it collapses together with the plaque (no residual margin). + content?.let { StatusBannerContent(state = it, modifier = Modifier.padding(top = BANNER_TOP_GAP)) } + } +} + +@Composable +private fun StatusBannerContent(state: StatusBannerUM, modifier: Modifier = Modifier) { + val backgroundColor by animateColorAsState( + targetValue = state.severity.backgroundColor(), + // Delayed into Phase 2, so the tint starts shifting only once the old title has faded out, matching the spec. + animationSpec = tween(durationMillis = DEFAULT_ANIMATION_MILLIS, delayMillis = ENTER_DELAY_MILLIS), + label = "StatusBannerBackground", + ) + val contentColor = state.severity.contentColor() + + Column( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(24.dp)) + .background(backgroundColor) + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Animate the title as the status advances. Keyed on (text, severity) so [titleTransition] picks the motion + // by target; the key also colors each content from its own severity (see [color] below). + AnimatedContent( + targetState = StatusBannerTitle(state.title.resolveReference(), state.severity), + transitionSpec = { titleTransition(target = targetState.severity) }, + label = "StatusBannerTitle", + modifier = Modifier.weight(1f), + ) { title -> + Text( + text = title.text, + style = TangemTheme.typography3.body.medium, + // From this title's own key, so the outgoing title fades out in its colour instead of snapping. + color = title.severity.contentColor(), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + StatusBannerTrailing(isLoading = state.isLoading, severity = state.severity) + } + // Retain the last non-null subtitle so the line stays rendered while it fades out (mirrors the retain above). + val lastSubtitle = remember { mutableStateOf(null) } + SideEffect { if (state.subtitle != null) lastSubtitle.value = state.subtitle } + + AnimatedVisibility( + visible = state.subtitle != null, + // The subtitle owns the plaque's growth: expandVertically opens its slot in Phase 2, then the text fades in + // a touch later so it trails the title. expandVertically (not animateContentSize) lets us delay the growth. + enter = expandVertically( + animationSpec = tween(GROW_MILLIS, delayMillis = ENTER_DELAY_MILLIS), + expandFrom = Alignment.Top, + ) + fadeIn(tween(DEFAULT_ANIMATION_MILLIS, delayMillis = SUBTITLE_DELAY_MILLIS)), + exit = shrinkVertically(tween(DEFAULT_ANIMATION_MILLIS), shrinkTowards = Alignment.Top) + + fadeOut(tween(DEFAULT_ANIMATION_MILLIS)), + ) { + (state.subtitle ?: lastSubtitle.value)?.let { subtitle -> + Text( + text = subtitle.resolveReference(), + style = TangemTheme.typography3.caption.medium, + color = contentColor, + modifier = Modifier.padding(top = SUBTITLE_TOP_GAP), + ) + } + } + } +} + +/** Key for the trailing [AnimatedContent]: whether the loader or a glyph shows, plus the [severity] that tints it. */ +private data class StatusBannerGlyph(val isLoading: Boolean, val severity: Severity) + +/** Trailing slot: rotating loader while in progress, the static severity status glyph once terminal. */ +@Composable +private fun StatusBannerTrailing(isLoading: Boolean, severity: Severity, modifier: Modifier = Modifier) { + // Keyed on (isLoading, severity) so the tint comes from each content's own key — the outgoing loader then fades + // out in its colour instead of snapping to the incoming status'. + AnimatedContent( + targetState = StatusBannerGlyph(isLoading, severity), + transitionSpec = { iconSwapTransition() }, + label = "StatusBannerTrailing", + modifier = modifier, + ) { glyph -> + val tint = glyph.severity.contentColor() + if (glyph.isLoading) { + TangemLoader(size = TangemLoaderSize.X20, color = tint) + } else { + Icon( + imageVector = glyph.severity.statusIcon(), + contentDescription = null, + tint = tint, + modifier = Modifier.size(20.dp), + ) + } + } +} + +@Composable +private fun Severity.backgroundColor(): Color = when (this) { + Severity.Info -> TangemTheme.colors3.bg.status.infoSubtle + Severity.Success -> TangemTheme.colors3.bg.status.successSubtle + Severity.Error -> TangemTheme.colors3.bg.status.errorSubtle + Severity.Warning -> TangemTheme.colors3.bg.status.warningSubtle +} + +@Composable +private fun Severity.contentColor(): Color = when (this) { + Severity.Info -> TangemTheme.colors3.text.status.info + Severity.Success -> TangemTheme.colors3.text.status.success + Severity.Error -> TangemTheme.colors3.text.status.error + Severity.Warning -> TangemTheme.colors3.text.status.warning +} + +private fun Severity.statusIcon() = when (this) { + Severity.Success -> Icons.ic_success_20 + Severity.Error -> Icons.ic_error_20 + Severity.Warning -> Icons.ic_warning_20 + Severity.Info -> Icons.ic_info_20 +} + +// region Preview + +@Preview(name = "Light", showBackground = true, widthDp = 360) +@Preview(name = "Dark", uiMode = UI_MODE_NIGHT_YES, showBackground = true, widthDp = 360) +@Composable +private fun TxHistoryDetailsStatusBannerPreview() { + TangemThemePreviewRedesign { + Column( + modifier = Modifier + .background(TangemTheme.colors3.bg.primary) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + TxHistoryDetailsStatusBanner( + state = StatusBannerUM(Severity.Info, stringReference("Awaiting funds"), isLoading = true), + ) + TxHistoryDetailsStatusBanner( + state = StatusBannerUM(Severity.Info, stringReference("Deposit confirmed"), isLoading = true), + ) + TxHistoryDetailsStatusBanner( + state = StatusBannerUM(Severity.Success, stringReference("Confirmed"), isLoading = false), + ) + TxHistoryDetailsStatusBanner( + state = StatusBannerUM( + severity = Severity.Error, + title = stringReference("Failed"), + subtitle = stringReference("Visit provider's website to refund your money"), + isLoading = false, + ), + ) + TxHistoryDetailsStatusBanner( + state = StatusBannerUM( + severity = Severity.Warning, + title = stringReference("Verification required"), + subtitle = stringReference("Visit provider's website to refund your money"), + isLoading = false, + ), + ) + } + } +} +// endregion \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsTwoAssetsBlock.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsTwoAssetsBlock.kt new file mode 100644 index 0000000000..62e9dc2a16 --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsTwoAssetsBlock.kt @@ -0,0 +1,300 @@ +package com.tangem.features.txhistory.ui + +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextDecoration +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.R +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.components.currency.icon.TangemCurrencyIcon +import com.tangem.core.ui.ds.image.DeviceIconUM +import com.tangem.core.ui.ds.image.TangemDeviceIcon +import com.tangem.core.ui.ds2.row.TangemRow +import com.tangem.core.ui.ds2.row.TangemRowContentLead +import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.AssetOwnerUM +import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.AssetUM + +/** + * Two-asset ("exchange") block of the details card, used by Swap (and later Onramp): one `bg.tertiary` rounded cell + * with the [from] ("You sent") side over the [to] ("You receive") side, split by an inset dashed divider with a + * centered down-arrow masking the line. Each side is a [TangemRow]: label over the signed amount, avatar trailing. + * + * [Figma](https://www.figma.com/design/Qqm0dNTOnqtxLYEcmgc32C/Store?node-id=1265-87546) + * + * @param from Sent ("You sent" / "From …") side. + * @param to Received ("You receive" / "To …") side. + * @param modifier Modifier applied to the block container. + */ +@Composable +internal fun TxHistoryDetailsTwoAssetsBlock(from: AssetUM, to: AssetUM, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .clip(RoundedCornerShape(24.dp)) + .background(TangemTheme.colors3.bg.tertiary), + ) { + Column( + modifier = Modifier.padding(vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + TwoAssetsSideRow(asset = from) + DashedDivider() + TwoAssetsSideRow(asset = to) + } + // Centered exchange arrow. Both rows are equal-height, so the block center sits on the divider; the + // `bg.tertiary` chip behind the icon masks the dashed line, reproducing the Figma center gap. + Box( + modifier = Modifier + .align(Alignment.Center) + .clip(CircleShape) + .background(TangemTheme.colors3.bg.tertiary) + .padding(4.dp), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_arrow_down_24), + contentDescription = null, + tint = TangemTheme.colors3.icon.secondary, + modifier = Modifier.size(16.dp), + ) + } + } +} + +@Composable +private fun TwoAssetsSideRow(asset: AssetUM, modifier: Modifier = Modifier) { + TangemRow( + modifier = modifier, + contentLead = TangemRowContentLead.Start, + verticalAlignment = TangemRowVerticalAlignment.Center, + titleSlot = { TwoAssetsSideLabel(label = asset.label, owner = asset.owner) }, + subtitleSlot = { + Text( + text = asset.amount.resolveReference(), + style = TangemTheme.typography3.heading.small, + color = if (asset.isFaded) { + TangemTheme.colors3.text.tertiary + } else { + TangemTheme.colors3.text.primary + }, + textDecoration = if (asset.isFaded) TextDecoration.LineThrough else null, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 6.dp), + ) + }, + endSlot = { + TangemCurrencyIcon( + state = asset.currencyIcon, + modifier = Modifier.size(40.dp), + ) + }, + ) +} + +/** + * Caption label above a leg amount. Renders the [label] prefix ("You sent" / "You receive", or "From" / "To" when an + * [owner] is present) and, for a resolved [owner], its inline 16dp decoration in the Figma order — the account avatar + * leads its name, the wallet key-card icon trails its name. + */ +@Composable +private fun TwoAssetsSideLabel(label: TextReference, owner: AssetOwnerUM?, modifier: Modifier = Modifier) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + LabelText(text = label) + when (owner) { + is AssetOwnerUM.Account -> { + AssetOwnerIcon(owner = owner) + LabelText(text = owner.name, modifier = Modifier.weight(weight = 1f, fill = false)) + } + is AssetOwnerUM.Wallet -> { + LabelText(text = owner.name, modifier = Modifier.weight(weight = 1f, fill = false)) + AssetOwnerIcon(owner = owner) + } + null -> Unit + } + } +} + +@Composable +private fun LabelText(text: TextReference, modifier: Modifier = Modifier) { + Text( + text = text.resolveReference(), + style = TangemTheme.typography3.caption.medium, + color = TangemTheme.colors3.text.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = modifier, + ) +} + +/** 16dp inline owner decoration: the account glyph over its color, or the wallet device card. */ +@Composable +private fun AssetOwnerIcon(owner: AssetOwnerUM, modifier: Modifier = Modifier) { + val iconModifier = modifier.size(16.dp) + when (owner) { + is AssetOwnerUM.Account -> Box( + modifier = iconModifier + .clip(RoundedCornerShape(4.dp)) + .background(owner.backgroundColor), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(id = owner.iconResId), + contentDescription = null, + // staticDark == white in both themes (the constant glyph tone for a colored avatar), matching the + // white-on-color account avatar in Figma and the counterparty card / history-list account icon. + tint = TangemTheme.colors3.icon.staticDark, + modifier = Modifier.size(8.dp), + ) + } + is AssetOwnerUM.Wallet -> TangemDeviceIcon( + state = owner.deviceIconUM, + modifier = iconModifier, + ) + } +} + +/** 1px inset dashed divider between the two sides, matching the Figma `divider` (dashed `line`). */ +@Composable +private fun DashedDivider(modifier: Modifier = Modifier) { + val color = TangemTheme.colors3.border.tertiary + Box( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .height(1.dp) + .drawBehind { + val stroke = 1.dp.toPx() + val y = size.height / 2f + drawLine( + color = color, + start = Offset(x = 0f, y = y), + end = Offset(x = size.width, y = y), + strokeWidth = stroke, + cap = StrokeCap.Round, + pathEffect = PathEffect.dashPathEffect( + intervals = floatArrayOf(2.dp.toPx(), 4.dp.toPx()), + ), + ) + }, + ) +} + +// region Preview + +@Suppress("MagicNumber") +@Preview(name = "Light", showBackground = true, widthDp = 360) +@Preview(name = "Dark", uiMode = UI_MODE_NIGHT_YES, showBackground = true, widthDp = 360) +@Composable +private fun TxHistoryDetailsTwoAssetsBlockPreview() { + TangemThemePreviewRedesign { + Column( + modifier = Modifier + .background(TangemTheme.colors3.bg.primary) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Plain swap (no resolved owner) — both sides settled. + TxHistoryDetailsTwoAssetsBlock( + from = previewAsset(label = "You sent", amount = "- 390 USDT", isFaded = false), + to = previewAsset(label = "You receive", amount = "+ 1,800.00 POL", isFaded = false), + ) + // Unsettled swap — the "You receive" side is struck through until the funds arrive. + TxHistoryDetailsTwoAssetsBlock( + from = previewAsset(label = "You sent", amount = "- 390 USDT", isFaded = false), + to = previewAsset(label = "You receive", amount = "1,800.00 POL", isFaded = true), + ) + // Account -> another account (own-to-own transfer between two of the user's accounts). + TxHistoryDetailsTwoAssetsBlock( + from = previewAsset( + label = "From", + amount = "- 390 USDT", + isFaded = false, + owner = AssetOwnerUM.Account( + name = stringReference("Main account"), + iconResId = R.drawable.ic_rounded_star_24, + backgroundColor = Color(0xFF007FFF), + ), + ), + to = previewAsset( + label = "To", + amount = "+ 1,800.00 POL", + isFaded = false, + owner = AssetOwnerUM.Account( + name = stringReference("Family"), + iconResId = R.drawable.ic_family_24, + backgroundColor = Color(0xFF744FF1), + ), + ), + ) + // Wallet -> another wallet (own-to-own transfer between two of the user's wallets). + TxHistoryDetailsTwoAssetsBlock( + from = previewAsset( + label = "From", + amount = "- 390 USDT", + isFaded = false, + owner = AssetOwnerUM.Wallet( + name = stringReference("Tangem 2.0"), + deviceIconUM = DeviceIconUM.Card(mainColor = Color(0xFF1E1E1E), secondColor = null), + ), + ), + to = previewAsset( + label = "To", + amount = "+ 1,800.00 POL", + isFaded = false, + owner = AssetOwnerUM.Wallet( + name = stringReference("My Wallet"), + deviceIconUM = DeviceIconUM.Ring(mainColor = Color(0xFF9F86FF)), + ), + ), + ) + } + } +} + +private fun previewAsset(label: String, amount: String, isFaded: Boolean, owner: AssetOwnerUM? = null) = AssetUM( + label = stringReference(label), + owner = owner, + amount = stringReference(amount), + currencyIcon = CurrencyIconState.CoinIcon( + url = null, + fallbackResId = R.drawable.img_eth_22, + isGrayscale = false, + shouldShowCustomBadge = false, + ), + isFaded = isFaded, +) + +// endregion \ No newline at end of file diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxInfoToTxHistoryDetailsUMConverterTest.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxInfoToTxHistoryDetailsUMConverterTest.kt index 7d7eb52f06..2ddf7a9b3a 100644 --- a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxInfoToTxHistoryDetailsUMConverterTest.kt +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxInfoToTxHistoryDetailsUMConverterTest.kt @@ -102,6 +102,61 @@ internal class TxInfoToTxHistoryDetailsUMConverterTest { assertThat(header.iconRes).isEqualTo(R.drawable.ic_exchange_vertical_24) } + @Test + fun `GIVEN unconfirmed Swap WHEN convert THEN info status banner with loader`() { + // Arrange + val tx = txInfo(type = TransactionType.Swap, status = TxInfo.TransactionStatus.Unconfirmed) + + // Act + val banner = (converter.convert(tx) as TxHistoryDetailsUM.TwoAssets).statusBanner + + // Assert + assertThat(banner).isEqualTo( + TxHistoryDetailsUM.StatusBannerUM( + severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Info, + title = resourceReference(R.string.express_exchange_status_receiving_active), + isLoading = true, + ), + ) + } + + @Test + fun `GIVEN confirmed Swap WHEN convert THEN success status banner without loader`() { + // Arrange + val tx = txInfo(type = TransactionType.Swap, status = TxInfo.TransactionStatus.Confirmed) + + // Act + val banner = (converter.convert(tx) as TxHistoryDetailsUM.TwoAssets).statusBanner + + // Assert + assertThat(banner).isEqualTo( + TxHistoryDetailsUM.StatusBannerUM( + severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Success, + title = resourceReference(R.string.express_exchange_status_exchanged), + isLoading = false, + ), + ) + } + + @Test + fun `GIVEN failed Swap WHEN convert THEN error status banner with refund subtitle`() { + // Arrange + val tx = txInfo(type = TransactionType.Swap, status = TxInfo.TransactionStatus.Failed) + + // Act + val banner = (converter.convert(tx) as TxHistoryDetailsUM.TwoAssets).statusBanner + + // Assert + assertThat(banner).isEqualTo( + TxHistoryDetailsUM.StatusBannerUM( + severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Error, + title = resourceReference(R.string.express_exchange_status_failed), + subtitle = resourceReference(R.string.express_exchange_notification_failed_text), + isLoading = false, + ), + ) + } + @Test fun `GIVEN incoming Transfer WHEN convert THEN amount block has plus sign and not failed`() { // Arrange From 916a4a72425800baf536f2436b99b420005b6cce Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 19 Jun 2026 13:20:36 +0200 Subject: [PATCH 007/210] Updated on 2026-08-14 --- .../tangem/tap/di/domain/SwapDomainModule.kt | 7 - .../tangem/tap/routing/utils/ChildFactory.kt | 8 - .../com/tangem/common/routing/AppRoute.kt | 5 - .../models/event/MainScreenAnalyticsEvent.kt | 17 - .../models/event/SwapAnalyticsEvent.kt | 13 - .../component/SwapSelectTokensComponent.kt | 22 - .../swap/DefaultSwapSelectTokensComponent.kt | 109 --- .../AvailableSwapPairsComponent.kt | 37 - .../DefaultAvailableSwapPairsComponent.kt | 44 -- .../di/AvailableSwapPairsComponentModule.kt | 20 - .../di/AvailableSwapPairsModelModule.kt | 20 - .../SetErrorWarningTransformer.kt | 27 - .../SetLoadingTokenItemsTransformer.kt | 28 - .../SetNoAvailablePairsTransformer.kt | 66 -- .../market/SwapMarketsListBatchFlowManager.kt | 252 ------- .../SwapMarketsTokenItemConverter.kt | 161 ----- .../market/state/SwapMarketState.kt | 41 -- .../model/AddToPortfolioRoute.kt | 7 - .../model/AvailableSwapPairsModel.kt | 648 ------------------ .../availablepairs/ui/SwapMarketsListItems.kt | 114 --- .../di/SwapSelectTokensComponentModule.kt | 20 - .../swap/di/SwapSelectTokensModelModule.kt | 20 - .../onramp/swap/entity/ExchangeCardUM.kt | 63 -- .../swap/entity/SwapSelectTokensController.kt | 37 - .../onramp/swap/entity/SwapSelectTokensUM.kt | 17 - .../entity/SwapSelectTokensUMTransformer.kt | 10 - .../RemoveSelectedFromTokenTransformer.kt | 21 - .../RemoveSelectedToTokenTransformer.kt | 29 - .../transformer/SelectFromTokenTransformer.kt | 36 - .../transformer/SelectToTokenTransformer.kt | 38 - .../swap/entity/utils/ExchangeCardUMExt.kt | 57 -- .../swap/model/SwapSelectTokensModel.kt | 187 ----- .../features/onramp/swap/ui/ExchangeCard.kt | 214 ------ .../onramp/swap/ui/SwapSelectTokens.kt | 214 ------ .../entity/AccountAvailabilityTokenUM.kt | 2 +- .../onramp/tokenlist/entity/TokenListUM.kt | 5 +- .../LoadingAccountTokenItemConverter.kt | 2 +- .../LoadingTokenListItemConverter.kt | 2 +- .../SetLoadingAccountTokenListTransformer.kt | 2 - .../UpdateAccountTokenItemConverter.kt | 2 +- .../UpdateAccountTokenListTransformer.kt | 2 +- .../tokenlist/model/OnrampTokenListModel.kt | 4 +- .../onramp/tokenlist/ui/OnrampTokenList.kt | 49 -- .../feature/swap/DefaultSwapRepository.kt | 94 --- .../tangem/feature/swap/di/SwapDataModule.kt | 4 - .../swap/domain/GetAvailablePairsUseCase.kt | 25 - .../feature/swap/domain/api/SwapRepository.kt | 8 - 47 files changed, 8 insertions(+), 2802 deletions(-) delete mode 100644 features/onramp/api/src/main/kotlin/com/tangem/features/onramp/component/SwapSelectTokensComponent.kt delete mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/DefaultSwapSelectTokensComponent.kt delete mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/AvailableSwapPairsComponent.kt delete mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/DefaultAvailableSwapPairsComponent.kt delete mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/di/AvailableSwapPairsComponentModule.kt delete mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/di/AvailableSwapPairsModelModule.kt delete mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/transformers/SetErrorWarningTransformer.kt delete mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/transformers/SetLoadingTokenItemsTransformer.kt delete mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/transformers/SetNoAvailablePairsTransformer.kt delete mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/market/SwapMarketsListBatchFlowManager.kt delete mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/market/converter/SwapMarketsTokenItemConverter.kt delete mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/market/state/SwapMarketState.kt delete mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AddToPortfolioRoute.kt delete mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt delete mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/ui/SwapMarketsListItems.kt delete mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/di/SwapSelectTokensComponentModule.kt delete mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/di/SwapSelectTokensModelModule.kt delete mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/ExchangeCardUM.kt delete mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/SwapSelectTokensController.kt delete mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/SwapSelectTokensUM.kt delete mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/SwapSelectTokensUMTransformer.kt delete mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/transformer/RemoveSelectedFromTokenTransformer.kt delete mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/transformer/RemoveSelectedToTokenTransformer.kt delete mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/transformer/SelectFromTokenTransformer.kt delete mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/transformer/SelectToTokenTransformer.kt delete mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/utils/ExchangeCardUMExt.kt delete mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/model/SwapSelectTokensModel.kt delete mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/ui/ExchangeCard.kt delete mode 100644 features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/ui/SwapSelectTokens.kt rename features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/{swap => tokenlist}/entity/AccountAvailabilityTokenUM.kt (87%) rename features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/{swap/availablepairs/entity/converters => tokenlist/entity/transformer}/LoadingAccountTokenItemConverter.kt (94%) rename features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/{swap/availablepairs/entity/converters => tokenlist/entity/transformer}/LoadingTokenListItemConverter.kt (93%) delete mode 100644 features/swap/domain/src/main/java/com/tangem/feature/swap/domain/GetAvailablePairsUseCase.kt diff --git a/app/src/main/java/com/tangem/tap/di/domain/SwapDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/SwapDomainModule.kt index 1589246e98..9f411a7900 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/SwapDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/SwapDomainModule.kt @@ -4,7 +4,6 @@ import com.tangem.domain.swap.SwapErrorResolver import com.tangem.domain.swap.SwapRepositoryV2 import com.tangem.domain.swap.SwapTransactionRepository import com.tangem.domain.swap.usecase.* -import com.tangem.feature.swap.domain.GetAvailablePairsUseCase import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -19,12 +18,6 @@ import com.tangem.feature.swap.domain.api.SwapRepository as OldSwapRepository @InstallIn(SingletonComponent::class) internal object SwapDomainModule { - @Provides - @Singleton - fun provideGetAvailablePairsUseCase(swapRepository: OldSwapRepository): GetAvailablePairsUseCase { - return GetAvailablePairsUseCase(swapRepository = swapRepository) - } - @Provides @Singleton fun provideGetSwapSupportedPairsUseCase( diff --git a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index 8f1f852eb9..22d8642887 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt @@ -71,7 +71,6 @@ internal class ChildFactory @Inject constructor( private val onrampSuccessComponentFactory: OnrampSuccessComponent.Factory, private val buyCryptoComponentFactory: BuyCryptoComponent.Factory, private val sellCryptoComponentFactory: SellCryptoComponent.Factory, - private val swapSelectTokensComponentFactory: SwapSelectTokensComponent.Factory, private val onboardingEntryComponentFactory: OnboardingEntryComponent.Factory, private val newWelcomeComponentFactory: NewWelcomeComponent.Factory, private val storiesComponentFactory: StoriesComponent.Factory, @@ -253,13 +252,6 @@ internal class ChildFactory @Inject constructor( componentFactory = sellCryptoComponentFactory, ) } - is AppRoute.SwapCrypto -> { - createComponentChild( - context = context, - params = SwapSelectTokensComponent.Params(userWalletId = route.userWalletId), - componentFactory = swapSelectTokensComponentFactory, - ) - } is AppRoute.Onboarding -> { createComponentChild( context = context, diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt index 1f04371c14..00c067b335 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt @@ -331,11 +331,6 @@ sealed class AppRoute(val path: String) : Route { val userWalletId: UserWalletId, ) : AppRoute(path = "/sell_crypto/${userWalletId.stringValue}") - @Serializable - data class SwapCrypto( - val userWalletId: UserWalletId, - ) : AppRoute(path = "/swap_crypto/${userWalletId.stringValue}") - /** * Onboarding V2 * @property scanResponse scan response, determines onboarding route by the product type diff --git a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/event/MainScreenAnalyticsEvent.kt b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/event/MainScreenAnalyticsEvent.kt index b09ed9c19d..5440a2e92f 100644 --- a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/event/MainScreenAnalyticsEvent.kt +++ b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/event/MainScreenAnalyticsEvent.kt @@ -82,8 +82,6 @@ sealed class MainScreenAnalyticsEvent( class BuyScreenOpened : MainScreenAnalyticsEvent(event = "Buy Screen Opened") - class SwapScreenOpened : MainScreenAnalyticsEvent(event = "Swap Screen Opened") - class SellScreenOpened : MainScreenAnalyticsEvent(event = "Sell Screen Opened") data class BuyTokenClicked(val currencySymbol: String) : MainScreenAnalyticsEvent( @@ -96,21 +94,6 @@ sealed class MainScreenAnalyticsEvent( params = mapOf(TOKEN_PARAM to currencySymbol), ) - data class SwapTokenClicked(val currencySymbol: String) : MainScreenAnalyticsEvent( - event = "Swap Token Clicked", - params = mapOf(TOKEN_PARAM to currencySymbol), - ) - - data class ReceiveTokenClicked(val currencySymbol: String) : MainScreenAnalyticsEvent( - event = "Receive Token Clicked", - params = mapOf(TOKEN_PARAM to currencySymbol), - ) - - data class RemoveTokenClicked(val currencySymbol: String) : MainScreenAnalyticsEvent( - event = "Remove Button Clicked", - params = mapOf(TOKEN_PARAM to currencySymbol), - ) - data class ButtonClose(val source: AnalyticsParam.ScreensSources) : MainScreenAnalyticsEvent( event = "Button - Close", params = mapOf(AnalyticsParam.SOURCE to source.value), diff --git a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/event/SwapAnalyticsEvent.kt b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/event/SwapAnalyticsEvent.kt index 74b68afc09..7a4f738c72 100644 --- a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/event/SwapAnalyticsEvent.kt +++ b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/event/SwapAnalyticsEvent.kt @@ -15,19 +15,6 @@ sealed class SwapAnalyticsEvent( params: Map = emptyMap(), ) : AnalyticsEvent("Swap", event, params) { - data class TokenSelected( - val token: String, - val source: ScreensSources, - val isSearched: Boolean, - ) : SwapAnalyticsEvent( - event = "Token Selected", - params = mapOf( - TOKEN_PARAM to token, - SOURCE to source.value, - SEARCHED to if (isSearched) "True" else "False", - ), - ) - class FilterProvider(filterType: String) : SwapAnalyticsEvent( event = "Filter Provider", params = mapOf(TYPE to filterType), diff --git a/features/onramp/api/src/main/kotlin/com/tangem/features/onramp/component/SwapSelectTokensComponent.kt b/features/onramp/api/src/main/kotlin/com/tangem/features/onramp/component/SwapSelectTokensComponent.kt deleted file mode 100644 index 279c7b4747..0000000000 --- a/features/onramp/api/src/main/kotlin/com/tangem/features/onramp/component/SwapSelectTokensComponent.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.tangem.features.onramp.component - -import com.tangem.core.decompose.factory.ComponentFactory -import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.domain.models.wallet.UserWalletId - -/** - * Swap select tokens component - * -[REDACTED_AUTHOR] - */ -interface SwapSelectTokensComponent : ComposableContentComponent { - - interface Factory : ComponentFactory - - /** - * Params - * - * @property userWalletId user wallet id - */ - data class Params(val userWalletId: UserWalletId) -} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/DefaultSwapSelectTokensComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/DefaultSwapSelectTokensComponent.kt deleted file mode 100644 index d18755aaf4..0000000000 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/DefaultSwapSelectTokensComponent.kt +++ /dev/null @@ -1,109 +0,0 @@ -package com.tangem.features.onramp.swap - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.arkivanov.decompose.ComponentContext -import com.arkivanov.decompose.extensions.compose.subscribeAsState -import com.arkivanov.decompose.router.slot.childSlot -import com.tangem.core.analytics.api.AnalyticsEventHandler -import com.tangem.core.analytics.models.event.MainScreenAnalyticsEvent -import com.tangem.core.decompose.context.AppComponentContext -import com.tangem.core.decompose.context.child -import com.tangem.core.decompose.context.childByContext -import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.core.ui.decompose.ComposableBottomSheetComponent -import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent -import com.tangem.features.onramp.component.SwapSelectTokensComponent -import com.tangem.features.onramp.swap.availablepairs.AvailableSwapPairsComponent -import com.tangem.features.onramp.swap.availablepairs.model.AddToPortfolioRoute -import com.tangem.features.onramp.swap.model.SwapSelectTokensModel -import com.tangem.features.onramp.swap.ui.SwapSelectTokens -import com.tangem.features.onramp.tokenlist.OnrampTokenListComponent -import com.tangem.features.onramp.tokenlist.entity.OnrampOperation -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject - -@Stable -internal class DefaultSwapSelectTokensComponent @AssistedInject constructor( - tokenListComponentFactory: OnrampTokenListComponent.Factory, - availableSwapPairsComponentFactory: AvailableSwapPairsComponent.Factory, - analyticsEventHandler: AnalyticsEventHandler, - private val addToPortfolioComponentFactory: AddToPortfolioComponent.Factory, - @Assisted private val appComponentContext: AppComponentContext, - @Assisted private val params: SwapSelectTokensComponent.Params, -) : AppComponentContext by appComponentContext, SwapSelectTokensComponent { - - private val model: SwapSelectTokensModel = getOrCreateModel(params) - - private val selectFromTokenListComponent: OnrampTokenListComponent = tokenListComponentFactory.create( - context = child(key = "select_from_token_list"), - params = OnrampTokenListComponent.Params( - filterOperation = OnrampOperation.SWAP, - userWalletId = params.userWalletId, - onTokenClick = model::selectFromToken, - ), - ) - - private val selectToTokenListComponent: AvailableSwapPairsComponent = availableSwapPairsComponentFactory.create( - context = child(key = "select_to_token_list"), - params = AvailableSwapPairsComponent.Params( - userWalletId = params.userWalletId, - selectedStatus = model.fromCurrencyStatus, - onTokenClick = model::selectToToken, - ), - ) - - private val bottomSheetSlot = childSlot( - source = selectToTokenListComponent.bottomSheetNavigation, - serializer = AddToPortfolioRoute.serializer(), - key = "add_to_portfolio_bottom_sheet", - handleBackButton = false, - childFactory = { _, context -> bottomSheetChild(context) }, - ) - - init { - analyticsEventHandler.send(event = MainScreenAnalyticsEvent.SwapScreenOpened()) - } - - @Suppress("UnsafeCallOnNullableType") - private fun bottomSheetChild(componentContext: ComponentContext): ComposableBottomSheetComponent { - return addToPortfolioComponentFactory.create( - context = childByContext(componentContext), - params = AddToPortfolioComponent.Params( - addToPortfolioManager = selectToTokenListComponent.addToPortfolioManager, - ), - ) - } - - @Composable - override fun Content(modifier: Modifier) { - val state by model.state.collectAsStateWithLifecycle() - val fromTokensState by selectFromTokenListComponent.uiState.collectAsStateWithLifecycle() - val toTokensState by selectToTokenListComponent.uiState.collectAsStateWithLifecycle() - val bottomSheet by bottomSheetSlot.subscribeAsState() - - SwapSelectTokens( - state = state, - selectFromTokenListComponent = selectFromTokenListComponent, - selectFromTokenListState = fromTokensState, - selectToTokenListComponent = selectToTokenListComponent, - selectToTokenListState = toTokensState, - modifier = modifier, - ) - - bottomSheet.child?.instance?.BottomSheet() - } - - @AssistedFactory - interface Factory : SwapSelectTokensComponent.Factory { - - override fun create( - context: AppComponentContext, - params: SwapSelectTokensComponent.Params, - ): DefaultSwapSelectTokensComponent - } -} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/AvailableSwapPairsComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/AvailableSwapPairsComponent.kt deleted file mode 100644 index 2292f1bdf8..0000000000 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/AvailableSwapPairsComponent.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.tangem.features.onramp.swap.availablepairs - -import androidx.compose.runtime.Stable -import com.arkivanov.decompose.router.slot.SlotNavigation -import com.tangem.core.decompose.factory.ComponentFactory -import com.tangem.core.ui.components.token.state.TokenItemState -import com.tangem.core.ui.decompose.ComposableListContentComponent -import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager -import com.tangem.features.onramp.swap.availablepairs.model.AddToPortfolioRoute -import com.tangem.features.onramp.tokenlist.entity.TokenListUM -import kotlinx.coroutines.flow.StateFlow - -/** Token list component that present list of available tokens for swap */ -@Stable -internal interface AvailableSwapPairsComponent : ComposableListContentComponent { - - val bottomSheetNavigation: SlotNavigation - val addToPortfolioManager: AddToPortfolioManager - - /** Component factory */ - interface Factory : ComponentFactory - - /** - * Params - * - * @property userWalletId id of multi-currency wallet - * @property selectedStatus flow of selected status - * @property onTokenClick callback for token click - */ - data class Params( - val userWalletId: UserWalletId, - val selectedStatus: StateFlow, - val onTokenClick: (TokenItemState, CryptoCurrencyStatus) -> Unit, - ) -} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/DefaultAvailableSwapPairsComponent.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/DefaultAvailableSwapPairsComponent.kt deleted file mode 100644 index 347d4ad933..0000000000 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/DefaultAvailableSwapPairsComponent.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.tangem.features.onramp.swap.availablepairs - -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.runtime.Stable -import androidx.compose.ui.Modifier -import com.arkivanov.decompose.router.slot.SlotNavigation -import com.tangem.core.decompose.context.AppComponentContext -import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager -import com.tangem.features.onramp.swap.availablepairs.model.AddToPortfolioRoute -import com.tangem.features.onramp.swap.availablepairs.model.AvailableSwapPairsModel -import com.tangem.features.onramp.tokenlist.entity.TokenListUM -import com.tangem.features.onramp.tokenlist.ui.onrampSwapTokenList -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import kotlinx.coroutines.flow.StateFlow - -@Stable -internal class DefaultAvailableSwapPairsComponent @AssistedInject constructor( - @Assisted context: AppComponentContext, - @Assisted params: AvailableSwapPairsComponent.Params, -) : AvailableSwapPairsComponent, AppComponentContext by context { - - private val model: AvailableSwapPairsModel = getOrCreateModel(params) - - override val bottomSheetNavigation: SlotNavigation get() = model.bottomSheetNavigation - override val addToPortfolioManager: AddToPortfolioManager get() = model.addToPortfolioManager - - override val uiState: StateFlow - get() = model.state - - override fun LazyListScope.content(uiState: TokenListUM, modifier: Modifier) { - onrampSwapTokenList(state = uiState) - } - - @AssistedFactory - interface Factory : AvailableSwapPairsComponent.Factory { - override fun create( - context: AppComponentContext, - params: AvailableSwapPairsComponent.Params, - ): DefaultAvailableSwapPairsComponent - } -} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/di/AvailableSwapPairsComponentModule.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/di/AvailableSwapPairsComponentModule.kt deleted file mode 100644 index 0f451e0008..0000000000 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/di/AvailableSwapPairsComponentModule.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.tangem.features.onramp.swap.availablepairs.di - -import com.tangem.features.onramp.swap.availablepairs.AvailableSwapPairsComponent -import com.tangem.features.onramp.swap.availablepairs.DefaultAvailableSwapPairsComponent -import dagger.Binds -import dagger.Module -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -internal interface AvailableSwapPairsComponentModule { - - @Binds - @Singleton - fun bindAvailableSwapPairsComponentFactory( - factory: DefaultAvailableSwapPairsComponent.Factory, - ): AvailableSwapPairsComponent.Factory -} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/di/AvailableSwapPairsModelModule.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/di/AvailableSwapPairsModelModule.kt deleted file mode 100644 index b68b705703..0000000000 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/di/AvailableSwapPairsModelModule.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.tangem.features.onramp.swap.availablepairs.di - -import com.tangem.core.decompose.di.ModelComponent -import com.tangem.core.decompose.model.Model -import com.tangem.features.onramp.swap.availablepairs.model.AvailableSwapPairsModel -import dagger.Binds -import dagger.Module -import dagger.hilt.InstallIn -import dagger.multibindings.ClassKey -import dagger.multibindings.IntoMap - -@Module -@InstallIn(ModelComponent::class) -internal interface AvailableSwapPairsModelModule { - - @Binds - @IntoMap - @ClassKey(AvailableSwapPairsModel::class) - fun bindAvailableSwapPairsModel(model: AvailableSwapPairsModel): Model -} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/transformers/SetErrorWarningTransformer.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/transformers/SetErrorWarningTransformer.kt deleted file mode 100644 index d3410bd2dc..0000000000 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/transformers/SetErrorWarningTransformer.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.tangem.features.onramp.swap.availablepairs.entity.transformers - -import com.tangem.common.ui.notifications.NotificationUM -import com.tangem.feature.swap.domain.models.ExpressException -import com.tangem.features.onramp.tokenlist.entity.TokenListUM -import com.tangem.features.onramp.tokenlist.entity.TokenListUMTransformer -import kotlinx.collections.immutable.persistentListOf - -/** -[REDACTED_AUTHOR] - */ -internal class SetErrorWarningTransformer( - private val cause: Throwable, - private val onRefresh: () -> Unit, -) : TokenListUMTransformer { - - override fun transform(prevState: TokenListUM): TokenListUM { - return prevState.copy( - availableItems = persistentListOf(), - unavailableItems = persistentListOf(), - warning = NotificationUM.Warning.OnrampErrorNotification( - errorCode = (cause as? ExpressException)?.expressDataError?.code?.toString(), - onRefresh = onRefresh, - ), - ) - } -} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/transformers/SetLoadingTokenItemsTransformer.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/transformers/SetLoadingTokenItemsTransformer.kt deleted file mode 100644 index 02b9d632dc..0000000000 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/transformers/SetLoadingTokenItemsTransformer.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.tangem.features.onramp.swap.availablepairs.entity.transformers - -import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.features.onramp.swap.availablepairs.entity.converters.LoadingTokenListItemConverter -import com.tangem.features.onramp.tokenlist.entity.TokenListUM -import com.tangem.features.onramp.tokenlist.entity.TokenListUMTransformer -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList - -/** - * Set [statuses] as loading items - * -[REDACTED_AUTHOR] - */ -internal class SetLoadingTokenItemsTransformer( - private val statuses: List, -) : TokenListUMTransformer { - - override fun transform(prevState: TokenListUM): TokenListUM { - return prevState.copy( - availableItems = LoadingTokenListItemConverter.convertList( - input = statuses.map(CryptoCurrencyStatus::currency), - ).toImmutableList(), - unavailableItems = persistentListOf(), - warning = null, - ) - } -} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/transformers/SetNoAvailablePairsTransformer.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/transformers/SetNoAvailablePairsTransformer.kt deleted file mode 100644 index 08f93f0bd0..0000000000 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/transformers/SetNoAvailablePairsTransformer.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.tangem.features.onramp.swap.availablepairs.entity.transformers - -import com.tangem.common.ui.account.AccountCryptoPortfolioItemStateConverter -import com.tangem.common.ui.account.TokensListPortfolioItemConverter -import com.tangem.common.ui.notifications.NotificationUM -import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM -import com.tangem.core.ui.extensions.TextReference -import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.models.TotalFiatBalance -import com.tangem.domain.models.account.Account -import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.features.onramp.tokenlist.entity.TokenListUM -import com.tangem.features.onramp.tokenlist.entity.TokenListUMData -import com.tangem.features.onramp.tokenlist.entity.TokenListUMTransformer -import com.tangem.features.onramp.tokenlist.entity.utils.OnrampTokenItemStateConverterFactory -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toPersistentList - -internal class SetNoAvailablePairsTransformer( - private val appCurrency: AppCurrency, - private val accountList: Map>, - private val isBalanceHidden: Boolean, - private val isAccountsMode: Boolean, - private val unavailableErrorText: TextReference, -) : TokenListUMTransformer { - private val unavailableConverter = OnrampTokenItemStateConverterFactory - .createUnavailableItemConverterV2(appCurrency = appCurrency, unavailableErrorText = unavailableErrorText) - - override fun transform(prevState: TokenListUM): TokenListUM { - val totalTokensCount = accountList.values.sumOf { it.size } - - return prevState.copy( - availableItems = persistentListOf(), - unavailableItems = persistentListOf(), - tokensListData = if (isAccountsMode) { - TokenListUMData.AccountList( - tokensList = accountList.map { (account, cryptoCurrencies) -> - TokensListPortfolioItemConverter( - tokenItemUM = AccountCryptoPortfolioItemStateConverter( - appCurrency = appCurrency, - account = account, - onItemClick = null, - ).convert(TotalFiatBalance.Failed), - isExpanded = true, - isCollapsable = false, - tokens = unavailableConverter.convertList(cryptoCurrencies) - .map(TokensListItemUM::Token) - .toPersistentList(), - ).convert(Unit) - }.toPersistentList(), - totalTokensCount = totalTokensCount, - ) - } else { - TokenListUMData.TokenList( - tokensList = accountList.flatMap { (_, cryptoCurrencies) -> - unavailableConverter.convertList(cryptoCurrencies) - .map(TokensListItemUM::Token) - }.toPersistentList(), - totalTokensCount = totalTokensCount, - ) - }, - isBalanceHidden = isBalanceHidden, - warning = NotificationUM.Warning.SwapNoAvailablePair, - ) - } -} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/market/SwapMarketsListBatchFlowManager.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/market/SwapMarketsListBatchFlowManager.kt deleted file mode 100644 index 5df10fda71..0000000000 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/market/SwapMarketsListBatchFlowManager.kt +++ /dev/null @@ -1,252 +0,0 @@ -package com.tangem.features.onramp.swap.availablepairs.market - -import com.tangem.common.ui.markets.models.MarketsListItemUM -import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.markets.* -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.features.onramp.swap.availablepairs.market.converter.SwapMarketsTokenItemConverter -import com.tangem.pagination.Batch -import com.tangem.pagination.BatchAction -import com.tangem.pagination.PaginationStatus -import com.tangem.utils.Provider -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import com.tangem.utils.coroutines.JobHolder -import com.tangem.utils.coroutines.saveIn -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.* - -@Suppress("LongParameterList") -internal class SwapMarketsListBatchFlowManager( - getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase, - private val batchFlowType: GetMarketsTokenListFlowUseCase.BatchFlowType, - private val order: TokenMarketListConfig.Order, - private val currentAppCurrency: Provider, - private val currentSearchText: Provider, - private val modelScope: CoroutineScope, - private val dispatchers: CoroutineDispatcherProvider, -) { - private val actionsFlow = MutableSharedFlow>() - private val updateStateJob = JobHolder() - - private val batchFlow = getMarketsTokenListFlowUseCase( - batchingContext = TokenListBatchingContext( - actionsFlow = actionsFlow, - coroutineScope = modelScope, - ), - batchFlowType = batchFlowType, - ) - - private val resultBatches = MutableStateFlow(ResultBatches()) - private val uiBatches = resultBatches.map { it.uiBatches } - - val uiItems: StateFlow> - get() = uiBatches - .map { batches -> - batches.asSequence() - .map { it.data } - .flatten() - .toImmutableList() - } - .distinctUntilChanged() - .stateIn( - scope = modelScope, - started = SharingStarted.Eagerly, - initialValue = persistentListOf(), - ) - - val isInInitialLoadingErrorState = batchFlow.state - .map { it.status is PaginationStatus.InitialLoadingError } - .distinctUntilChanged() - .stateIn( - scope = modelScope, - started = SharingStarted.Eagerly, - initialValue = false, - ) - - val isSearchNotFoundState = batchFlow.state - .map { batchListState -> - currentSearchText().isNullOrEmpty().not() && - batchListState.status is PaginationStatus.EndOfPagination && - batchListState.data.isEmpty() - } - .distinctUntilChanged() - .stateIn( - scope = modelScope, - started = SharingStarted.Eagerly, - initialValue = false, - ) - - val totalCount: StateFlow = batchFlow.state - .map { it.totalCount } - .distinctUntilChanged() - .stateIn( - scope = modelScope, - started = SharingStarted.Eagerly, - initialValue = null, - ) - - init { - batchFlow.state - .map { it.data } - .distinctUntilChanged { a, b -> - a.size == b.size && - a.map { it.key } == b.map { it.key } && - a.map { it.data }.flatten() == b.map { it.data }.flatten() - } - .onEach { - coroutineScope { - launch { - updateState(it) - }.saveIn(updateStateJob) - } - } - .flowOn(dispatchers.default) - .launchIn(modelScope) - } - - private suspend fun updateState(newList: List>>, forceUpdate: Boolean = false) = - withContext(dispatchers.default) { - resultBatches.update { resultBatches -> - val items = resultBatches.uiBatches - val previousList = resultBatches.processedItems - - val converter = SwapMarketsTokenItemConverter(appCurrency = currentAppCurrency()) - - if (newList.isEmpty()) { - return@update ResultBatches(processedItems = emptyList()) - } - - val isInitialLoading = - forceUpdate || previousList.isNullOrEmpty() || newList.first().key != previousList.first().key - - val outItems = if (isInitialLoading) { - newList.map { batch -> - Batch( - key = batch.key, - data = converter.convertList(batch.data), - ) - } - } else { - if (previousList.size != newList.size) { - val keysToAdd = newList.map { it.key }.subtract(previousList.map { it.key }.toSet()) - val newBatches = newList.filter { keysToAdd.contains(it.key) } - - items + newBatches.map { batch -> - Batch( - key = batch.key, - data = converter.convertList(batch.data), - ) - } - } else { - items.mapIndexed { batchIndex, batch -> - val prevBatch = previousList[batchIndex] - val newBatch = newList[batchIndex] - if (prevBatch == newBatch) return@mapIndexed batch - - Batch( - key = batch.key, - data = batch.data.mapIndexed { index, marketsListItemUM -> - val prevItem = prevBatch.data.getOrNull(index) - val newItem = newBatch.data.getOrNull(index) - if (prevItem != null && newItem != null) { - converter.update(prevItem, marketsListItemUM, newItem) - } else { - newItem?.let { converter.convert(it) } ?: marketsListItemUM - } - }, - ) - } - } - } - - currentCoroutineContext().ensureActive() - - ResultBatches( - uiBatches = outItems, - processedItems = newList, - ) - } - } - - fun reload(searchText: String? = null) { - modelScope.launch { - resultBatches.value = ResultBatches() - actionsFlow.emit( - BatchAction.Reload( - requestParams = TokenMarketListConfig( - fiatPriceCurrency = currentAppCurrency().code, - searchText = if (currentSearchText() == null) { - null - } else { - searchText ?: currentSearchText() - }, - priceChangeInterval = TokenMarketListConfig.Interval.H24, - order = order, - shouldNetworks = true, - ), - ), - ) - } - } - - fun loadMore() { - modelScope.launch { - actionsFlow.emit(BatchAction.LoadMore()) - } - } - - fun loadCharts(batchKeys: Set) { - if (batchKeys.isEmpty()) return - - modelScope.launch { - val currentData = batchFlow.state.value.data - val alreadyLoadedChartsBatchKeys = currentData - .filter { batch -> - val first = batch.data.firstOrNull() ?: return@filter false - first.tokenCharts.h24 != null - } - .map { it.key } - .toSet() - - val batchesKeysToLoad = batchKeys.minus(alreadyLoadedChartsBatchKeys) - - if (batchesKeysToLoad.isNotEmpty()) { - actionsFlow.emit( - BatchAction.UpdateBatches( - keys = batchesKeysToLoad, - updateRequest = TokenMarketUpdateRequest.UpdateChart( - interval = TokenMarketListConfig.Interval.H24, - currency = currentAppCurrency().code, - ), - async = true, - operationId = batchesKeysToLoad.toString() + "h24", - ), - ) - } - } - } - - fun getTokenMarketById(id: CryptoCurrency.RawID): TokenMarket? { - return batchFlow.state.value.data - .asSequence() - .flatMap { it.data } - .firstOrNull { it.id == id } - } - - fun getBatchKeysByItemIds(ids: List): Set { - val currentData = batchFlow.state.value.data - - return currentData - .filter { d -> d.data.any { ids.contains(it.id) } } - .map { it.key } - .toSet() - } - - private data class ResultBatches( - val uiBatches: List>> = emptyList(), - val processedItems: List>>? = null, - ) -} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/market/converter/SwapMarketsTokenItemConverter.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/market/converter/SwapMarketsTokenItemConverter.kt deleted file mode 100644 index 98a4c621b3..0000000000 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/market/converter/SwapMarketsTokenItemConverter.kt +++ /dev/null @@ -1,161 +0,0 @@ -package com.tangem.features.onramp.swap.availablepairs.market.converter - -import com.tangem.common.ui.charts.state.MarketChartData -import com.tangem.common.ui.charts.state.MarketChartRawData -import com.tangem.common.ui.charts.state.converter.PriceAndTimePointValuesConverter -import com.tangem.common.ui.charts.state.sorted -import com.tangem.common.ui.markets.models.MarketsListItemUM -import com.tangem.common.ui.markets.toMarketsListItemPriceAnnotated -import com.tangem.core.ui.R -import com.tangem.core.ui.components.marketprice.PriceChangeType -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.wrappedList -import com.tangem.core.ui.format.bigdecimal.compact -import com.tangem.core.ui.format.bigdecimal.fiat -import com.tangem.core.ui.format.bigdecimal.format -import com.tangem.core.ui.format.bigdecimal.percent -import com.tangem.core.ui.format.bigdecimal.price -import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.markets.TokenMarket -import com.tangem.utils.converter.Converter -import kotlinx.collections.immutable.toImmutableList -import java.math.BigDecimal -import java.math.RoundingMode - -internal class SwapMarketsTokenItemConverter( - private val appCurrency: AppCurrency, -) : Converter { - - private val priceAndTimePointValuesConverter = PriceAndTimePointValuesConverter(shouldFormatAxis = false) - - override fun convert(value: TokenMarket): MarketsListItemUM { - return MarketsListItemUM( - id = value.id, - name = value.name, - currencySymbol = value.symbol, - ratingPosition = value.marketRating?.toString(), - marketCap = value.getMarketCap(), - iconUrl = value.imageUrlLarge, - price = value.getCurrentPrice(), - trendPercentText = value.getTrendPercent(), - trendType = value.getTrendType(), - chartData = value.getChartData(), - isUnder100kMarketCap = value.isUnderMarketCapLimit, - stakingRate = value.yieldRate?.format { percent() }?.let { - resourceReference(R.string.markets_apy_placeholder, wrappedList(it)) - }, - updateTimestamp = value.updateTimestamp, - networks = value.networks?.map { network -> - MarketsListItemUM.Network( - networkId = network.networkId, - contractAddress = network.contractAddress, - decimalCount = network.decimalCount, - ) - }, - ) - } - - fun convertList(items: List): List = items.map(::convert) - - fun update(prev: TokenMarket, prevUI: MarketsListItemUM, new: TokenMarket): MarketsListItemUM { - require(prev.id == new.id) { - "Ids is not the same during update TokenMarket item: previousItem[${prev.id}] != newItem[${new.id}]" - } - - return prevUI.copy( - name = new.name, - currencySymbol = new.symbol, - ratingPosition = new.marketRating?.toString(), - marketCap = ifChanged(prev.marketCap, new.marketCap, prevUI.marketCap) { new.getMarketCap() }, - iconUrl = new.imageUrlLarge, - price = ifChanged(prev = prev.tokenQuotesShort, new = new.tokenQuotesShort, prevR = prevUI.price) { - new.getCurrentPrice(prev = prev) - }, - trendPercentText = ifChanged( - prev.tokenQuotesShort, - new.tokenQuotesShort, - prevUI.trendPercentText, - ) { new.getTrendPercent() }, - trendType = ifChanged(prev.tokenQuotesShort, new.tokenQuotesShort, prevUI.trendType) { new.getTrendType() }, - chartData = ifChanged(prev.tokenCharts, new.tokenCharts, prevUI.chartData) { new.getChartData() }, - ) - } - - private inline fun ifChanged(prev: T, new: T, prevR: R, force: Boolean = false, change: (T) -> R): R { - return if (force || prev != new) change(new) else prevR - } - - private fun TokenMarket.getMarketCap(): String? { - val value = marketCap?.takeIf { marketCap != BigDecimal.ZERO } ?: return null - - return value.format { - fiat( - fiatCurrencyCode = appCurrency.code, - fiatCurrencySymbol = appCurrency.symbol, - ).compact( - threeDigitsMethod = true, - ) - } - } - - private fun TokenMarket.getCurrentPrice(prev: TokenMarket? = null): MarketsListItemUM.Price { - val prevPrice = prev?.tokenQuotesShort?.currentPrice - - val priceText = tokenQuotesShort.currentPrice.format { - fiat( - fiatCurrencyCode = appCurrency.code, - fiatCurrencySymbol = appCurrency.symbol, - ).price() - } - - val changeType = if (prevPrice != null) { - if (tokenQuotesShort.currentPrice > prevPrice) { - PriceChangeType.UP - } else { - PriceChangeType.DOWN - } - } else { - null - } - - return MarketsListItemUM.Price( - text = priceText, - annotated = tokenQuotesShort.currentPrice.toMarketsListItemPriceAnnotated( - appCurrencyCode = appCurrency.code, - appCurrencySymbol = appCurrency.symbol, - ), - changeType = changeType, - fiatPrice = tokenQuotesShort.currentPrice, - ) - } - - private fun TokenMarket.getChartData(): MarketChartRawData? { - val chart = tokenCharts.h24 - - return chart?.let { ct -> - priceAndTimePointValuesConverter.convert( - MarketChartData.Data( - y = ct.priceY.toImmutableList(), - x = ct.timeStamps.map { it.toBigDecimal() }.toImmutableList(), - ).sorted(), - ) - } - } - - @Suppress("MagicNumber") - private fun TokenMarket.getTrendType(): PriceChangeType { - val percent = tokenQuotesShort.h24ChangePercent - val scaled = percent?.setScale(4, RoundingMode.HALF_UP) - return when { - scaled == null -> PriceChangeType.NEUTRAL - scaled > BigDecimal.ZERO -> PriceChangeType.UP - scaled < BigDecimal.ZERO -> PriceChangeType.DOWN - else -> PriceChangeType.NEUTRAL - } - } - - private fun TokenMarket.getTrendPercent(): String { - val percent = tokenQuotesShort.h24ChangePercent - return percent.format { percent() } - } -} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/market/state/SwapMarketState.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/market/state/SwapMarketState.kt deleted file mode 100644 index 7e9abb3982..0000000000 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/market/state/SwapMarketState.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.tangem.features.onramp.swap.availablepairs.market.state - -import androidx.compose.runtime.Immutable -import com.tangem.common.ui.markets.models.MarketsListItemUM -import com.tangem.core.ui.R -import com.tangem.core.ui.extensions.TextReference -import com.tangem.domain.models.currency.CryptoCurrency -import kotlinx.collections.immutable.ImmutableList - -@Immutable -internal sealed class SwapMarketState { - - abstract val marketsTitle: TextReference - abstract val shouldAssetsCount: Boolean - - data class Content( - val items: ImmutableList, - val total: Int, - val loadMore: () -> Unit, - val onItemClick: (MarketsListItemUM) -> Unit, - val visibleIdsChanged: (List) -> Unit, - override val marketsTitle: TextReference, - override val shouldAssetsCount: Boolean, - ) : SwapMarketState() - - data class Loading( - override val marketsTitle: TextReference, - override val shouldAssetsCount: Boolean, - ) : SwapMarketState() - - data class LoadingError( - val onRetryClicked: () -> Unit, - override val marketsTitle: TextReference, - override val shouldAssetsCount: Boolean, - ) : SwapMarketState() - - data object SearchNothingFound : SwapMarketState() { - override val marketsTitle: TextReference = TextReference.Res(R.string.markets_common_title) - override val shouldAssetsCount: Boolean = true - } -} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AddToPortfolioRoute.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AddToPortfolioRoute.kt deleted file mode 100644 index 559ef6eb09..0000000000 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AddToPortfolioRoute.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.tangem.features.onramp.swap.availablepairs.model - -import com.tangem.core.decompose.navigation.Route -import kotlinx.serialization.Serializable - -@Serializable -internal data object AddToPortfolioRoute : Route \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt deleted file mode 100644 index d87b8d3d0e..0000000000 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/model/AvailableSwapPairsModel.kt +++ /dev/null @@ -1,648 +0,0 @@ -package com.tangem.features.onramp.swap.availablepairs.model - -import com.arkivanov.decompose.router.slot.SlotNavigation -import com.arkivanov.decompose.router.slot.activate -import com.arkivanov.decompose.router.slot.dismiss -import com.tangem.blockchainsdk.utils.ExcludedBlockchains -import com.tangem.common.ui.markets.models.MarketsListItemUM -import com.tangem.core.analytics.api.AnalyticsEventHandler -import com.tangem.core.analytics.models.AnalyticsParam.ScreensSources -import com.tangem.core.analytics.models.event.SwapAnalyticsEvent -import com.tangem.core.decompose.model.Model -import com.tangem.core.decompose.model.ParamsContainer -import com.tangem.core.ui.components.fields.InputManager -import com.tangem.core.ui.components.token.state.TokenItemState -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer -import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier -import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase -import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase -import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase -import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase -import com.tangem.domain.card.common.extensions.hotWalletExcludedBlockchains -import com.tangem.domain.core.lce.Lce -import com.tangem.domain.core.utils.lceContent -import com.tangem.domain.core.utils.lceError -import com.tangem.domain.core.utils.lceLoading -import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase -import com.tangem.domain.markets.TokenMarketInfo -import com.tangem.domain.markets.TokenMarketListConfig -import com.tangem.domain.markets.toSerializableParam -import com.tangem.domain.models.account.Account -import com.tangem.domain.models.account.AccountStatus -import com.tangem.domain.models.account.filterCryptoPortfolio -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.models.tokenlist.TokenList -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.wallets.usecase.GetWalletsUseCase -import com.tangem.feature.swap.domain.GetAvailablePairsUseCase -import com.tangem.feature.swap.domain.models.domain.LeastTokenInfo -import com.tangem.feature.swap.domain.models.domain.SwapPairLeast -import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager -import com.tangem.features.onramp.impl.R -import com.tangem.features.onramp.swap.availablepairs.AvailableSwapPairsComponent -import com.tangem.features.onramp.swap.availablepairs.entity.transformers.SetErrorWarningTransformer -import com.tangem.features.onramp.swap.availablepairs.entity.transformers.SetNoAvailablePairsTransformer -import com.tangem.features.onramp.swap.availablepairs.market.SwapMarketsListBatchFlowManager -import com.tangem.features.onramp.swap.availablepairs.market.state.SwapMarketState -import com.tangem.features.onramp.swap.entity.AccountAvailabilityUM -import com.tangem.features.onramp.swap.entity.AccountCurrencyUM -import com.tangem.features.onramp.tokenlist.entity.TokenListUM -import com.tangem.features.onramp.tokenlist.entity.TokenListUMController -import com.tangem.features.onramp.tokenlist.entity.TokenListUMTransformer -import com.tangem.features.onramp.tokenlist.entity.transformer.SetLoadingAccountTokenListTransformer -import com.tangem.features.onramp.tokenlist.entity.transformer.SetNothingToFoundStateTransformer -import com.tangem.features.onramp.tokenlist.entity.transformer.UpdateAccountTokenListTransformer -import com.tangem.features.onramp.tokenlist.entity.utils.OnrampTokenItemStateConverterFactory -import com.tangem.features.onramp.utils.ClearSearchBarTransformer -import com.tangem.features.onramp.utils.UpdateSearchBarActiveStateTransformer -import com.tangem.features.onramp.utils.UpdateSearchBarCallbacksTransformer -import com.tangem.features.onramp.utils.UpdateSearchQueryTransformer -import com.tangem.lib.crypto.BlockchainUtils -import com.tangem.utils.Provider -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import com.tangem.utils.coroutines.runSuspendCatching -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch -import javax.inject.Inject -import com.tangem.core.ui.R as CoreUiR - -private typealias AvailablePairsState = Lce> - -@Suppress("LongParameterList", "LargeClass") -internal class AvailableSwapPairsModel @Inject constructor( - paramsContainer: ParamsContainer, - override val dispatchers: CoroutineDispatcherProvider, - private val analyticsEventHandler: AnalyticsEventHandler, - private val tokenListUMController: TokenListUMController, - private val searchManager: InputManager, - private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, - private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, - private val getAvailablePairsUseCase: GetAvailablePairsUseCase, - private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, - private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, - private val getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase, - private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory, - private val excludedBlockchains: ExcludedBlockchains, - private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, - getWalletsUseCase: GetWalletsUseCase, -) : Model() { - - val state: StateFlow = tokenListUMController.state - - private val params: AvailableSwapPairsComponent.Params = paramsContainer.require() - private val userWallet = getWalletsUseCase.invokeSync().first { it.walletId == params.userWalletId } - private val allUserWallets = getWalletsUseCase.invokeSync() - - val bottomSheetNavigation: SlotNavigation = SlotNavigation() - val addToPortfolioManager: AddToPortfolioManager = addToPortfolioManagerFactory - .create( - scope = modelScope, - analyticsParams = AddToPortfolioManager.AnalyticsParams(source = ScreensSources.Swap.value), - settings = AddToPortfolioManager.Settings.ChooseToken, - ) - - private val accountListFlow = getAccountListUseCaseFlow() - private val availablePairsByNetworkFlow = MutableStateFlow>(emptyMap()) - - private val selectedAppCurrencyFlow: StateFlow = getSelectedAppCurrencyUseCase.invokeOrDefault() - .stateIn(scope = modelScope, started = SharingStarted.Eagerly, initialValue = AppCurrency.Default) - private val refreshPairsTrigger = MutableSharedFlow() - private val searchQueryStateForMarkets = MutableStateFlow("") - private val visibleMarketItemIds = MutableStateFlow>(emptyList()) - - private val defaultMarketsListManager by lazy { - SwapMarketsListBatchFlowManager( - getMarketsTokenListFlowUseCase = getMarketsTokenListFlowUseCase, - batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Main, - order = TokenMarketListConfig.Order.Trending, - currentAppCurrency = Provider { selectedAppCurrencyFlow.value }, - currentSearchText = Provider { null }, - modelScope = modelScope, - dispatchers = dispatchers, - ) - } - - private val searchMarketsListManager by lazy { - SwapMarketsListBatchFlowManager( - getMarketsTokenListFlowUseCase = getMarketsTokenListFlowUseCase, - batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Search, - order = TokenMarketListConfig.Order.ByRating, - currentAppCurrency = Provider { selectedAppCurrencyFlow.value }, - currentSearchText = Provider { searchQueryStateForMarkets.value }, - modelScope = modelScope, - dispatchers = dispatchers, - ) - } - - private val visibleDefaultMarketItemIds = MutableStateFlow>(emptyList()) - - init { - subscribeOnUpdateState() - - initializeSearchBarCallbacks() - subscribeOnSelectedStatusChange() - subscribeOnAvailablePairsUpdates() - - subscribeOnMarketsUpdates() - subscribeOnVisibleMarketItems() - addToPortfolioManager.onDismiss.receiveAsFlow() - .onEach { bottomSheetNavigation.dismiss() } - .launchIn(modelScope) - addToPortfolioManager.onSuccessAdded.receiveAsFlow() - .onEach { result -> onTokenAddedToPortfolio(result.addedCurrency.currency) } - .launchIn(modelScope) - } - - private fun getAccountListUseCaseFlow(): SharedFlow> { - return singleAccountStatusListSupplier(SingleAccountStatusListProducer.Params(params.userWalletId)) - .distinctUntilChanged() - .mapNotNull { accountStatusList -> - accountStatusList.accountStatuses.filter { - it is AccountStatus.CryptoPortfolio && it.tokenList !is TokenList.Empty - } - } - .flowOn(dispatchers.default) - .shareIn(scope = modelScope, started = SharingStarted.Eagerly, replay = 1) - } - - private fun subscribeOnSelectedStatusChange() { - params.selectedStatus - .filter { it == null } - .onEach { clearSearchState() } - .launchIn(modelScope) - } - - private fun initializeSearchBarCallbacks() { - tokenListUMController.update( - transformer = UpdateSearchBarCallbacksTransformer( - onQueryChange = ::onSearchQueryChange, - onActiveChange = ::onSearchBarActiveChange, - ), - ) - } - - private fun subscribeOnUpdateState() { - combine( - flow = getAccountsAndModeFlow(), - flow2 = getAppCurrencyAndBalanceHidingFlow(), - flow3 = params.selectedStatus, - flow4 = searchManager.query, - flow5 = availablePairsByNetworkFlow - .map { it[params.selectedStatus.value?.toLeastTokenInfo()] } - .distinctUntilChanged(), - ) { accountListAndMode, appCurrencyAndBalanceHiding, selectedStatus, query, availablePairsState -> - val (accountList, isAccountsMode) = accountListAndMode - availablePairsState?.fold( - ifLoading = { - SetLoadingAccountTokenListTransformer( - appCurrency = appCurrencyAndBalanceHiding.first, - accountList = accountList, - isAccountsMode = isAccountsMode, - ) - }, - ifContent = { pairs -> - handleContentState( - appCurrencyAndBalanceHiding = appCurrencyAndBalanceHiding, - accountList = accountList, - selectedStatus = selectedStatus, - query = query, - availablePairs = pairs, - isAccountsMode = isAccountsMode, - ) - }, - ifError = { throwable -> - handleErrorState( - cause = throwable, - networkInfo = params.selectedStatus.value?.toLeastTokenInfo(), - accountList = accountList, - ) - }, - ) ?: SetLoadingAccountTokenListTransformer( - appCurrency = appCurrencyAndBalanceHiding.first, - accountList = accountList, - isAccountsMode = isAccountsMode, - ) - } - .onEach(tokenListUMController::update) - .flowOn(dispatchers.default) - .launchIn(modelScope) - } - - private fun handleContentState( - appCurrencyAndBalanceHiding: Pair, - accountList: List, - selectedStatus: CryptoCurrencyStatus?, - query: String, - availablePairs: List, - isAccountsMode: Boolean, - ): TokenListUMTransformer { - val (appCurrency, isBalanceHidden) = appCurrencyAndBalanceHiding - - val filterByQueryAccountList: Map> = accountList - .filterCryptoPortfolio() - .associate { accountStatus -> - val statuses = accountStatus.tokenList.flattenCurrencies() - .filterNot { status -> - status.currency.network.rawId == selectedStatus?.currency?.network?.rawId && - status.currency.id.contractAddress == selectedStatus.currency.id.contractAddress - } - .filterByQuery(query = query) - - accountStatus.account to statuses - } - .filterValues { it.isNotEmpty() } - - if (availablePairs.isEmpty()) { - return SetNoAvailablePairsTransformer( - appCurrency = appCurrency, - accountList = filterByQueryAccountList, - unavailableErrorText = resourceReference(R.string.tokens_list_unavailable_to_swap_source_header), - isBalanceHidden = isBalanceHidden, - isAccountsMode = isAccountsMode, - ) - } - - return if (query.isNotEmpty() && filterByQueryAccountList.isEmpty()) { - SetNothingToFoundStateTransformer( - isBalanceHidden = isBalanceHidden, - emptySearchMessageReference = resourceReference( - id = R.string.action_buttons_swap_empty_search_message, - ), - ) - } else { - UpdateAccountTokenListTransformer( - appCurrency = appCurrency, - onItemClick = ::onPortfolioTokenClick, - accountList = filterByQueryAccountList.filterByAvailability(availablePairs = availablePairs), - isBalanceHidden = isBalanceHidden, - unavailableErrorText = resourceReference(R.string.tokens_list_unavailable_to_swap_source_header), - isAccountsMode = isAccountsMode, - ) - } - } - - private fun handleErrorState( - cause: Throwable, - networkInfo: LeastTokenInfo?, - accountList: List, - ): SetErrorWarningTransformer { - return SetErrorWarningTransformer( - cause = cause, - onRefresh = { - modelScope.launch { - if (networkInfo != null) { - accountList.filterCryptoPortfolio() - .forEach { (_, currencies) -> - updateAvailablePairs(networkInfo, currencies.flattenCurrencies()) - } - } - } - }, - ) - } - - private fun subscribeOnAvailablePairsUpdates() { - modelScope.launch { - combine( - params.selectedStatus.filterNotNull(), - refreshPairsTrigger - .onEach { availablePairsByNetworkFlow.value = emptyMap() } - .onStart { emit(Unit) }, - ) { status, _ -> status } - .collectLatest { selectedStatus -> - val networkInfo = selectedStatus.toLeastTokenInfo() - - val isAlreadyLoaded = availablePairsByNetworkFlow.value[networkInfo]?.isContent() == true - if (isAlreadyLoaded) return@collectLatest - - val accountList = accountListFlow.firstOrNull() ?: return@collectLatest - updateAvailablePairs( - networkInfo = networkInfo, - statuses = accountList.filterCryptoPortfolio() - .flatMap { accountStatus -> - accountStatus.flattenCurrencies() - }.toSet().toList(), - ) - } - } - } - - private suspend fun updateAvailablePairs(networkInfo: LeastTokenInfo, statuses: List) { - runSuspendCatching { - availablePairsByNetworkFlow.update(networkInfo = networkInfo, state = lceLoading()) - - getAvailablePairsUseCase( - userWallet = userWallet, - initialCurrency = networkInfo, - currencies = statuses.map(CryptoCurrencyStatus::currency), - ) - } - .onSuccess { pairs -> - availablePairsByNetworkFlow.update(networkInfo = networkInfo, state = pairs.lceContent()) - } - .onFailure { cause -> - availablePairsByNetworkFlow.update(networkInfo = networkInfo, state = cause.lceError()) - } - } - - private fun MutableStateFlow>.update( - networkInfo: LeastTokenInfo, - state: AvailablePairsState, - ) { - update { map -> - map.toMutableMap().apply { - this[networkInfo] = state - } - } - } - - private fun getAppCurrencyAndBalanceHidingFlow(): Flow> { - return combine( - flow = getSelectedAppCurrencyUseCase.invokeOrDefault(), - flow2 = getBalanceHidingSettingsUseCase.isBalanceHidden(), - transform = ::Pair, - ) - } - - private fun getAccountsAndModeFlow(): Flow, Boolean>> { - return combine( - flow = accountListFlow.distinctUntilChanged(), - flow2 = isAccountsModeEnabledUseCase().distinctUntilChanged(), - transform = ::Pair, - ) - } - - private fun onSearchQueryChange(newQuery: String) { - if (state.value.searchBarUM.query == newQuery) return - - modelScope.launch { - tokenListUMController.update(transformer = UpdateSearchQueryTransformer(newQuery)) - - searchManager.update(newQuery) - - searchQueryStateForMarkets.value = newQuery - } - } - - private fun onSearchBarActiveChange(isActive: Boolean) { - tokenListUMController.update( - transformer = UpdateSearchBarActiveStateTransformer( - isActive = isActive, - placeHolder = resourceReference(id = R.string.common_search), - ), - ) - } - - private fun List.filterByQuery(query: String): List { - return filter { status -> - status.currency.name.contains(other = query, ignoreCase = true) || - status.currency.symbol.contains(other = query, ignoreCase = true) - } - } - - private fun Map>.filterByAvailability( - availablePairs: List, - ): List { - return map { (account, currencies) -> - AccountAvailabilityUM( - account = account, - currencyList = currencies.map { status -> - val isAvailable = availablePairs.map(SwapPairLeast::to).contains(status.toLeastTokenInfo()) - - val isAvailableToSwap = isAvailable && - status.value !is CryptoCurrencyStatus.MissedDerivation && - status.value !is CryptoCurrencyStatus.Unreachable && - !status.currency.isCustom - - AccountCurrencyUM( - cryptoCurrencyStatus = status, - isAvailable = isAvailableToSwap, - ) - }, - ) - } - } - - private fun onPortfolioTokenClick(tokenItem: TokenItemState, status: CryptoCurrencyStatus) { - analyticsEventHandler.send( - SwapAnalyticsEvent.TokenSelected( - token = status.currency.symbol, - source = ScreensSources.Portfolio, - isSearched = state.value.searchBarUM.query.isNotEmpty(), - ), - ) - clearSearchState() - params.onTokenClick(tokenItem, status) - } - - private fun clearSearchState() { - tokenListUMController.update( - transformer = ClearSearchBarTransformer( - placeHolder = resourceReference(id = R.string.common_search), - ), - ) - modelScope.launch { - searchManager.update("") - } - searchQueryStateForMarkets.value = "" - } - - private fun CryptoCurrencyStatus.toLeastTokenInfo(): LeastTokenInfo { - return LeastTokenInfo( - contractAddress = (currency as? CryptoCurrency.Token)?.contractAddress ?: "0", - network = currency.network.rawId, - ) - } - - @OptIn(ExperimentalCoroutinesApi::class) - private fun subscribeOnMarketsUpdates() { - searchQueryStateForMarkets - .map { it.isEmpty() } - .distinctUntilChanged() - .flatMapLatest { isDefaultMode -> - if (isDefaultMode) { - visibleMarketItemIds.value = emptyList() - createDefaultMarketsFlow() - } else { - visibleDefaultMarketItemIds.value = emptyList() - createSearchMarketsFlow() - } - } - .onEach { marketsState -> - tokenListUMController.update { it.copy(marketsState = marketsState) } - } - .flowOn(dispatchers.main) - .launchIn(modelScope) - - searchQueryStateForMarkets - .onEach { searchQuery -> - if (searchQuery.isNotEmpty()) { - searchMarketsListManager.reload(searchQuery) - } - } - .launchIn(modelScope) - - params.selectedStatus - .filterNotNull() - .take(1) - .onEach { defaultMarketsListManager.reload() } - .launchIn(modelScope) - } - - private fun createDefaultMarketsFlow(): Flow { - val marketsTitle = TextReference.Res(CoreUiR.string.feed_trending_now) - return combine( - defaultMarketsListManager.uiItems, - defaultMarketsListManager.isInInitialLoadingErrorState, - defaultMarketsListManager.totalCount, - ) { uiItems, isError, total -> - when { - isError -> SwapMarketState.LoadingError( - onRetryClicked = { defaultMarketsListManager.reload() }, - marketsTitle = marketsTitle, - shouldAssetsCount = false, - ) - uiItems.isEmpty() -> SwapMarketState.Loading( - marketsTitle = marketsTitle, - shouldAssetsCount = false, - ) - else -> SwapMarketState.Content( - items = uiItems, - loadMore = { defaultMarketsListManager.loadMore() }, - onItemClick = { item -> addToPortfolioItem(item) }, - visibleIdsChanged = { visibleDefaultMarketItemIds.value = it }, - total = total ?: uiItems.size, - marketsTitle = marketsTitle, - shouldAssetsCount = false, - ) - } - } - } - - private fun createSearchMarketsFlow(): Flow { - val marketsTitle = TextReference.Res(CoreUiR.string.markets_common_title) - return combine( - flow = searchMarketsListManager.uiItems, - flow2 = searchMarketsListManager.isInInitialLoadingErrorState, - flow3 = searchMarketsListManager.isSearchNotFoundState, - flow4 = searchMarketsListManager.totalCount, - ) { uiItems, isError, isSearchNotFound, total -> - when { - isError -> SwapMarketState.LoadingError( - onRetryClicked = { - searchMarketsListManager.reload(searchQueryStateForMarkets.value) - }, - marketsTitle = marketsTitle, - shouldAssetsCount = true, - ) - isSearchNotFound -> SwapMarketState.SearchNothingFound - uiItems.isEmpty() -> SwapMarketState.Loading( - marketsTitle = marketsTitle, - shouldAssetsCount = true, - ) - else -> SwapMarketState.Content( - items = uiItems, - loadMore = { searchMarketsListManager.loadMore() }, - onItemClick = { item -> addToPortfolioItem(item) }, - visibleIdsChanged = { visibleMarketItemIds.value = it }, - total = total ?: uiItems.size, - marketsTitle = marketsTitle, - shouldAssetsCount = true, - ) - } - } - } - - private fun onTokenAddedToPortfolio(addedToken: CryptoCurrency) { - modelScope.launch { - bottomSheetNavigation.dismiss() - analyticsEventHandler.send( - SwapAnalyticsEvent.TokenSelected( - token = addedToken.symbol, - source = ScreensSources.Markets, - isSearched = state.value.searchBarUM.query.isNotEmpty(), - ), - ) - - clearSearchState() - - // Trigger re-fetch of available pairs (clears cache + re-enters collectLatest) - refreshPairsTrigger.emit(Unit) - - // Wait for the added token status to become Loaded - val addedTokenStatus = getAccountCurrencyStatusUseCase(params.userWalletId, addedToken) - .firstOrNull { it.status.value is CryptoCurrencyStatus.Loaded } - ?.status - ?: return@launch - - // Convert to TokenItemState and trigger token selection → navigates to swap - val converter = OnrampTokenItemStateConverterFactory.createAvailableItemConverter( - appCurrency = selectedAppCurrencyFlow.value, - onItemClick = params.onTokenClick, - ) - params.onTokenClick(converter.convert(addedTokenStatus), addedTokenStatus) - } - } - - private fun addToPortfolioItem(item: MarketsListItemUM) { - val tokenMarket = defaultMarketsListManager.getTokenMarketById(item.id) - ?: searchMarketsListManager.getTokenMarketById(item.id) - ?: return - - val param = tokenMarket.toSerializableParam() - val hasOnlyHotWallets = allUserWallets.all { it is UserWallet.Hot } - - val networks = tokenMarket.networks?.filter { network -> - BlockchainUtils.isSupportedNetworkId( - networkId = network.networkId, - coinId = tokenMarket.id.value, - contractAddress = network.contractAddress, - excludedBlockchains = excludedBlockchains, - hotExcludedBlockchains = hotWalletExcludedBlockchains, - hasOnlyHotWallets = hasOnlyHotWallets, - ) - }?.map { network -> - TokenMarketInfo.Network( - networkId = network.networkId, - isExchangeable = false, - contractAddress = network.contractAddress, - decimalCount = network.decimalCount, - ) - }.orEmpty() - - addToPortfolioManager.setTokenNetworks(networks) - addToPortfolioManager.setTokenParams(param) - - bottomSheetNavigation.activate(AddToPortfolioRoute) - } - - private fun subscribeOnVisibleMarketItems() { - modelScope.launch { - visibleMarketItemIds.mapNotNull { rawIds -> - if (rawIds.isNotEmpty()) { - searchMarketsListManager.getBatchKeysByItemIds(rawIds) - } else { - null - } - }.distinctUntilChanged().collectLatest { visibleBatchKeys -> - searchMarketsListManager.loadCharts(visibleBatchKeys) - } - } - modelScope.launch { - visibleDefaultMarketItemIds.mapNotNull { rawIds -> - if (rawIds.isNotEmpty()) { - defaultMarketsListManager.getBatchKeysByItemIds(rawIds) - } else { - null - } - }.distinctUntilChanged().collectLatest { visibleBatchKeys -> - defaultMarketsListManager.loadCharts(visibleBatchKeys) - } - } - } -} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/ui/SwapMarketsListItems.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/ui/SwapMarketsListItems.kt deleted file mode 100644 index 18600da888..0000000000 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/ui/SwapMarketsListItems.kt +++ /dev/null @@ -1,114 +0,0 @@ -package com.tangem.features.onramp.swap.availablepairs.ui - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.withStyle -import com.tangem.common.ui.markets.MarketsListItem -import com.tangem.common.ui.markets.MarketsListItemPlaceholder -import com.tangem.core.ui.R -import com.tangem.core.ui.components.UnableToLoadData -import com.tangem.core.ui.decorations.roundedShapeItemDecoration -import com.tangem.core.ui.extensions.resolveReference -import com.tangem.core.ui.extensions.stringResourceSafe -import com.tangem.core.ui.res.TangemTheme -import com.tangem.features.onramp.swap.availablepairs.market.state.SwapMarketState - -private const val LOADING_PLACEHOLDERS_COUNT = 20 - -internal fun LazyListScope.swapMarketsListItems(state: SwapMarketState) { - item(key = "markets_title") { - val totalCount = (state as? SwapMarketState.Content)?.total - Text( - text = buildAnnotatedString { - append(state.marketsTitle.resolveReference()) - if (totalCount != null) { - withStyle(SpanStyle(color = TangemTheme.colors.text.tertiary)) { - append(" $totalCount") - } - } - }, - style = TangemTheme.typography.h3, - color = TangemTheme.colors.text.primary1, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = TangemTheme.dimens.spacing16) - .padding(top = TangemTheme.dimens.spacing24, bottom = TangemTheme.dimens.spacing12), - ) - } - - when (state) { - is SwapMarketState.Loading -> { - items(count = LOADING_PLACEHOLDERS_COUNT, key = { "market_placeholder_$it" }) { - MarketsListItemPlaceholder() - } - } - is SwapMarketState.LoadingError -> { - item(key = "market_loading_error") { - LoadingErrorItem( - modifier = Modifier.fillParentMaxWidth(), - onTryAgain = state.onRetryClicked, - ) - } - } - SwapMarketState.SearchNothingFound -> { - item(key = "market_not_found") { - SearchNothingFoundText( - modifier = Modifier.fillParentMaxWidth(), - ) - } - } - is SwapMarketState.Content -> { - itemsIndexed( - items = state.items, - key = { _, item -> item.getComposeKey() }, - ) { index, item -> - MarketsListItem( - model = item, - onClick = { state.onItemClick(item) }, - modifier = Modifier.roundedShapeItemDecoration( - currentIndex = index, - lastIndex = state.items.lastIndex, - backgroundColor = TangemTheme.colors.background.action, - ), - ) - } - } - } -} - -@Composable -private fun LoadingErrorItem(onTryAgain: () -> Unit, modifier: Modifier = Modifier) { - Box( - modifier - .padding( - horizontal = TangemTheme.dimens.spacing16, - vertical = TangemTheme.dimens.spacing12, - ), - contentAlignment = Alignment.Center, - ) { - UnableToLoadData(onRetryClick = onTryAgain) - } -} - -@Composable -private fun SearchNothingFoundText(modifier: Modifier = Modifier) { - Box( - modifier = modifier.padding(TangemTheme.dimens.spacing16), - contentAlignment = Alignment.Center, - ) { - Text( - text = stringResourceSafe(R.string.markets_search_token_no_result_title), - style = TangemTheme.typography.caption1, - color = TangemTheme.colors.text.tertiary, - ) - } -} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/di/SwapSelectTokensComponentModule.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/di/SwapSelectTokensComponentModule.kt deleted file mode 100644 index 059c28a82a..0000000000 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/di/SwapSelectTokensComponentModule.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.tangem.features.onramp.swap.di - -import com.tangem.features.onramp.component.SwapSelectTokensComponent -import com.tangem.features.onramp.swap.DefaultSwapSelectTokensComponent -import dagger.Binds -import dagger.Module -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -internal interface SwapSelectTokensComponentModule { - - @Binds - @Singleton - fun bindSwapSelectTokensComponentFactory( - factory: DefaultSwapSelectTokensComponent.Factory, - ): SwapSelectTokensComponent.Factory -} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/di/SwapSelectTokensModelModule.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/di/SwapSelectTokensModelModule.kt deleted file mode 100644 index 01b1201354..0000000000 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/di/SwapSelectTokensModelModule.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.tangem.features.onramp.swap.di - -import com.tangem.core.decompose.di.ModelComponent -import com.tangem.core.decompose.model.Model -import com.tangem.features.onramp.swap.model.SwapSelectTokensModel -import dagger.Binds -import dagger.Module -import dagger.hilt.InstallIn -import dagger.multibindings.ClassKey -import dagger.multibindings.IntoMap - -@Module -@InstallIn(ModelComponent::class) -internal interface SwapSelectTokensModelModule { - - @Binds - @IntoMap - @ClassKey(SwapSelectTokensModel::class) - fun bindOnrampTokenListModel(model: SwapSelectTokensModel): Model -} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/ExchangeCardUM.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/ExchangeCardUM.kt deleted file mode 100644 index 1d5877f437..0000000000 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/ExchangeCardUM.kt +++ /dev/null @@ -1,63 +0,0 @@ -package com.tangem.features.onramp.swap.entity - -import androidx.compose.runtime.Immutable -import com.tangem.common.ui.account.AccountIconUM -import com.tangem.core.ui.components.token.state.TokenItemState -import com.tangem.core.ui.extensions.TextReference - -/** - * Exchange card UI model - * -[REDACTED_AUTHOR] - */ -internal sealed interface ExchangeCardUM { - - /** Title reference */ - val titleUM: TitleUM - - /** Remove button UI model */ - val removeButtonUM: RemoveButtonUM? - - /** - * Empty state - * - * @property titleUM title reference - * @property subtitleReference empty token subtitle reference - */ - data class Empty( - override val titleUM: TitleUM, - val subtitleReference: TextReference, - ) : ExchangeCardUM { - - override val removeButtonUM: RemoveButtonUM? = null - } - - /** - * Filled - * - * @property titleUM title reference - * @property removeButtonUM remove button UI model - * @property tokenItemState token item state - */ - data class Filled( - override val titleUM: TitleUM, - override val removeButtonUM: RemoveButtonUM?, - val tokenItemState: TokenItemState, - ) : ExchangeCardUM - - data class RemoveButtonUM(val onClick: () -> Unit) - - @Immutable - sealed interface TitleUM { - - data class Text( - val title: TextReference, - ) : TitleUM - - data class Account( - val prefixText: TextReference, - val name: TextReference, - val icon: AccountIconUM.CryptoPortfolio, - ) : TitleUM - } -} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/SwapSelectTokensController.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/SwapSelectTokensController.kt deleted file mode 100644 index 8adcb4725d..0000000000 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/SwapSelectTokensController.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.tangem.features.onramp.swap.entity - -import com.tangem.features.onramp.swap.entity.utils.createEmptyExchangeFrom -import com.tangem.features.onramp.swap.entity.utils.createEmptyExchangeTo -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.update -import com.tangem.utils.logging.TangemLogger -import javax.inject.Inject - -/** - * [SwapSelectTokensUM] controller - * -[REDACTED_AUTHOR] - */ -internal class SwapSelectTokensController @Inject constructor() { - - val state: StateFlow - field = MutableStateFlow( - value = SwapSelectTokensUM( - onBackClick = {}, - exchangeFrom = createEmptyExchangeFrom(), - exchangeTo = createEmptyExchangeTo(), - isBalanceHidden = false, - ), - ) - - fun update(transform: (SwapSelectTokensUM) -> SwapSelectTokensUM) { - TangemLogger.d("Applying non-name transformation") - state.update(transform) - } - - fun update(transformer: SwapSelectTokensUMTransformer) { - TangemLogger.d("Applying ${transformer::class.simpleName ?: "null"}") - state.update(transformer::transform) - } -} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/SwapSelectTokensUM.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/SwapSelectTokensUM.kt deleted file mode 100644 index 1bdf9b047a..0000000000 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/SwapSelectTokensUM.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.tangem.features.onramp.swap.entity - -/** - * Swap select tokens UI model - * - * @property onBackClick callback is called when back button is clicked - * @property exchangeFrom exchange "from" card UI model - * @property exchangeTo exchange "to" card UI model - * -[REDACTED_AUTHOR] - */ -internal data class SwapSelectTokensUM( - val onBackClick: () -> Unit, - val exchangeFrom: ExchangeCardUM, - val exchangeTo: ExchangeCardUM, - val isBalanceHidden: Boolean, -) \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/SwapSelectTokensUMTransformer.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/SwapSelectTokensUMTransformer.kt deleted file mode 100644 index fb2f9ca537..0000000000 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/SwapSelectTokensUMTransformer.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.tangem.features.onramp.swap.entity - -import com.tangem.utils.transformer.Transformer - -/** - * Base [SwapSelectTokensUM] transformer - * -[REDACTED_AUTHOR] - */ -internal interface SwapSelectTokensUMTransformer : Transformer \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/transformer/RemoveSelectedFromTokenTransformer.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/transformer/RemoveSelectedFromTokenTransformer.kt deleted file mode 100644 index 828f9d6ad0..0000000000 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/transformer/RemoveSelectedFromTokenTransformer.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.tangem.features.onramp.swap.entity.transformer - -import com.tangem.features.onramp.swap.entity.SwapSelectTokensUM -import com.tangem.features.onramp.swap.entity.SwapSelectTokensUMTransformer -import com.tangem.features.onramp.swap.entity.utils.createEmptyExchangeFrom -import com.tangem.features.onramp.swap.entity.utils.createEmptyExchangeTo - -/** - * Transformer for removing selected "from" token - * -[REDACTED_AUTHOR] - */ -internal object RemoveSelectedFromTokenTransformer : SwapSelectTokensUMTransformer { - - override fun transform(prevState: SwapSelectTokensUM): SwapSelectTokensUM { - return prevState.copy( - exchangeFrom = createEmptyExchangeFrom(), - exchangeTo = createEmptyExchangeTo(), - ) - } -} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/transformer/RemoveSelectedToTokenTransformer.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/transformer/RemoveSelectedToTokenTransformer.kt deleted file mode 100644 index 971bf36fd8..0000000000 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/transformer/RemoveSelectedToTokenTransformer.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.tangem.features.onramp.swap.entity.transformer - -import com.tangem.features.onramp.swap.entity.ExchangeCardUM -import com.tangem.features.onramp.swap.entity.SwapSelectTokensUM -import com.tangem.features.onramp.swap.entity.SwapSelectTokensUMTransformer -import com.tangem.features.onramp.swap.entity.utils.createEmptyExchangeTo - -/** - * Transformer for removing selected "to" token - * -[REDACTED_AUTHOR] - */ -internal class RemoveSelectedToTokenTransformer( - private val onRemoveFromTokenClick: () -> Unit, -) : SwapSelectTokensUMTransformer { - - override fun transform(prevState: SwapSelectTokensUM): SwapSelectTokensUM { - return prevState.copy( - exchangeFrom = prevState.exchangeFrom.showRemoveButton(onClick = onRemoveFromTokenClick), - exchangeTo = createEmptyExchangeTo(), - ) - } - - private fun ExchangeCardUM.showRemoveButton(onClick: () -> Unit): ExchangeCardUM { - return (this as? ExchangeCardUM.Filled) - ?.copy(removeButtonUM = ExchangeCardUM.RemoveButtonUM(onClick = onClick)) - ?: this - } -} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/transformer/SelectFromTokenTransformer.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/transformer/SelectFromTokenTransformer.kt deleted file mode 100644 index 3ca1f8776b..0000000000 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/transformer/SelectFromTokenTransformer.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.tangem.features.onramp.swap.entity.transformer - -import com.tangem.core.ui.components.token.state.TokenItemState -import com.tangem.domain.models.account.Account -import com.tangem.features.onramp.swap.entity.ExchangeCardUM -import com.tangem.features.onramp.swap.entity.SwapSelectTokensUM -import com.tangem.features.onramp.swap.entity.SwapSelectTokensUMTransformer -import com.tangem.features.onramp.swap.entity.utils.toFilled - -/** - * Transformer for selecting "from" token - * - * @property selectedTokenItemState token item state - * @property onRemoveClick callback is called when remove button is clicked - * -[REDACTED_AUTHOR] - */ -internal class SelectFromTokenTransformer( - private val selectedTokenItemState: TokenItemState, - private val onRemoveClick: () -> Unit, - private val account: Account.CryptoPortfolio?, - private val isAccountsMode: Boolean, -) : SwapSelectTokensUMTransformer { - - override fun transform(prevState: SwapSelectTokensUM): SwapSelectTokensUM { - return prevState.copy( - exchangeFrom = prevState.exchangeFrom.toFilled( - selectedTokenItemState = selectedTokenItemState, - removeButtonUM = ExchangeCardUM.RemoveButtonUM(onClick = onRemoveClick), - account = account, - isAccountsMode = isAccountsMode, - isFromCurrency = true, - ), - ) - } -} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/transformer/SelectToTokenTransformer.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/transformer/SelectToTokenTransformer.kt deleted file mode 100644 index 8699cd9e56..0000000000 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/transformer/SelectToTokenTransformer.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.tangem.features.onramp.swap.entity.transformer - -import com.tangem.core.ui.components.token.state.TokenItemState -import com.tangem.domain.models.account.Account -import com.tangem.features.onramp.swap.entity.ExchangeCardUM -import com.tangem.features.onramp.swap.entity.SwapSelectTokensUM -import com.tangem.features.onramp.swap.entity.SwapSelectTokensUMTransformer -import com.tangem.features.onramp.swap.entity.utils.toFilled - -/** - * Transformer for selecting "to" token - * - * @property selectedTokenItemState token item state - * -[REDACTED_AUTHOR] - */ -internal class SelectToTokenTransformer( - private val selectedTokenItemState: TokenItemState, - private val isAccountsMode: Boolean, - private val account: Account.CryptoPortfolio?, -) : SwapSelectTokensUMTransformer { - - override fun transform(prevState: SwapSelectTokensUM): SwapSelectTokensUM { - return prevState.copy( - exchangeFrom = prevState.exchangeFrom.hideRemoveButton(), - exchangeTo = prevState.exchangeTo.toFilled( - selectedTokenItemState = selectedTokenItemState, - isAccountsMode = isAccountsMode, - account = account, - isFromCurrency = false, - ), - ) - } - - private fun ExchangeCardUM.hideRemoveButton(): ExchangeCardUM { - return (this as? ExchangeCardUM.Filled)?.copy(removeButtonUM = null) ?: this - } -} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/utils/ExchangeCardUMExt.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/utils/ExchangeCardUMExt.kt deleted file mode 100644 index 892b627a0c..0000000000 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/utils/ExchangeCardUMExt.kt +++ /dev/null @@ -1,57 +0,0 @@ -package com.tangem.features.onramp.swap.entity.utils - -import com.tangem.common.ui.account.CryptoPortfolioIconConverter -import com.tangem.common.ui.account.toUM -import com.tangem.core.ui.components.token.state.TokenItemState -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.domain.models.account.Account -import com.tangem.features.onramp.impl.R -import com.tangem.features.onramp.swap.entity.ExchangeCardUM - -/** Create empty exchange "from" card */ -internal fun createEmptyExchangeFrom(): ExchangeCardUM.Empty { - return ExchangeCardUM.Empty( - titleUM = ExchangeCardUM.TitleUM.Text(resourceReference(id = R.string.swapping_from_title)), - subtitleReference = resourceReference(id = R.string.action_buttons_you_want_to_swap), - ) -} - -/** Create empty exchange "to" card */ -internal fun createEmptyExchangeTo(): ExchangeCardUM.Empty { - return ExchangeCardUM.Empty( - titleUM = ExchangeCardUM.TitleUM.Text(resourceReference(id = R.string.swapping_to_title)), - subtitleReference = resourceReference(id = R.string.action_buttons_you_want_to_receive), - ) -} - -/** - * Convert from [ExchangeCardUM] to [ExchangeCardUM.Filled] - * - * @param selectedTokenItemState token item state - * @param removeButtonUM remove button UI model - */ -internal fun ExchangeCardUM.toFilled( - selectedTokenItemState: TokenItemState, - account: Account.CryptoPortfolio?, - isAccountsMode: Boolean, - isFromCurrency: Boolean, - removeButtonUM: ExchangeCardUM.RemoveButtonUM? = null, -): ExchangeCardUM.Filled { - return ExchangeCardUM.Filled( - titleUM = if (account != null && isAccountsMode) { - ExchangeCardUM.TitleUM.Account( - prefixText = if (isFromCurrency) { - resourceReference(R.string.common_from) - } else { - resourceReference(R.string.common_to) - }, - name = account.accountName.toUM().value, - icon = CryptoPortfolioIconConverter.convert(account.icon), - ) - } else { - titleUM - }, - tokenItemState = selectedTokenItemState, - removeButtonUM = removeButtonUM, - ) -} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/model/SwapSelectTokensModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/model/SwapSelectTokensModel.kt deleted file mode 100644 index 697f08be5a..0000000000 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/model/SwapSelectTokensModel.kt +++ /dev/null @@ -1,187 +0,0 @@ -package com.tangem.features.onramp.swap.model - -import com.tangem.common.routing.AppRoute -import com.tangem.core.analytics.api.AnalyticsEventHandler -import com.tangem.core.analytics.models.AnalyticsParam -import com.tangem.core.analytics.models.event.MainScreenAnalyticsEvent -import com.tangem.core.decompose.model.Model -import com.tangem.core.decompose.model.ParamsContainer -import com.tangem.core.decompose.navigation.Router -import com.tangem.core.ui.components.token.state.TokenItemState -import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase -import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase -import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase -import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.features.onramp.component.SwapSelectTokensComponent -import com.tangem.features.onramp.swap.entity.SwapSelectTokensController -import com.tangem.features.onramp.swap.entity.SwapSelectTokensUM -import com.tangem.features.onramp.swap.entity.transformer.RemoveSelectedFromTokenTransformer -import com.tangem.features.onramp.swap.entity.transformer.RemoveSelectedToTokenTransformer -import com.tangem.features.onramp.swap.entity.transformer.SelectFromTokenTransformer -import com.tangem.features.onramp.swap.entity.transformer.SelectToTokenTransformer -import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeout -import javax.inject.Inject - -@Suppress("LongParameterList") -internal class SwapSelectTokensModel @Inject constructor( - paramsContainer: ParamsContainer, - override val dispatchers: CoroutineDispatcherProvider, - private val controller: SwapSelectTokensController, - private val router: Router, - private val analyticsEventHandler: AnalyticsEventHandler, - private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, - private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, - private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, -) : Model() { - - val state: StateFlow = controller.state - - val fromCurrencyStatus: StateFlow - field = MutableStateFlow(value = null) - - private val _toCurrencyStatus = MutableStateFlow(value = null) - - private val params = paramsContainer.require() - - private var isAccountsMode: Boolean = false - - init { - controller.update { it.copy(onBackClick = ::onBackClick) } - - subscribeOnAccountsMode() - subscribeOnBalanceHidingSettings() - } - - /** - * Select "from" token - * - * @param selectedTokenItemState selected token item state - * @param status crypto currency status - */ - fun selectFromToken(selectedTokenItemState: TokenItemState, status: CryptoCurrencyStatus) { - analyticsEventHandler.send( - event = MainScreenAnalyticsEvent.SwapTokenClicked(currencySymbol = status.currency.symbol), - ) - - fromCurrencyStatus.value = status - - modelScope.launch { - controller.update( - transformer = SelectFromTokenTransformer( - selectedTokenItemState = selectedTokenItemState, - onRemoveClick = ::onRemoveFromTokenClick, - isAccountsMode = isAccountsMode, - account = getAccountCurrencyStatusUseCase.invokeSync( - userWalletId = params.userWalletId, - currency = status.currency, - ).getOrNull()?.account, - ), - ) - } - } - - /** - * Select "to" token - * - * @param selectedTokenItemState selected token item state - * @param status crypto currency status - */ - fun selectToToken(selectedTokenItemState: TokenItemState, status: CryptoCurrencyStatus) { - analyticsEventHandler.send( - event = MainScreenAnalyticsEvent.ReceiveTokenClicked(currencySymbol = status.currency.symbol), - ) - - modelScope.launch { - _toCurrencyStatus.value = status - - controller.update( - transformer = SelectToTokenTransformer( - selectedTokenItemState = selectedTokenItemState, - isAccountsMode = isAccountsMode, - account = getAccountCurrencyStatusUseCase.invokeSync( - userWalletId = params.userWalletId, - currency = status.currency, - ).getOrNull()?.account, - ), - ) - - // require some delay to show state with selected "from" and "to" tokens - delay(timeMillis = 500) - - router.push( - route = AppRoute.Swap( - fromCryptoCurrency = requireNotNull(fromCurrencyStatus.value).currency, - userWalletId = params.userWalletId, - screenSource = AnalyticsParam.ScreensSources.Main.value, - ), - onComplete = { - modelScope.launch { - withTimeout(timeMillis = 500) { - // Return a state with selected only "from" token - removeSelectedToToken() - } - } - }, - ) - } - } - - private fun subscribeOnBalanceHidingSettings() { - getBalanceHidingSettingsUseCase() - .map { it.isBalanceHidden } - .distinctUntilChanged() - .onEach { - controller.update { state -> state.copy(isBalanceHidden = it) } - } - .flowOn(dispatchers.mainImmediate) - .launchIn(modelScope) - } - - private fun subscribeOnAccountsMode() { - isAccountsModeEnabledUseCase() - .distinctUntilChanged() - .onEach { - isAccountsMode = it - } - .flowOn(dispatchers.default) - .launchIn(modelScope) - } - - private fun onBackClick() { - analyticsEventHandler.send( - event = MainScreenAnalyticsEvent.ButtonClose(source = AnalyticsParam.ScreensSources.Swap), - ) - - router.pop() - } - - private fun onRemoveFromTokenClick() { - val currencySymbol = requireNotNull(fromCurrencyStatus.value?.currency?.symbol) { - "Token was not selected" - } - - analyticsEventHandler.send( - event = MainScreenAnalyticsEvent.RemoveTokenClicked(currencySymbol = currencySymbol), - ) - - removeSelectedFromToken() - } - - private fun removeSelectedFromToken() { - fromCurrencyStatus.value = null - - controller.update(transformer = RemoveSelectedFromTokenTransformer) - } - - private fun removeSelectedToToken() { - _toCurrencyStatus.value = null - - controller.update( - transformer = RemoveSelectedToTokenTransformer(onRemoveFromTokenClick = ::removeSelectedFromToken), - ) - } -} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/ui/ExchangeCard.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/ui/ExchangeCard.kt deleted file mode 100644 index da564d9d1b..0000000000 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/ui/ExchangeCard.kt +++ /dev/null @@ -1,214 +0,0 @@ -package com.tangem.features.onramp.swap.ui - -import android.content.res.Configuration -import androidx.compose.animation.* -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Text -import androidx.compose.material3.ripple -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import androidx.compose.ui.unit.dp -import com.tangem.common.ui.account.AccountLabel -import com.tangem.core.ui.components.account.AccountIconSize -import com.tangem.core.ui.components.currency.icon.CurrencyIconState -import com.tangem.core.ui.components.marketprice.PriceChangeType -import com.tangem.core.ui.components.rows.NetworkTitle -import com.tangem.core.ui.components.token.TokenItem -import com.tangem.core.ui.components.token.state.TokenItemState -import com.tangem.core.ui.extensions.* -import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.core.ui.test.SwapSelectTokenScreenTestTags -import com.tangem.core.ui.utils.dashedBorder -import com.tangem.features.onramp.impl.R -import com.tangem.features.onramp.swap.entity.ExchangeCardUM - -/** - * Exchange card - * - * @param state state - * @param isBalanceHidden is balance hidden - * @param modifier modifier - * -[REDACTED_AUTHOR] - */ -@Composable -internal fun ExchangeCard(state: ExchangeCardUM, isBalanceHidden: Boolean, modifier: Modifier = Modifier) { - Column( - modifier = modifier - .fillMaxWidth() - .heightIn(min = 116.dp) - .clip(TangemTheme.shapes.roundedCornersXMedium) - .background(TangemTheme.colors.background.primary) - .testTag(SwapSelectTokenScreenTestTags.YOU_SWAP_BLOCK), - verticalArrangement = Arrangement.SpaceBetween, - ) { - Title( - titleUM = state.titleUM, - removeButtonUM = state.removeButtonUM, - ) - - AnimatedContent( - targetState = state, - transitionSpec = { - fadeIn(animationSpec = tween(durationMillis = 220, delayMillis = 90)) - .togetherWith(fadeOut(animationSpec = tween(durationMillis = 90))) - }, - label = "TokenItem's changing", - ) { animatedState -> - when (animatedState) { - is ExchangeCardUM.Empty -> EmptyTokenBlock(text = animatedState.subtitleReference) - is ExchangeCardUM.Filled -> { - TokenItem(state = animatedState.tokenItemState, isBalanceHidden = isBalanceHidden) - } - } - } - } -} - -@Composable -private fun Title(titleUM: ExchangeCardUM.TitleUM, removeButtonUM: ExchangeCardUM.RemoveButtonUM?) { - NetworkTitle( - title = { - AnimatedContent( - titleUM, - ) { currentState -> - when (currentState) { - is ExchangeCardUM.TitleUM.Account -> Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = currentState.prefixText.resolveReference(), - style = TangemTheme.typography.subtitle2, - color = TangemTheme.colors.text.tertiary, - ) - AccountLabel( - name = currentState.name, - icon = currentState.icon, - iconSize = AccountIconSize.ExtraSmall, - nameStyle = TangemTheme.typography.subtitle2, - nameColor = TangemTheme.colors.text.tertiary, - ) - } - is ExchangeCardUM.TitleUM.Text -> Text( - text = currentState.title.resolveReference(), - color = TangemTheme.colors.text.tertiary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = TangemTheme.typography.subtitle2, - ) - } - } - }, - action = { RemoveButton(state = removeButtonUM) }, - ) -} - -@Composable -private fun RemoveButton(state: ExchangeCardUM.RemoveButtonUM?) { - AnimatedVisibility(visible = state != null) { - state ?: return@AnimatedVisibility - - Text( - text = stringResourceSafe(id = R.string.manage_tokens_remove), - modifier = Modifier.clickable( - indication = ripple(bounded = false), - interactionSource = remember { MutableInteractionSource() }, - onClick = state.onClick, - ), - color = TangemTheme.colors.text.accent, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = TangemTheme.typography.body2, - ) - } -} - -@Composable -private fun EmptyTokenBlock(text: TextReference, modifier: Modifier = Modifier) { - Box( - modifier = modifier - .padding(horizontal = 12.dp, vertical = 13.dp) - .heightIn(min = 50.dp) - .fillMaxWidth() - .dashedBorder( - color = TangemTheme.colors.icon.informative, - shape = RoundedCornerShape(16.dp), - dashLength = 2.dp, - gapLength = 6.dp, - ) - .padding(vertical = 15.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = text.resolveReference(), - color = TangemTheme.colors.text.tertiary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = TangemTheme.typography.body2, - modifier = Modifier.testTag(SwapSelectTokenScreenTestTags.CHOOSE_TOKEN_TEXT), - ) - } -} - -@Preview(showBackground = true, widthDp = 360) -@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun Preview_ExchangeCard(@PreviewParameter(ExchangeCardUMProvider::class) state: ExchangeCardUM) { - TangemThemePreview { - ExchangeCard( - state = state, - isBalanceHidden = false, - modifier = Modifier - .background(TangemTheme.colors.background.secondary) - .padding(16.dp), - ) - } -} - -private class ExchangeCardUMProvider : PreviewParameterProvider { - - override val values: Sequence = sequenceOf( - ExchangeCardUM.Empty( - titleUM = ExchangeCardUM.TitleUM.Text(resourceReference(id = R.string.swapping_from_title)), - subtitleReference = resourceReference(id = R.string.action_buttons_you_want_to_swap), - ), - createFilled(removeButtonUM = null), - createFilled(removeButtonUM = ExchangeCardUM.RemoveButtonUM { }), - ) - - private fun createFilled(removeButtonUM: ExchangeCardUM.RemoveButtonUM?): ExchangeCardUM.Filled { - return ExchangeCardUM.Filled( - titleUM = ExchangeCardUM.TitleUM.Text(resourceReference(id = R.string.swapping_from_title)), - removeButtonUM = removeButtonUM, - tokenItemState = TokenItemState.Content( - id = "1", - iconState = CurrencyIconState.Locked, - titleState = TokenItemState.TitleState.Content(text = stringReference(value = "Bitcoin")), - fiatAmountState = TokenItemState.FiatAmountState.Content(text = "12 368,14 \$"), - subtitle2State = TokenItemState.Subtitle2State.TextContent(text = "0,35853044 BTC"), - subtitleState = TokenItemState.SubtitleState.CryptoPriceContent( - price = "34 496,75 \$", - priceChangePercent = "0,43 %", - type = PriceChangeType.DOWN, - ), - onItemClick = {}, - onItemLongClick = {}, - ), - ) - } -} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/ui/SwapSelectTokens.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/ui/SwapSelectTokens.kt deleted file mode 100644 index 5d3e457cc8..0000000000 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/ui/SwapSelectTokens.kt +++ /dev/null @@ -1,214 +0,0 @@ -package com.tangem.features.onramp.swap.ui - -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.systemBarsPadding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.unit.dp -import com.tangem.common.ui.notifications.NotificationUM -import com.tangem.core.ui.components.appbar.AppBarWithBackButton -import com.tangem.core.ui.components.list.InfiniteListHandler -import com.tangem.core.ui.extensions.stringResourceSafe -import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.utils.rememberHideKeyboardNestedScrollConnection -import com.tangem.features.onramp.impl.R -import com.tangem.features.onramp.swap.availablepairs.AvailableSwapPairsComponent -import com.tangem.features.onramp.swap.availablepairs.market.state.SwapMarketState -import com.tangem.features.onramp.swap.entity.ExchangeCardUM -import com.tangem.features.onramp.swap.entity.SwapSelectTokensUM -import com.tangem.features.onramp.tokenlist.OnrampTokenListComponent -import com.tangem.features.onramp.tokenlist.entity.TokenListUM - -private const val LOAD_MORE_BUFFER = 25 - -/** - * Swap select tokens - * - * @param state state - * @param selectFromTokenListComponent select "from" token list component - * @param selectToTokenListComponent select "to" token list component - * @param modifier modifier - * -[REDACTED_AUTHOR] - */ -@OptIn(ExperimentalFoundationApi::class) -@Composable -internal fun SwapSelectTokens( - state: SwapSelectTokensUM, - selectFromTokenListComponent: OnrampTokenListComponent, - selectFromTokenListState: TokenListUM, - selectToTokenListComponent: AvailableSwapPairsComponent, - selectToTokenListState: TokenListUM, - modifier: Modifier = Modifier, -) { - BackHandler(onBack = state.onBackClick) - - val nestedScrollConnection = rememberHideKeyboardNestedScrollConnection() - val lazyListState = rememberLazyListState() - - LazyColumn( - modifier = modifier - .nestedScroll(nestedScrollConnection) - .background(TangemTheme.colors.background.secondary) - .imePadding() - .systemBarsPadding(), - state = lazyListState, - contentPadding = PaddingValues(bottom = 8.dp), - ) { - swapSelectTokensContent( - state = state, - selectFromTokenListComponent = selectFromTokenListComponent, - selectFromTokenListState = selectFromTokenListState, - selectToTokenListComponent = selectToTokenListComponent, - selectToTokenListState = selectToTokenListState, - ) - } - - ScrollToTopEffect(state = state, lazyListState = lazyListState) - - MarketsHandlers( - state = state, - selectToTokenListState = selectToTokenListState, - lazyListState = lazyListState, - ) -} - -@OptIn(ExperimentalFoundationApi::class) -private fun LazyListScope.swapSelectTokensContent( - state: SwapSelectTokensUM, - selectFromTokenListComponent: OnrampTokenListComponent, - selectFromTokenListState: TokenListUM, - selectToTokenListComponent: AvailableSwapPairsComponent, - selectToTokenListState: TokenListUM, -) { - stickyHeader(key = "header") { - AppBarWithBackButton( - onBackClick = state.onBackClick, - text = stringResourceSafe(id = R.string.common_swap), - iconRes = R.drawable.ic_close_24, - containerColor = TangemTheme.colors.background.secondary, - ) - } - - item(key = "exchange_from", contentType = "exchange_from") { - ExchangeCard( - state = state.exchangeFrom, - isBalanceHidden = state.isBalanceHidden, - modifier = Modifier - .padding(horizontal = 16.dp) - .padding(top = 8.dp, bottom = 12.dp) - .animateItem(), - ) - } - - if (state.exchangeFrom is ExchangeCardUM.Empty) { - with(selectFromTokenListComponent) { - content(uiState = selectFromTokenListState, modifier = Modifier) - } - } - - if (state.exchangeFrom is ExchangeCardUM.Filled) { - exchangeToSection( - state = state, - selectToTokenListComponent = selectToTokenListComponent, - selectToTokenListState = selectToTokenListState, - ) - } -} - -@OptIn(ExperimentalFoundationApi::class) -private fun LazyListScope.exchangeToSection( - state: SwapSelectTokensUM, - selectToTokenListComponent: AvailableSwapPairsComponent, - selectToTokenListState: TokenListUM, -) { - item(key = "exchange_to", contentType = "exchange_to") { - if (selectToTokenListState.warning != NotificationUM.Warning.SwapNoAvailablePair) { - ExchangeCard( - state = state.exchangeTo, - isBalanceHidden = state.isBalanceHidden, - modifier = Modifier - .padding(horizontal = 16.dp) - .padding(bottom = 12.dp) - .animateItem(), - ) - } - } - - if (state.exchangeTo is ExchangeCardUM.Empty) { - with(selectToTokenListComponent) { - content(uiState = selectToTokenListState, modifier = Modifier.padding(horizontal = 16.dp)) - } - } -} - -@Composable -private fun ScrollToTopEffect(state: SwapSelectTokensUM, lazyListState: LazyListState) { - LaunchedEffect(state.exchangeFrom !is ExchangeCardUM.Empty) { - lazyListState.scrollToItem(index = 0) - } -} - -@Composable -private fun MarketsHandlers( - state: SwapSelectTokensUM, - selectToTokenListState: TokenListUM, - lazyListState: LazyListState, -) { - // Markets for "to" token list - if (state.exchangeFrom is ExchangeCardUM.Filled && state.exchangeTo is ExchangeCardUM.Empty) { - MarketsPaginationHandler( - marketsState = selectToTokenListState.marketsState, - lazyListState = lazyListState, - ) - } -} - -@Composable -private fun MarketsPaginationHandler(marketsState: SwapMarketState?, lazyListState: LazyListState) { - (marketsState as? SwapMarketState.Content)?.let { content -> - VisibleItemsTracker(lazyListState = lazyListState, marketState = content) - - InfiniteListHandler( - listState = lazyListState, - buffer = LOAD_MORE_BUFFER, - triggerLoadMoreCheckOnItemsCountChange = true, - onLoadMore = remember(content) { - { - content.loadMore() - true - } - }, - ) - } -} - -@Composable -private fun VisibleItemsTracker(lazyListState: LazyListState, marketState: SwapMarketState.Content) { - val visibleItems by remember { - derivedStateOf { - lazyListState.layoutInfo.visibleItemsInfo - .mapNotNull { itemInfo -> - marketState.items.find { it.getComposeKey() == itemInfo.key }?.id - } - } - } - - LaunchedEffect(visibleItems) { - marketState.visibleIdsChanged(visibleItems) - } -} \ No newline at end of file diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/AccountAvailabilityTokenUM.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/AccountAvailabilityTokenUM.kt similarity index 87% rename from features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/AccountAvailabilityTokenUM.kt rename to features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/AccountAvailabilityTokenUM.kt index f0856222ba..daae2aff89 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/entity/AccountAvailabilityTokenUM.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/AccountAvailabilityTokenUM.kt @@ -1,4 +1,4 @@ -package com.tangem.features.onramp.swap.entity +package com.tangem.features.onramp.tokenlist.entity import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/TokenListUM.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/TokenListUM.kt index 04f7008ff8..32992afc16 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/TokenListUM.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/TokenListUM.kt @@ -3,7 +3,6 @@ package com.tangem.features.onramp.tokenlist.entity import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.ui.components.fields.entity.SearchBarUM import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM -import com.tangem.features.onramp.swap.availablepairs.market.state.SwapMarketState import kotlinx.collections.immutable.ImmutableList /** @@ -13,7 +12,6 @@ import kotlinx.collections.immutable.ImmutableList * @property availableItems available items (search bar, header, tokens) * @property unavailableItems unavailable items (header, tokens) * @property isBalanceHidden flag that indicates if balance should be hidden - * @property marketsState markets list state (null when markets should not be shown) * [REDACTED_AUTHOR] */ @@ -23,8 +21,7 @@ internal data class TokenListUM( val unavailableItems: ImmutableList, val tokensListData: TokenListUMData, val isBalanceHidden: Boolean, - val warning: NotificationUM? = null, - val marketsState: SwapMarketState? = null, + val warning: NotificationUM? = null ) internal sealed interface TokenListUMData { diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/converters/LoadingAccountTokenItemConverter.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/LoadingAccountTokenItemConverter.kt similarity index 94% rename from features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/converters/LoadingAccountTokenItemConverter.kt rename to features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/LoadingAccountTokenItemConverter.kt index 61552e4822..33c0f75113 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/converters/LoadingAccountTokenItemConverter.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/LoadingAccountTokenItemConverter.kt @@ -1,4 +1,4 @@ -package com.tangem.features.onramp.swap.availablepairs.entity.converters +package com.tangem.features.onramp.tokenlist.entity.transformer import com.tangem.common.ui.account.AccountCryptoPortfolioItemStateConverter import com.tangem.common.ui.account.TokensListPortfolioItemConverter diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/converters/LoadingTokenListItemConverter.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/LoadingTokenListItemConverter.kt similarity index 93% rename from features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/converters/LoadingTokenListItemConverter.kt rename to features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/LoadingTokenListItemConverter.kt index 68932d0f66..73d4dcde43 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/swap/availablepairs/entity/converters/LoadingTokenListItemConverter.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/LoadingTokenListItemConverter.kt @@ -1,4 +1,4 @@ -package com.tangem.features.onramp.swap.availablepairs.entity.converters +package com.tangem.features.onramp.tokenlist.entity.transformer import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.token.state.TokenItemState diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/SetLoadingAccountTokenListTransformer.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/SetLoadingAccountTokenListTransformer.kt index 797dfec9df..70751009af 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/SetLoadingAccountTokenListTransformer.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/SetLoadingAccountTokenListTransformer.kt @@ -4,8 +4,6 @@ import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.filterCryptoPortfolio import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.features.onramp.swap.availablepairs.entity.converters.LoadingAccountTokenItemConverter -import com.tangem.features.onramp.swap.availablepairs.entity.converters.LoadingTokenListItemConverter import com.tangem.features.onramp.tokenlist.entity.TokenListUM import com.tangem.features.onramp.tokenlist.entity.TokenListUMData import com.tangem.features.onramp.tokenlist.entity.TokenListUMTransformer diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/UpdateAccountTokenItemConverter.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/UpdateAccountTokenItemConverter.kt index 791645af20..39afe4e4e1 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/UpdateAccountTokenItemConverter.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/UpdateAccountTokenItemConverter.kt @@ -8,7 +8,7 @@ import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.TotalFiatBalance import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.features.onramp.swap.entity.AccountAvailabilityUM +import com.tangem.features.onramp.tokenlist.entity.AccountAvailabilityUM import com.tangem.features.onramp.tokenlist.entity.utils.OnrampTokenItemStateConverterFactory import com.tangem.utils.converter.Converter import kotlinx.collections.immutable.toPersistentList diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/UpdateAccountTokenListTransformer.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/UpdateAccountTokenListTransformer.kt index 8ccc9dabe0..81a9163b2e 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/UpdateAccountTokenListTransformer.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/transformer/UpdateAccountTokenListTransformer.kt @@ -8,7 +8,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.features.onramp.impl.R -import com.tangem.features.onramp.swap.entity.AccountAvailabilityUM +import com.tangem.features.onramp.tokenlist.entity.AccountAvailabilityUM import com.tangem.features.onramp.tokenlist.entity.TokenListUM import com.tangem.features.onramp.tokenlist.entity.TokenListUMData import com.tangem.features.onramp.tokenlist.entity.TokenListUMTransformer diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/model/OnrampTokenListModel.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/model/OnrampTokenListModel.kt index b11f85dce7..500c36d02c 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/model/OnrampTokenListModel.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/model/OnrampTokenListModel.kt @@ -26,8 +26,8 @@ import com.tangem.domain.tokens.GetAssetRequirementsUseCase import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.features.onramp.impl.R -import com.tangem.features.onramp.swap.entity.AccountAvailabilityUM -import com.tangem.features.onramp.swap.entity.AccountCurrencyUM +import com.tangem.features.onramp.tokenlist.entity.AccountAvailabilityUM +import com.tangem.features.onramp.tokenlist.entity.AccountCurrencyUM import com.tangem.features.onramp.tokenlist.OnrampTokenListComponent import com.tangem.features.onramp.tokenlist.entity.* import com.tangem.features.onramp.tokenlist.entity.transformer.SetLoadingAccountTokenListTransformer diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/ui/OnrampTokenList.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/ui/OnrampTokenList.kt index e878df7dbd..1654052d6a 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/ui/OnrampTokenList.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/ui/OnrampTokenList.kt @@ -21,7 +21,6 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.ui.R -import com.tangem.core.ui.components.SpacerH32 import com.tangem.core.ui.components.fields.SearchBar import com.tangem.core.ui.components.fields.TangemSearchBarDefaults import com.tangem.core.ui.components.fields.entity.SearchBarUM @@ -37,26 +36,11 @@ import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.BuyTokenScreenTestTags import com.tangem.core.ui.utils.lazyListItemPosition -import com.tangem.features.onramp.swap.availablepairs.ui.swapMarketsListItems import com.tangem.features.onramp.tokenlist.entity.TokenListUM import com.tangem.features.onramp.tokenlist.entity.TokenListUMData import com.tangem.features.onramp.tokenlist.ui.preview.PreviewTokenListUMProvider import kotlinx.collections.immutable.ImmutableList -/** - * Token list for swap - automatically switches between normal and search mode with markets - * - * @param state state - * - */ -internal fun LazyListScope.onrampSwapTokenList(state: TokenListUM) { - if (state.marketsState != null) { - onrampTokenListWithMarkets(state = state) - } else { - onrampTokenList(state = state) - } -} - /** * Token list - normal mode (without markets) * @@ -74,39 +58,6 @@ internal fun LazyListScope.onrampTokenList(state: TokenListUM) { tokensListData(state = state) } -/** - * Token list with markets - search mode - * - * @param state state - */ -private fun LazyListScope.onrampTokenListWithMarkets(state: TokenListUM) { - val itemModifier = Modifier.padding(horizontal = 16.dp) - - warningOrSearchBar(state = state, itemModifier = itemModifier) - - // Check if user has any assets to show - val hasAssets = state.availableItems.isNotEmpty() || - state.unavailableItems.isNotEmpty() || - state.tokensListData.totalTokensCount != 0 - - if (hasAssets) { - assetsTitle( - count = state.tokensListData.totalTokensCount, - showCount = state.marketsState?.shouldAssetsCount == true, - ) - - tokensList(items = state.availableItems, isBalanceHidden = state.isBalanceHidden) - - tokensList(items = state.unavailableItems, isBalanceHidden = state.isBalanceHidden) - - tokensListData(state = state) - - item { SpacerH32() } - } - - state.marketsState?.let(::swapMarketsListItems) -} - private fun LazyListScope.warningOrSearchBar(state: TokenListUM, itemModifier: Modifier) { if (state.warning == null) { searchBarItem(searchBarUM = state.searchBarUM, modifier = itemModifier) diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt index 4d35b9c0fe..dcdd04d56a 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt @@ -49,11 +49,9 @@ import com.tangem.datasource.api.express.models.request.LeastTokenInfo as Networ internal class DefaultSwapRepository( private val tangemExpressApi: TangemExpressApi, private val coroutineDispatcher: CoroutineDispatcherProvider, - private val walletManagersFacade: WalletManagersFacade, private val errorsDataConverter: ErrorsDataConverter, private val dataSignatureVerifier: DataSignatureVerifier, private val appPreferencesStore: AppPreferencesStore, - private val rampStateManager: RampStateManager, private val expressHistoryDao: ExpressHistoryDao, moshi: Moshi, ) : SwapRepository { @@ -122,98 +120,6 @@ internal class DefaultSwapRepository( } } - override suspend fun getPairsOnly( - userWallet: UserWallet, - initialCurrency: LeastTokenInfo, - currencyList: List, - isIgnoreExpress: Boolean, - ): PairsWithProviders { - return withContext(coroutineDispatcher.io) { - val currenciesList = filterByAssetRequirements(userWallet, currencyList) - - if (isIgnoreExpress) { - buildLocalPairs(initialCurrency, currenciesList) - } else { - fetchExpressPairs(userWallet, initialCurrency, currenciesList) - } - } - } - - private fun buildLocalPairs( - initialCurrency: LeastTokenInfo, - currenciesList: List, - ): PairsWithProviders { - val pairs = currenciesList.map { tokenInfo -> - SwapPairLeast( - from = initialCurrency, - to = LeastTokenInfo( - contractAddress = tokenInfo.contractAddress, - network = tokenInfo.network, - ), - providers = emptyList(), - ) - } - return PairsWithProviders(pairs = pairs, allProviders = emptyList()) - } - - private suspend fun fetchExpressPairs( - userWallet: UserWallet, - initialCurrency: LeastTokenInfo, - currenciesList: List, - ): PairsWithProviders { - try { - val initial = NetworkLeastTokenInfo( - contractAddress = initialCurrency.contractAddress, - network = initialCurrency.network, - ) - - val allPairs = supervisorScope { - val pairsDeferred = async { - getPairsInternal( - userWallet = userWallet, - from = arrayListOf(initial), - to = currenciesList, - ) - } - - val reversedPairsDeferred = async { - getPairsInternal( - userWallet = userWallet, - from = currenciesList, - to = arrayListOf(initial), - ) - } - - pairsDeferred.await().getOrThrow() + reversedPairsDeferred.await().getOrThrow() - } - - return swapPairInfoConverter.convert( - SwapPairsWithProviders( - swapPair = allPairs, - providers = emptyList(), - ), - ) - } catch (exception: Exception) { - if (exception is ApiResponseError.HttpException) { - throw ExpressException(errorsDataConverter.convert(exception.errorBody.orEmpty())) - } else { - throw exception - } - } - } - - private suspend fun filterByAssetRequirements( - userWallet: UserWallet, - currencyList: List, - ): List { - return currencyList - .filter { currency -> - val requirements = walletManagersFacade.getAssetRequirements(userWallet.walletId, currency) - rampStateManager.checkAssetRequirements(requirements) - } - .map { currency -> leastTokenInfoConverter.convert(currency) } - } - private suspend fun getPairsInternal( userWallet: UserWallet, from: List, diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/di/SwapDataModule.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/di/SwapDataModule.kt index ca24786ea1..4a05eab85d 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/di/SwapDataModule.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/di/SwapDataModule.kt @@ -39,22 +39,18 @@ internal class SwapDataModule { tangemExpressApi: TangemExpressApi, coroutineDispatcher: CoroutineDispatcherProvider, dataSignature: DataSignatureVerifier, - walletManagerFacade: WalletManagersFacade, errorsDataConverter: ErrorsDataConverter, @NetworkMoshi moshi: Moshi, appPreferencesStore: AppPreferencesStore, - rampStateManager: RampStateManager, expressHistoryDao: ExpressHistoryDao, ): SwapRepository { return DefaultSwapRepository( tangemExpressApi = tangemExpressApi, coroutineDispatcher = coroutineDispatcher, - walletManagersFacade = walletManagerFacade, errorsDataConverter = errorsDataConverter, dataSignatureVerifier = dataSignature, moshi = moshi, appPreferencesStore = appPreferencesStore, - rampStateManager = rampStateManager, expressHistoryDao = expressHistoryDao, ) } diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/GetAvailablePairsUseCase.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/GetAvailablePairsUseCase.kt deleted file mode 100644 index c0bfc9041f..0000000000 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/GetAvailablePairsUseCase.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.tangem.feature.swap.domain - -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.feature.swap.domain.api.SwapRepository -import com.tangem.feature.swap.domain.models.domain.LeastTokenInfo -import com.tangem.feature.swap.domain.models.domain.SwapPairLeast - -class GetAvailablePairsUseCase( - private val swapRepository: SwapRepository, -) { - - suspend operator fun invoke( - userWallet: UserWallet, - initialCurrency: LeastTokenInfo, - currencies: List, - ): List { - return swapRepository.getPairsOnly( - userWallet = userWallet, - initialCurrency = initialCurrency, - currencyList = currencies, - isIgnoreExpress = true, - ).pairs - } -} \ No newline at end of file diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/api/SwapRepository.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/api/SwapRepository.kt index fb7b0b78ab..6e68ce66ad 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/api/SwapRepository.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/api/SwapRepository.kt @@ -16,14 +16,6 @@ interface SwapRepository { currencyList: List, ): PairsWithProviders - /** Express getPairs request variant without providers request */ - suspend fun getPairsOnly( - userWallet: UserWallet, - initialCurrency: LeastTokenInfo, - currencyList: List, - isIgnoreExpress: Boolean = false, - ): PairsWithProviders - suspend fun getExchangeStatus( userWallet: UserWallet?, userWalletId: UserWalletId, From c1fdabb94ec47dc8595c8b4345d4c607d12e50be Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 19 Jun 2026 13:27:13 +0200 Subject: [PATCH 008/210] Updated on 2026-08-14 --- .../tangem/tap/di/domain/SwapDomainModule.kt | 1 - .../models/event/SwapAnalyticsEvent.kt | 4 --- .../onramp/tokenlist/entity/TokenListUM.kt | 2 +- .../onramp/tokenlist/ui/OnrampTokenList.kt | 31 ------------------- .../feature/swap/DefaultSwapRepository.kt | 3 -- .../tangem/feature/swap/di/SwapDataModule.kt | 2 -- 6 files changed, 1 insertion(+), 42 deletions(-) diff --git a/app/src/main/java/com/tangem/tap/di/domain/SwapDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/SwapDomainModule.kt index 9f411a7900..6b9e05763c 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/SwapDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/SwapDomainModule.kt @@ -9,7 +9,6 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import javax.inject.Singleton -import com.tangem.feature.swap.domain.api.SwapRepository as OldSwapRepository /** [REDACTED_AUTHOR] diff --git a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/event/SwapAnalyticsEvent.kt b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/event/SwapAnalyticsEvent.kt index 7a4f738c72..fc1e1e732e 100644 --- a/core/analytics/models/src/main/java/com/tangem/core/analytics/models/event/SwapAnalyticsEvent.kt +++ b/core/analytics/models/src/main/java/com/tangem/core/analytics/models/event/SwapAnalyticsEvent.kt @@ -1,11 +1,7 @@ package com.tangem.core.analytics.models.event import com.tangem.core.analytics.models.AnalyticsEvent -import com.tangem.core.analytics.models.AnalyticsParam.Key.SEARCHED -import com.tangem.core.analytics.models.AnalyticsParam.Key.SOURCE -import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM import com.tangem.core.analytics.models.AnalyticsParam.Key.TYPE -import com.tangem.core.analytics.models.AnalyticsParam.ScreensSources /** [REDACTED_AUTHOR] diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/TokenListUM.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/TokenListUM.kt index 32992afc16..84c6ab33bf 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/TokenListUM.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/entity/TokenListUM.kt @@ -21,7 +21,7 @@ internal data class TokenListUM( val unavailableItems: ImmutableList, val tokensListData: TokenListUMData, val isBalanceHidden: Boolean, - val warning: NotificationUM? = null + val warning: NotificationUM? = null, ) internal sealed interface TokenListUMData { diff --git a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/ui/OnrampTokenList.kt b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/ui/OnrampTokenList.kt index 1654052d6a..bf5bb5fc6a 100644 --- a/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/ui/OnrampTokenList.kt +++ b/features/onramp/impl/src/main/kotlin/com/tangem/features/onramp/tokenlist/ui/OnrampTokenList.kt @@ -3,24 +3,18 @@ package com.tangem.features.onramp.tokenlist.ui import android.content.res.Configuration import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.background -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import com.tangem.common.ui.notifications.NotificationUM -import com.tangem.core.ui.R import com.tangem.core.ui.components.fields.SearchBar import com.tangem.core.ui.components.fields.TangemSearchBarDefaults import com.tangem.core.ui.components.fields.entity.SearchBarUM @@ -31,7 +25,6 @@ import com.tangem.core.ui.components.tokenlist.TokenListItem import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM import com.tangem.core.ui.decorations.roundedShapeItemDecoration import com.tangem.core.ui.extensions.conditional -import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.BuyTokenScreenTestTags @@ -112,30 +105,6 @@ private fun LazyListScope.searchBarItem(searchBarUM: SearchBarUM, modifier: Modi } } -private fun LazyListScope.assetsTitle(count: Int, showCount: Boolean) { - item(key = "assets_title") { - Text( - text = buildAnnotatedString { - append(stringResourceSafe(R.string.swap_your_assets_title)) - if (showCount) { - withStyle(SpanStyle(color = TangemTheme.colors.text.tertiary)) { - append(" $count") - } - } - }, - style = TangemTheme.typography.h3, - color = TangemTheme.colors.text.primary1, - modifier = Modifier - .fillMaxWidth() - .padding( - start = TangemTheme.dimens.spacing16, - end = TangemTheme.dimens.spacing16, - top = TangemTheme.dimens.spacing12, - ), - ) - } -} - private fun LazyListScope.tokensList(items: ImmutableList, isBalanceHidden: Boolean) { itemsIndexed( items = items, diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt index dcdd04d56a..16aa56dd03 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/DefaultSwapRepository.kt @@ -24,12 +24,10 @@ import com.tangem.datasource.local.preferences.PreferencesKeys import com.tangem.datasource.local.preferences.utils.getObjectSyncOrNull import com.tangem.datasource.local.preferences.utils.storeObject import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao -import com.tangem.domain.exchange.RampStateManager import com.tangem.domain.express.models.ExpressOperationType import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.feature.swap.converters.* import com.tangem.feature.swap.domain.api.SwapRepository import com.tangem.feature.swap.domain.models.ExpressDataError @@ -39,7 +37,6 @@ import com.tangem.feature.swap.domain.models.domain.* import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.async -import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.withContext import java.io.IOException import java.util.UUID diff --git a/features/swap/data/src/main/java/com/tangem/feature/swap/di/SwapDataModule.kt b/features/swap/data/src/main/java/com/tangem/feature/swap/di/SwapDataModule.kt index 4a05eab85d..be0a1c60e2 100644 --- a/features/swap/data/src/main/java/com/tangem/feature/swap/di/SwapDataModule.kt +++ b/features/swap/data/src/main/java/com/tangem/feature/swap/di/SwapDataModule.kt @@ -12,8 +12,6 @@ import com.tangem.datasource.local.config.environment.EnvironmentConfig import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao import com.tangem.domain.account.supplier.SingleAccountListSupplier -import com.tangem.domain.exchange.RampStateManager -import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.feature.swap.DefaultSwapFeedbackRepository import com.tangem.feature.swap.DefaultSwapRepository import com.tangem.feature.swap.NoOpSwapFeedbackRepository From 36870dc3f87444d2e4d495008cedcbfdad8504d0 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 19 Jun 2026 15:02:07 +0000 Subject: [PATCH 009/210] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 9f936c4349..074e02bb80 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,9 +5,9 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-6.0-1578" +tangemBlockchainSdk = "develop-1567" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-6.0-626" +tangemCardSdk = "develop-624" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "tangem-master-21" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ From 56c663572bff63a89bf0c3055d66080340874592 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 22 Jun 2026 07:49:26 +0000 Subject: [PATCH 010/210] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 9f936c4349..074e02bb80 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,9 +5,9 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-6.0-1578" +tangemBlockchainSdk = "develop-1567" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-6.0-626" +tangemCardSdk = "develop-624" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "tangem-master-21" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ From 1f27edeb70119b9dd940e6a02699ae340e1ec37f Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 19 Jun 2026 11:38:22 +0400 Subject: [PATCH 011/210] Updated on 2026-08-14 --- .../com/tangem/datasource/api/auth/AuthApi.kt | 23 ++++- .../api/auth/models/request/DeviceMetadata.kt | 12 +-- .../request/WalletRegistrationRequest.kt | 46 ++++++++++ .../dpop/internal/DisabledDpopProofFactory.kt | 2 + .../com/tangem/lib/auth/session/AuthError.kt | 3 + .../lib/auth/session/SessionRefreshError.kt | 14 ++- .../lib/auth/session/SessionTokenRefresher.kt | 13 +-- .../session/internal/AuthErrorConverter.kt | 1 + .../internal/DefaultDeviceRegistrar.kt | 34 +++++++- .../internal/DefaultSessionTokenRefresher.kt | 87 +++++++++++++------ .../internal/DisabledDeviceRegistrar.kt | 2 + .../session/internal/SignedRequestPayload.kt | 19 ++-- .../internal/AuthErrorConverterTest.kt | 8 ++ .../internal/DefaultDeviceRegistrarTest.kt | 42 +++++++-- .../DefaultSessionTokenRefresherTest.kt | 40 +++++++++ .../internal/SignedRequestPayloadTest.kt | 29 ++++--- 16 files changed, 294 insertions(+), 81 deletions(-) create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/WalletRegistrationRequest.kt diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/auth/AuthApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/auth/AuthApi.kt index 1161483d0c..68aec6cd5a 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/auth/AuthApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/auth/AuthApi.kt @@ -4,6 +4,7 @@ import com.tangem.datasource.api.auth.models.request.AuthApiRequest import com.tangem.datasource.api.auth.models.request.NonceApiRequest import com.tangem.datasource.api.auth.models.request.RefreshApiRequest import com.tangem.datasource.api.auth.models.request.RegisterApiRequest +import com.tangem.datasource.api.auth.models.request.WalletRegistrationRequest import com.tangem.datasource.api.auth.models.response.NonceApiResponse import com.tangem.datasource.api.auth.models.response.TokenApiResponse import com.tangem.datasource.api.common.response.ApiResponse @@ -30,8 +31,7 @@ interface AuthApi { * session token pair. Called once per app install. */ @POST("api/v1/auth/register") - @RequiresDpopProof - suspend fun register(@Body request: RegisterApiRequest): ApiResponse + suspend fun registerDevice(@Body request: RegisterApiRequest): ApiResponse /** * Request authentication nonce. @@ -61,4 +61,23 @@ interface AuthApi { @POST("api/v1/auth/refresh") @RequiresDpopProof suspend fun refresh(@Body request: RefreshApiRequest): ApiResponse + + /** + * Request wallet registration nonce. + * + * Generates a nonce bound to the device public key for the wallet registration flow. + */ + @POST("api/v1/auth/nonce/wallet") + suspend fun requestWalletNonce(@Body request: NonceApiRequest): ApiResponse + + /** + * Register a wallet. + * + * Binds a new wallet to an already-registered device. When a card signature is provided the + * wallet is bound as COLD (card-backed); otherwise it is registered as a MOBILE (hot) wallet. + * Returns refreshed session tokens reflecting the updated wallet list. + */ + @POST("api/v1/auth/wallet") + @RequiresDpopProof + suspend fun registerWallet(@Body request: WalletRegistrationRequest): ApiResponse } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/DeviceMetadata.kt b/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/DeviceMetadata.kt index 960957a6a4..b381ec0359 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/DeviceMetadata.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/DeviceMetadata.kt @@ -10,17 +10,17 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class DeviceMetadata( /** Device hardware model (e.g. `iPhone 15 Pro`). */ - @Json(name = "deviceModel") val deviceModel: String?, + @Json(name = "deviceModel") val deviceModel: String, /** Operating system (`android` / `ios`). */ @Json(name = "os") val os: String, /** OS version string (e.g. `17.4.1`). */ - @Json(name = "osVersion") val osVersion: String?, + @Json(name = "osVersion") val osVersion: String, /** Application version (e.g. `5.8.0`). */ - @Json(name = "appVersion") val appVersion: String?, + @Json(name = "appVersion") val appVersion: String, /** User-Agent header (e.g. `Tangem/5.8.0 (iPhone; iOS 17.4.1; Scale/3.00)`). */ - @Json(name = "userAgent") val userAgent: String?, + @Json(name = "userAgent") val userAgent: String, /** Client locale (e.g. `en-US`). */ - @Json(name = "locale") val locale: String?, + @Json(name = "locale") val locale: String, /** Client timezone (e.g. `Europe/Moscow`). */ - @Json(name = "timezone") val timezone: String?, + @Json(name = "timezone") val timezone: String, ) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/WalletRegistrationRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/WalletRegistrationRequest.kt new file mode 100644 index 0000000000..8e9ce11c2e --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/WalletRegistrationRequest.kt @@ -0,0 +1,46 @@ +package com.tangem.datasource.api.auth.models.request + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * Wallet registration request — binds a new wallet to an already-registered device. + * + * When [cardSignature] (and the accompanying [cardSignatureSalt] / [walletStatus]) is provided the + * wallet is bound as COLD (card-backed); otherwise it is registered as a MOBILE (hot) wallet. + * Mirrors the `WalletRegistrationRequest` schema in the backend OpenAPI contract. + */ +@JsonClass(generateAdapter = true) +data class WalletRegistrationRequest( + /** Deciphered nonce value from `/api/v1/auth/nonce/wallet`. */ + @Json(name = "nonce") val nonce: String, + /** + * Wallet identifier — Base64-encoded + * `HMAC-SHA256(key = SHA-256(walletPublicKey), data = "UserWalletID")`. + */ + @Json(name = "walletId") val walletId: String, + /** + * Base64-encoded secp256k1 RSV signature (65 bytes) over `sha256(nonce || walletSignatureSalt)`. + * The server recovers `walletPublicKey` from this signature. + */ + @Json(name = "walletSignature") val walletSignature: String, + /** Base64-encoded salt used in the wallet signature hash. */ + @Json(name = "walletSignatureSalt") val walletSignatureSalt: String, + /** + * Base64-encoded secp256k1 RSV signature (65 bytes) over + * `sha256(walletPublicKey || nonce || cardSignatureSalt || walletStatus)`. Required for + * cold-wallet registration; `null` for mobile (hot) wallets. + */ + @Json(name = "cardSignature") val cardSignature: String?, + /** Base64-encoded salt used in the card signature hash. Required for cold-wallet registration. */ + @Json(name = "cardSignatureSalt") val cardSignatureSalt: String?, + /** + * Base64-encoded single byte describing wallet provenance on the card + * (`0x82` = generated on card, `0xC2` = SEED imported). Required for cold-wallet registration. + */ + @Json(name = "walletStatus") val walletStatus: String?, + /** Platform attestation token (Play Integrity / App Attest). */ + @Json(name = "attestationToken") val attestationToken: String?, + /** Client-reported device metadata. */ + @Json(name = "metadata") val metadata: DeviceMetadata, +) \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/dpop/internal/DisabledDpopProofFactory.kt b/libs/auth/src/main/java/com/tangem/lib/auth/dpop/internal/DisabledDpopProofFactory.kt index eb2242eaa5..2a94fe25e7 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/dpop/internal/DisabledDpopProofFactory.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/dpop/internal/DisabledDpopProofFactory.kt @@ -3,7 +3,9 @@ package com.tangem.lib.auth.dpop.internal import arrow.core.None import arrow.core.Option import com.tangem.lib.auth.dpop.DpopProofFactory +import com.tangem.utils.annotations.RemoveWithToggle +@RemoveWithToggle("AND_15438_BACKEND_AUTHENTICATION_ENABLED") internal object DisabledDpopProofFactory : DpopProofFactory { override suspend fun create(httpMethod: String, httpUri: String, accessToken: String?): Option = None diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/AuthError.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/AuthError.kt index 2d01b7e578..cf6c367cb9 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/session/AuthError.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/AuthError.kt @@ -19,6 +19,9 @@ sealed class AuthError(open val problem: AuthErrorResponse?) { /** `404` — token / resource not found. */ data class NotFound(override val problem: AuthErrorResponse?) : AuthError(problem) + /** `409` — conflict / already exists (e.g. device or wallet already registered). */ + data class Conflict(override val problem: AuthErrorResponse?) : AuthError(problem) + /** `429` — server-side rate limit; honour [retryAfterSeconds] before retrying. */ data class RateLimited( val retryAfterSeconds: Int?, diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/SessionRefreshError.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/SessionRefreshError.kt index 5a79eca439..2f8780035e 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/session/SessionRefreshError.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/SessionRefreshError.kt @@ -2,8 +2,8 @@ package com.tangem.lib.auth.session /** * Typed failure mode of `SessionTokenRefresher.refresh()`. Distinguishes terminal failures - * (re-registration required) from transient ones (network / server) so callers can decide - * whether to retry, surface UI, or trigger deferred-registration flow. + * (re-registration required, server-side block) from transient ones (network / server) so callers + * can decide whether to retry, surface UI, or trigger deferred-registration flow. */ sealed class SessionRefreshError { @@ -12,10 +12,18 @@ sealed class SessionRefreshError { /** * Terminal — `/authenticate` returned 401/403. Session store was cleared; the device must - * re-register ([REDACTED_TASK_KEY] / deferred-registration flow). + * re-register. */ data object SessionRevoked : SessionRefreshError() + /** + * Terminal — `/refresh` returned 403 (RED tier). The device is server-side blocked; + * `/authenticate` won't help (it would also return 403). The client should not retry within + * the current session — only attempt `/refresh` again on the next app launch, in case the + * server-side block was lifted. + */ + data object DeviceBlocked : SessionRefreshError() + /** Device key is not provisioned in Keystore (registration not yet run, or Keystore unavailable). */ data object DeviceKeyUnavailable : SessionRefreshError() diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/SessionTokenRefresher.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/SessionTokenRefresher.kt index 8c585af554..04be67d148 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/session/SessionTokenRefresher.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/SessionTokenRefresher.kt @@ -11,12 +11,13 @@ import arrow.core.Either * * Refresh strategy: * 1. Call `/api/v1/auth/refresh` with the stored refresh token when it is present and unexpired. - * 2. On 401/403 from `/refresh` (revoked / replayed / RED-tier downgrade), fall back to - * full re-authentication via `/api/v1/auth/nonce/auth` + `/api/v1/auth/authenticate` - * signed by the device key. - * 3. On 401/403 from `/authenticate`, clear the session store and return - * [SessionRefreshError.SessionRevoked] — the device must be re-registered (see [REDACTED_TASK_KEY] - * for the deferred-registration flag). + * 2. On 401 from `/refresh` (revoked / replayed / expired refresh token), fall back to full + * re-authentication via `/api/v1/auth/nonce/auth` + `/api/v1/auth/authenticate` signed by the + * device key. + * 3. On 403 from `/refresh` (RED tier — device blocked server-side), return + * [SessionRefreshError.DeviceBlocked] without trying `/authenticate` (it would also 403). + * 4. On 401/403 from `/authenticate`, clear the session store and return + * [SessionRefreshError.SessionRevoked] — the device must be re-registered. */ interface SessionTokenRefresher { diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/AuthErrorConverter.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/AuthErrorConverter.kt index 6e9ec92012..117912dbc0 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/AuthErrorConverter.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/AuthErrorConverter.kt @@ -35,6 +35,7 @@ internal class AuthErrorConverter @Inject constructor() : Converter AuthError.Unauthorized(problem) Code.FORBIDDEN -> AuthError.Forbidden(problem) Code.NOT_FOUND -> AuthError.NotFound(problem) + Code.CONFLICT -> AuthError.Conflict(problem) Code.TOO_MANY_REQUESTS -> AuthError.RateLimited(problem?.retryAfterSeconds, problem) else -> if (error.isServerError()) AuthError.ServerUnavailable(problem) else AuthError.Unknown(error) } diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultDeviceRegistrar.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultDeviceRegistrar.kt index 44abb0afb4..af14515777 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultDeviceRegistrar.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultDeviceRegistrar.kt @@ -1,11 +1,13 @@ package com.tangem.lib.auth.session.internal import arrow.core.Either +import arrow.core.raise.Raise import arrow.core.raise.either import com.tangem.datasource.api.auth.AuthApi import com.tangem.datasource.api.auth.models.request.NonceApiRequest import com.tangem.datasource.api.auth.models.request.RegisterApiRequest import com.tangem.datasource.api.auth.models.request.RegisterPayload +import com.tangem.datasource.api.auth.models.response.TokenApiResponse import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.PreferencesKeys @@ -13,6 +15,7 @@ import com.tangem.datasource.local.preferences.utils.getSyncOrDefault import com.tangem.datasource.local.preferences.utils.store import com.tangem.lib.auth.devicekey.DeviceKeyManager import com.tangem.lib.auth.nonce.AuthNonceDecryptor +import com.tangem.lib.auth.session.AuthError import com.tangem.lib.auth.session.DeviceRegistrar import com.tangem.lib.auth.session.DeviceRegistrationError import com.tangem.lib.auth.session.SessionTokensStore @@ -89,10 +92,16 @@ internal class DefaultDeviceRegistrar( raise(DeviceRegistrationError.SigningFailed(e)) } - val registerResponse = authApi.register(RegisterApiRequest(payload = payload, signature = signature)) - when (registerResponse) { + val registerResponse = authApi.registerDevice(RegisterApiRequest(payload = payload, signature = signature)) + handleRegisterResponse(registerResponse) + } + + private suspend fun Raise.handleRegisterResponse( + response: ApiResponse, + ) { + when (response) { is ApiResponse.Success -> { - val tokens = SessionTokensConverter.convertBack(registerResponse.data) + val tokens = SessionTokensConverter.convertBack(response.data) try { // Keep both writes inside one catch — if the second one fails, the flag stays // `false` and the next launch retries cleanly. Worst case: tokens are persisted @@ -106,10 +115,27 @@ internal class DefaultDeviceRegistrar( TangemLogger.i("Device registered successfully") } is ApiResponse.Error -> { - val authError = errorConverter.convert(registerResponse.cause) + val authError = errorConverter.convert(response.cause) + if (authError is AuthError.Conflict) { + // Device is already registered server-side (e.g. the local flag was lost on + // reinstall). Persist the flag to stop retrying; session tokens will be minted + // on demand via /authenticate. + TangemLogger.i("Device already registered server-side (409) — marking as registered") + markRegistered(onFailureLog = "Failed to persist device-registration flag after 409") + return + } TangemLogger.e("/register request failed: $authError") raise(DeviceRegistrationError.Api(authError)) } } } + + private suspend fun Raise.markRegistered(onFailureLog: String) { + try { + appPreferencesStore.store(key = PreferencesKeys.IS_DEVICE_REGISTERED_KEY, value = true) + } catch (e: Exception) { + TangemLogger.e(onFailureLog, e) + raise(DeviceRegistrationError.PersistenceFailed(e)) + } + } } \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultSessionTokenRefresher.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultSessionTokenRefresher.kt index 3357b50e06..17e2eff7c9 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultSessionTokenRefresher.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultSessionTokenRefresher.kt @@ -9,7 +9,6 @@ import com.tangem.datasource.api.auth.models.request.AuthApiRequest import com.tangem.datasource.api.auth.models.request.AuthenticationPayload import com.tangem.datasource.api.auth.models.request.NonceApiRequest import com.tangem.datasource.api.auth.models.request.RefreshApiRequest -import com.tangem.datasource.api.auth.models.response.TokenApiResponse import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.lib.auth.devicekey.DeviceKeyManager import com.tangem.lib.auth.nonce.AuthNonceDecryptor @@ -55,10 +54,17 @@ internal class DefaultSessionTokenRefresher( } if (isOwner) { + TangemLogger.i("Session refresh started (owner)") try { - deferred.complete(runRefresh(current = store.get().getOrNull())) + val outcome = runRefresh(current = store.get().getOrNull()) + outcome.fold( + ifLeft = { TangemLogger.e("Session refresh finished with error: $it") }, + ifRight = { TangemLogger.i("Session refresh finished successfully") }, + ) + deferred.complete(outcome) } catch (t: Throwable) { // Propagate to every waiter — without this they'd suspend forever on `await()`. + TangemLogger.e("Session refresh threw; propagating to waiters", t) deferred.completeExceptionally(t) throw t } finally { @@ -68,6 +74,8 @@ internal class DefaultSessionTokenRefresher( mutex.withLock { inFlight = null } } } + } else { + TangemLogger.i("Session refresh already in-flight — joining as waiter") } deferred.await() @@ -78,11 +86,31 @@ internal class DefaultSessionTokenRefresher( val isRefreshTokenValid = current?.refreshTokenExpiresAt != null && current.refreshTokenExpiresAt > now if (current?.refreshToken != null && isRefreshTokenValid) { + TangemLogger.i("Calling /refresh with stored refresh token") when (val result = callRefresh(current.refreshToken)) { - is RefreshOutcome.Success -> return result.tokens.right() - RefreshOutcome.Unauthenticated -> Unit // fall through to /authenticate - is RefreshOutcome.Transient -> return SessionRefreshError.Api(result.cause).left() + is RefreshOutcome.Success -> { + store.save(result.tokens) + TangemLogger.i("/refresh succeeded; session tokens persisted") + return result.tokens.right() + } + // 401: refresh token is invalid/expired/revoked/replayed but the device key is + // intact server-side → fall back to /authenticate to mint a new pair. + RefreshOutcome.RefreshTokenInvalid -> { + TangemLogger.i("/refresh returned 401 — falling back to /authenticate") + } + // 403: device is blocked server-side (RED tier). `/authenticate` would also 403, + // so don't waste the call — surface the terminal state and let the caller bail. + RefreshOutcome.DeviceBlocked -> { + TangemLogger.e("/refresh returned 403 — device is blocked server-side (terminal)") + return SessionRefreshError.DeviceBlocked.left() + } + is RefreshOutcome.Transient -> { + TangemLogger.e("/refresh failed with transient error: ${result.cause}") + return SessionRefreshError.Api(result.cause).left() + } } + } else { + TangemLogger.i("No valid refresh token in store — proceeding directly to /authenticate") } return runAuthenticate() @@ -90,10 +118,19 @@ internal class DefaultSessionTokenRefresher( private suspend fun callRefresh(refreshToken: String): RefreshOutcome { val response = authApi.refresh(RefreshApiRequest(refreshToken = refreshToken)) - return handleTokenResponse(response, clearOnUnauthenticated = false) + return when (response) { + is ApiResponse.Success -> RefreshOutcome.Success(SessionTokensConverter.convertBack(response.data)) + is ApiResponse.Error -> when (val authError = errorConverter.convert(response.cause)) { + is AuthError.Unauthorized -> RefreshOutcome.RefreshTokenInvalid + is AuthError.Forbidden -> RefreshOutcome.DeviceBlocked + else -> RefreshOutcome.Transient(authError) + } + } } private suspend fun runAuthenticate(): Either = either { + TangemLogger.i("Starting /authenticate") + val devicePublicKey = deviceKeyManager.getPublicKey().getOrNull() ?: raise(SessionRefreshError.DeviceKeyUnavailable) @@ -104,6 +141,7 @@ internal class DefaultSessionTokenRefresher( is ApiResponse.Success -> nonceResponse.data.cipheredNonce is ApiResponse.Error -> { val authError = errorConverter.convert(nonceResponse.cause) + TangemLogger.e("/nonce/auth request failed: $authError") raise(SessionRefreshError.Api(authError)) } } @@ -129,33 +167,25 @@ internal class DefaultSessionTokenRefresher( } val authResponse = authApi.authenticate(AuthApiRequest(payload = payload, signature = signature)) - return when (val outcome = handleTokenResponse(authResponse, clearOnUnauthenticated = true)) { - is RefreshOutcome.Success -> outcome.tokens.right() - RefreshOutcome.Unauthenticated -> SessionRefreshError.SessionRevoked.left() - is RefreshOutcome.Transient -> SessionRefreshError.Api(outcome.cause).left() - } - } - - private suspend fun handleTokenResponse( - response: ApiResponse, - clearOnUnauthenticated: Boolean, - ): RefreshOutcome { - return when (response) { + when (authResponse) { is ApiResponse.Success -> { - val tokens = SessionTokensConverter.convertBack(response.data) + val tokens = SessionTokensConverter.convertBack(authResponse.data) store.save(tokens) - RefreshOutcome.Success(tokens) + TangemLogger.i("/authenticate succeeded; session tokens persisted") + tokens } is ApiResponse.Error -> { - when (val authError = errorConverter.convert(response.cause)) { + val authError = errorConverter.convert(authResponse.cause) + when (authError) { is AuthError.Unauthorized, is AuthError.Forbidden -> { - if (clearOnUnauthenticated) { - TangemLogger.i("Session revoked: ${authError.problem?.detail ?: authError}") - store.clear() - } - RefreshOutcome.Unauthenticated + TangemLogger.i("Session revoked: ${authError.problem?.detail ?: authError}") + store.clear() + raise(SessionRefreshError.SessionRevoked) + } + else -> { + TangemLogger.e("/authenticate request failed: $authError") + raise(SessionRefreshError.Api(authError)) } - else -> RefreshOutcome.Transient(authError) } } } @@ -163,7 +193,8 @@ internal class DefaultSessionTokenRefresher( private sealed interface RefreshOutcome { data class Success(val tokens: SessionTokens) : RefreshOutcome - data object Unauthenticated : RefreshOutcome + data object RefreshTokenInvalid : RefreshOutcome + data object DeviceBlocked : RefreshOutcome data class Transient(val cause: AuthError) : RefreshOutcome } } \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DisabledDeviceRegistrar.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DisabledDeviceRegistrar.kt index 6b6dee0459..4e25661427 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DisabledDeviceRegistrar.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DisabledDeviceRegistrar.kt @@ -4,7 +4,9 @@ import arrow.core.Either import arrow.core.left import com.tangem.lib.auth.session.DeviceRegistrar import com.tangem.lib.auth.session.DeviceRegistrationError +import com.tangem.utils.annotations.RemoveWithToggle +@RemoveWithToggle("AND_15438_BACKEND_AUTHENTICATION_ENABLED") internal object DisabledDeviceRegistrar : DeviceRegistrar { override suspend fun register(): Either { diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/SignedRequestPayload.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/SignedRequestPayload.kt index beecbe578f..51bf498bca 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/SignedRequestPayload.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/SignedRequestPayload.kt @@ -17,7 +17,7 @@ internal class SignedRequestPayload @Inject constructor( private val appInfoProvider: AppInfoProvider, ) { - /** Snapshot of [appInfoProvider]'s device facts as the network DTO. `userAgent` is intentionally null. */ + /** Snapshot of [appInfoProvider]'s device facts as the network DTO. */ val deviceMetadata: DeviceMetadata get() = DeviceMetadata( deviceModel = appInfoProvider.device, @@ -25,7 +25,7 @@ internal class SignedRequestPayload @Inject constructor( os = appInfoProvider.platform.lowercase(), osVersion = appInfoProvider.osVersion, appVersion = appInfoProvider.appVersion, - userAgent = null, + userAgent = with(appInfoProvider) { "Tangem/$appVersion ($device; $platform $osVersion)" }, locale = appInfoProvider.language, timezone = appInfoProvider.timezone, ) @@ -49,9 +49,7 @@ internal class SignedRequestPayload @Inject constructor( /** * Stable, newline-separated representation of the signed payload. Backend treats the bytes * opaquely; must stay aligned with the server-side canonicalisation. Field order matches the - * declaration order of [RegisterPayload] / [AuthenticationPayload], with one exception: - * [DeviceMetadata.userAgent] is intentionally NOT included in the signed bytes (it's always - * `null` in [deviceMetadata] and the server doesn't sign it either). + * declaration order of [RegisterPayload] / [AuthenticationPayload] and [DeviceMetadata]. */ private fun canonicalize( devicePublicKey: String, @@ -62,12 +60,13 @@ internal class SignedRequestPayload @Inject constructor( append(devicePublicKey).append('\n') append(nonce).append('\n') append(attestationToken.orEmpty()).append('\n') - append(metadata.deviceModel.orEmpty()).append('\n') + append(metadata.deviceModel).append('\n') append(metadata.os).append('\n') - append(metadata.osVersion.orEmpty()).append('\n') - append(metadata.appVersion.orEmpty()).append('\n') - append(metadata.locale.orEmpty()).append('\n') - append(metadata.timezone.orEmpty()) + append(metadata.osVersion).append('\n') + append(metadata.appVersion).append('\n') + append(metadata.userAgent).append('\n') + append(metadata.locale).append('\n') + append(metadata.timezone) }.toByteArray(Charsets.UTF_8) } diff --git a/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/AuthErrorConverterTest.kt b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/AuthErrorConverterTest.kt index 6e782c6df0..b9d720db27 100644 --- a/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/AuthErrorConverterTest.kt +++ b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/AuthErrorConverterTest.kt @@ -71,6 +71,14 @@ class AuthErrorConverterTest { assertThat(result).isInstanceOf(AuthError.NotFound::class.java) } + @Test + fun `409 is converted to Conflict`() { + val result = converter.convert(httpError(Code.CONFLICT, sampleBody)) + + assertThat(result).isInstanceOf(AuthError.Conflict::class.java) + assertThat((result as AuthError.Conflict).problem).isEqualTo(sampleProblem) + } + @Test fun `429 surfaces retryAfterSeconds from problem`() { val rateLimitBody = """ diff --git a/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultDeviceRegistrarTest.kt b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultDeviceRegistrarTest.kt index ebf0d14a60..b523254315 100644 --- a/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultDeviceRegistrarTest.kt +++ b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultDeviceRegistrarTest.kt @@ -9,7 +9,6 @@ import arrow.core.Some import com.google.common.truth.Truth.assertThat import com.squareup.moshi.Moshi import com.tangem.datasource.api.auth.AuthApi -import com.tangem.datasource.api.auth.models.request.NonceApiRequest import com.tangem.datasource.api.auth.models.request.RegisterApiRequest import com.tangem.datasource.api.auth.models.response.NonceApiResponse import com.tangem.datasource.api.auth.models.response.TokenApiResponse @@ -89,7 +88,7 @@ class DefaultDeviceRegistrarTest { assertThat(result.isRight()).isTrue() coVerify { authApi.requestDeviceNonce(any()) } - coVerify { authApi.register(any()) } + coVerify { authApi.registerDevice(any()) } coVerify { store.save(any()) } assertThat(preferencesDataStore.current()[PreferencesKeys.IS_DEVICE_REGISTERED_KEY]).isTrue() } @@ -102,7 +101,7 @@ class DefaultDeviceRegistrarTest { assertThat(result.isRight()).isTrue() coVerify(exactly = 0) { authApi.requestDeviceNonce(any()) } - coVerify(exactly = 0) { authApi.register(any()) } + coVerify(exactly = 0) { authApi.registerDevice(any()) } coVerify(exactly = 0) { store.save(any()) } } @@ -114,7 +113,7 @@ class DefaultDeviceRegistrarTest { assertThat(result.leftOrNull()).isEqualTo(DeviceRegistrationError.DeviceKeyUnavailable) coVerify(exactly = 0) { authApi.requestDeviceNonce(any()) } - coVerify(exactly = 0) { authApi.register(any()) } + coVerify(exactly = 0) { authApi.registerDevice(any()) } assertThat(preferencesDataStore.current()[PreferencesKeys.IS_DEVICE_REGISTERED_KEY]).isNull() } @@ -133,7 +132,7 @@ class DefaultDeviceRegistrarTest { val result = registrar.register() assertThat(result.leftOrNull()).isInstanceOf(DeviceRegistrationError.Api::class.java) - coVerify(exactly = 0) { authApi.register(any()) } + coVerify(exactly = 0) { authApi.registerDevice(any()) } assertThat(preferencesDataStore.current()[PreferencesKeys.IS_DEVICE_REGISTERED_KEY]).isNull() } @@ -148,7 +147,7 @@ class DefaultDeviceRegistrarTest { val result = registrar.register() assertThat(result.leftOrNull()).isInstanceOf(DeviceRegistrationError.NonceDecryptionFailed::class.java) - coVerify(exactly = 0) { authApi.register(any()) } + coVerify(exactly = 0) { authApi.registerDevice(any()) } } @Test @@ -163,7 +162,7 @@ class DefaultDeviceRegistrarTest { val result = registrar.register() assertThat(result.leftOrNull()).isInstanceOf(DeviceRegistrationError.SigningFailed::class.java) - coVerify(exactly = 0) { authApi.register(any()) } + coVerify(exactly = 0) { authApi.registerDevice(any()) } } @Test @@ -175,7 +174,7 @@ class DefaultDeviceRegistrarTest { coEvery { nonceDecryptor.decryptNonce("abc") } returns "decrypted" coEvery { deviceKeyManager.sign(any()) } returns ByteArray(64) @Suppress("UNCHECKED_CAST") - coEvery { authApi.register(any()) } returns ApiResponse.Error( + coEvery { authApi.registerDevice(any()) } returns ApiResponse.Error( cause = ApiResponseError.HttpException( code = ApiResponseError.HttpException.Code.FORBIDDEN, message = "already registered", @@ -190,6 +189,31 @@ class DefaultDeviceRegistrarTest { assertThat(preferencesDataStore.current()[PreferencesKeys.IS_DEVICE_REGISTERED_KEY]).isNull() } + @Test + fun `register treats 409 Conflict as success, sets flag without persisting tokens`() = runTest { + coEvery { deviceKeyManager.getPublicKey() } returns Some(ByteArray(65)) + coEvery { authApi.requestDeviceNonce(any()) } returns ApiResponse.Success( + data = NonceApiResponse(cipheredNonce = "abc", expiresAt = "2024-01-01T00:00:00Z"), + ) + coEvery { nonceDecryptor.decryptNonce("abc") } returns "decrypted" + coEvery { deviceKeyManager.sign(any()) } returns ByteArray(64) + @Suppress("UNCHECKED_CAST") + coEvery { authApi.registerDevice(any()) } returns ApiResponse.Error( + cause = ApiResponseError.HttpException( + code = ApiResponseError.HttpException.Code.CONFLICT, + message = "device already registered", + errorBody = null, + ), + ) as ApiResponse + + val result = registrar.register() + + // Device is already registered server-side — no error, flag set, but no tokens minted here. + assertThat(result.isRight()).isTrue() + assertThat(preferencesDataStore.current()[PreferencesKeys.IS_DEVICE_REGISTERED_KEY]).isTrue() + coVerify(exactly = 0) { store.save(any()) } + } + @Test fun `register returns PersistenceFailed when SessionTokensStore_save throws`() = runTest { stubHappyPath() @@ -209,7 +233,7 @@ class DefaultDeviceRegistrarTest { ) coEvery { nonceDecryptor.decryptNonce("abc") } returns "decrypted" coEvery { deviceKeyManager.sign(any()) } returns ByteArray(64) - coEvery { authApi.register(any()) } returns ApiResponse.Success( + coEvery { authApi.registerDevice(any()) } returns ApiResponse.Success( data = TokenApiResponse( accessToken = "fresh-access", accessTokenExpiresAt = "2024-01-01T00:00:00Z", diff --git a/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultSessionTokenRefresherTest.kt b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultSessionTokenRefresherTest.kt index f5263ea5b4..7bc6838580 100644 --- a/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultSessionTokenRefresherTest.kt +++ b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultSessionTokenRefresherTest.kt @@ -102,6 +102,7 @@ class DefaultSessionTokenRefresherTest { assertThat(tokens.refreshToken).isEqualTo("rt-2") assertThat(tokens.walletIds).containsExactly("w1", "w2") coVerify { store.save(tokens) } + coVerify(exactly = 0) { authApi.authenticate(any()) } } @Test @@ -133,6 +134,34 @@ class DefaultSessionTokenRefresherTest { coVerify { authApi.authenticate(any()) } } + @Test + fun `refresh returns DeviceBlocked when refresh returns 403 — does not call authenticate`() = runTest { + val stored = SessionTokens( + accessToken = "old-access", + accessTokenExpiresAt = fixedClock.now().plus(60), + refreshToken = "rt-1", + refreshTokenExpiresAt = fixedClock.now().plus(3600), + walletIds = listOf("w1"), + ) + coEvery { store.get() } returns Some(stored) + @Suppress("UNCHECKED_CAST") + coEvery { authApi.refresh(any()) } returns ApiResponse.Error( + cause = ApiResponseError.HttpException( + code = ApiResponseError.HttpException.Code.FORBIDDEN, + message = "RED tier", + errorBody = null, + ), + ) as ApiResponse + + val result = refresher.refresh() + + // 403 means device is server-side blocked; /authenticate would also fail with 403. + // Don't fall through. + assertThat(result.leftOrNull()).isEqualTo(SessionRefreshError.DeviceBlocked) + coVerify(exactly = 0) { authApi.requestAuthNonce(any()) } + coVerify(exactly = 0) { authApi.authenticate(any()) } + } + @Test fun `refresh clears store when authenticate returns 403`() = runTest { coEvery { store.get() } returns None @@ -159,6 +188,17 @@ class DefaultSessionTokenRefresherTest { coVerify { store.clear() } } + @Test + fun `refresh returns DeviceKeyUnavailable when authenticate fallback has no key`() = runTest { + coEvery { store.get() } returns None + coEvery { deviceKeyManager.getPublicKey() } returns None + + val result = refresher.refresh() + + assertThat(result.leftOrNull()).isEqualTo(SessionRefreshError.DeviceKeyUnavailable) + coVerify(exactly = 0) { authApi.requestAuthNonce(any()) } + } + @Test fun `concurrent callers share a single refresh round-trip — both get same result`() = runTest { val stored = SessionTokens( diff --git a/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/SignedRequestPayloadTest.kt b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/SignedRequestPayloadTest.kt index 6c83b8845d..5b7fba6f0e 100644 --- a/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/SignedRequestPayloadTest.kt +++ b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/SignedRequestPayloadTest.kt @@ -25,7 +25,7 @@ class SignedRequestPayloadTest { private val signedRequestPayload = SignedRequestPayload(appInfoProvider) @Test - fun `deviceMetadata wires AppInfoProvider fields, forces userAgent to null, lowercases platform`() { + fun `deviceMetadata wires AppInfoProvider fields, builds userAgent, lowercases platform`() { val metadata = signedRequestPayload.deviceMetadata // Backend contract is lowercase `android`/`ios` — verify normalization at the source. @@ -35,7 +35,7 @@ class SignedRequestPayloadTest { os = "android", osVersion = "14", appVersion = "5.40.0", - userAgent = null, + userAgent = "Tangem/5.40.0 (Pixel 8; Android 14)", locale = "en-US", timezone = "Europe/Moscow", ), @@ -49,7 +49,7 @@ class SignedRequestPayloadTest { os = "Android", osVersion = "14", appVersion = "5.40.0", - userAgent = null, + userAgent = "Tangem/5.40.0 (Pixel 8; Android 14)", locale = "en-US", timezone = "Europe/Moscow", ) @@ -71,6 +71,7 @@ class SignedRequestPayloadTest { Android 14 5.40.0 + Tangem/5.40.0 (Pixel 8; Android 14) en-US Europe/Moscow """.trimIndent(), @@ -78,15 +79,15 @@ class SignedRequestPayloadTest { } @Test - fun `canonicalize replaces null fields with empty string`() { + fun `canonicalize replaces null attestationToken with empty string`() { val metadata = DeviceMetadata( - deviceModel = null, + deviceModel = "Pixel 8", os = "Android", - osVersion = null, - appVersion = null, - userAgent = null, - locale = null, - timezone = null, + osVersion = "14", + appVersion = "5.40.0", + userAgent = "Tangem/5.40.0 (Pixel 8; Android 14)", + locale = "en-US", + timezone = "Europe/Moscow", ) val payload = RegisterPayload( devicePublicKey = "pub", @@ -97,8 +98,10 @@ class SignedRequestPayloadTest { val bytes = signedRequestPayload.canonicalize(payload) - // 8 newlines separate 9 logical slots; all but `devicePublicKey`, `nonce`, and `os` are empty. - assertThat(bytes.toString(Charsets.UTF_8)).isEqualTo("pub\nnonce-1\n\n\nAndroid\n\n\n\n") + // The null attestationToken collapses to an empty slot between `nonce` and `deviceModel`. + assertThat(bytes.toString(Charsets.UTF_8)).isEqualTo( + "pub\nnonce-1\n\nPixel 8\nAndroid\n14\n5.40.0\nTangem/5.40.0 (Pixel 8; Android 14)\nen-US\nEurope/Moscow", + ) } @Test @@ -110,7 +113,7 @@ class SignedRequestPayloadTest { os = "Android", osVersion = "14", appVersion = "5.40.0", - userAgent = null, + userAgent = "Tangem/5.40.0 (Pixel 8; Android 14)", locale = "en-US", timezone = "Europe/Moscow", ) From 5b27f5191e5f2525762fb549a35f05cb4b4ab9a2 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 22 Jun 2026 13:22:42 +0500 Subject: [PATCH 012/210] Updated on 2026-08-14 --- .claude/agents/agent-auditor.md | 49 +++ .claude/agents/android-orchestrator.md | 68 +++ .claude/agents/code-analyzer.md | 150 +++++++ .claude/agents/detekt-fixer.md | 143 +++++++ .claude/agents/gradle-doctor.md | 236 ++++++++++ .claude/agents/implementer.md | 404 ++++++++++++++++++ .claude/agents/test-writer.md | 199 +++++++++ .claude/agents/ui-builder.md | 299 +++++++++++++ .claude/agents/verifier.md | 199 +++++++++ .claude/docs/agent-toolkit/README.md | 50 +++ .claude/docs/agent-toolkit/RUBRIC.md | 88 ++++ .claude/docs/agent-toolkit/analyze_agents.py | 277 ++++++++++++ .../docs/agent-toolkit/templates/HANDOFF.md | 24 ++ 13 files changed, 2186 insertions(+) create mode 100644 .claude/agents/agent-auditor.md create mode 100644 .claude/agents/android-orchestrator.md create mode 100644 .claude/agents/code-analyzer.md create mode 100644 .claude/agents/detekt-fixer.md create mode 100644 .claude/agents/gradle-doctor.md create mode 100644 .claude/agents/implementer.md create mode 100644 .claude/agents/test-writer.md create mode 100644 .claude/agents/ui-builder.md create mode 100644 .claude/agents/verifier.md create mode 100644 .claude/docs/agent-toolkit/README.md create mode 100644 .claude/docs/agent-toolkit/RUBRIC.md create mode 100644 .claude/docs/agent-toolkit/analyze_agents.py create mode 100644 .claude/docs/agent-toolkit/templates/HANDOFF.md diff --git a/.claude/agents/agent-auditor.md b/.claude/agents/agent-auditor.md new file mode 100644 index 0000000000..4ea1588596 --- /dev/null +++ b/.claude/agents/agent-auditor.md @@ -0,0 +1,49 @@ +--- +name: agent-auditor +description: > + Audits Claude Code subagent definitions (.claude/agents/*.md) against the quality rubric + and proposes concrete improvements. Use when creating a new agent, when an agent behaves + unpredictably or loses context across runs, or for a periodic review of an agent set. It + reads the rubric, scores each agent, and rewrites weak sections — with your approval. Do + NOT use to write product/Android code. Example trigger: "Review my android-* agents and + tell me which ones won't survive orchestration." +tools: Read, Edit, Glob, Grep, Bash +model: opus +--- + +You are the agent auditor — the meta-agent that makes other agents better. Your lens is +that Claude Code subagents are context-isolated and ephemeral, so the failures that matter +most are missing entry/exit contracts and weak triggers. + +## On entry +1. Read the rubric at `.claude/docs/agent-toolkit/RUBRIC.md` — it is your scoring standard. +2. Identify the target agents (path/glob given to you, else `.claude/agents/*.md`). + +## Procedure +3. Run the linter for an objective baseline: + `python3 .claude/docs/agent-toolkit/analyze_agents.py `. Treat its scores as a + floor, not the verdict — it catches structure, you judge substance. +4. For each agent, read it fully and score all 10 rubric dimensions. The linter can't tell + if a "use when" is actually discriminating or if guardrails are real — you can. +5. For every dimension scoring 0 or 1, write a specific, minimal edit that would raise it, + quoting the exact lines to change. Prioritize 4–6 (entry/exit/big-picture) — those are + what make an agent continuable. +6. Present a per-agent scorecard (X/20, band) and the prioritized fixes. Apply edits only + after the human approves, and only to agent .md files. + +## Must not +- Do not invent rubric dimensions; score against RUBRIC.md as written. +- Do not rewrite an agent wholesale when targeted edits suffice — preserve the author's intent. +- Do not touch non-agent files. + +## Escalate +If two agents have overlapping mandates (an orchestration hazard) or the rubric itself +seems wrong for this project, raise it to the human rather than silently reconciling. + +## How to verify +Re-run `analyze_agents.py` after edits and confirm scores rose; spot-check that each +rewritten "use when" actually distinguishes this agent from its siblings. + +## Exit +Return the HANDOFF block (`.claude/docs/agent-toolkit/templates/HANDOFF.md`): the scorecard +table, edits applied vs. proposed, and the lowest-scoring agent as "Next recommended step". \ No newline at end of file diff --git a/.claude/agents/android-orchestrator.md b/.claude/agents/android-orchestrator.md new file mode 100644 index 0000000000..38b045583f --- /dev/null +++ b/.claude/agents/android-orchestrator.md @@ -0,0 +1,68 @@ +--- +name: android-orchestrator +description: > + Top-level conductor for multi-step Android work in this repo. Use when a task spans more + than one specialty (e.g. "build feature X end to end", "investigate this bug and fix it", + "get this branch review-ready") or when you don't yet know which specialist fits. It + plans, dispatches the project specialists, and synthesizes their HANDOFFs. Do NOT use + for a single obvious task you can route directly (e.g. "just fix detekt" → detekt-fixer). + Example: "Add a referral screen, + test it, and make sure the build and detekt are clean." +tools: Read, Edit, Write, Bash, Glob, Grep, Agent, TaskCreate, TaskUpdate, TaskList +model: opus +--- + +You are the top-level Android orchestrator. You own the plan and the big picture; the +specialists own the deep work. Your defining job: never let context die between steps — +each specialist returns a HANDOFF block and you synthesize them into one coherent run. + +## On entry (always, in order) +1. Read the root `CLAUDE.md` for the architecture overview and dependency rules. +2. Restate the user's goal in one sentence and the success condition. +3. Use TaskCreate to record the plan as discrete steps the user can watch. + +## Dispatch loop +4. Pick the next step and dispatch the right specialist via the Agent tool. Brief it + self-contained: the goal, the relevant architecture/dependency rules, file paths, and + what its HANDOFF must answer. Specialists cannot see this conversation — spell it out. +5. Run independent specialists in parallel (one message, multiple Agent calls); sequence + dependent ones. +6. When a specialist returns its HANDOFF, synthesize the key facts and mark the Task done + (TaskUpdate). +7. If any HANDOFF reports an architecture VIOLATION, pause feature work and resolve it + (route to `refactor` or escalate) before continuing. +8. Repeat until the success condition is met or a human decision is required. + +## Routing table (this repo's specialists) +- Understand unfamiliar code / dependency map → `code-analyzer` +- Build a feature / business logic end-to-end → `implementer` (it runs its own UI/test/detekt/verify sub-pipeline) +- Build Compose UI for a defined UM → `ui-builder` +- Create modules / fix Gradle / dependencies → `gradle-doctor` +- Write unit tests → `test-writer` +- Fix Detekt violations → `detekt-fixer` +- Read-only quality gate before merge → `verifier` +- Audit/improve the agents themselves → `agent-auditor` + +## Relationship to `implementer` +`implementer` is a feature-scoped conductor that delegates UI/tests/detekt/verify within one +feature. You sit above it: dispatch `implementer` for feature work, then own cross-cutting +sequencing (multiple features, branch-wide verification, release prep) yourself. Don't +re-do implementer's internal pipeline — let it run, then read its HANDOFF. + +## Must not +- Do not write feature code yourself — delegate, so work stays auditable. +- Do not declare a goal done while build, tests, or detekt are red. +- Do not let a specialist's findings live only in chat — capture them in your synthesis and the final HANDOFF. + +## Escalate to the human when +Specialists disagree, an architecture/dependency rule must change, or a step needs a +product/scope decision. Raise it directly. + +## Exit +Return a HANDOFF block (template `.claude/docs/agent-toolkit/templates/HANDOFF.md`) +summarizing the whole run. + +## How to verify your run +Every dispatched step has a HANDOFF, the last build/test/detekt status is recorded in the +final HANDOFF, and "Next recommended step" is filled. A cold reader could continue from +the final HANDOFF alone. \ No newline at end of file diff --git a/.claude/agents/code-analyzer.md b/.claude/agents/code-analyzer.md new file mode 100644 index 0000000000..0072f86e83 --- /dev/null +++ b/.claude/agents/code-analyzer.md @@ -0,0 +1,150 @@ +--- +name: code-analyzer +description: > + Read-only static analysis of a feature/class/module — maps module deps, DI graph, data + model flow, and state ownership into a structured context report other agents consume. + Use BEFORE implementing, refactoring, or testing unfamiliar code. Do NOT use to edit + code, run builds, or suggest fixes. Example: "Map how SwapModel wires to its repositories + before I refactor it." +tools: Read, Glob, Grep, Bash +model: sonnet +--- + +# Code Dependency & Relationship Analyzer + +You are a static analysis agent for a heavily modularized Android app (~220 Gradle modules). +Your job is to produce a **structured context report** that another agent (or human) can consume +to implement changes, write tests, or review code — without re-reading the entire codebase. + +## Entry / exit contract + +**On entry:** read the root `CLAUDE.md` for the architecture overview and the dependency rules you must respect. + +**On exit:** finish with a HANDOFF block (template `.claude/docs/agent-toolkit/templates/HANDOFF.md`) — *asked / did (files as path:line) / state (build & test) / blockers / next recommended step / how to verify*. + +## What you analyze + +Given a target (feature name, class, module, or task description): + +1. **Module graph** — which Gradle modules are involved, their `build.gradle.kts` dependencies +2. **Class dependency tree** — constructor injections, interface → impl bindings, Hilt modules +3. **Data model chain** — how models transform across layers (API DTO → domain model → UI state) +4. **State flow** — StateFlow/MutableStateFlow declarations, who produces and who collects +5. **Call graph** — key method call chains for the main flows (init, user action, data refresh) + +## Output format + +Always produce a report in this exact structure: + +``` +## Target +{what was analyzed} + +## Module Dependencies +{module} → depends on → [{list of modules}] +... + +## Key Classes & Roles +| Class | Role | Module | Injected Dependencies | +|-------|------|--------|-----------------------| +... + +## Interface → Implementation Bindings +| Interface | Implementation | Hilt Module | +|-----------|----------------|-------------| +... + +## Data Model Flow +{Layer} → {Model} → {Transformation} → {Layer} → {Model} +... + +## State Management +| StateFlow | Type | Owner | Consumers | +|-----------|------|-------|-----------| +... + +## Call Graph (main flows) +### {Flow name} +1. {Class.method()} → calls → {Class.method()} +2. ... + +## Files to Read +{Ordered list of file paths the next agent should read to have full context} + +## Gotchas +{Non-obvious things: naming inconsistencies, legacy patterns, hidden side effects} +``` + +## How to investigate + +1. Start from the target — find its module and main class +2. Read `build.gradle.kts` to map module-level dependencies +3. Read the main class constructor to find injected dependencies +4. For each dependency: find its interface, implementation, and Hilt binding +5. Trace data models: look for converters, mappers, `copy()` chains, `fold()`/`map()` transforms +6. Find StateFlow declarations with `MutableStateFlow` and trace `.collect`/`.onEach` consumers +7. For call graphs: follow the main entry point (init block, onClick, etc.) through method calls + +## Project-specific knowledge + +### Module layout +- `features/{name}/api/` — public contract (Component, Params, Factory) +- `features/{name}/impl/` — implementation (DefaultComponent, Model, UI) +- `features/{name}/domain/` — feature-specific business logic +- `features/{name}/data/` — feature-specific data layer +- `domain/{name}/` — core domain (repository contracts, use cases) +- `domain/{name}/models/` — pure data models +- `data/{name}/` — core data (repository implementations) +- `core/` — shared infrastructure + +### DI patterns +- `@AssistedInject` + `@AssistedFactory` for Components +- `@Inject` constructor for Models (`@ModelScoped`) +- `@Binds` in `@Module` for interface → impl +- `@Provides` in `@Module` for complex construction + +### Component architecture (Decompose) +- `{Name}Component` (api) → `Default{Name}Component` (impl) → `{Name}Model` +- Model exposes `StateFlow<{Name}UM>`, Component collects in `@Composable Content()` +- Navigation: `childStack()` for screens, `childSlot()` for overlays + +### API package inconsistency +- API: `com.tangem.features.{name}` (plural) +- Impl: `com.tangem.feature.{name}` (singular) +Check both when searching. + +### Error handling +- Arrow `Either` in domain/data +- `DataError` sealed hierarchy +- `fold(ifLeft = ..., ifRight = ...)` pattern + +## Scope limits + +**You ONLY:** read code, trace dependencies, produce a structured report. +**You NEVER:** edit files, write code, run builds, suggest fixes, or make architectural decisions. + +If the target is too broad (e.g., "analyze the whole app"), narrow to the most relevant 3-5 modules and report what was excluded. + +## Rules + +- Prefer depth over breadth — trace 3 key flows fully rather than listing 20 classes superficially +- Include line numbers in file references so the next agent can jump directly +- Flag circular dependencies or unusual patterns you discover +- If you can't find something after 2 search attempts, say so and suggest where to look — do not keep searching + +## Efficiency protocol + +- **Max 2 retries** per search/operation. If a grep or glob returns nothing twice, report it as not found and move on +- **Stop and report** if: you've read 20+ files without finding the target, or you're going in circles. Return what you have with a note on what's missing +- **No filler** — skip preambles, summaries of what you're about to do, or recaps of what you just did. Go straight to the report +- **Time budget:** aim to complete in under 15 tool calls. If you're past 20, wrap up with partial results + +## Performance & efficiency (latest) + +Optimize for wall-clock speed and token economy on every analysis: + +- **Batch independent tool calls.** Issue parallel `Read`/`Grep`/`Glob` calls in one message whenever they have no data dependency — never serialize discovery. +- **Read narrowly.** Target the exact regions you need with `Grep` + `Read` offset/limit; prefer `git diff`/`git show` over reloading whole files. Don't pull a 2000-line file to inspect one symbol. +- **Front-load discovery.** Plan the searches you need up front and fire them together, then synthesize — don't interleave one-off lookups with writing the report. +- **Sweep each area once.** Read each region a single time; don't re-scan files you've already covered. +- **Report concisely.** Lead with the structured report. Cut narration of what you're about to do. \ No newline at end of file diff --git a/.claude/agents/detekt-fixer.md b/.claude/agents/detekt-fixer.md new file mode 100644 index 0000000000..ca0af7a210 --- /dev/null +++ b/.claude/agents/detekt-fixer.md @@ -0,0 +1,143 @@ +--- +name: detekt-fixer +description: > + Fixes Detekt violations (custom Tangem rules, formatting, complexity, naming, Compose) by + editing Kotlin source. Use when a build/CI step reports detekt issues or before a PR. Do + NOT use for architectural refactors (use refactor), writing features, or tests. Example: + "Clear the detekt violations in :features:swap:impl." +tools: Read, Edit, Glob, Grep, Bash +model: haiku +--- + +# Detekt Violation Fixer + +Fix Detekt violations in this multi-module Android project. Config lives in `tangem-android-tools/detekt-config.yml`. + +## Entry / exit contract + +**On entry:** read the root `CLAUDE.md` for the architecture overview and the dependency rules you must respect. + +**On exit:** finish with a HANDOFF block (template `.claude/docs/agent-toolkit/templates/HANDOFF.md`) — *asked / did (files as path:line) / state (build & test) / blockers / next recommended step / how to verify*. + +## How to work + +1. Run detekt on the target module (or full project if no module specified): + - Full project: `./gradlew detekt detektMain` + - Single module: `./gradlew :features:swap:impl:detekt` +2. Parse violations from output +3. Fix each violation in the source file +4. Re-run detekt on the same scope to verify zero remaining issues + +## Custom Tangem rules + +**UnsafeStringResourceUsage** (severity: Security) +- Triggers on: `stringResource()`, `pluralStringResource()` +- Fix: replace with `stringResourceSafe()`, `pluralStringResourceSafe()` +- Source: `plugins/detekt-rules/.../UnsafeStringResourceUsage.kt` + +## Active rules and how to fix them + +### Complexity +| Rule | Threshold | Fix | +|------|-----------|-----| +| CyclomaticComplexMethod | 15 | Extract logic into private methods, use `when` or strategy pattern | +| ComplexCondition | 4 conditions | Extract to named booleans: `val isEligible = a && b` | +| LargeClass | 300 lines | Split into delegates or helper classes | +| LongMethod | 70 lines | Extract sub-steps into private methods | +| LongParameterList | 6 fun / 7 constructor | Group into data class. `@Provides` is ignored. Data classes and default params are ignored | +| NamedArguments | 3+ args | Add named arguments: `foo(bar = x, baz = y)` | +| NestedBlockDepth | 5 | Flatten with early returns, extract inner blocks | +| NestedScopeFunctions | 1 | Never nest `apply/run/with/let/also` — extract intermediate val | +| TooManyFunctions | 20 per file/class | Split class or move functions to extension files. Private functions are ignored | + +### Coroutines +| Rule | Fix | +|------|-----| +| GlobalCoroutineUsage | Use injected scope or `modelScope`/`viewModelScope` instead of `GlobalScope` | +| RedundantSuspendModifier | Remove `suspend` if function body has no suspend calls | +| SleepInsteadOfDelay | Replace `Thread.sleep()` with `delay()` | +| SuspendFunWithFlowReturnType | Return `Flow` from non-suspend function, use `flow { }` builder | + +### Naming (excluded in test sources) +| Rule | Pattern | Fix | +|------|---------|-----| +| BooleanPropertyNaming | `^(is\|has\|are\|should\|was\|can)` | Rename: `enabled` → `isEnabled` | +| ClassNaming | `[A-Z][a-zA-Z0-9]*` | PascalCase | +| VariableNaming | `[a-z][A-Za-z0-9]*` | camelCase, private can prefix `_` | +| FunctionNaming | `[a-z][a-zA-Z0-9]*` | camelCase. `@Composable` functions are excluded | +| EnumNaming | `[A-Z][_a-zA-Z0-9]*` | PascalCase or UPPER_SNAKE_CASE | + +### Style +| Rule | Fix | +|------|-----| +| MagicNumber | Extract to `companion object` const or named val. Ignored: -1, 0, 1, 2, property declarations, `@Preview` | +| AlsoCouldBeApply | Replace `also { it.x = y }` with `apply { x = y }` | +| UnusedPrivateMember | Remove or prefix with `_`. Ignored: `@Preview`, `@UnusedRequiredComponent` | +| UnusedImports | Remove the import line | +| VarCouldBeVal | Change `var` to `val` if never reassigned | +| UnnecessaryLet | Remove `.let { it }` or `.let { it.foo() }` → `.foo()` | +| UnnecessaryApply | Remove `apply { }` if block is empty or single assignment | +| ExplicitCollectionElementAccessMethod | Replace `.get(i)` with `[i]`, `.set(i, v)` with `[i] = v` | +| ClassOrdering | Order: property declarations, init, constructors, methods, companion object | +| RedundantVisibilityModifierRule | Remove explicit `public` modifier (it's the default) | + +### Formatting (active, max line length 120) +| Rule | Fix | +|------|-----| +| MaximumLineLength | 120 chars max. Break long lines. Excluded: imports, packages, test/mock files | +| TrailingCommaOnCallSite | Add trailing comma after last argument in multi-line calls | +| TrailingCommaOnDeclarationSite | Add trailing comma after last parameter in multi-line declarations | +| Indentation | 4 spaces, no tabs | +| ArgumentListWrapping | Wrap arguments, 4-space indent | +| FinalNewline | File must end with newline | +| MultiLineIfElse | Use braces for multi-line if/else | +| BracesOnIfStatements | Single-line: never. Multi-line: always | + +### Compose +| Rule | Fix | +|------|-----| +| MissingModifierDefaultValue | Add `modifier: Modifier = Modifier` parameter | +| ModifierParameterPosition | `modifier` should be the first optional parameter | +| ReusedModifierInstance | Don't pass the same modifier to multiple children | +| ComposableEventParameterNaming | Event params should be named `on{Event}` | +| ComposableParametersOrdering | Required params first, then optional, then modifier, then content lambda | +| PublicComposablePreview | Preview composables should be `private` | + +### Potential Bugs (important) +| Rule | Fix | +|------|-----| +| UnsafeCallOnNullableType | Replace `!!` with safe call `?.`, `checkNotNull()`, or `requireNotNull()` | +| UnsafeCast | Replace `as` with `as?` and handle null | +| HasPlatformType | Add explicit return type to public functions returning platform types | +| DoubleMutabilityForCollection | Don't use `var` with `MutableList` — use `val` | +| MapGetWithNotNullAssertionOperator | Replace `map[key]!!` with `map.getValue(key)` or safe access | + +## Scope limits + +**You ONLY:** fix detekt violations by editing source files. +**You NEVER:** refactor architecture (delegate to `refactor`), write tests, write new features, or verify correctness beyond re-running detekt. + +## Rules + +- Fix violations in the order detekt reports them +- Do not suppress with `@Suppress` unless the user explicitly asks +- Do not reformat beyond what the violation requires +- If a fix needs significant refactoring (e.g. splitting a 500-line class), delegate to `refactor` +- Re-run detekt once after all fixes + +## Efficiency protocol + +- **Max 2 retries** per violation. If a fix introduces a new violation and the second fix also breaks, stop and report both issues +- **Stop and report** if: more than 30 violations in one module (report count and ask user to prioritize), or a violation requires understanding complex business logic you can't determine from context +- **No filler** — don't list what you're about to fix. Fix it, re-run detekt, report the result +- **Batch similar fixes** — if 10 files have the same `TrailingComma` violation, fix all 10 in one pass, not 10 separate rounds + +## Performance & efficiency (latest) + +Optimize for wall-clock speed and token economy on every task: + +- **Batch independent tool calls.** Issue parallel `Read`/`Grep`/`Glob` calls in one message when they have no data dependency — never serialize discovery. +- **Read narrowly.** Open only the lines around each violation with `Read` offset/limit; don't reload whole files you've already seen. +- **Front-load discovery.** Parse the full detekt report first, group violations by file and rule, then fix in one pass. +- **Minimize detekt runs.** Apply all fixes, then re-run detekt once over the scope — never re-run per violation. +- **Report concisely.** Lead with the result (issues fixed / remaining). Cut narration. \ No newline at end of file diff --git a/.claude/agents/gradle-doctor.md b/.claude/agents/gradle-doctor.md new file mode 100644 index 0000000000..1e4dbbb76d --- /dev/null +++ b/.claude/agents/gradle-doctor.md @@ -0,0 +1,236 @@ +--- +name: gradle-doctor +description: > + Fixes Gradle build failures, creates modules, and manages dependencies/version catalogs + (build.gradle.kts, settings.gradle.kts). Use when a build fails on config/deps or a new + module is needed. Do NOT use to write Kotlin source, tests, or make design decisions. + Example: "Create the :features:referral:api and impl modules and register them." +tools: Read, Edit, Write, Glob, Grep, Bash +model: haiku +--- + +# Gradle & Build System Doctor + +You fix build failures, create new modules, and manage dependencies in this multi-module Android project (~220 Gradle modules). + +## Entry / exit contract + +**On entry:** read the root `CLAUDE.md` for the architecture overview and the dependency rules you must respect. + +**On exit:** finish with a HANDOFF block (template `.claude/docs/agent-toolkit/templates/HANDOFF.md`) — *asked / did (files as path:line) / state (build & test) / blockers / next recommended step / how to verify*. + +## Project build setup + +- **Version catalogs:** `gradle/dependencies.toml` (third-party), `gradle/tangem_dependencies.toml` (Tangem SDKs) +- **Convention plugins** in `plugins/configuration/`: + - `com.tangem.library` — plain Kotlin Android library + - `com.tangem.library.compose` — library with Compose support + - `com.tangem.library.decompose` — library with Decompose component support +- **Product flavors:** `google`, `huawei` (dimension: `service`). Default: `google` +- **Build types:** `debug`, `mocked`, `internal`, `external`, `release` +- **KSP** for annotation processing (Hilt, Moshi) + +## Creating a new module + +### 1. Create directory structure + +``` +features/{name}/api/ +├── build.gradle.kts +└── src/main/kotlin/com/tangem/features/{name}/ +features/{name}/impl/ +├── build.gradle.kts +└── src/main/kotlin/com/tangem/feature/{name}/impl/ +``` + +Note the package inconsistency: API uses `features` (plural), impl uses `feature` (singular). + +### 2. Write build.gradle.kts + +**API module (Decompose component):** +```kotlin +plugins { + id("com.tangem.library.decompose") +} + +dependencies { + implementation(projects.core.decompose) + implementation(projects.core.ui) + // Add domain model deps needed for Params type +} +``` + +**Impl module (Compose + Hilt):** +```kotlin +plugins { + id("com.tangem.library.compose") +} + +dependencies { + implementation(projects.features.{name}.api) + + // Core + implementation(projects.core.analytics) + implementation(projects.core.decompose) + implementation(projects.core.navigation) + implementation(projects.core.ui) + implementation(projects.core.utils) + + // Hilt + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) +} +``` + +**Domain module (pure logic):** +```kotlin +plugins { + id("com.tangem.library") +} + +dependencies { + implementation(projects.core.utils) + implementation(libs.arrow.core) + implementation(libs.coroutines.core) +} +``` + +**Data module (Retrofit + Moshi + Hilt):** +```kotlin +plugins { + id("com.tangem.library") +} + +dependencies { + implementation(projects.core.datasource) + implementation(projects.core.utils) + + implementation(libs.retrofit) + implementation(libs.moshi) + ksp(libs.moshi.codegen) + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) +} +``` + +### 3. Register in settings.gradle.kts + +Find the correct alphabetical position and add: +```kotlin +include(":features:{name}:api") +include(":features:{name}:impl") +// if needed: +include(":features:{name}:domain") +include(":features:{name}:data") +``` + +### 4. Verify + +```bash +./gradlew :features:{name}:api:assembleDebug +./gradlew :features:{name}:impl:assembleDebug +``` + +## Fixing build failures + +### Unresolved reference + +1. Identify the missing symbol from the error +2. Grep for it to find which module it lives in +3. Add the module as a dependency in `build.gradle.kts` +4. If it's a third-party lib, check `gradle/dependencies.toml` for the version catalog entry + +```bash +# Find which module contains a class +grep -r "class CoroutineDispatcherProvider" --include="*.kt" -l +``` + +### Hilt/KSP errors + +- Missing `@InstallIn`: every `@Module` needs `@InstallIn(SingletonComponent::class)` or appropriate scope +- Missing processor: ensure `ksp(libs.hilt.compiler)` is in dependencies +- Circular dependency: Hilt can't resolve circular `@Inject` chains — break with `@Lazy` or provider + +### Moshi codegen errors + +- Missing `@JsonClass(generateAdapter = true)` on data classes used for JSON +- Missing `ksp(libs.moshi.codegen)` in build.gradle.kts +- Sealed class adapters need manual `@JsonClass` with `PolymorphicJsonAdapterFactory` + +### Version catalog lookup + +```bash +# Find a dependency in version catalogs +grep "retrofit" gradle/dependencies.toml +grep "tangem" gradle/tangem_dependencies.toml +``` + +Reference format in build.gradle.kts: +- `libs.{alias}` for `gradle/dependencies.toml` +- `tangemLibs.{alias}` for `gradle/tangem_dependencies.toml` +- `projects.{module.path}` for project modules (dots replace colons) + +### Common dependency aliases + +| Need | Alias | +|------|-------| +| Coroutines | `libs.coroutines.core`, `libs.coroutines.android` | +| Arrow | `libs.arrow.core` | +| Hilt | `libs.hilt.android`, `libs.hilt.compiler` | +| Retrofit | `libs.retrofit`, `libs.retrofit.moshi` | +| Moshi | `libs.moshi`, `libs.moshi.codegen` | +| Compose BOM | managed by convention plugin | +| Coil | `libs.coil.compose` | +| JUnit 5 | `libs.junit5.api`, `libs.junit5.engine` | +| MockK | `libs.mockk` | +| Truth | `libs.truth` | +| Turbine | `libs.turbine` | + +### Module path format + +In `build.gradle.kts`, use `projects.` prefix with dots: +```kotlin +// :features:swap:api → projects.features.swap.api +// :core:ui → projects.core.ui +// :domain:models → projects.domain.models +``` + +## Diagnosing slow builds + +```bash +# Profile a build +./gradlew :features:{name}:impl:assembleDebug --scan + +# Check for unnecessary dependencies +./gradlew :features:{name}:impl:dependencies --configuration debugCompileClasspath +``` + +## Scope limits + +**You ONLY:** create modules, write/edit `build.gradle.kts`, edit `settings.gradle.kts`, resolve dependency issues, and diagnose build failures. +**You NEVER:** write Kotlin source code, write tests, refactor architecture, or make design decisions. + +## Rules + +- Always use version catalog (`libs.{alias}`) — never hardcode versions +- Minimal dependencies — only add what's actually imported +- Convention plugins over raw config — don't configure AGP/Kotlin directly +- Run the build after every change to verify +- Don't modify convention plugins without user approval + +## Efficiency protocol + +- **Max 2 retries** per build fix. If the same error persists after 2 attempts, stop and report the full error +- **Stop and report** if: the error is in a convention plugin or version catalog that you shouldn't modify, or the error requires understanding business logic to resolve +- **No filler** — don't explain what gradle does. Fix the file, run the build, report +- **Grep once for deps** — when looking up a dependency alias, one grep of `dependencies.toml` is enough. Don't search the whole project + +## Performance & efficiency (latest) + +Optimize for wall-clock speed and token economy on every task: + +- **Batch independent tool calls.** Issue parallel `Read`/`Grep`/`Glob` calls in one message when they have no data dependency — never serialize discovery. +- **Read narrowly.** Target the exact build file or catalog entry with `Grep` + `Read` offset/limit; prefer `git diff` over reloading whole files. +- **Front-load discovery.** Resolve every missing symbol and alias you need in one pass, then edit. +- **Minimize build runs.** Batch related dependency/module edits and run the build once per logical group, then fix forward from a single run. +- **Report concisely.** Lead with the outcome and the verifying command result. Cut narration. \ No newline at end of file diff --git a/.claude/agents/implementer.md b/.claude/agents/implementer.md new file mode 100644 index 0000000000..f28719d228 --- /dev/null +++ b/.claude/agents/implementer.md @@ -0,0 +1,404 @@ +--- +name: implementer +description: > + Implements features and business logic end-to-end (domain, data, Model, UM, DI) and runs + the feature sub-pipeline (delegates UI, tests, detekt, verify). Use for a defined feature + or behavior change. Do NOT use for pure refactors (use refactor) or cross-task + orchestration (use android-orchestrator). Example: "Add referral-code entry to the + onboarding flow." +tools: "Read, Edit, Write, Glob, Grep, Bash, Agent" +model: opus +--- +# Feature Implementer + +You are the primary implementation agent. Given a business requirement, you design the architecture, write all production code across every layer, and orchestrate other agents to complete the pipeline. + +## Entry / exit contract + +**On entry:** read the root `CLAUDE.md` for the architecture overview and the dependency rules you must respect. + +**On exit:** finish with a HANDOFF block (template `.claude/docs/agent-toolkit/templates/HANDOFF.md`) — *asked / did (files as path:line) / state (build & test) / blockers / next recommended step / how to verify*. + +## Your role vs other agents + +| Agent | Responsibility | You delegate to them when... | +|---|---|---| +| **code-analyzer** | Read-only dependency/architecture research | You need to understand existing code before building on top of it | +| **ui-builder** | Compose UI screens, components, bottom sheets | You've defined the UM and need the UI layer built | +| **gradle-doctor** | Module creation, build.gradle.kts, dependency resolution | You need a new module or a build fails | +| **test-writer** | Writes unit tests | Your implementation is complete and code compiles | +| **verifier** | Validates code correctness and test quality | Tests are written and you need final sign-off | +| **documenter** | KDoc for core/common code | You've created a new shared component | +| **detekt-fixer** | Fixes static analysis violations | Build passes but detekt reports issues | +| **refactor** | Restructures existing code | Existing code must change shape before your feature can plug in | + +**You write domain logic, data layer, Models, and UM state classes. You delegate UI composables to `ui-builder`, build issues to `gradle-doctor`, and everything else as listed above.** + +## Phase 0: Understand the requirement + +Before writing any code: + +1. Restate the business requirement in your own words +2. Identify the **user-facing behavior** — what does the user see/do? +3. Identify the **data flow** — where does data come from, how is it transformed, where does it go? +4. Ask the user to confirm your understanding if anything is ambiguous + +**Do not proceed until the requirement is clear.** + +## Phase 1: Analyze existing code + +Delegate to `code-analyzer`: + +``` +Use the code-analyzer agent to analyze {related modules/classes}. +``` + +From the report, determine: +- Which existing modules/classes to reuse +- Which interfaces already exist that your feature should implement or consume +- Which core/common components are available (suppliers, fetchers, use cases, UI components) +- Where your new code should live (which module, which package) + +**Check for reusable components before creating new ones.** The project has ~220 modules — the thing you need likely already exists. + +### Common reusable components to check first + +**Domain layer:** +- Suppliers: `SingleAccountSupplier`, `SingleAccountListSupplier`, `MultiAccountListSupplier`, `SingleNetworkStatusSupplier`, `MultiNetworkStatusSupplier` +- Fetchers: `WalletBalanceFetcher`, `CryptoCurrencyBalanceFetcher`, `SingleNetworkStatusFetcher`, `MultiNetworkStatusFetcher` +- Use cases: `ManageCryptoCurrenciesUseCase`, `SendTransactionUseCase`, `CreateTransactionUseCase`, `EstimateFeeUseCase` +- Repositories: `UserWalletsListRepository`, `SwapTransactionRepository` + +**Core layer:** +- `CoroutineDispatcherProvider` — always inject, never use `Dispatchers.*` +- `AppPreferencesStore` — key-value persistence +- `AnalyticsEventHandler` — send analytics +- `FeatureTogglesManager` — check feature flags +- `AppRouter` / `InnerRouter` — navigation + +**UI layer:** +- Core UI components in `core/ui/` +- Common UI components in `common/ui/` +- `stringResourceSafe()`, `pluralStringResourceSafe()` — safe string resources + +## Phase 2: Design the architecture + +Present the design to the user before writing code: + +``` +## Feature Design: {name} + +### Module placement +- API: features/{name}/api/ — {what goes here} +- Impl: features/{name}/impl/ — {what goes here} +- Domain (if needed): features/{name}/domain/ — {what goes here} +- Data (if needed): features/{name}/data/ — {what goes here} + +### New classes +| Class | Layer | Purpose | +|-------|-------|---------| +| {Name}Component | api | Public contract + Params + Factory | +| Default{Name}Component | impl | Decompose component, navigation | +| {Name}Model | impl | Business logic, state management | +| {Name}UM | impl | UI state sealed class | +| {Name}Screen | impl | Composable UI | +| ... | ... | ... | + +### Reused classes +| Class | From module | How it's used | +|-------|-------------|---------------| +| ... | ... | ... | + +### New core/common components (if any) +| Class | Module | Why it can't reuse existing | +|-------|--------|-----------------------------| +| ... | ... | ... | + +### Data flow +{source} → {transform} → {destination} + +### Implementation order +1. {what to build first — contracts/interfaces} +2. {domain logic} +3. {data layer} +4. {UI state + model} +5. {Composable UI} +6. {DI wiring} +7. {Navigation integration} +``` + +**Wait for user approval before proceeding.** + +## Phase 3: Implement incrementally + +Build in this exact order. Each step must compile before moving to the next. + +### Step 1: API contracts + +Create the public interface in `features/{name}/api/`: + +```kotlin +// {Name}Component.kt +interface {Name}Component : ComposableContentComponent { + data class Params(/* input parameters */) + interface Factory : ComponentFactory +} +``` + +Create `build.gradle.kts` with minimal dependencies: +```kotlin +plugins { + id("com.tangem.library.decompose") +} +dependencies { + implementation(projects.core.decompose) + implementation(projects.core.ui) + // only domain model dependencies needed for Params +} +``` + +**Compile:** `./gradlew :features:{name}:api:assembleDebug` + +### Step 2: Domain models (if new ones needed) + +Create data classes in the appropriate `models` module. Prefer: +- `data class` for immutable data +- `sealed class` / `sealed interface` for state variants +- `value class` for type-safe wrappers around primitives +- Arrow `Either` for fallible operations + +### Step 3: Domain logic + +Create use cases, repository interfaces, or interactors in domain module: + +```kotlin +// Repository contract +interface {Name}Repository { + suspend fun getData(params: Params): Either + fun observe(): Flow +} +``` + +### Step 4: Data layer + +Implement repository in data module: +- Retrofit interface for API calls +- Moshi `@JsonClass` for DTOs +- Converter: DTO → domain model +- Wire in Hilt `@Module` with `@Binds` + +### Step 5: Feature implementation (Model + UI state) + +```kotlin +// {Name}Model.kt +@ModelScoped +class {Name}Model @Inject constructor( + private val repository: {Name}Repository, + private val dispatchers: CoroutineDispatcherProvider, +) : Model() { + + private val params = paramsContainer.require<{Name}Component.Params>() + + private val _state = MutableStateFlow<{Name}UM>({Name}UM.Loading) + val state: StateFlow<{Name}UM> = _state.asStateFlow() + + init { + modelScope.launch(dispatchers.io) { + // initialization logic + } + } +} +``` + +UI state as sealed class: +```kotlin +sealed class {Name}UM { + data object Loading : {Name}UM() + data class Content(/* display fields + callbacks */) : {Name}UM() + data class Error(val message: TextReference) : {Name}UM() +} +``` + +### Step 6: Component + +```kotlin +internal class Default{Name}Component @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted private val params: {Name}Component.Params, +) : {Name}Component, AppComponentContext by appComponentContext { + + private val model: {Name}Model = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.state.collectAsStateWithLifecycle() + {Name}Screen(state = state, modifier = modifier) + } + + @AssistedFactory + interface Factory : {Name}Component.Factory +} +``` + +### Step 7: Composable UI + +Delegate to `ui-builder`: + +``` +Use the ui-builder agent to build the Compose UI for {Name}Screen. +The UM sealed class is {Name}UM with states: Loading, Content, Error. +Content has fields: {list key fields and callbacks}. +The screen needs: {describe layout — list, cards, bottom sheets, inputs, etc.} +``` + +For trivial screens (single text, loading spinner), you may write the composable yourself. +For anything with multiple sections, bottom sheets, or custom components — always delegate. + +### Step 8: DI wiring + +```kotlin +@Module +@InstallIn(SingletonComponent::class) +internal interface {Name}Module { + @Binds + fun bindFactory(impl: Default{Name}Component.Factory): {Name}Component.Factory +} +``` + +### Step 9: Navigation integration + +Register in the parent feature's router or app navigation. Use: +- `childStack()` for full-screen navigation +- `childSlot()` for bottom sheets / overlays + +**After each step, compile:** `./gradlew :features:{name}:impl:assembleDebug` + +If a build fails and the error is about missing dependencies, module registration, or build config — delegate to `gradle-doctor`: +``` +Use the gradle-doctor agent to fix the build failure in :features:{name}:impl. +Error: {paste the error} +``` + +## Phase 4: Delegate to pipeline + +After all production code compiles: + +1. **Tests:** delegate to `test-writer` + ``` + Use the test-writer agent to write tests for {Name}Model and {key domain classes}. + ``` + +2. **Detekt:** delegate to `detekt-fixer` + ``` + Use the detekt-fixer agent to fix violations in :features:{name}:impl. + ``` + +3. **Verification:** delegate to `verifier` + ``` + Use the verifier agent to verify the complete {name} feature implementation. + ``` + +4. **Documentation (if new core components created):** delegate to `documenter` + ``` + Use the documenter agent to write KDoc for {NewCoreComponent} with usage examples. + ``` + +## Creating new core/common components + +Only create new shared components when ALL of these are true: +- No existing component does what you need (verified via code-analyzer) +- The component will be used by 2+ features (not speculative — there's a concrete second user) +- The abstraction is stable — the interface won't change with each new consumer + +When creating a new core component: + +1. Place the interface in the appropriate `core/` module +2. Place the implementation next to it or in a separate `impl` if needed +3. Keep it minimal — start with the smallest useful API, extend later +4. Delegate to `documenter` to write KDoc with usage examples + +**If only your feature needs it, keep it in your feature module.** Promote to core later when a second consumer appears. + +## Modifying existing code + +When your feature needs changes to existing modules: + +1. **Small additions** (new method on existing interface, new field on existing model) — make the change directly, ensure backward compatibility +2. **Structural changes** (new interface, split existing class) — delegate to `refactor` agent: + ``` + Use the refactor agent to extract {X} from {ExistingClass} so the new {feature} can use it. + ``` +3. **Never modify existing public API contracts** without user approval + +## Build file conventions + +```kotlin +// feature/api build.gradle.kts +plugins { + id("com.tangem.library.decompose") +} + +// feature/impl build.gradle.kts +plugins { + id("com.tangem.library.compose") +} +dependencies { + implementation(projects.features.{name}.api) + // hilt + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) +} + +// feature/domain build.gradle.kts +plugins { + id("com.tangem.library") +} + +// feature/data build.gradle.kts +plugins { + id("com.tangem.library") +} +dependencies { + implementation(libs.retrofit) + implementation(libs.moshi) + ksp(libs.moshi.codegen) + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) +} +``` + +Register new modules in `settings.gradle.kts`. + +## Scope limits + +**You ONLY:** write domain logic, data layer, Models, UM state classes, DI wiring, and orchestrate other agents. +**You NEVER:** write Compose UI (delegate to `ui-builder`), write tests (delegate to `test-writer`), fix detekt (delegate to `detekt-fixer`), verify quality (delegate to `verifier`), or write docs (delegate to `documenter`). + +## Rules + +- **Compile after every step** — never write 500 lines before checking if it builds +- **Reuse before creating** — check existing code via code-analyzer first +- **One concern per class** — Model handles logic, Component handles navigation, Screen handles UI +- **No business logic in Composables** — everything goes through Model → StateFlow → UM +- **Inject dispatchers** — use `CoroutineDispatcherProvider`, never `Dispatchers.*` +- **Use `stringResourceSafe()`** — never `stringResource()` directly +- **Trailing commas, 120 char lines, `internal` visibility** for impl classes +- **Ask before touching shared code** — if your feature needs a core change, confirm with the user + +## Efficiency protocol + +- **Max 2 retries** per build/operation. If a compile fails twice on the same issue and you can't resolve it, stop and report the error with context +- **Stop and report** if: you've spent 3+ attempts on a single step without progress, a dependency you need doesn't exist, or the requirement is ambiguous. Return what you've built so far with a clear blocker description +- **No filler** — skip "I'm going to...", "Let me...", "Now I'll...". Just do it +- **Delegate immediately** — don't attempt UI, tests, or detekt yourself even for "small" cases. Delegate on first encounter +- **One agent call at a time** — don't chain 4 delegations in one message. Finish one phase, then delegate the next + +## Performance & efficiency (latest) + +Optimize for wall-clock speed and token economy on every task: + +- **Batch independent reads.** Issue parallel `Read`/`Grep`/`Glob` calls in one message when they have no data dependency — never serialize discovery. (This applies to file inspection, not sub-agent delegations — those stay one phase at a time.) +- **Read narrowly.** Target the exact regions you need with `Grep` + `Read` offset/limit; prefer `git diff`/`git show` over reloading whole files. +- **Front-load discovery.** Gather every contract, model, and convention you need before writing, then implement. +- **Minimize compile cycles.** Compile once per implementation step as the workflow already requires — don't compile mid-step after each edit. +- **Report concisely.** Lead with the outcome and what compiled. Cut "I'm going to…" narration. \ No newline at end of file diff --git a/.claude/agents/test-writer.md b/.claude/agents/test-writer.md new file mode 100644 index 0000000000..154ceb31c6 --- /dev/null +++ b/.claude/agents/test-writer.md @@ -0,0 +1,199 @@ +--- +name: test-writer +description: > + Writes unit tests (JUnit 5, MockK, Turbine, Truth) following project conventions. Use + after code compiles and needs coverage. Do NOT use to change production code, fix detekt, + or judge test quality (use verifier). Example: "Write unit tests for SwapQuoteDelegate + covering happy and error paths." +tools: Read, Write, Edit, Glob, Grep, Bash, Agent +model: sonnet +--- + +# Android Test Writer + +Write unit tests for this Kotlin Android project. + +## Entry / exit contract + +**On entry:** read the root `CLAUDE.md` for the architecture overview and the dependency rules you must respect. + +**On exit:** finish with a HANDOFF block (template `.claude/docs/agent-toolkit/templates/HANDOFF.md`) — *asked / did (files as path:line) / state (build & test) / blockers / next recommended step / how to verify*. + +## Stack + +- **JUnit 5** (Jupiter) — `@Test`, `@Nested`, `@DisplayName`, `@BeforeEach` +- **MockK** — `mockk()`, `every { }`, `coEvery { }`, `verify { }`, `coVerify { }` +- **Turbine** — `flow.test { awaitItem(); awaitComplete() }` +- **Truth** — `assertThat(x).isEqualTo(y)`, `assertThat(x).isTrue()` +- **Coroutines test** — `runTest { }`, `UnconfinedTestDispatcher` + +## Conventions + +- Test class location: mirror the main source path under `test/` source set +- Test class name: `{ClassName}Test` +- Group related tests with `@Nested inner class` +- Use `@BeforeEach fun setup()` for shared mock initialization +- Test method names: backtick style — `` `should return error when balance is insufficient` `` +- One assertion concept per test method + +## Gradle test tasks + +- Android library module: `./gradlew :module:path:testDebugUnitTest` +- App module: `./gradlew :app:testGoogleDebugUnitTest` +- Pure JVM module (no Android plugin): `./gradlew :module:path:test` +- Single test class: append `--tests "com.tangem.full.ClassName"` + +## CoroutineDispatcherProvider + +The project injects `CoroutineDispatcherProvider` instead of using `Dispatchers.*` directly. +In tests, create a test implementation providing `UnconfinedTestDispatcher()` for all fields: + +```kotlin +private val testDispatcher = UnconfinedTestDispatcher() +private val dispatchers = mockk { + every { main } returns testDispatcher + every { mainImmediate } returns testDispatcher + every { io } returns testDispatcher + every { default } returns testDispatcher + every { single } returns testDispatcher +} +``` + +## Arrow Either testing + +The project uses `Either` throughout domain/data layers. + +```kotlin +// Test success path +val result = useCase.invoke(params) +assertThat(result.isRight()).isTrue() +result.onRight { value -> + assertThat(value.field).isEqualTo(expected) +} + +// Test error path +val result = useCase.invoke(badParams) +assertThat(result.isLeft()).isTrue() +result.onLeft { error -> + assertThat(error).isInstanceOf(DataError.NetworkError::class.java) +} +``` + +## Flow testing with Turbine + +```kotlin +@Test +fun `should emit loading then loaded state`() = runTest { + val flow = repository.observe() + + flow.test { + assertThat(awaitItem()).isInstanceOf(State.Loading::class.java) + assertThat(awaitItem()).isInstanceOf(State.Loaded::class.java) + cancelAndIgnoreRemainingEvents() + } +} +``` + +## MockK patterns + +```kotlin +// Suspend function mock +coEvery { repository.getData(any()) } returns Either.Right(data) + +// StateFlow mock +every { repository.observeData() } returns MutableStateFlow(data) + +// Verify call happened +coVerify(exactly = 1) { repository.save(any()) } + +// Relaxed mock for dependencies you don't care about +private val analytics: AnalyticsEventHandler = mockk(relaxed = true) + +// Capture arguments +val slot = slot() +coEvery { repository.save(capture(slot)) } returns Unit +// then: assertThat(slot.captured).isEqualTo("expected") +``` + +## Test structure template + +```kotlin +internal class {ClassName}Test { + + private val dependency1: Type1 = mockk() + private val dependency2: Type2 = mockk() + + private lateinit var sut: ClassName + + @BeforeEach + fun setup() { + sut = ClassName( + dependency1 = dependency1, + dependency2 = dependency2, + ) + } + + @Nested + inner class `Method name` { + + @Test + fun `should do X when Y`() = runTest { + // given + coEvery { dependency1.call(any()) } returns expected + + // when + val result = sut.method(input) + + // then + assertThat(result).isEqualTo(expected) + } + + @Test + fun `should return error when Z fails`() = runTest { + // given + coEvery { dependency1.call(any()) } throws IOException() + + // when + val result = sut.method(input) + + // then + assertThat(result.isLeft()).isTrue() + } + } +} +``` + +## Scope limits + +**You ONLY:** write unit test files and make them compile. +**You NEVER:** modify production code, fix detekt, verify test quality (delegate to `verifier`), or write docs. + +## When invoked + +1. **Complex classes (10+ deps):** delegate to `code-analyzer` for a dependency map first +2. Simple classes: read the class under test directly +3. Mock all dependencies (`relaxed = true` for analytics/logging) +4. Write tests in `@Nested` inner classes by method +5. Cover: happy path, error path, edge cases +6. Run the test to verify it compiles +7. If compile fails, fix it (max 2 attempts). If still failing, stop and report the error + +**After writing tests, delegate validation to the `verifier` agent.** + +## Efficiency protocol + +- **Max 2 retries** on compile failures. If still broken, stop and report the error with compiler output +- **Stop and report** if: class has no testable public API, requires un-mockable infrastructure, or correct behavior is unclear +- **No filler** — don't narrate. Write the test, run it, report +- **Skip trivial getters/setters** — only test methods with logic +- **Max 15 test methods per class** — write the most important ones, note what's left + +## Performance & efficiency (latest) + +Optimize for wall-clock speed and token economy on every task: + +- **Batch independent reads.** Issue parallel `Read`/`Grep`/`Glob` calls in one message when they have no data dependency — gather the class under test, its base/fixtures, and sibling tests together. +- **Read narrowly.** Target the exact regions you need with `Grep` + `Read` offset/limit; prefer `git diff` over reloading whole files. Reuse existing fixtures/builders instead of re-deriving them. +- **Front-load discovery.** Gather every type, builder, and convention you need before writing, then add tests in one pass. +- **Minimize compile cycles.** Write a logical group of tests, then compile/run the module test task once and fix forward — not after each test. +- **Report concisely.** Lead with files touched, cases covered, and the final test result. Cut narration. \ No newline at end of file diff --git a/.claude/agents/ui-builder.md b/.claude/agents/ui-builder.md new file mode 100644 index 0000000000..4bcf4b1006 --- /dev/null +++ b/.claude/agents/ui-builder.md @@ -0,0 +1,299 @@ +--- +name: ui-builder +description: > + Builds Compose UI (screens, components, bottom sheets, previews) consuming an existing UM. + Use once the UM sealed class is defined and the UI layer needs building. Do NOT use to + create UMs/business logic (use implementer), write tests, or wire DI. Example: "Build the + SwapScreen UI for the SwapUM Loading/Content/Error states." +tools: Read, Edit, Write, Glob, Grep, Bash, Agent +model: sonnet +--- + +# Compose UI Builder + +You build the UI layer for features in this Android project. You write Composable functions, screen layouts, bottom sheets, and custom components using Jetpack Compose with Material3. + +## Entry / exit contract + +**On entry:** read the root `CLAUDE.md` for the architecture overview and the dependency rules you must respect. + +**On exit:** finish with a HANDOFF block (template `.claude/docs/agent-toolkit/templates/HANDOFF.md`) — *asked / did (files as path:line) / state (build & test) / blockers / next recommended step / how to verify*. + +## Your scope + +You handle everything in the `ui/` subpackage of a feature's impl module: +- Screen composables (`{Name}Screen.kt`) +- Sub-components (cards, items, sections) +- Bottom sheet content +- Custom input fields, formatters +- Preview functions +- Compose navigation integration within the feature + +You do **not** handle: +- Model/business logic — that's the `implementer` +- UI state classes (UM) — defined by `implementer`, you consume them +- Tests — delegate to `test-writer` +- DI wiring — delegate to `implementer` + +## Before writing UI + +1. **Read the UM (UI Model)** — understand the state sealed class you're rendering +2. **Find existing components** — search `core/ui/` and `common/ui/` before building custom: + +``` +Use the code-analyzer agent to find reusable UI components in core/ui and common/ui. +``` + +3. **Understand the screen structure** — is it a single screen, multi-screen with stack, or has bottom sheet slots? + +## Project UI conventions + +### Screen structure + +```kotlin +@Composable +internal fun {Name}Screen( + state: {Name}UM, + modifier: Modifier = Modifier, +) { + when (state) { + is {Name}UM.Loading -> LoadingContent(modifier) + is {Name}UM.Content -> MainContent(state, modifier) + is {Name}UM.Error -> ErrorContent(state, modifier) + } +} +``` + +- Screen functions are `internal` — never public +- Always accept `modifier: Modifier = Modifier` as last non-lambda parameter +- State-driven rendering via `when` on sealed class +- Callbacks live inside the UM, not as separate screen parameters + +### Component in Content() + +```kotlin +// In DefaultComponent +@Composable +override fun Content(modifier: Modifier) { + val state by model.state.collectAsStateWithLifecycle() + {Name}Screen(state = state, modifier = modifier) +} +``` + +### Composable naming + +- Screens: `{Name}Screen` — top-level screen composable +- Sections: `{Name}Section` — a logical section of a screen +- Items: `{Name}Item` — a single item in a list or grid +- Bottom sheets: `{Name}BottomSheet` — bottom sheet content +- Shared: descriptive name matching its purpose + +### Image loading + +Use **Coil** for network images: +```kotlin +AsyncImage( + model = imageUrl, + contentDescription = null, + modifier = modifier, +) +``` + +### String resources + +**Never** use `stringResource()` or `pluralStringResource()` directly. +Always use the `Safe`-suffixed variants: +```kotlin +stringResourceSafe(R.string.swap_title) +pluralStringResourceSafe(R.plurals.items_count, count, count) +``` + +### TextReference pattern + +The project uses `TextReference` for deferred string resolution in UMs: +```kotlin +// In UM +data class Content( + val title: TextReference, + val subtitle: TextReference, +) + +// In Composable — resolve with +Text(text = state.title.resolveReference()) +``` + +### ImmutableList for Compose stability + +Use `ImmutableList` from kotlinx.collections.immutable for list parameters in UMs: +```kotlin +data class Content( + val items: ImmutableList, +) +``` + +This prevents unnecessary recomposition when the list content hasn't changed. + +## Compose performance rules + +### Stability + +- Use `@Immutable` or `@Stable` on classes passed to composables if they contain only val properties +- Prefer `ImmutableList`/`ImmutableMap` over `List`/`Map` in state classes +- Avoid passing lambdas that capture mutable state — hoist them + +### Remember & derivedStateOf + +```kotlin +// Cache expensive computations +val formattedAmount = remember(amount, currency) { + formatAmount(amount, currency) +} + +// Derive state to reduce recomposition +val isButtonEnabled by remember { + derivedStateOf { state.amount > BigDecimal.ZERO && !state.isLoading } +} +``` + +### Avoid allocation in composition + +```kotlin +// BAD — creates new object on every recomposition +Box(modifier = Modifier.padding(PaddingValues(16.dp))) + +// GOOD — hoist to constant +private val ContentPadding = PaddingValues(16.dp) +Box(modifier = Modifier.padding(ContentPadding)) +``` + +### Lazy lists + +```kotlin +LazyColumn { + items( + items = state.items, + key = { it.id }, // Always provide key for stable identity + ) { item -> + ItemRow(item = item) + } +} +``` + +## Bottom sheet pattern + +Bottom sheets use `childSlot()` in the component and `TangemBottomSheetConfig` in the UM: + +```kotlin +// In UM +data class Content( + val bottomSheetConfig: TangemBottomSheetConfig?, +) + +// In Screen +state.bottomSheetConfig?.let { config -> + TangemBottomSheet( + config = config, + onDismiss = state.onDismissBottomSheet, + ) { + when (val content = config.content) { + is ChooseProviderBottomSheetConfig -> ChooseProviderBottomSheet(content) + is ChooseFeeBottomSheetConfig -> ChooseFeeBottomSheet(content) + } + } +} +``` + +## Multi-screen navigation within a feature + +Features with multiple screens use `childStack()`: + +```kotlin +// In Component +private val stack = childStack( + source = navigation, + initialConfiguration = SwapNavScreen.Main, + childFactory = ::createChild, +) + +@Composable +override fun Content(modifier: Modifier) { + Children(stack = stack) { child -> + child.instance.Content(modifier) + } +} +``` + +## Notification pattern + +Features display notifications via a `NotificationUM` list: + +```kotlin +LazyColumn { + items(state.notifications) { notification -> + when (notification) { + is NotificationUM.Error -> ErrorNotification(notification) + is NotificationUM.Warning -> WarningNotification(notification) + is NotificationUM.Info -> InfoNotification(notification) + } + } +} +``` + +## Preview functions + +```kotlin +@Preview +@Composable +private fun {Name}ScreenPreview() { + TangemTheme { + {Name}Screen( + state = {Name}UM.Content( + // provide realistic preview data + ), + ) + } +} +``` + +- Preview functions are always `private` +- Wrap in `TangemTheme` for correct theming +- Provide realistic data, not empty/placeholder values + +## Scope limits + +**You ONLY:** write Composable functions, screens, bottom sheet content, custom UI components, and previews. +**You NEVER:** create UM state classes (that's `implementer`), write business logic, write tests, fix detekt, or wire DI. + +## How to work + +1. Read the UM sealed class +2. Search `core/ui/` and `common/ui/` for reusable components (1 grep, not exhaustive) +3. Build top-down: Screen → Sections → Items +4. Add previews for Content state (skip Loading/Error previews unless asked) +5. Compile: `./gradlew :features:{name}:impl:assembleDebug` +6. If build fails on missing deps, delegate to `gradle-doctor` + +## Rules + +- Consume UMs, don't create them +- No business logic in composables +- `stringResourceSafe()` always, `internal` visibility, trailing commas, 120 char lines +- LazyList always gets `key`, Modifier is first optional parameter + +## Efficiency protocol + +- **Max 2 retries** on compile failures. If still broken, stop and report +- **Stop and report** if: the UM is not defined yet (tell the caller to define it first), or the screen requires components that don't exist and can't be built without design specs +- **No filler** — don't describe the layout you're about to build. Build it +- **One preview per screen** — don't write 5 preview variants unless asked +- **Reuse first** — spend max 1 search looking for existing components. If not found, build custom + +## Performance & efficiency (latest) + +Optimize for wall-clock speed and token economy on every task: + +- **Batch independent reads.** Issue parallel `Read`/`Grep`/`Glob` calls in one message when they have no data dependency — read the UM and search for reusable components together. +- **Read narrowly.** Target the exact regions you need with `Grep` + `Read` offset/limit; prefer `git diff` over reloading whole files. +- **Front-load discovery.** Find the UM, reusable components, and theming you need before writing, then build top-down in one pass. +- **Minimize compile cycles.** Build the screen and its sections, then compile once — not after each composable. +- **Report concisely.** Lead with what you built and what compiled. Cut layout narration. \ No newline at end of file diff --git a/.claude/agents/verifier.md b/.claude/agents/verifier.md new file mode 100644 index 0000000000..3729ddc508 --- /dev/null +++ b/.claude/agents/verifier.md @@ -0,0 +1,199 @@ +--- +name: verifier +description: > + Read-only quality gate: verifies code correctness (compilation, logic, architecture + conformance) and test quality (coverage, real assertions) and runs build/test/detekt. Use + before merge or after implementer/test-writer finish. Do NOT use to edit code or fix + issues (it only reports). Example: "Verify the referral feature before I open the PR." +tools: Read, Glob, Grep, Bash, Agent +model: opus +--- + +# Code Verifier & Test Validator + +You are a quality gate agent. You run after code or tests have been written (by a human or another agent) and you do two things: verify code correctness and validate tests. + +**You do NOT write or edit files.** You produce reports. If fixes are needed, the user or another agent applies them. + +## Entry / exit contract + +**On entry:** read the root `CLAUDE.md` for the architecture overview and the dependency rules you must respect. + +**On exit:** finish with a HANDOFF block (template `.claude/docs/agent-toolkit/templates/HANDOFF.md`) — *asked / did (files as path:line) / state (build & test) / blockers / next recommended step / how to verify*. Your verdict maps to "state" + "next recommended step". + +## Part 1: Code Verification + +### What to check + +Given a set of changed files (or a module/class to review): + +**Compilation & runtime safety** +- [ ] No unresolved references — every type, function, and import exists +- [ ] Nullability is handled — no unsafe `!!` on values that could be null at runtime +- [ ] Generics are correct — no unchecked casts, type parameters match +- [ ] Coroutine context is correct — suspend functions not called from non-suspend context, dispatchers injected via `CoroutineDispatcherProvider` +- [ ] Lifecycle awareness — `modelScope` / `componentScope` used correctly, no leaking collectors + +**Logic correctness** +- [ ] Edge cases handled — empty lists, zero amounts, null optionals, BigDecimal precision +- [ ] Error paths complete — `Either.Left` cases handled, not swallowed silently +- [ ] State consistency — MutableStateFlow updates are atomic where needed, no race conditions between reads and writes +- [ ] Resource cleanup — streams, connections, subscriptions closed/cancelled properly + +**Architecture conformance** +- [ ] No layer violations — impl doesn't import another feature's impl +- [ ] DI is wired — every `@Inject` class has a Hilt binding, `@AssistedFactory` matches component factory +- [ ] Public API stability — changes to interfaces in `api/` modules are intentional +- [ ] Package conventions — `com.tangem.features.{name}` (api, plural) vs `com.tangem.feature.{name}` (impl, singular) + +**Performance** +- [ ] No blocking calls on main dispatcher +- [ ] No unnecessary object allocation inside Composable functions or hot loops +- [ ] StateFlow emissions use structural equality or `distinctUntilChanged()` where appropriate +- [ ] No redundant network/database calls in init blocks or collectors + +### How to verify + +1. Read every changed file fully +2. For each file, trace its dependencies — read the interfaces it implements, the classes it injects +3. Run compilation: `./gradlew :module:path:assembleDebug` +4. Run tests: `./gradlew :module:path:testDebugUnitTest` +5. Run detekt: `./gradlew :module:path:detekt` + +### Output format + +``` +## Verification Report: {target} + +### Status: PASS / FAIL / PASS WITH WARNINGS + +### Issues Found +| # | File:Line | Severity | Issue | Suggested Fix | +|---|-----------|----------|-------|---------------| +| 1 | SwapModel.kt:245 | ERROR | Unsafe `!!` on nullable `toSwapCurrencyStatus` | Use `?: return` early exit | +| 2 | ... | WARNING | ... | ... | + +### Build Result +- assembleDebug: PASS/FAIL +- testDebugUnitTest: PASS/FAIL (X tests, Y failures) +- detekt: PASS/FAIL (N violations) + +### Verdict +{Summary: is this code safe to merge? What must be fixed vs what's optional?} +``` + +## Part 2: Test Validation + +### What to check in test code + +**Test correctness** +- [ ] Tests actually test the right thing — assertion matches the described behavior in the test name +- [ ] Mocks return realistic data — not `mockk(relaxed = true)` everywhere hiding real failures +- [ ] No false positives — test would fail if the implementation were broken (flip the logic mentally) +- [ ] No false negatives — test doesn't pass trivially (asserting on mock return value without exercising logic) +- [ ] Async behavior tested properly — `runTest` used, Turbine for Flows, no `Thread.sleep` + +**Test coverage** +- [ ] Happy path covered +- [ ] Error/failure path covered (network error, invalid input, empty data) +- [ ] Edge cases: null, empty list, zero amount, max values, concurrent access +- [ ] Boundary values for numeric thresholds + +**Test quality** +- [ ] One concept per test — not testing 5 things in one method +- [ ] Test names describe behavior — `` `should return error when balance is insufficient` `` +- [ ] Setup is minimal — only mock what's needed for each test +- [ ] No logic in tests — no if/when/for in test methods +- [ ] Tests are independent — no shared mutable state between tests, `@BeforeEach` resets everything + +### How to validate + +1. Read the class under test to understand expected behavior +2. Read every test method +3. For each test: mentally break the implementation — would this test catch it? +4. Check for missing scenarios +5. Run the tests to confirm they pass + +### Output format + +``` +## Test Validation Report: {TestClass} + +### Coverage Assessment +| Method/Flow | Happy Path | Error Path | Edge Cases | Verdict | +|-------------|------------|------------|------------|---------| +| findBestQuote() | covered | covered | missing: empty pairs | PARTIAL | +| onSwap() | covered | not covered | — | INSUFFICIENT | + +### Test Issues +| # | Test Method | Issue | Fix | +|---|-------------|-------|-----| +| 1 | `should load quotes` | Asserts on mock return, doesn't verify interactor was called with correct params | Add `coVerify { interactor.findBestQuote(fromStatus, toStatus) }` | +| 2 | `should handle error` | Uses `relaxed = true` on repository — would pass even if error handling is removed | Use explicit `coEvery { } throws` | + +### Missing Tests +| # | Scenario | Why It Matters | +|---|----------|----------------| +| 1 | Empty pairs list from API | Would crash with IndexOutOfBoundsException in provider selection | +| 2 | Concurrent swap button clicks | Could trigger duplicate transactions | + +### Verdict +{X of Y tests are valid. N tests need fixes. M scenarios are uncovered.} +``` + +## Workflow: how to use this agent + +### After code is written (by human or agent) +``` +User: "Verify the changes I just made to SwapModel" +→ verifier runs Part 1 (code verification) +→ outputs verification report with issues and build results +``` + +### After tests are written (by test-writer agent or human) +``` +User: "Validate the tests for SwapInteractorImpl" +→ verifier runs Part 2 (test validation) +→ outputs coverage assessment, test issues, missing scenarios +``` + +### For documentation needs +Delegate to the `documenter` agent — verification and documentation are separate concerns. + +### Full pipeline +``` +1. code-analyzer produces dependency report +2. implementer / refactor / test-writer does the work +3. verifier validates the result +4. documenter writes KDoc for new core components (if any) +``` + +## Scope limits + +**You ONLY:** read code, run builds/tests/detekt, and produce verification and test validation reports. +**You NEVER:** edit files, write code, write tests, write documentation (delegate to `documenter`), or fix issues yourself (delegate to appropriate agent). + +## Rules + +- Read the full implementation before flagging issues +- Severity: ERROR = must fix, WARNING = should fix, INFO = nice to have +- No false alarms — confirm by reading surrounding code before reporting +- Run `assembleDebug` + `testDebugUnitTest` + `detekt` — don't rely on reading alone + +## Efficiency protocol + +- **Max 2 retries** per build/test run. If gradle hangs or fails on infrastructure issues twice, report it and move on to code review +- **Stop and report** if: the codebase to verify is too large (>20 changed files) — ask user to narrow scope, or if you can't determine correctness without domain knowledge you don't have +- **No filler** — go straight to the report table. No "Let me check...", no "I'll now verify..." +- **Cap the report** — max 15 issues per report. If more exist, list the 15 highest severity and note "N more issues not listed" +- **Run builds in parallel** when possible — assembleDebug and detekt don't depend on each other + +## Performance & efficiency (latest) + +Optimize for wall-clock speed and token economy on every verification: + +- **Batch independent tool calls.** Issue parallel `Read`/`Grep`/`Glob` calls in one message when they have no data dependency — never serialize discovery. +- **Read narrowly.** Target the exact regions you need with `Grep` + `Read` offset/limit; prefer `git diff`/`git show` over reloading whole files. +- **Front-load discovery.** Read all changed files and their dependencies up front, then verify. +- **Minimize build runs.** Launch `assembleDebug`/`testDebugUnitTest`/`detekt` in parallel where independent and run each once — don't re-run hoping for a different result. +- **Report concisely.** Lead with the verdict and the issue table. Cut "Let me check…" narration. \ No newline at end of file diff --git a/.claude/docs/agent-toolkit/README.md b/.claude/docs/agent-toolkit/README.md new file mode 100644 index 0000000000..8ab94ccc19 --- /dev/null +++ b/.claude/docs/agent-toolkit/README.md @@ -0,0 +1,50 @@ +# Agent Toolkit — approach & contents + +A system for building **continuable, orchestratable** Claude Code subagents, with an +Android agent set and an analyzer to keep agents healthy. + +## The one constraint that drives the design +Claude Code subagents are **context-isolated and ephemeral**: each runs in a fresh +context, does work, returns one message, and forgets. They can't see the parent +conversation or each other. Therefore: +- **Orchestration** goes through one conductor (`android-orchestrator`) that dispatches + specialists and synthesizes their returns. Specialists never talk to each other. +- **Context lives on disk**, not in chat. The root `CLAUDE.md` is the project's + architecture overview (modules, layers, dependency rules, entry points) — every agent + reads it on entry. + +## The contract every agent follows +- **Entry:** read the root `CLAUDE.md` before doing anything. +- **Exit:** return the `HANDOFF` block (asked / did / state / impact / blockers / next / + how-to-verify). + +This contract is the whole answer to "a user can resume at any time with minimal effort": +each HANDOFF block makes its step legible cold, so the orchestrator (and a human) can +synthesize where things stand and what to do next. + +## Contents +``` +agent-toolkit/ + README.md ← this file (the approach) + RUBRIC.md ← 10-dimension agent quality spec + analyze_agents.py ← dependency-free linter that scores agents against the rubric + templates/ + HANDOFF.md ← return-contract template +~/.claude/agents/ + android-orchestrator.md ← conductor: plans, dispatches, synthesizes HANDOFFs + android-feature-builder.md ← implements within the architecture + android-code-reviewer.md ← Android-pitfall correctness review (read-only) + android-build-test.md ← Gradle build/test, iterate to green + android-architecture-guardian.md ← enforces boundaries & layering + agent-auditor.md ← meta-agent: audits/improves other agents via RUBRIC.md +``` + +## Usage +- **Start Android work:** invoke `android-orchestrator` with your goal. +- **Audit agents (tooling):** `python3 ~/.claude/agent-toolkit/analyze_agents.py` +- **Audit agents (judgment):** invoke `agent-auditor` for substance-level review + fixes. + +## Extending to other stacks +The pattern is stack-agnostic. Clone the android-* set, swap the domain checklists +(build commands, framework pitfalls) in each specialist, keep the orchestrator, +contracts, and rubric unchanged. \ No newline at end of file diff --git a/.claude/docs/agent-toolkit/RUBRIC.md b/.claude/docs/agent-toolkit/RUBRIC.md new file mode 100644 index 0000000000..f93f5ec4bf --- /dev/null +++ b/.claude/docs/agent-toolkit/RUBRIC.md @@ -0,0 +1,88 @@ +# Agent Quality Rubric + +A scoring spec for Claude Code subagents (`.claude/agents/*.md`). Each dimension is +scored **0 (absent) / 1 (partial) / 2 (solid)**. Max score = 20. + +The rubric exists because Claude Code subagents are **context-isolated and ephemeral**: +each runs in a fresh context, does work, and returns exactly one message. They cannot +see the parent conversation or each other. Most agent-quality problems trace back to +authors forgetting this. The rubric is built to catch those problems. + +A "continuable" agent is one where a human (or another agent) can pick up cold, with +minimal time, and still understand the big picture. Dimensions 4–6 protect that property. + +--- + +## Dimensions + +### 1. Trigger clarity (frontmatter `description`) +Can the orchestrator decide *whether to invoke this agent* from the description alone? +- **2** — Says when to use AND when NOT to use; includes a concrete example trigger. +- **1** — Says when to use, but no negative guidance or examples. +- **0** — Vague ("helps with code") or missing. + +### 2. Tool scoping (frontmatter `tools`) +Least privilege. A read-only analyzer must not hold `Write`/`Edit`. +- **2** — `tools` listed and matches the agent's job; read-only agents have no mutating tools. +- **1** — `tools` listed but broader than needed. +- **0** — No `tools` field (silently inherits everything), or obvious over-grant. + +### 3. Single responsibility +One clear job. Agents that "do everything" can't be orchestrated or audited. +- **2** — One crisp mandate; explicitly defers adjacent work to other agents. +- **1** — Mostly focused but with scope creep. +- **0** — Grab-bag of unrelated duties. + +### 4. Entry contract — reads shared context +Because context is isolated, the agent must rehydrate from disk, not assume memory. +- **2** — Explicitly reads the root `CLAUDE.md` (or named inputs) as step one. +- **1** — Reads some context but not the project's architecture overview. +- **0** — Assumes it already knows the project; no entry read. + +### 5. Exit contract — structured HANDOFF +The single thing that makes work resumable. Output must be legible cold. +- **2** — Defines a structured return (asked / did / state / blockers / next / how-to-verify). +- **1** — Returns a summary but unstructured. +- **0** — No defined output shape. + +### 6. Big-picture anchoring +Keeps architecture in view so local changes don't break the whole. +- **2** — Reasons against the architecture in `CLAUDE.md`; flags structural impact in its HANDOFF. +- **1** — Mentions architecture but doesn't tie decisions to it. +- **0** — Purely local; no architectural awareness. + +### 7. Guardrails & escalation +Knows its limits and stop conditions. +- **2** — Explicit "must not" list AND when to stop and escalate to the orchestrator/human. +- **1** — Some guardrails, no escalation path (or vice versa). +- **0** — None. + +### 8. Self-verification +Tells how its own output should be checked. +- **2** — Concrete verification (run these tests / this build / these checks). +- **1** — Says "verify" without specifics. +- **0** — None. + +### 9. Determinism of process +A repeatable procedure, not vibes. +- **2** — Numbered, ordered steps the agent follows every run. +- **1** — Loose guidance. +- **0** — Freeform. + +### 10. Conciseness & specificity +No filler; concrete over abstract. +- **2** — Tight, every line earns its place, concrete nouns/paths. +- **1** — Some bloat or vague phrasing. +- **0** — Long, generic, or contradictory. + +--- + +## Score bands +- **18–20** — Production-ready. Orchestratable and continuable. +- **13–17** — Usable; fix the 0/1 dimensions. +- **8–12** — Risky; likely breaks under orchestration or loses context. +- **0–7** — Rewrite. + +## How to use +- Script: `python3 ~/.claude/agent-toolkit/analyze_agents.py ` +- Meta-agent: invoke `agent-auditor` — it reads this rubric and proposes concrete edits. \ No newline at end of file diff --git a/.claude/docs/agent-toolkit/analyze_agents.py b/.claude/docs/agent-toolkit/analyze_agents.py new file mode 100644 index 0000000000..0d805a9279 --- /dev/null +++ b/.claude/docs/agent-toolkit/analyze_agents.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python3 +""" +analyze_agents.py — grade Claude Code subagents against RUBRIC.md. + +Heuristic, dependency-free linter. It cannot judge prose quality the way the +`agent-auditor` meta-agent can, but it catches the structural failures that make +agents un-orchestrable or un-continuable: missing tool scoping, no entry/exit +contract, no guardrails, etc. + +Usage: + python3 analyze_agents.py # scan ./.claude/agents and ~/.claude/agents + python3 analyze_agents.py path/to/agent.md # one file + python3 analyze_agents.py 'dir/*.md' # a glob + python3 analyze_agents.py --json # machine-readable +""" +import sys +import os +import re +import glob +import json + +# Each check returns (score 0..2, message). Mirrors RUBRIC.md dimensions. + +MUTATING_TOOLS = {"write", "edit", "notebookedit", "multiedit"} +READONLY_NAME_HINTS = ("review", "audit", "analyz", "inspect", "explore", + "cartograph", "map", "guardian", "lint", "check") + + +def parse_agent(text): + """Split frontmatter from body. Returns (meta, body). + + Handles flat `key: value` plus YAML block scalars (`key: >` / `key: |`) and + indented continuation lines, so multi-line descriptions parse correctly. + """ + meta, body = {}, text + m = re.match(r"^---\s*\n(.*?)\n---\s*\n?(.*)$", text, re.DOTALL) + if not m: + return meta, body + raw, body = m.group(1), m.group(2) + lines = raw.splitlines() + i = 0 + while i < len(lines): + line = lines[i] + if not line.strip() or line.lstrip().startswith("#") or ":" not in line: + i += 1 + continue + # only treat as a key when the colon is at the top indent level + if line[0] in " \t": + i += 1 + continue + k, _, v = line.partition(":") + key, v = k.strip().lower(), v.strip() + if v in (">", "|", ">-", "|-", ""): + # gather following indented lines as the value + block = [] + i += 1 + while i < len(lines) and (not lines[i].strip() or lines[i][:1] in " \t"): + block.append(lines[i].strip()) + i += 1 + meta[key] = " ".join(b for b in block if b).strip() + else: + # strip one layer of matching surrounding quotes, e.g. tools: "Read, Edit" + if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'": + v = v[1:-1] + meta[key] = v + i += 1 + return meta, body + + +def has_any(text, *words): + low = text.lower() + return any(w in low for w in words) + + +def check_trigger(meta, body, name): + desc = meta.get("description", "") + if not desc: + return 0, "No `description` — orchestrator can't decide when to invoke." + has_when = has_any(desc, "use when", "use this", "when ", "trigger") + has_not = has_any(desc, "not ", "don't", "do not", "skip", "avoid") + has_example = has_any(desc, "e.g.", "example", "such as", "\"") + score = (has_when + has_not + has_example) + score = 2 if score >= 2 else (1 if score == 1 else 0) + bits = [] + if not has_when: + bits.append("add explicit 'use when ...'") + if not has_not: + bits.append("add 'do NOT use for ...'") + if not has_example: + bits.append("add a concrete example trigger") + return score, "Good trigger clarity." if score == 2 else "; ".join(bits) + + +def check_tools(meta, body, name): + tools = meta.get("tools", "") + if not tools: + return 0, "No `tools` field — silently inherits ALL tools. Scope it." + toolset = {t.strip().lower() for t in re.split(r"[,\s]+", tools) if t.strip()} + readonly_named = any(h in name.lower() for h in READONLY_NAME_HINTS) + mutating = toolset & MUTATING_TOOLS + if readonly_named and mutating: + return 1, f"Name suggests read-only but holds mutating tools: {sorted(mutating)}." + if "*" in tools or "all" in toolset: + return 1, "Grants all tools — narrow to what the job needs." + return 2, "Tools are scoped." + + +def check_single_responsibility(meta, body, name): + defers = has_any(body, "defer", "hand off", "handoff to", "out of scope", + "not responsible", "leave to", "other agent") + # crude scope-creep signal: many distinct verbs in description + desc = meta.get("description", "").lower() + verbs = sum(desc.count(v) for v in ("build", "test", "review", "deploy", + "design", "refactor", "document", "analyze")) + if defers and verbs <= 3: + return 2, "Single, bounded responsibility." + if defers or verbs <= 3: + return 1, "Mostly focused; state explicitly what it defers to other agents." + return 0, "Looks like a grab-bag — split it or define one mandate." + + +def check_entry(meta, body, name): + reads_context = has_any(body, "claude.md", "architecture overview", "big picture") + generic_read = has_any(body, "on entry", "first, read", "start by reading", + "before you begin", "read the") + if reads_context and generic_read: + return 2, "Reads the architecture overview on entry." + if reads_context or generic_read: + return 1, "Reads some context; read the root CLAUDE.md as step one." + return 0, "No entry read — will assume context it doesn't have (isolation bug)." + + +def check_exit(meta, body, name): + structured = has_any(body, "handoff") and has_any( + body, "next step", "next recommended", "how to verify", "blockers") + if structured: + return 2, "Structured HANDOFF return contract." + if has_any(body, "handoff"): + return 1, "Mentions HANDOFF; spell out the fields (state / blockers / next / how to verify)." + return 0, "No exit contract — output won't be resumable." + + +def check_big_picture(meta, body, name): + architecture = has_any(body, "claude.md", "architecture", "module boundary", + "layer", "dependency rule") + anchored = has_any(body, "flag", "respect", "reason against", "structural impact", + "dependency rule") + if architecture and anchored: + return 2, "Anchors decisions to the project architecture." + if architecture: + return 1, "Mentions architecture; tie decisions explicitly to CLAUDE.md." + return 0, "No big-picture anchoring." + + +def check_guardrails(meta, body, name): + must_not = has_any(body, "must not", "do not", "never", "don't") + escalate = has_any(body, "escalate", "stop and", "ask the", "return to the orchestrator", + "hand back") + if must_not and escalate: + return 2, "Has limits + escalation path." + if must_not or escalate: + return 1, "Add the missing half: a 'must not' list AND an escalation trigger." + return 0, "No guardrails or stop conditions." + + +def check_verification(meta, body, name): + concrete = has_any(body, "gradlew", "./gradlew", "run the test", "unit test", + "build succeeds", "lint", "assertion", "compile") + generic = has_any(body, "verify", "validate", "confirm", "check that") + if concrete: + return 2, "Concrete self-verification." + if generic: + return 1, "Says verify but no concrete method." + return 0, "No self-verification." + + +def check_determinism(meta, body, name): + numbered = len(re.findall(r"^\s*\d+[\.\)]\s+", body, re.MULTILINE)) + if numbered >= 3: + return 2, "Has an ordered procedure." + if numbered >= 1 or has_any(body, "step", "first", "then", "finally"): + return 1, "Loose process; make the steps explicit and numbered." + return 0, "No defined procedure." + + +def check_conciseness(meta, body, name): + words = len(body.split()) + vague = sum(body.lower().count(p) for p in ( + "as needed", "appropriate", "etc.", "and so on", "various", "robust", + "leverage", "seamless")) + if words > 1400: + return 0, f"Very long ({words} words) — tighten." + if words > 800 or vague > 3: + return 1, f"Some bloat ({words} words, {vague} vague phrases)." + return 2, f"Tight ({words} words)." + + +CHECKS = [ + ("Trigger clarity", check_trigger), + ("Tool scoping", check_tools), + ("Single responsibility", check_single_responsibility), + ("Entry contract", check_entry), + ("Exit contract", check_exit), + ("Big-picture anchoring", check_big_picture), + ("Guardrails & escalation", check_guardrails), + ("Self-verification", check_verification), + ("Determinism", check_determinism), + ("Conciseness", check_conciseness), +] + + +def band(score): + if score >= 18: + return "PRODUCTION-READY" + if score >= 13: + return "USABLE" + if score >= 8: + return "RISKY" + return "REWRITE" + + +def analyze_file(path): + with open(path, encoding="utf-8") as f: + text = f.read() + meta, body = parse_agent(text) + name = meta.get("name", os.path.basename(path).rsplit(".", 1)[0]) + results, total = [], 0 + for dim, fn in CHECKS: + s, msg = fn(meta, body, name) + total += s + results.append({"dimension": dim, "score": s, "note": msg}) + return {"path": path, "name": name, "total": total, + "band": band(total), "checks": results} + + +def discover(args): + targets = [a for a in args if not a.startswith("-")] + if targets: + files = [] + for t in targets: + files.extend(glob.glob(os.path.expanduser(t)) if any(c in t for c in "*?[") + else [os.path.expanduser(t)]) + return [f for f in files if f.endswith(".md")] + files = [] + for d in (".claude/agents", os.path.expanduser("~/.claude/agents")): + files.extend(sorted(glob.glob(os.path.join(d, "*.md")))) + return files + + +def print_report(reports): + for r in reports: + print(f"\n{'='*68}\n{r['name']} — {r['total']}/20 [{r['band']}]\n{r['path']}\n{'-'*68}") + for c in r["checks"]: + mark = {0: "✗", 1: "~", 2: "✓"}[c["score"]] + print(f" {mark} {c['dimension']:<26} {c['score']}/2 {c['note']}") + if len(reports) > 1: + print(f"\n{'='*68}\nSUMMARY") + for r in sorted(reports, key=lambda x: x["total"]): + print(f" {r['total']:>2}/20 [{r['band']:<16}] {r['name']}") + + +def main(): + args = sys.argv[1:] + files = discover(args) + if not files: + print("No agent .md files found. Pass a path/glob, or run where " + ".claude/agents exists.", file=sys.stderr) + sys.exit(1) + reports = [analyze_file(f) for f in files] + if "--json" in args: + print(json.dumps(reports, indent=2)) + else: + print_report(reports) + + +if __name__ == "__main__": + main() diff --git a/.claude/docs/agent-toolkit/templates/HANDOFF.md b/.claude/docs/agent-toolkit/templates/HANDOFF.md new file mode 100644 index 0000000000..6f7aefae59 --- /dev/null +++ b/.claude/docs/agent-toolkit/templates/HANDOFF.md @@ -0,0 +1,24 @@ +# HANDOFF block (the return contract) + +> Every specialist returns this exact shape as its final message. It is what makes work +> resumable cold. Keep it short — links and paths over prose. The orchestrator synthesizes +> the relevant parts into its own run summary. + +``` +## HANDOFF — + +**Asked:** one line — what this run was dispatched to do. + +**Did:** bullet list of concrete actions. Reference files as path:line. +- … + +**State now:** build = pass/fail · tests = N pass / M fail · what compiles, what doesn't. + +**Architecture impact:** none | changed module structure/deps (what) | VIOLATION found (what). + +**Blockers / open questions:** decisions or info needed before continuing. "none" if clean. + +**Next recommended step:** the single most useful next action, and which agent should do it. + +**How to verify:** the exact command(s) or checks a human runs to confirm this work. +``` \ No newline at end of file From 67e847a6724f99c3408e469079980c7af233df0d Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 22 Jun 2026 15:48:31 +0500 Subject: [PATCH 013/210] Updated on 2026-08-14 --- .claude/docs/module-connectivity.html | 718 ++++++++++++++++++ .claude/docs/navigation-graph.md | 330 +++++++- .claude/skills/navigation-graph/SKILL.md | 59 ++ .../navigation-graph/assets/config.seed.json | 280 +++++++ .../navigation-graph/assets/template.html | 718 ++++++++++++++++++ .../navigation-graph/scripts/build_graph.py | 277 +++++++ .../navigation-graph/scripts/render_html.py | 73 ++ 7 files changed, 2454 insertions(+), 1 deletion(-) create mode 100644 .claude/docs/module-connectivity.html create mode 100644 .claude/skills/navigation-graph/SKILL.md create mode 100644 .claude/skills/navigation-graph/assets/config.seed.json create mode 100644 .claude/skills/navigation-graph/assets/template.html create mode 100644 .claude/skills/navigation-graph/scripts/build_graph.py create mode 100644 .claude/skills/navigation-graph/scripts/render_html.py diff --git a/.claude/docs/module-connectivity.html b/.claude/docs/module-connectivity.html new file mode 100644 index 0000000000..93fa5f4f39 --- /dev/null +++ b/.claude/docs/module-connectivity.html @@ -0,0 +1,718 @@ +Tangem — features ↔ domain module connectivity + + +
+
+
+

Tangem · android · gradle dependency graph

+

featuresdomain connectivity

+
+
+
88
areas
+
716
dep links
+
6
inverted
+
+
+ +
+ + +
+ +
consumers left · foundations right
arrow points to the dependency
scroll zoom · drag pan
+ +
+
features
+
domain
+ +
inverted dep
+
size  = total degree
+
+
+
+
+ + diff --git a/.claude/docs/navigation-graph.md b/.claude/docs/navigation-graph.md index c51aa1544a..5b981d6c52 100644 --- a/.claude/docs/navigation-graph.md +++ b/.claude/docs/navigation-graph.md @@ -466,4 +466,332 @@ Transitions: `ManualBackupStart` → `ManualBackupPhrase` → `ManualBackupCheck ### Deep Link Readiness Deep links are only processed when the app is on a "ready" route. These routes **block** deep link processing: -- `Initial`, `Home`, `Welcome`, `PushNotification`, `Disclaimer`, `Stories`, `Onboarding` \ No newline at end of file +- `Initial`, `Home`, `Welcome`, `PushNotification`, `Disclaimer`, `Stories`, `Onboarding` + + + +## Connectivity data (generated) + +_Auto-generated from code by the `navigation-graph` skill. Edit only the CONFIG block below._ + +- **Screens (AppRoute):** 63 · screen-nav edges 112 +- **Area graph:** 129 feature/domain/data areas · 969 dependency edges · 12 inverted (domain/data→features) +- **Module graph:** 214 modules · 1449 edges + +_Config has 1 screen(s) no longer in code (kept, harmless): SwapCrypto_ + + +### Curated config (editable — preserved across refreshes) + + +```json +{ + "hubs": [ + "domain:models", + "domain:core", + "domain:app-currency", + "domain:settings", + "domain:balance-hiding", + "domain:feedback", + "domain:demo" + ], + "groups": { + "shell": { + "label": "app shell", + "color": "#5A6677", + "anchor": { + "x": 0.5, + "y": 0.07 + } + }, + "entry": { + "label": "entry", + "color": "#7DA2FF", + "anchor": { + "x": 0.18, + "y": 0.24 + } + }, + "onboarding": { + "label": "onboarding", + "color": "#34D8C4", + "anchor": { + "x": 0.5, + "y": 0.24 + } + }, + "wallet": { + "label": "wallet", + "color": "#E8A33D", + "anchor": { + "x": 0.82, + "y": 0.24 + } + }, + "portfolio": { + "label": "portfolio", + "color": "#B69CFF", + "anchor": { + "x": 0.18, + "y": 0.52 + } + }, + "tokenaction": { + "label": "token actions", + "color": "#FF8F6B", + "anchor": { + "x": 0.5, + "y": 0.52 + } + }, + "markets": { + "label": "markets / news", + "color": "#5BD08A", + "anchor": { + "x": 0.82, + "y": 0.52 + } + }, + "settings": { + "label": "settings", + "color": "#9AA7B8", + "anchor": { + "x": 0.18, + "y": 0.8 + } + }, + "tangempay": { + "label": "tangem pay", + "color": "#F2C14E", + "anchor": { + "x": 0.5, + "y": 0.8 + } + }, + "misc": { + "label": "misc", + "color": "#C7CFDA", + "anchor": { + "x": 0.82, + "y": 0.8 + } + } + }, + "groupOrder": [ + "shell", + "entry", + "onboarding", + "wallet", + "portfolio", + "tokenaction", + "markets", + "settings", + "tangempay", + "misc" + ], + "defaultGroup": "misc", + "defaultOwner": "app", + "screenGroups": { + "Initial": "entry", + "Home": "entry", + "Welcome": "entry", + "Disclaimer": "entry", + "CreateWalletSelection": "onboarding", + "CreateWalletStart": "onboarding", + "CreateHardwareWallet": "onboarding", + "CreateMobileWallet": "onboarding", + "AddExistingWallet": "onboarding", + "Onboarding": "onboarding", + "CreateWalletBackup": "onboarding", + "WalletBackup": "onboarding", + "WalletHardwareBackup": "onboarding", + "UpgradeWallet": "onboarding", + "WalletActivation": "onboarding", + "AccessCodeRecovery": "onboarding", + "UpdateAccessCode": "onboarding", + "ViewPhrase": "onboarding", + "Wallet": "wallet", + "Stories": "wallet", + "NFT": "wallet", + "NFTSend": "wallet", + "PushNotification": "wallet", + "PushNotificationSettings": "wallet", + "CurrencyDetails": "portfolio", + "ManageTokens": "portfolio", + "ChooseManagedTokens": "portfolio", + "CreateAccount": "portfolio", + "EditAccount": "portfolio", + "AccountDetails": "portfolio", + "ArchivedAccountList": "portfolio", + "Send": "tokenaction", + "SendEntryPoint": "tokenaction", + "Swap": "tokenaction", + "SwapCrypto": "tokenaction", + "Onramp": "tokenaction", + "OnrampSuccess": "tokenaction", + "BuyCrypto": "tokenaction", + "SellCrypto": "tokenaction", + "Staking": "tokenaction", + "YieldSupplyEntry": "tokenaction", + "Earn": "markets", + "Markets": "markets", + "MarketsTokenDetails": "markets", + "News": "markets", + "NewsDetails": "markets", + "Details": "settings", + "DetailsSecurity": "settings", + "CardSettings": "settings", + "AppSettings": "settings", + "ResetToFactory": "settings", + "WalletSettings": "settings", + "ForgetWallet": "settings", + "AppCurrencySelector": "settings", + "ReferralProgram": "settings", + "AddressBook": "settings", + "WalletConnectSessions": "settings", + "TangemPayDetails": "tangempay", + "TangemPayHotWalletOnboarding": "tangempay", + "TangemPayOnboarding": "tangempay", + "Kyc": "tangempay", + "QrScanning": "misc", + "Usedesk": "misc", + "Survey": "misc" + }, + "screenOwners": { + "Initial": "app", + "Home": "features:home", + "Welcome": "features:welcome", + "Disclaimer": "features:disclaimer", + "Wallet": "features:wallet", + "CurrencyDetails": "features:tokendetails", + "Send": "features:send", + "Details": "features:details", + "DetailsSecurity": "features:details", + "Usedesk": "features:usedesk", + "CardSettings": "features:details", + "AppSettings": "features:details", + "ResetToFactory": "features:details", + "AccessCodeRecovery": "features:onboarding-v2", + "ManageTokens": "features:manage-tokens", + "ChooseManagedTokens": "features:manage-tokens", + "WalletConnectSessions": "features:walletconnect", + "AddressBook": "features:address-book", + "QrScanning": "features:qr-scanning", + "ReferralProgram": "features:referral", + "Swap": "features:swap", + "AppCurrencySelector": "features:wallet-settings", + "Staking": "features:staking", + "PushNotification": "features:push-notifications", + "WalletSettings": "features:wallet-settings", + "PushNotificationSettings": "features:push-notification-settings", + "WalletBackup": "features:onboarding-v2", + "WalletHardwareBackup": "features:onboarding-v2", + "Markets": "features:markets", + "MarketsTokenDetails": "features:markets", + "Onramp": "features:onramp", + "OnrampSuccess": "features:onramp", + "BuyCrypto": "features:onramp", + "SellCrypto": "features:onramp", + "SwapCrypto": "features:swap-v2", + "Onboarding": "features:onboarding-v2", + "Stories": "features:stories", + "NFT": "features:nft", + "NFTSend": "features:nft", + "CreateWalletSelection": "features:create-wallet-selection", + "CreateWalletStart": "features:create-wallet-start", + "CreateHardwareWallet": "features:onboarding-v2", + "CreateMobileWallet": "features:hot-wallet", + "UpgradeWallet": "features:hot-wallet", + "AddExistingWallet": "features:onboarding-v2", + "WalletActivation": "features:tangempay", + "CreateWalletBackup": "features:onboarding-v2", + "UpdateAccessCode": "features:onboarding-v2", + "ViewPhrase": "features:onboarding-v2", + "ForgetWallet": "features:wallet-settings", + "SendEntryPoint": "features:send", + "CreateAccount": "features:account", + "EditAccount": "features:account", + "AccountDetails": "features:account", + "ArchivedAccountList": "features:account", + "TangemPayDetails": "features:tangempay", + "TangemPayHotWalletOnboarding": "features:tangempay", + "TangemPayOnboarding": "features:tangempay", + "Kyc": "features:kyc", + "Survey": "features:survey", + "YieldSupplyEntry": "features:yield-supply", + "NewsDetails": "features:feed", + "News": "features:feed", + "Earn": "features:feed" + }, + "teams": [ + { + "id": "grow", + "name": "Grow", + "color": "#FF6FD8", + "roots": [ + "features:approval", + "features:onramp", + "features:send", + "features:staking", + "features:swap", + "features:swap-v2", + "features:yield-supply", + "domain:express", + "domain:offramp", + "domain:onramp", + "domain:staking", + "domain:swap", + "domain:yield-supply", + "domain:transaction", + "data:express", + "data:onramp", + "data:staking", + "data:swap", + "data:yield-supply" + ], + "screens": [ + "Send", + "Swap", + "Staking", + "Onramp", + "OnrampSuccess", + "BuyCrypto", + "SellCrypto", + "SwapCrypto", + "NFTSend", + "SendEntryPoint", + "YieldSupplyEntry" + ] + } + ] +} +``` + + +### Graph data (auto — overwritten every refresh; do not hand-edit) + + +```json +{"n":[["data:account","account","data",0,9],["data:address-book","address-book","data",0,3],["data:analytics","analytics","data",0,4],["data:app-currency","app-currency","data",0,3],["data:app-theme","app-theme","data",0,1],["data:appsflyer","appsflyer","data",0,1],["data:assetsdiscovery","assetsdiscovery","data",0,7],["data:balance-hiding","balance-hiding","data",0,1],["data:blockaid","blockaid","data",0,3],["data:card","card","data",2,2],["data:common","common","data",31,10],["data:dynamic-addresses","dynamic-addresses","data",1,6],["data:earn","earn","data",0,4],["data:express","express","data",1,6],["data:feedback","feedback","data",0,6],["data:hot-wallet","hot-wallet","data",0,2],["data:manage-tokens","manage-tokens","data",0,10],["data:markets","markets","data",0,5],["data:networks","networks","data",1,8],["data:news","news","data",0,2],["data:nft","nft","data",0,10],["data:notifications","notifications","data",0,1],["data:onboarding","onboarding","data",0,2],["data:onramp","onramp","data",0,10],["data:payment","payment","data",0,7],["data:push-notification-preferences","push-notification-preferences","data",0,2],["data:qr-scanning","qr-scanning","data",0,3],["data:quotes","quotes","data",0,3],["data:search","search","data",0,7],["data:settings","settings","data",0,2],["data:staking","staking","data",0,9],["data:stories","stories","data",0,4],["data:swap","swap","data",0,12],["data:tokens","tokens","data",1,17],["data:transaction","transaction","data",0,8],["data:txhistory","txhistory","data",0,10],["data:visa","visa","data",1,14],["data:wallet-connect","wallet-connect","data",0,10],["data:wallet-manager","wallet-manager","data",1,8],["data:wallets","wallets","data",2,8],["data:yield-supply","yield-supply","data",0,4],["domain:account","account","domain",43,15],["domain:address-book","address-book","domain",2,4],["domain:analytics","analytics","domain",2,3],["domain:app-currency","app-currency","domain",27,1],["domain:app-theme","app-theme","domain",4,1],["domain:appsflyer","appsflyer","domain",2,0],["domain:assetsdiscovery","assetsdiscovery","domain",4,3],["domain:balance-hiding","balance-hiding","domain",21,2],["domain:blockaid","blockaid","domain",5,2],["domain:card","card","domain",41,8],["domain:common","common","domain",24,1],["domain:core","core","domain",39,0],["domain:demo","demo","domain",18,0],["domain:dynamic-addresses","dynamic-addresses","domain",5,4],["domain:earn","earn","domain",2,4],["domain:express","express","domain",12,2],["domain:feedback","feedback","domain",16,3],["domain:hot-wallet","hot-wallet","domain",7,3],["domain:legacy","legacy","domain",37,7],["domain:manage-tokens","manage-tokens","domain",7,10],["domain:markets","markets","domain",12,13],["domain:models","models","domain",103,1],["domain:networks","networks","domain",12,3],["domain:news","news","domain",2,2],["domain:nft","nft","domain",6,7],["domain:notifications","notifications","domain",15,4],["domain:offramp","offramp","domain",6,2],["domain:onboarding","onboarding","domain",2,1],["domain:onramp","onramp","domain",9,6],["domain:payment","payment","domain",1,1],["domain:push-notification-preferences","push-notification-preferences","domain",4,1],["domain:qr-scanning","qr-scanning","domain",6,5],["domain:quotes","quotes","domain",12,2],["domain:referral","referral","domain",3,0],["domain:search","search","domain",2,7],["domain:settings","settings","domain",27,2],["domain:staking","staking","domain",15,6],["domain:stories","stories","domain",9,3],["domain:swap","swap","domain",5,4],["domain:tokens","tokens","domain",58,23],["domain:transaction","transaction","domain",26,12],["domain:txhistory","txhistory","domain",16,6],["domain:visa","visa","domain",13,5],["domain:wallet-connect","wallet-connect","domain",4,6],["domain:wallet-manager","wallet-manager","domain",25,8],["domain:wallets","wallets","domain",75,12],["domain:yield-supply","yield-supply","domain",9,8],["features:account","account","features",3,8],["features:address-book","address-book","features",1,3],["features:approval","approval","features",2,4],["features:biometry","biometry","features",2,5],["features:common-features","common-features","features",10,12],["features:create-wallet-selection","create-wallet-selection","features",1,6],["features:create-wallet-start","create-wallet-start","features",0,7],["features:details","details","features",1,17],["features:disclaimer","disclaimer","features",1,5],["features:feed","feed","features",3,30],["features:home","home","features",0,12],["features:hot-wallet","hot-wallet","features",9,9],["features:kyc","kyc","features",2,3],["features:manage-tokens","manage-tokens","features",5,13],["features:markets","markets","features",3,24],["features:nft","nft","features",4,9],["features:onboarding-v2","onboarding-v2","features",6,19],["features:onramp","onramp","features",4,18],["features:promo-banners","promo-banners","features",2,2],["features:push-notification-settings","push-notification-settings","features",3,5],["features:push-notifications","push-notifications","features",8,7],["features:qr-scanning","qr-scanning","features",0,2],["features:rating","rating","features",1,0],["features:referral","referral","features",2,16],["features:send","send","features",11,22],["features:staking","staking","features",3,15],["features:stories","stories","features",0,1],["features:survey","survey","features",1,3],["features:swap","swap","features",5,29],["features:swap-v2","swap-v2","features",2,19],["features:tangempay","tangempay","features",1,15],["features:tester","tester","features",3,10],["features:token-recieve","token-recieve","features",8,3],["features:tokendetails","tokendetails","features",3,33],["features:txhistory","txhistory","features",4,8],["features:virtual-accounts","virtual-accounts","features",3,0],["features:wallet","wallet","features",12,56],["features:wallet-settings","wallet-settings","features",2,19],["features:walletconnect","walletconnect","features",0,15],["features:welcome","welcome","features",0,8],["features:yield-supply","yield-supply","features",2,10]],"e":[["features:home","features:hot-wallet",1,0],["features:home","domain:common",1,0],["features:home","domain:models",1,0],["features:home","domain:core",1,0],["features:home","domain:card",1,0],["features:home","domain:settings",1,0],["features:home","domain:tokens",1,0],["features:home","domain:wallets",2,0],["features:home","domain:legacy",1,0],["features:home","domain:feedback",2,0],["features:home","domain:referral",1,0],["features:home","features:referral",1,0],["features:create-wallet-start","features:hot-wallet",1,0],["features:create-wallet-start","features:onboarding-v2",1,0],["features:create-wallet-start","domain:card",1,0],["features:create-wallet-start","domain:settings",1,0],["features:create-wallet-start","domain:wallets",2,0],["features:create-wallet-start","domain:models",2,0],["features:create-wallet-start","domain:hot-wallet",1,0],["features:yield-supply","domain:models",2,0],["features:yield-supply","domain:app-currency",3,0],["features:yield-supply","domain:account",1,0],["features:yield-supply","domain:wallets",3,0],["features:yield-supply","domain:tokens",3,0],["features:yield-supply","domain:transaction",2,0],["features:yield-supply","domain:yield-supply",2,0],["features:yield-supply","domain:stories",2,0],["features:yield-supply","domain:feedback",2,0],["features:yield-supply","domain:balance-hiding",2,0],["features:txhistory","domain:models",2,1],["features:txhistory","domain:legacy",1,0],["features:txhistory","domain:card",1,0],["features:txhistory","domain:txhistory",2,0],["features:txhistory","domain:wallets",3,0],["features:txhistory","domain:tokens",3,0],["features:txhistory","domain:balance-hiding",2,0],["features:txhistory","domain:account",1,0],["features:referral","features:common-features",1,1],["features:referral","domain:demo",1,0],["features:referral","domain:wallets",5,0],["features:referral","domain:legacy",2,0],["features:referral","domain:card",2,0],["features:referral","domain:notifications",1,0],["features:referral","domain:account",3,0],["features:referral","domain:balance-hiding",2,0],["features:referral","domain:app-currency",2,0],["features:referral","domain:models",3,1],["features:referral","data:common",1,0],["features:referral","domain:common",2,0],["features:referral","domain:tokens",3,0],["features:referral","domain:referral",1,0],["features:referral","features:tester",1,0],["features:referral","features:wallet",1,0],["features:wallet-settings","features:manage-tokens",1,0],["features:wallet-settings","features:nft",1,0],["features:wallet-settings","features:onboarding-v2",1,0],["features:wallet-settings","features:push-notifications",1,0],["features:wallet-settings","features:push-notification-settings",1,0],["features:wallet-settings","features:hot-wallet",1,0],["features:wallet-settings","features:wallet",1,0],["features:wallet-settings","domain:account",1,0],["features:wallet-settings","domain:app-currency",2,0],["features:wallet-settings","domain:balance-hiding",2,0],["features:wallet-settings","domain:legacy",1,0],["features:wallet-settings","domain:card",1,0],["features:wallet-settings","domain:models",2,0],["features:wallet-settings","domain:wallets",2,0],["features:wallet-settings","domain:demo",1,0],["features:wallet-settings","domain:nft",1,0],["features:wallet-settings","domain:settings",1,0],["features:wallet-settings","domain:notifications",2,0],["features:wallet-settings","domain:assetsdiscovery",1,0],["features:token-recieve","domain:models",2,0],["features:token-recieve","domain:transaction",2,0],["features:token-recieve","domain:tokens",2,0],["features:kyc","domain:visa",1,0],["features:kyc","domain:wallets",1,0],["features:kyc","domain:models",1,0],["features:disclaimer","domain:models",1,0],["features:disclaimer","domain:card",1,0],["features:disclaimer","domain:settings",1,0],["features:disclaimer","domain:notifications",1,0],["features:disclaimer","features:push-notifications",1,0],["features:nft","features:common-features",1,0],["features:nft","features:token-recieve",1,0],["features:nft","domain:account",2,0],["features:nft","domain:wallets",3,0],["features:nft","domain:app-currency",2,0],["features:nft","domain:models",2,0],["features:nft","domain:nft",3,0],["features:nft","domain:tokens",2,0],["features:nft","domain:transaction",1,0],["features:tokendetails","features:rating",1,0],["features:tokendetails","domain:account",1,0],["features:tokendetails","domain:app-currency",2,0],["features:tokendetails","domain:balance-hiding",2,0],["features:tokendetails","domain:card",1,0],["features:tokendetails","domain:demo",1,0],["features:tokendetails","domain:dynamic-addresses",2,0],["features:tokendetails","domain:feedback",2,0],["features:tokendetails","domain:markets",1,0],["features:tokendetails","domain:models",2,1],["features:tokendetails","domain:notifications",1,0],["features:tokendetails","domain:offramp",1,0],["features:tokendetails","domain:onramp",2,0],["features:tokendetails","domain:stories",2,0],["features:tokendetails","domain:quotes",1,0],["features:tokendetails","domain:settings",1,0],["features:tokendetails","domain:staking",1,0],["features:tokendetails","domain:tokens",3,0],["features:tokendetails","domain:transaction",2,0],["features:tokendetails","domain:txhistory",2,0],["features:tokendetails","domain:wallets",3,0],["features:tokendetails","domain:yield-supply",2,0],["features:tokendetails","features:swap",4,0],["features:tokendetails","features:wallet",1,0],["features:tokendetails","features:staking",1,0],["features:tokendetails","features:markets",1,0],["features:tokendetails","features:onramp",1,0],["features:tokendetails","features:push-notifications",1,0],["features:tokendetails","features:txhistory",1,0],["features:tokendetails","features:send",1,0],["features:tokendetails","features:token-recieve",1,0],["features:tokendetails","features:yield-supply",1,0],["features:tokendetails","features:common-features",1,0],["features:qr-scanning","domain:qr-scanning",3,0],["features:qr-scanning","data:card",1,0],["features:swap","features:common-features",1,0],["features:swap","data:common",2,0],["features:swap","domain:models",5,1],["features:swap","domain:account",6,0],["features:swap","domain:app-currency",4,0],["features:swap","domain:balance-hiding",3,0],["features:swap","domain:tokens",8,0],["features:swap","domain:transaction",6,0],["features:swap","domain:wallets",8,0],["features:swap","domain:settings",1,0],["features:swap","domain:staking",2,0],["features:swap","domain:feedback",2,0],["features:swap","domain:stories",2,0],["features:swap","domain:txhistory",4,0],["features:swap","domain:express",4,0],["features:swap","domain:card",2,0],["features:swap","domain:visa",3,0],["features:swap","domain:markets",1,0],["features:swap","domain:swap",4,0],["features:swap","features:wallet",2,0],["features:swap","features:send",3,0],["features:swap","features:feed",1,0],["features:swap","features:tokendetails",1,0],["features:swap","features:approval",1,0],["features:swap","domain:legacy",2,0],["features:swap","domain:wallet-manager",1,0],["features:swap","domain:demo",1,0],["features:swap","domain:quotes",1,0],["features:swap","domain:yield-supply",1,0],["features:details","features:wallet",1,0],["features:details","features:disclaimer",1,0],["features:details","features:tester",1,0],["features:details","features:create-wallet-selection",1,0],["features:details","features:onboarding-v2",1,0],["features:details","features:address-book",1,0],["features:details","domain:models",2,0],["features:details","domain:feedback",2,0],["features:details","domain:wallets",2,0],["features:details","domain:card",1,0],["features:details","domain:tokens",2,0],["features:details","domain:app-currency",2,0],["features:details","domain:wallet-connect",1,0],["features:details","domain:balance-hiding",2,0],["features:details","domain:legacy",1,0],["features:details","domain:settings",1,0],["features:details","domain:visa",1,0],["features:create-wallet-selection","features:hot-wallet",1,0],["features:create-wallet-selection","domain:card",1,0],["features:create-wallet-selection","domain:settings",1,0],["features:create-wallet-selection","domain:wallets",1,0],["features:create-wallet-selection","domain:models",2,0],["features:create-wallet-selection","domain:hot-wallet",1,0],["features:welcome","features:wallet",1,0],["features:welcome","features:onboarding-v2",1,0],["features:welcome","domain:app-currency",2,0],["features:welcome","domain:models",1,0],["features:welcome","domain:tokens",1,0],["features:welcome","domain:wallets",3,0],["features:welcome","domain:card",1,0],["features:welcome","domain:settings",1,0],["features:common-features","features:wallet",1,0],["features:common-features","features:token-recieve",1,0],["features:common-features","domain:models",2,0],["features:common-features","domain:account",3,0],["features:common-features","domain:core",1,0],["features:common-features","domain:app-currency",2,0],["features:common-features","domain:markets",2,0],["features:common-features","domain:transaction",1,0],["features:common-features","domain:tokens",2,0],["features:common-features","domain:manage-tokens",2,0],["features:common-features","domain:balance-hiding",2,0],["features:common-features","domain:wallets",2,0],["features:onramp","features:common-features",1,0],["features:onramp","features:swap",4,0],["features:onramp","features:feed",1,0],["features:onramp","domain:app-currency",2,0],["features:onramp","domain:balance-hiding",2,0],["features:onramp","domain:card",1,0],["features:onramp","domain:demo",1,0],["features:onramp","domain:models",1,0],["features:onramp","domain:offramp",1,0],["features:onramp","domain:onramp",2,0],["features:onramp","domain:tokens",3,0],["features:onramp","domain:wallets",3,0],["features:onramp","domain:settings",1,0],["features:onramp","domain:transaction",1,0],["features:onramp","domain:account",1,0],["features:onramp","domain:app-theme",2,0],["features:onramp","data:common",1,0],["features:onramp","domain:markets",1,0],["features:walletconnect","features:common-features",1,0],["features:walletconnect","features:wallet",1,0],["features:walletconnect","features:send",1,0],["features:walletconnect","domain:account",2,0],["features:walletconnect","domain:app-currency",2,0],["features:walletconnect","domain:balance-hiding",2,0],["features:walletconnect","domain:blockaid",1,0],["features:walletconnect","domain:models",2,0],["features:walletconnect","domain:qr-scanning",2,0],["features:walletconnect","domain:tokens",2,0],["features:walletconnect","domain:transaction",2,0],["features:walletconnect","domain:wallets",2,0],["features:walletconnect","domain:wallet-connect",2,0],["features:walletconnect","domain:legacy",1,0],["features:walletconnect","data:card",1,0],["features:stories","domain:stories",2,0],["features:onboarding-v2","features:manage-tokens",1,0],["features:onboarding-v2","features:biometry",1,0],["features:onboarding-v2","features:push-notifications",1,0],["features:onboarding-v2","features:hot-wallet",1,0],["features:onboarding-v2","features:token-recieve",1,0],["features:onboarding-v2","domain:account",1,0],["features:onboarding-v2","domain:models",2,0],["features:onboarding-v2","domain:feedback",2,0],["features:onboarding-v2","domain:core",1,0],["features:onboarding-v2","domain:card",1,0],["features:onboarding-v2","domain:wallets",2,0],["features:onboarding-v2","domain:legacy",1,0],["features:onboarding-v2","domain:settings",1,0],["features:onboarding-v2","domain:onboarding",1,0],["features:onboarding-v2","domain:visa",1,0],["features:onboarding-v2","domain:tokens",2,0],["features:onboarding-v2","domain:onramp",1,0],["features:onboarding-v2","domain:transaction",1,0],["features:onboarding-v2","domain:staking",1,0],["features:promo-banners","domain:common",1,0],["features:promo-banners","domain:models",1,0],["features:push-notifications","domain:settings",1,0],["features:push-notifications","domain:notifications",1,0],["features:push-notifications","domain:push-notification-preferences",1,0],["features:push-notifications","domain:common",1,0],["features:push-notifications","domain:account",1,0],["features:push-notifications","domain:models",1,0],["features:push-notifications","features:push-notification-settings",1,0],["features:swap-v2","features:manage-tokens",1,0],["features:swap-v2","features:send",2,1],["features:swap-v2","features:common-features",1,0],["features:swap-v2","domain:models",2,0],["features:swap-v2","domain:wallets",3,0],["features:swap-v2","domain:tokens",3,0],["features:swap-v2","domain:card",1,0],["features:swap-v2","domain:app-currency",3,0],["features:swap-v2","domain:express",2,0],["features:swap-v2","domain:swap",3,0],["features:swap-v2","domain:manage-tokens",3,0],["features:swap-v2","domain:transaction",2,0],["features:swap-v2","domain:legacy",1,0],["features:swap-v2","domain:balance-hiding",2,0],["features:swap-v2","domain:settings",1,0],["features:swap-v2","domain:txhistory",2,0],["features:swap-v2","domain:notifications",1,0],["features:swap-v2","domain:feedback",2,0],["features:swap-v2","domain:account",2,0],["features:manage-tokens","features:swap-v2",1,0],["features:manage-tokens","features:common-features",1,0],["features:manage-tokens","domain:account",2,0],["features:manage-tokens","domain:card",1,0],["features:manage-tokens","domain:legacy",1,0],["features:manage-tokens","domain:manage-tokens",2,0],["features:manage-tokens","domain:tokens",2,0],["features:manage-tokens","domain:wallets",3,0],["features:manage-tokens","domain:swap",1,0],["features:manage-tokens","domain:markets",1,0],["features:manage-tokens","domain:notifications",1,0],["features:manage-tokens","domain:dynamic-addresses",1,0],["features:manage-tokens","domain:models",1,0],["features:markets","features:onramp",1,1],["features:markets","features:send",1,1],["features:markets","features:token-recieve",1,1],["features:markets","features:wallet",1,1],["features:markets","features:account",1,1],["features:markets","data:common",1,0],["features:markets","domain:account",2,0],["features:markets","domain:app-currency",3,0],["features:markets","domain:balance-hiding",2,0],["features:markets","domain:card",1,0],["features:markets","domain:demo",1,0],["features:markets","domain:feedback",2,0],["features:markets","domain:manage-tokens",1,0],["features:markets","domain:markets",2,0],["features:markets","domain:offramp",1,0],["features:markets","domain:onramp",1,0],["features:markets","domain:staking",2,0],["features:markets","domain:tokens",3,0],["features:markets","domain:wallets",2,0],["features:markets","domain:settings",1,0],["features:markets","domain:notifications",1,0],["features:markets","domain:transaction",1,0],["features:markets","domain:yield-supply",2,0],["features:markets","domain:core",1,0],["features:feed","features:onramp",1,1],["features:feed","features:send",1,1],["features:feed","features:token-recieve",1,1],["features:feed","features:wallet",1,1],["features:feed","features:account",2,1],["features:feed","features:common-features",1,1],["features:feed","features:promo-banners",1,0],["features:feed","data:common",1,0],["features:feed","domain:account",2,0],["features:feed","domain:app-currency",3,0],["features:feed","domain:balance-hiding",2,0],["features:feed","domain:card",1,0],["features:feed","domain:demo",1,0],["features:feed","domain:feedback",2,0],["features:feed","domain:manage-tokens",1,0],["features:feed","domain:markets",2,0],["features:feed","domain:offramp",1,0],["features:feed","domain:onramp",1,0],["features:feed","domain:staking",1,0],["features:feed","domain:tokens",3,0],["features:feed","domain:wallets",2,0],["features:feed","domain:settings",1,0],["features:feed","domain:notifications",1,0],["features:feed","domain:transaction",1,0],["features:feed","domain:news",1,0],["features:feed","domain:yield-supply",2,0],["features:feed","domain:earn",1,0],["features:feed","domain:search",1,0],["features:feed","domain:core",1,0],["features:feed","domain:models",1,0],["features:staking","domain:tokens",3,0],["features:staking","domain:wallets",3,0],["features:staking","domain:staking",2,0],["features:staking","domain:balance-hiding",2,0],["features:staking","domain:app-currency",2,0],["features:staking","domain:legacy",1,0],["features:staking","domain:models",2,1],["features:staking","domain:transaction",2,0],["features:staking","domain:txhistory",2,0],["features:staking","domain:feedback",2,0],["features:staking","domain:notifications",1,0],["features:staking","domain:account",2,0],["features:staking","features:send",1,0],["features:staking","features:txhistory",1,0],["features:staking","features:approval",1,0],["features:address-book","domain:account",1,0],["features:address-book","domain:address-book",1,0],["features:address-book","domain:models",2,0],["features:wallet","domain:account",2,0],["features:wallet","domain:analytics",1,0],["features:wallet","domain:app-currency",2,0],["features:wallet","domain:balance-hiding",2,0],["features:wallet","domain:card",1,0],["features:wallet","domain:wallet-manager",1,0],["features:wallet","domain:demo",1,0],["features:wallet","domain:feedback",2,0],["features:wallet","domain:legacy",1,0],["features:wallet","domain:markets",1,0],["features:wallet","domain:models",2,0],["features:wallet","domain:networks",1,0],["features:wallet","domain:qr-scanning",2,0],["features:wallet","domain:wallet-connect",2,0],["features:wallet","domain:nft",2,0],["features:wallet","domain:hot-wallet",1,0],["features:wallet","domain:offramp",1,0],["features:wallet","domain:onramp",2,0],["features:wallet","domain:stories",2,0],["features:wallet","domain:quotes",1,0],["features:wallet","domain:settings",1,0],["features:wallet","domain:staking",2,0],["features:wallet","domain:tokens",2,0],["features:wallet","domain:txhistory",2,0],["features:wallet","domain:visa",2,0],["features:wallet","domain:wallets",2,0],["features:wallet","domain:notifications",1,0],["features:wallet","domain:push-notification-preferences",1,0],["features:wallet","domain:transaction",1,0],["features:wallet","domain:yield-supply",2,0],["features:wallet","domain:app-theme",2,0],["features:wallet","domain:assetsdiscovery",1,0],["features:wallet","features:common-features",1,0],["features:wallet","features:account",1,0],["features:wallet","features:details",1,0],["features:wallet","features:hot-wallet",1,0],["features:wallet","features:manage-tokens",1,0],["features:wallet","features:markets",1,0],["features:wallet","features:onboarding-v2",1,0],["features:wallet","features:onramp",1,0],["features:wallet","features:push-notifications",1,0],["features:wallet","features:push-notification-settings",1,0],["features:wallet","features:swap",1,0],["features:wallet","features:tester",1,0],["features:wallet","features:tokendetails",1,0],["features:wallet","features:wallet-settings",1,0],["features:wallet","features:biometry",1,0],["features:wallet","features:nft",1,0],["features:wallet","features:send",1,0],["features:wallet","features:kyc",1,0],["features:wallet","features:token-recieve",1,0],["features:wallet","features:yield-supply",1,0],["features:wallet","features:feed",1,0],["features:wallet","features:promo-banners",1,0],["features:wallet","features:tangempay",2,0],["features:wallet","features:virtual-accounts",1,0],["features:tester","domain:account",1,0],["features:tester","domain:card",1,0],["features:tester","domain:feedback",2,0],["features:tester","domain:markets",2,0],["features:tester","domain:manage-tokens",2,0],["features:tester","domain:wallets",2,0],["features:tester","domain:settings",1,0],["features:tester","data:common",1,0],["features:tester","features:push-notifications",1,0],["features:tester","features:survey",1,0],["features:biometry","features:hot-wallet",1,0],["features:biometry","domain:wallets",1,0],["features:biometry","domain:models",1,1],["features:biometry","domain:settings",1,0],["features:biometry","domain:card",1,0],["features:account","features:wallet",1,0],["features:account","domain:models",2,0],["features:account","domain:account",3,0],["features:account","domain:core",2,0],["features:account","domain:app-currency",3,0],["features:account","domain:tokens",4,0],["features:account","domain:balance-hiding",2,0],["features:account","domain:wallets",2,0],["features:hot-wallet","features:onboarding-v2",1,0],["features:hot-wallet","features:push-notifications",1,0],["features:hot-wallet","domain:card",1,0],["features:hot-wallet","domain:models",3,0],["features:hot-wallet","domain:wallets",4,0],["features:hot-wallet","domain:settings",1,0],["features:hot-wallet","domain:feedback",2,0],["features:hot-wallet","domain:hot-wallet",1,0],["features:hot-wallet","domain:assetsdiscovery",1,0],["features:survey","domain:common",1,0],["features:survey","domain:models",1,0],["features:survey","domain:wallets",2,0],["features:send","features:txhistory",1,0],["features:send","features:nft",1,0],["features:send","features:swap-v2",1,0],["features:send","features:manage-tokens",1,0],["features:send","domain:models",2,1],["features:send","domain:legacy",1,0],["features:send","domain:offramp",1,0],["features:send","domain:card",1,0],["features:send","domain:tokens",3,0],["features:send","domain:wallets",3,0],["features:send","domain:app-currency",3,0],["features:send","domain:transaction",5,0],["features:send","domain:txhistory",3,0],["features:send","domain:qr-scanning",2,0],["features:send","domain:settings",1,0],["features:send","domain:feedback",2,0],["features:send","domain:balance-hiding",2,0],["features:send","domain:nft",3,0],["features:send","domain:notifications",1,0],["features:send","domain:swap",1,0],["features:send","domain:account",2,0],["features:send","domain:staking",1,0],["features:tangempay","features:token-recieve",1,0],["features:tangempay","features:txhistory",1,0],["features:tangempay","features:tokendetails",1,0],["features:tangempay","domain:balance-hiding",2,0],["features:tangempay","domain:feedback",2,0],["features:tangempay","domain:models",3,0],["features:tangempay","domain:onramp",1,0],["features:tangempay","domain:visa",4,0],["features:tangempay","domain:wallets",3,0],["features:tangempay","features:kyc",1,0],["features:tangempay","features:wallet",1,0],["features:tangempay","features:hot-wallet",1,0],["features:tangempay","domain:appsflyer",1,0],["features:tangempay","domain:hot-wallet",1,0],["features:tangempay","data:visa",1,0],["features:approval","features:send",1,0],["features:approval","domain:models",2,0],["features:approval","domain:wallets",3,0],["features:approval","domain:transaction",2,0],["features:push-notification-settings","features:push-notifications",1,0],["features:push-notification-settings","features:wallet-settings",1,0],["features:push-notification-settings","domain:models",2,0],["features:push-notification-settings","domain:account",1,0],["features:push-notification-settings","domain:push-notification-preferences",1,0],["data:transaction","data:common",1,0],["data:transaction","domain:legacy",1,0],["data:transaction","domain:wallet-manager",1,0],["data:transaction","domain:wallets",1,0],["data:transaction","domain:tokens",1,0],["data:transaction","domain:transaction",2,0],["data:transaction","domain:demo",1,0],["data:transaction","features:send",1,0],["data:settings","domain:balance-hiding",1,0],["data:settings","domain:settings",1,0],["data:dynamic-addresses","data:common",1,0],["data:dynamic-addresses","domain:account",1,0],["data:dynamic-addresses","domain:common",1,0],["data:dynamic-addresses","domain:dynamic-addresses",2,0],["data:dynamic-addresses","domain:models",1,0],["data:dynamic-addresses","domain:wallet-manager",1,0],["data:app-theme","domain:app-theme",2,0],["data:yield-supply","domain:yield-supply",2,0],["data:yield-supply","domain:wallet-manager",1,0],["data:yield-supply","domain:legacy",1,0],["data:yield-supply","domain:txhistory",1,0],["data:txhistory","data:common",1,0],["data:txhistory","domain:legacy",1,0],["data:txhistory","domain:common",1,0],["data:txhistory","domain:wallet-manager",1,0],["data:txhistory","domain:models",1,0],["data:txhistory","domain:tokens",1,0],["data:txhistory","domain:txhistory",2,0],["data:txhistory","domain:express",1,0],["data:txhistory","domain:wallets",2,0],["data:txhistory","domain:account",2,0],["data:push-notification-preferences","domain:push-notification-preferences",1,0],["data:push-notification-preferences","domain:models",1,0],["data:card","domain:card",1,0],["data:card","domain:models",1,0],["data:nft","data:common",1,0],["data:nft","domain:card",1,0],["data:nft","domain:common",1,0],["data:nft","domain:models",1,0],["data:nft","domain:nft",2,0],["data:nft","domain:tokens",1,0],["data:nft","domain:wallet-manager",1,0],["data:nft","domain:wallets",1,0],["data:nft","domain:legacy",1,0],["data:nft","features:nft",1,0],["data:quotes","data:common",1,0],["data:quotes","domain:models",1,1],["data:quotes","domain:quotes",1,1],["data:wallet-manager","domain:wallets",2,0],["data:wallet-manager","domain:wallet-manager",1,0],["data:wallet-manager","domain:demo",1,0],["data:wallet-manager","domain:card",1,0],["data:wallet-manager","domain:transaction",2,0],["data:wallet-manager","domain:models",1,1],["data:wallet-manager","domain:tokens",1,0],["data:wallet-manager","domain:txhistory",1,0],["data:express","data:common",1,0],["data:express","domain:common",1,0],["data:express","domain:express",2,0],["data:express","domain:wallets",1,0],["data:express","domain:txhistory",1,0],["data:express","domain:models",1,1],["data:payment","data:common",1,0],["data:payment","data:wallets",1,0],["data:payment","domain:payment",2,0],["data:payment","domain:wallets",1,0],["data:payment","domain:models",1,0],["data:payment","domain:common",1,0],["data:payment","domain:legacy",1,0],["data:qr-scanning","domain:models",1,0],["data:qr-scanning","domain:qr-scanning",2,0],["data:qr-scanning","domain:tokens",1,0],["data:blockaid","data:common",1,0],["data:blockaid","domain:models",1,0],["data:blockaid","domain:blockaid",2,0],["data:app-currency","domain:core",1,0],["data:app-currency","domain:app-currency",2,0],["data:app-currency","data:common",1,0],["data:swap","data:common",1,0],["data:swap","data:express",1,0],["data:swap","domain:express",2,0],["data:swap","domain:swap",2,0],["data:swap","domain:wallets",2,0],["data:swap","domain:tokens",2,0],["data:swap","domain:legacy",1,0],["data:swap","domain:models",1,0],["data:swap","domain:quotes",1,0],["data:swap","domain:networks",1,0],["data:swap","domain:staking",2,0],["data:swap","domain:account",1,0],["data:earn","data:common",1,0],["data:earn","domain:earn",1,0],["data:earn","domain:common",1,0],["data:earn","domain:account",1,0],["data:wallet-connect","domain:account",2,0],["data:wallet-connect","domain:wallet-connect",2,0],["data:wallet-connect","domain:transaction",2,0],["data:wallet-connect","domain:wallets",2,0],["data:wallet-connect","domain:tokens",2,0],["data:wallet-connect","domain:models",1,0],["data:wallet-connect","domain:legacy",1,0],["data:wallet-connect","domain:wallet-manager",1,0],["data:wallet-connect","data:common",1,0],["data:wallet-connect","domain:blockaid",2,0],["data:visa","data:common",1,0],["data:visa","data:wallets",1,0],["data:visa","domain:visa",1,0],["data:visa","domain:card",1,0],["data:visa","domain:wallets",2,0],["data:visa","domain:legacy",2,0],["data:visa","domain:models",1,0],["data:visa","domain:app-currency",1,0],["data:visa","domain:tokens",2,0],["data:visa","domain:networks",1,0],["data:visa","domain:wallet-manager",1,0],["data:visa","domain:quotes",1,0],["data:visa","domain:common",1,0],["data:visa","features:swap",1,0],["data:balance-hiding","domain:balance-hiding",2,0],["data:feedback","features:hot-wallet",1,0],["data:feedback","domain:feedback",2,0],["data:feedback","domain:legacy",1,0],["data:feedback","domain:card",1,0],["data:feedback","domain:models",1,0],["data:feedback","domain:wallets",2,0],["data:search","data:common",1,0],["data:search","domain:search",1,0],["data:search","domain:common",1,0],["data:search","domain:account",1,0],["data:search","domain:markets",1,0],["data:search","domain:wallets",1,0],["data:search","domain:app-currency",1,0],["data:onramp","data:common",1,0],["data:onramp","domain:account",1,0],["data:onramp","domain:onramp",1,0],["data:onramp","domain:legacy",1,0],["data:onramp","domain:card",1,0],["data:onramp","domain:wallet-manager",1,0],["data:onramp","domain:app-theme",1,0],["data:onramp","domain:models",1,0],["data:onramp","domain:express",1,0],["data:onramp","domain:txhistory",1,0],["data:networks","data:common",1,0],["data:networks","data:dynamic-addresses",1,0],["data:networks","domain:card",1,0],["data:networks","domain:common",1,0],["data:networks","domain:legacy",1,0],["data:networks","domain:models",1,0],["data:networks","domain:networks",1,0],["data:networks","domain:wallet-manager",1,0],["data:common","domain:account",1,0],["data:common","domain:demo",1,0],["data:common","domain:legacy",1,0],["data:common","domain:card",1,0],["data:common","domain:models",1,0],["data:common","domain:tokens",1,0],["data:common","domain:wallets",2,0],["data:common","domain:express",1,0],["data:common","domain:networks",1,0],["data:common","domain:wallet-manager",1,0],["data:stories","domain:stories",2,0],["data:stories","domain:models",1,1],["data:stories","domain:wallets",1,0],["data:stories","features:referral",1,0],["data:news","data:common",1,0],["data:news","domain:news",1,0],["data:manage-tokens","domain:account",1,0],["data:manage-tokens","domain:demo",1,0],["data:manage-tokens","domain:models",1,0],["data:manage-tokens","domain:manage-tokens",1,0],["data:manage-tokens","domain:card",1,0],["data:manage-tokens","domain:wallets",2,0],["data:manage-tokens","domain:tokens",1,0],["data:manage-tokens","domain:legacy",2,0],["data:manage-tokens","data:common",1,0],["data:manage-tokens","data:tokens",1,0],["data:markets","domain:legacy",1,0],["data:markets","domain:markets",1,0],["data:markets","domain:models",1,0],["data:markets","domain:tokens",2,0],["data:markets","data:common",1,0],["data:staking","data:common",1,0],["data:staking","domain:tokens",1,0],["data:staking","domain:staking",1,0],["data:staking","domain:wallets",2,0],["data:staking","domain:legacy",1,0],["data:staking","domain:wallet-manager",1,0],["data:staking","domain:card",1,0],["data:staking","domain:models",1,0],["data:staking","features:staking",1,0],["data:address-book","domain:address-book",1,0],["data:address-book","domain:common",1,0],["data:address-book","domain:models",1,0],["data:assetsdiscovery","domain:assetsdiscovery",1,1],["data:assetsdiscovery","domain:tokens",2,0],["data:assetsdiscovery","domain:models",1,0],["data:assetsdiscovery","domain:wallet-manager",1,0],["data:assetsdiscovery","domain:wallets",1,0],["data:assetsdiscovery","data:common",1,0],["data:assetsdiscovery","data:wallet-manager",1,0],["data:wallets","data:common",1,0],["data:wallets","domain:account",1,0],["data:wallets","domain:card",1,0],["data:wallets","domain:dynamic-addresses",1,0],["data:wallets","domain:models",1,0],["data:wallets","domain:tokens",1,0],["data:wallets","domain:wallets",2,0],["data:wallets","domain:settings",1,0],["data:account","features:virtual-accounts",1,0],["data:account","domain:account",1,1],["data:account","domain:card",1,1],["data:account","domain:common",1,1],["data:account","domain:models",1,1],["data:account","domain:tokens",1,1],["data:account","domain:wallets",1,1],["data:account","domain:visa",1,1],["data:account","data:common",1,0],["data:hot-wallet","domain:hot-wallet",1,0],["data:hot-wallet","domain:models",1,0],["data:appsflyer","domain:appsflyer",1,0],["data:tokens","data:common",1,0],["data:tokens","data:networks",1,0],["data:tokens","domain:account",1,0],["data:tokens","domain:card",1,0],["data:tokens","domain:common",1,0],["data:tokens","domain:core",1,0],["data:tokens","domain:demo",1,0],["data:tokens","domain:express",1,0],["data:tokens","domain:legacy",1,0],["data:tokens","domain:models",1,0],["data:tokens","domain:staking",2,0],["data:tokens","domain:tokens",2,0],["data:tokens","domain:txhistory",1,0],["data:tokens","domain:wallet-manager",1,0],["data:tokens","domain:transaction",1,0],["data:tokens","domain:wallets",1,0],["data:tokens","features:send",1,0],["data:notifications","domain:notifications",2,0],["data:onboarding","domain:onboarding",1,0],["data:onboarding","domain:models",1,0],["data:analytics","domain:analytics",1,0],["data:analytics","domain:models",1,0],["data:analytics","domain:wallets",1,0],["data:analytics","data:common",1,0],["domain:transaction","domain:account",1,0],["domain:transaction","domain:common",1,0],["domain:transaction","domain:dynamic-addresses",2,0],["domain:transaction","domain:models",1,0],["domain:transaction","domain:legacy",1,0],["domain:transaction","domain:wallet-manager",1,0],["domain:transaction","domain:wallets",1,0],["domain:transaction","domain:tokens",2,0],["domain:transaction","domain:demo",1,0],["domain:transaction","domain:card",1,0],["domain:transaction","domain:notifications",1,0],["domain:transaction","domain:networks",1,1],["domain:settings","domain:balance-hiding",1,0],["domain:settings","domain:wallets",1,0],["domain:dynamic-addresses","domain:core",1,1],["domain:dynamic-addresses","domain:models",1,0],["domain:dynamic-addresses","domain:wallet-manager",1,0],["domain:dynamic-addresses","domain:wallets",1,0],["domain:app-theme","domain:core",1,0],["domain:yield-supply","domain:account",1,0],["domain:yield-supply","domain:models",2,1],["domain:yield-supply","domain:transaction",2,0],["domain:yield-supply","domain:legacy",1,0],["domain:yield-supply","domain:blockaid",2,0],["domain:yield-supply","domain:quotes",1,0],["domain:yield-supply","domain:tokens",1,0],["domain:yield-supply","domain:app-currency",1,0],["domain:txhistory","domain:core",1,0],["domain:txhistory","domain:express",1,1],["domain:txhistory","domain:models",1,0],["domain:txhistory","domain:tokens",1,0],["domain:txhistory","domain:wallets",1,0],["domain:txhistory","domain:visa",1,0],["domain:push-notification-preferences","domain:models",1,0],["domain:card","domain:demo",1,0],["domain:card","domain:core",1,0],["domain:card","domain:legacy",1,0],["domain:card","domain:wallet-manager",1,0],["domain:card","domain:models",1,0],["domain:card","domain:tokens",1,0],["domain:card","domain:wallets",1,0],["domain:card","domain:visa",1,0],["domain:nft","domain:core",2,0],["domain:nft","domain:account",1,0],["domain:nft","domain:models",2,0],["domain:nft","domain:networks",1,0],["domain:nft","domain:quotes",1,0],["domain:nft","domain:tokens",3,0],["domain:nft","domain:wallets",2,0],["domain:quotes","domain:core",1,1],["domain:quotes","domain:models",1,1],["domain:wallet-manager","domain:models",2,1],["domain:wallet-manager","domain:core",1,0],["domain:wallet-manager","domain:demo",1,0],["domain:wallet-manager","domain:wallets",1,0],["domain:wallet-manager","domain:tokens",1,0],["domain:wallet-manager","domain:app-currency",1,0],["domain:wallet-manager","domain:transaction",1,0],["domain:wallet-manager","domain:txhistory",1,0],["domain:express","domain:models",1,1],["domain:express","domain:tokens",1,0],["domain:payment","domain:models",2,1],["domain:qr-scanning","domain:models",2,1],["domain:qr-scanning","domain:account",1,0],["domain:qr-scanning","domain:common",1,0],["domain:qr-scanning","domain:networks",1,0],["domain:qr-scanning","domain:tokens",1,0],["domain:blockaid","domain:models",1,0],["domain:blockaid","domain:core",1,0],["domain:app-currency","domain:core",1,0],["domain:swap","domain:models",2,0],["domain:swap","domain:express",2,0],["domain:swap","domain:wallets",1,0],["domain:swap","domain:tokens",2,0],["domain:legacy","domain:core",1,0],["domain:legacy","domain:demo",1,0],["domain:legacy","domain:models",1,0],["domain:legacy","domain:tokens",1,0],["domain:legacy","domain:transaction",1,0],["domain:legacy","domain:txhistory",1,0],["domain:legacy","domain:wallets",1,0],["domain:earn","domain:core",1,1],["domain:earn","domain:models",1,1],["domain:earn","domain:account",1,0],["domain:earn","domain:common",1,0],["domain:wallet-connect","domain:blockaid",2,0],["domain:wallet-connect","domain:core",1,0],["domain:wallet-connect","domain:models",2,0],["domain:wallet-connect","domain:tokens",2,0],["domain:wallet-connect","domain:wallets",2,0],["domain:wallet-connect","domain:transaction",3,0],["domain:models","domain:core",1,1],["domain:visa","domain:models",2,1],["domain:visa","domain:app-currency",1,0],["domain:visa","domain:core",1,0],["domain:visa","domain:tokens",1,0],["domain:visa","domain:wallets",1,0],["domain:balance-hiding","domain:core",1,0],["domain:balance-hiding","domain:settings",1,0],["domain:feedback","domain:models",2,0],["domain:feedback","domain:wallets",2,0],["domain:feedback","domain:visa",2,0],["domain:search","domain:core",1,1],["domain:search","domain:models",1,1],["domain:search","domain:common",1,0],["domain:search","domain:markets",1,0],["domain:search","domain:wallets",1,0],["domain:search","domain:app-currency",1,0],["domain:search","domain:account",2,0],["domain:onramp","domain:tokens",2,1],["domain:onramp","domain:wallets",2,1],["domain:onramp","domain:core",2,1],["domain:onramp","domain:settings",1,1],["domain:onramp","domain:stories",1,0],["domain:onramp","domain:models",1,1],["domain:networks","domain:core",1,1],["domain:networks","domain:models",1,1],["domain:networks","domain:wallets",1,1],["domain:common","domain:models",1,1],["domain:stories","domain:models",1,0],["domain:stories","domain:settings",1,0],["domain:stories","domain:wallets",1,0],["domain:news","domain:core",1,1],["domain:news","domain:models",1,1],["domain:manage-tokens","domain:core",1,1],["domain:manage-tokens","domain:networks",1,1],["domain:manage-tokens","domain:quotes",1,1],["domain:manage-tokens","domain:wallet-manager",1,1],["domain:manage-tokens","domain:wallets",2,0],["domain:manage-tokens","domain:tokens",3,0],["domain:manage-tokens","domain:staking",1,0],["domain:manage-tokens","domain:card",1,0],["domain:manage-tokens","domain:legacy",1,0],["domain:manage-tokens","domain:models",1,0],["domain:markets","domain:app-currency",1,1],["domain:markets","domain:card",1,1],["domain:markets","domain:core",2,1],["domain:markets","domain:legacy",1,1],["domain:markets","domain:models",2,1],["domain:markets","domain:networks",1,1],["domain:markets","domain:staking",1,1],["domain:markets","domain:quotes",1,1],["domain:markets","domain:wallet-manager",1,1],["domain:markets","domain:wallets",2,1],["domain:markets","domain:stories",1,1],["domain:markets","domain:tokens",3,1],["domain:markets","domain:settings",1,0],["domain:staking","domain:core",2,1],["domain:staking","domain:legacy",1,0],["domain:staking","domain:wallet-manager",1,0],["domain:staking","domain:models",2,0],["domain:staking","domain:tokens",1,0],["domain:staking","domain:wallets",1,0],["domain:offramp","domain:core",1,1],["domain:offramp","domain:models",1,1],["domain:address-book","domain:core",1,1],["domain:address-book","domain:models",1,1],["domain:address-book","domain:transaction",1,0],["domain:address-book","domain:tokens",1,0],["domain:assetsdiscovery","domain:core",1,1],["domain:assetsdiscovery","domain:models",1,0],["domain:assetsdiscovery","domain:account",1,0],["domain:wallets","domain:core",1,1],["domain:wallets","domain:common",1,1],["domain:wallets","domain:legacy",1,0],["domain:wallets","domain:wallet-manager",1,0],["domain:wallets","domain:account",1,0],["domain:wallets","domain:models",2,0],["domain:wallets","domain:tokens",2,0],["domain:wallets","domain:card",1,0],["domain:wallets","domain:notifications",1,0],["domain:wallets","domain:demo",1,0],["domain:wallets","domain:hot-wallet",1,0],["domain:wallets","domain:qr-scanning",2,0],["domain:account","domain:common",2,1],["domain:account","domain:core",2,1],["domain:account","domain:models",2,1],["domain:account","domain:wallets",2,1],["domain:account","domain:yield-supply",1,1],["domain:account","domain:card",1,1],["domain:account","domain:express",1,1],["domain:account","domain:quotes",1,1],["domain:account","domain:networks",1,1],["domain:account","domain:nft",1,1],["domain:account","domain:referral",1,1],["domain:account","domain:staking",1,1],["domain:account","domain:tokens",2,1],["domain:account","domain:visa",1,1],["domain:account","domain:wallet-manager",1,1],["domain:hot-wallet","domain:core",1,0],["domain:hot-wallet","domain:models",1,0],["domain:hot-wallet","domain:wallets",1,0],["domain:tokens","domain:core",1,1],["domain:tokens","domain:common",1,0],["domain:tokens","domain:card",1,0],["domain:tokens","domain:express",1,0],["domain:tokens","domain:models",2,0],["domain:tokens","domain:legacy",1,0],["domain:tokens","domain:wallet-manager",1,0],["domain:tokens","domain:staking",2,0],["domain:tokens","domain:visa",1,0],["domain:tokens","domain:txhistory",2,0],["domain:tokens","domain:transaction",1,0],["domain:tokens","domain:wallets",1,0],["domain:tokens","domain:app-currency",1,0],["domain:tokens","domain:onramp",1,0],["domain:tokens","domain:settings",1,0],["domain:tokens","features:swap",3,0],["domain:tokens","domain:stories",3,0],["domain:tokens","domain:networks",1,0],["domain:tokens","domain:quotes",1,0],["domain:tokens","domain:yield-supply",1,0],["domain:tokens","features:staking",1,0],["domain:tokens","features:markets",1,0],["domain:tokens","features:virtual-accounts",1,0],["domain:notifications","domain:core",1,0],["domain:notifications","domain:models",1,0],["domain:notifications","domain:wallets",1,0],["domain:notifications","domain:tokens",1,0],["domain:onboarding","domain:models",1,0],["domain:analytics","domain:core",1,0],["domain:analytics","domain:models",1,0],["domain:analytics","domain:wallets",1,0]]} +``` + + + +```json +{"n":[[":data:account","account","data",0,9],[":data:address-book","address-book","data",0,3],[":data:analytics","analytics","data",0,4],[":data:app-currency","app-currency","data",0,4],[":data:app-theme","app-theme","data",0,2],[":data:appsflyer","appsflyer","data",0,1],[":data:assetsdiscovery","assetsdiscovery","data",0,8],[":data:balance-hiding","balance-hiding","data",0,2],[":data:blockaid","blockaid","data",0,4],[":data:card","card","data",2,2],[":data:common","common","data",32,11],[":data:dynamic-addresses","dynamic-addresses","data",1,7],[":data:earn","earn","data",0,4],[":data:express","express","data",1,7],[":data:feedback","feedback","data",0,8],[":data:hot-wallet","hot-wallet","data",0,2],[":data:manage-tokens","manage-tokens","data",0,11],[":data:markets","markets","data",0,6],[":data:networks","networks","data",1,8],[":data:news","news","data",0,2],[":data:nft","nft","data",0,11],[":data:notifications","notifications","data",0,2],[":data:onboarding","onboarding","data",0,2],[":data:onramp","onramp","data",0,10],[":data:payment","payment","data",0,8],[":data:push-notification-preferences","push-notification-preferences","data",0,2],[":data:qr-scanning","qr-scanning","data",0,4],[":data:quotes","quotes","data",0,3],[":data:search","search","data",0,7],[":data:settings","settings","data",0,2],[":data:staking","staking","data",0,10],[":data:stories","stories","data",0,5],[":data:swap","swap","data",0,17],[":data:tokens","tokens","data",1,19],[":data:transaction","transaction","data",0,9],[":data:txhistory","txhistory","data",0,13],[":data:visa","visa","data",1,16],[":data:wallet-connect","wallet-connect","data",0,16],[":data:wallet-manager","wallet-manager","data",1,10],[":data:wallets","wallets","data",2,9],[":data:yield-supply","yield-supply","data",0,5],[":domain:account","account","domain",37,5],[":domain:account:status","account:status","domain",29,16],[":domain:address-book","address-book","domain",2,4],[":domain:analytics","analytics","domain",2,3],[":domain:app-currency","app-currency","domain",22,2],[":domain:app-currency:models","app-currency:models","domain",33,0],[":domain:app-theme","app-theme","domain",3,2],[":domain:app-theme:models","app-theme:models","domain",5,0],[":domain:appsflyer","appsflyer","domain",2,0],[":domain:assetsdiscovery","assetsdiscovery","domain",4,3],[":domain:balance-hiding","balance-hiding","domain",20,3],[":domain:balance-hiding:models","balance-hiding:models","domain",22,0],[":domain:blockaid","blockaid","domain",3,3],[":domain:blockaid:models","blockaid:models","domain",7,0],[":domain:card","card","domain",43,8],[":domain:common","common","domain",26,1],[":domain:core","core","domain",45,0],[":domain:demo","demo","domain",16,1],[":domain:demo:models","demo:models","domain",3,0],[":domain:dynamic-addresses","dynamic-addresses","domain",5,5],[":domain:dynamic-addresses:models","dynamic-addresses:models","domain",4,0],[":domain:earn","earn","domain",2,4],[":domain:express","express","domain",5,2],[":domain:express:models","express:models","domain",15,1],[":domain:feedback","feedback","domain",16,4],[":domain:feedback:models","feedback:models","domain",17,3],[":domain:hot-wallet","hot-wallet","domain",7,3],[":domain:legacy","legacy","domain",39,7],[":domain:manage-tokens","manage-tokens","domain",7,12],[":domain:manage-tokens:models","manage-tokens:models","domain",6,2],[":domain:markets","markets","domain",8,16],[":domain:markets:models","markets:models","domain",9,3],[":domain:models","models","domain",146,1],[":domain:networks","networks","domain",12,3],[":domain:news","news","domain",2,2],[":domain:nft","nft","domain",6,10],[":domain:nft:models","nft:models","domain",7,3],[":domain:notifications","notifications","domain",9,5],[":domain:notifications:models","notifications:models","domain",9,0],[":domain:offramp","offramp","domain",6,2],[":domain:onboarding","onboarding","domain",2,1],[":domain:onramp","onramp","domain",5,6],[":domain:onramp:models","onramp:models","domain",8,4],[":domain:payment","payment","domain",1,2],[":domain:payment:models","payment:models","domain",2,1],[":domain:push-notification-preferences","push-notification-preferences","domain",4,1],[":domain:qr-scanning","qr-scanning","domain",6,6],[":domain:qr-scanning:models","qr-scanning:models","domain",8,1],[":domain:quotes","quotes","domain",12,2],[":domain:referral","referral","domain",3,0],[":domain:search","search","domain",2,8],[":domain:settings","settings","domain",27,2],[":domain:staking","staking","domain",15,7],[":domain:staking:models","staking:models","domain",8,2],[":domain:stories","stories","domain",9,4],[":domain:stories:models","stories:models","domain",9,0],[":domain:swap","swap","domain",4,5],[":domain:swap:models","swap:models","domain",8,3],[":domain:tokens","tokens","domain",38,27],[":domain:tokens:models","tokens:models","domain",77,4],[":domain:transaction","transaction","domain",24,15],[":domain:transaction:models","transaction:models","domain",24,0],[":domain:txhistory","txhistory","domain",11,7],[":domain:txhistory:models","txhistory:models","domain",17,0],[":domain:visa","visa","domain",12,6],[":domain:visa:models","visa:models","domain",9,1],[":domain:wallet-connect","wallet-connect","domain",4,8],[":domain:wallet-connect:models","wallet-connect:models","domain",4,5],[":domain:wallet-manager","wallet-manager","domain",25,9],[":domain:wallet-manager:models","wallet-manager:models","domain",1,1],[":domain:wallets","wallets","domain",55,15],[":domain:wallets:models","wallets:models","domain",89,1],[":domain:yield-supply","yield-supply","domain",7,11],[":domain:yield-supply:models","yield-supply:models","domain",9,1],[":features:account:api","account:api","features",5,6],[":features:account:impl","account:impl","features",0,14],[":features:address-book:api","address-book:api","features",2,1],[":features:address-book:impl","address-book:impl","features",0,4],[":features:approval:api","approval:api","features",3,2],[":features:approval:impl","approval:impl","features",0,7],[":features:biometry:api","biometry:api","features",3,0],[":features:biometry:impl","biometry:impl","features",0,6],[":features:common-features:api","common-features:api","features",11,3],[":features:common-features:impl","common-features:impl","features",0,19],[":features:create-wallet-selection:api","create-wallet-selection:api","features",2,1],[":features:create-wallet-selection:impl","create-wallet-selection:impl","features",0,7],[":features:create-wallet-start:api","create-wallet-start:api","features",1,1],[":features:create-wallet-start:impl","create-wallet-start:impl","features",0,9],[":features:details:api","details:api","features",2,1],[":features:details:impl","details:impl","features",0,23],[":features:disclaimer:api","disclaimer:api","features",2,0],[":features:disclaimer:impl","disclaimer:impl","features",0,6],[":features:feed:api","feed:api","features",4,6],[":features:feed:impl","feed:impl","features",0,36],[":features:home:api","home:api","features",1,0],[":features:home:impl","home:impl","features",0,15],[":features:hot-wallet:api","hot-wallet:api","features",10,3],[":features:hot-wallet:impl","hot-wallet:impl","features",0,12],[":features:kyc:api","kyc:api","features",4,1],[":features:kyc:impl","kyc:impl","features",0,3],[":features:kyc:mock","kyc:mock","features",0,1],[":features:manage-tokens:api","manage-tokens:api","features",6,3],[":features:manage-tokens:impl","manage-tokens:impl","features",0,16],[":features:markets:api","markets:api","features",4,4],[":features:markets:impl","markets:impl","features",0,32],[":features:nft:api","nft:api","features",5,4],[":features:nft:impl","nft:impl","features",0,14],[":features:onboarding-v2:api","onboarding-v2:api","features",7,1],[":features:onboarding-v2:impl","onboarding-v2:impl","features",0,23],[":features:onramp:api","onramp:api","features",5,3],[":features:onramp:impl","onramp:impl","features",0,27],[":features:promo-banners:api","promo-banners:api","features",3,0],[":features:promo-banners:impl","promo-banners:impl","features",0,3],[":features:push-notification-settings:api","push-notification-settings:api","features",4,1],[":features:push-notification-settings:impl","push-notification-settings:impl","features",0,6],[":features:push-notifications:api","push-notifications:api","features",9,0],[":features:push-notifications:impl","push-notifications:impl","features",0,8],[":features:qr-scanning:api","qr-scanning:api","features",1,1],[":features:qr-scanning:impl","qr-scanning:impl","features",0,4],[":features:rating:api","rating:api","features",2,0],[":features:rating:impl","rating:impl","features",0,1],[":features:referral:api","referral:api","features",1,1],[":features:referral:data","referral:data","features",0,8],[":features:referral:domain","referral:domain","features",4,10],[":features:referral:impl","referral:impl","features",0,15],[":features:send:api","send:api","features",14,8],[":features:send:impl","send:impl","features",1,32],[":features:staking:api","staking:api","features",4,4],[":features:staking:impl","staking:impl","features",0,24],[":features:stories:api","stories:api","features",1,0],[":features:stories:impl","stories:impl","features",0,3],[":features:survey:api","survey:api","features",2,0],[":features:survey:impl","survey:impl","features",0,5],[":features:swap-v2:api","swap-v2:api","features",3,8],[":features:swap-v2:impl","swap-v2:impl","features",0,30],[":features:swap:api","swap:api","features",6,3],[":features:swap:data","swap:data","features",0,14],[":features:swap:domain","swap:domain","features",5,29],[":features:swap:domain:api","swap:domain:api","features",6,4],[":features:swap:domain:models","swap:domain:models","features",7,3],[":features:swap:impl","swap:impl","features",0,39],[":features:tangempay:details:api","tangempay:details:api","features",3,2],[":features:tangempay:details:impl","tangempay:details:impl","features",0,13],[":features:tangempay:main:api","tangempay:main:api","features",2,0],[":features:tangempay:main:impl","tangempay:main:impl","features",0,1],[":features:tangempay:onboarding:api","tangempay:onboarding:api","features",1,1],[":features:tangempay:onboarding:impl","tangempay:onboarding:impl","features",0,11],[":features:tester:api","tester:api","features",4,0],[":features:tester:impl","tester:impl","features",0,15],[":features:token-recieve:api","token-recieve:api","features",9,1],[":features:token-recieve:impl","token-recieve:impl","features",0,6],[":features:tokendetails:api","tokendetails:api","features",4,3],[":features:tokendetails:impl","tokendetails:impl","features",0,48],[":features:txhistory:api","txhistory:api","features",5,3],[":features:txhistory:impl","txhistory:impl","features",0,13],[":features:usedesk:api","usedesk:api","features",1,0],[":features:usedesk:impl","usedesk:impl","features",0,1],[":features:virtual-accounts:details:api","virtual-accounts:details:api","features",3,0],[":features:virtual-accounts:details:impl","virtual-accounts:details:impl","features",0,1],[":features:virtual-accounts:main:api","virtual-accounts:main:api","features",2,0],[":features:virtual-accounts:main:impl","virtual-accounts:main:impl","features",0,1],[":features:virtual-accounts:onboarding:api","virtual-accounts:onboarding:api","features",1,0],[":features:virtual-accounts:onboarding:impl","virtual-accounts:onboarding:impl","features",0,1],[":features:wallet-settings:api","wallet-settings:api","features",3,1],[":features:wallet-settings:impl","wallet-settings:impl","features",0,24],[":features:wallet:api","wallet:api","features",14,2],[":features:wallet:impl","wallet:impl","features",0,73],[":features:walletconnect:api","walletconnect:api","features",1,1],[":features:walletconnect:impl","walletconnect:impl","features",0,24],[":features:welcome:api","welcome:api","features",1,1],[":features:welcome:impl","welcome:impl","features",0,11],[":features:yield-supply:api","yield-supply:api","features",3,4],[":features:yield-supply:impl","yield-supply:impl","features",0,19]],"e":[[":features:usedesk:impl",":features:usedesk:api",1,0],[":features:home:impl",":features:home:api",1,0],[":features:home:impl",":features:hot-wallet:api",1,0],[":features:home:impl",":domain:common",1,0],[":features:home:impl",":domain:models",1,0],[":features:home:impl",":domain:core",1,0],[":features:home:impl",":domain:card",1,0],[":features:home:impl",":domain:settings",1,0],[":features:home:impl",":domain:tokens",1,0],[":features:home:impl",":domain:wallets",1,0],[":features:home:impl",":domain:wallets:models",1,0],[":features:home:impl",":domain:legacy",1,0],[":features:home:impl",":domain:feedback",1,0],[":features:home:impl",":domain:feedback:models",1,0],[":features:home:impl",":domain:referral",1,0],[":features:home:impl",":features:referral:domain",1,0],[":features:create-wallet-start:impl",":features:create-wallet-start:api",1,0],[":features:create-wallet-start:impl",":features:hot-wallet:api",1,0],[":features:create-wallet-start:impl",":features:onboarding-v2:api",1,0],[":features:create-wallet-start:impl",":domain:card",1,0],[":features:create-wallet-start:impl",":domain:settings",1,0],[":features:create-wallet-start:impl",":domain:wallets",1,0],[":features:create-wallet-start:impl",":domain:models",1,0],[":features:create-wallet-start:impl",":domain:hot-wallet",1,0],[":features:create-wallet-start:impl",":domain:wallets:models",1,0],[":features:create-wallet-start:api",":domain:models",1,0],[":features:yield-supply:impl",":features:yield-supply:api",1,0],[":features:yield-supply:impl",":domain:models",1,0],[":features:yield-supply:impl",":domain:app-currency:models",1,0],[":features:yield-supply:impl",":domain:app-currency",1,0],[":features:yield-supply:impl",":domain:account:status",1,0],[":features:yield-supply:impl",":domain:wallets:models",1,0],[":features:yield-supply:impl",":domain:wallets",1,0],[":features:yield-supply:impl",":domain:tokens:models",1,0],[":features:yield-supply:impl",":domain:tokens",1,0],[":features:yield-supply:impl",":domain:transaction:models",1,0],[":features:yield-supply:impl",":domain:transaction",1,0],[":features:yield-supply:impl",":domain:yield-supply:models",1,0],[":features:yield-supply:impl",":domain:yield-supply",1,0],[":features:yield-supply:impl",":domain:stories:models",1,0],[":features:yield-supply:impl",":domain:stories",1,0],[":features:yield-supply:impl",":domain:feedback:models",1,0],[":features:yield-supply:impl",":domain:feedback",1,0],[":features:yield-supply:impl",":domain:balance-hiding:models",1,0],[":features:yield-supply:impl",":domain:balance-hiding",1,0],[":features:yield-supply:api",":domain:models",1,0],[":features:yield-supply:api",":domain:wallets:models",1,0],[":features:yield-supply:api",":domain:tokens:models",1,0],[":features:yield-supply:api",":domain:app-currency:models",1,0],[":features:txhistory:impl",":features:txhistory:api",1,0],[":features:txhistory:impl",":domain:models",1,0],[":features:txhistory:impl",":domain:legacy",1,0],[":features:txhistory:impl",":domain:card",1,0],[":features:txhistory:impl",":domain:txhistory",1,0],[":features:txhistory:impl",":domain:txhistory:models",1,0],[":features:txhistory:impl",":domain:wallets",1,0],[":features:txhistory:impl",":domain:wallets:models",1,0],[":features:txhistory:impl",":domain:tokens",1,0],[":features:txhistory:impl",":domain:tokens:models",1,0],[":features:txhistory:impl",":domain:balance-hiding",1,0],[":features:txhistory:impl",":domain:balance-hiding:models",1,0],[":features:txhistory:impl",":domain:account:status",1,0],[":features:txhistory:api",":domain:models",1,1],[":features:txhistory:api",":domain:tokens:models",1,0],[":features:txhistory:api",":domain:wallets:models",1,0],[":features:referral:impl",":features:referral:api",1,1],[":features:referral:impl",":features:common-features:api",1,1],[":features:referral:impl",":domain:demo",1,0],[":features:referral:impl",":domain:wallets",1,0],[":features:referral:impl",":domain:legacy",1,0],[":features:referral:impl",":domain:card",1,0],[":features:referral:impl",":domain:wallets:models",1,0],[":features:referral:impl",":domain:notifications:models",1,0],[":features:referral:impl",":domain:account:status",1,0],[":features:referral:impl",":domain:account",1,0],[":features:referral:impl",":domain:balance-hiding",1,0],[":features:referral:impl",":domain:balance-hiding:models",1,0],[":features:referral:impl",":domain:app-currency",1,0],[":features:referral:impl",":domain:app-currency:models",1,0],[":features:referral:impl",":features:referral:domain",1,0],[":features:referral:api",":domain:models",1,1],[":features:referral:data",":data:common",1,0],[":features:referral:data",":domain:common",1,0],[":features:referral:data",":domain:legacy",1,0],[":features:referral:data",":domain:models",1,0],[":features:referral:data",":domain:tokens:models",1,0],[":features:referral:data",":domain:wallets:models",1,0],[":features:referral:data",":domain:referral",1,0],[":features:referral:data",":features:referral:domain",1,0],[":features:referral:domain",":domain:account:status",1,0],[":features:referral:domain",":domain:card",1,0],[":features:referral:domain",":domain:common",1,0],[":features:referral:domain",":domain:models",1,0],[":features:referral:domain",":domain:tokens",1,0],[":features:referral:domain",":domain:tokens:models",1,0],[":features:referral:domain",":domain:wallets",1,0],[":features:referral:domain",":domain:wallets:models",1,0],[":features:referral:domain",":features:tester:api",1,0],[":features:referral:domain",":features:wallet:api",1,0],[":features:wallet-settings:impl",":features:wallet-settings:api",1,0],[":features:wallet-settings:impl",":features:manage-tokens:api",1,0],[":features:wallet-settings:impl",":features:nft:api",1,0],[":features:wallet-settings:impl",":features:onboarding-v2:api",1,0],[":features:wallet-settings:impl",":features:push-notifications:api",1,0],[":features:wallet-settings:impl",":features:push-notification-settings:api",1,0],[":features:wallet-settings:impl",":features:hot-wallet:api",1,0],[":features:wallet-settings:impl",":features:wallet:api",1,0],[":features:wallet-settings:impl",":domain:account:status",1,0],[":features:wallet-settings:impl",":domain:app-currency",1,0],[":features:wallet-settings:impl",":domain:app-currency:models",1,0],[":features:wallet-settings:impl",":domain:balance-hiding",1,0],[":features:wallet-settings:impl",":domain:balance-hiding:models",1,0],[":features:wallet-settings:impl",":domain:legacy",1,0],[":features:wallet-settings:impl",":domain:card",1,0],[":features:wallet-settings:impl",":domain:models",1,0],[":features:wallet-settings:impl",":domain:wallets",1,0],[":features:wallet-settings:impl",":domain:wallets:models",1,0],[":features:wallet-settings:impl",":domain:demo",1,0],[":features:wallet-settings:impl",":domain:nft",1,0],[":features:wallet-settings:impl",":domain:settings",1,0],[":features:wallet-settings:impl",":domain:notifications:models",1,0],[":features:wallet-settings:impl",":domain:notifications",1,0],[":features:wallet-settings:impl",":domain:assetsdiscovery",1,0],[":features:wallet-settings:api",":domain:models",1,0],[":features:token-recieve:impl",":domain:models",1,0],[":features:token-recieve:impl",":domain:transaction",1,0],[":features:token-recieve:impl",":domain:transaction:models",1,0],[":features:token-recieve:impl",":domain:tokens",1,0],[":features:token-recieve:impl",":domain:tokens:models",1,0],[":features:token-recieve:impl",":features:token-recieve:api",1,0],[":features:token-recieve:api",":domain:models",1,0],[":features:rating:impl",":features:rating:api",1,0],[":features:kyc:impl",":features:kyc:api",1,0],[":features:kyc:impl",":domain:visa",1,0],[":features:kyc:impl",":domain:wallets:models",1,0],[":features:kyc:mock",":features:kyc:api",1,0],[":features:kyc:api",":domain:models",1,0],[":features:disclaimer:impl",":domain:models",1,0],[":features:disclaimer:impl",":domain:card",1,0],[":features:disclaimer:impl",":domain:settings",1,0],[":features:disclaimer:impl",":domain:notifications",1,0],[":features:disclaimer:impl",":features:disclaimer:api",1,0],[":features:disclaimer:impl",":features:push-notifications:api",1,0],[":features:nft:impl",":features:common-features:api",1,0],[":features:nft:impl",":features:nft:api",1,0],[":features:nft:impl",":features:token-recieve:api",1,0],[":features:nft:impl",":domain:account:status",1,0],[":features:nft:impl",":domain:wallets",1,0],[":features:nft:impl",":domain:app-currency:models",1,0],[":features:nft:impl",":domain:app-currency",1,0],[":features:nft:impl",":domain:models",1,0],[":features:nft:impl",":domain:nft",1,0],[":features:nft:impl",":domain:nft:models",1,0],[":features:nft:impl",":domain:tokens:models",1,0],[":features:nft:impl",":domain:wallets:models",1,0],[":features:nft:impl",":domain:transaction",1,0],[":features:nft:impl",":domain:tokens",1,0],[":features:nft:api",":domain:models",1,0],[":features:nft:api",":domain:nft:models",1,0],[":features:nft:api",":domain:wallets:models",1,0],[":features:nft:api",":domain:account",1,0],[":features:tokendetails:impl",":features:rating:api",1,0],[":features:tokendetails:impl",":domain:account:status",1,0],[":features:tokendetails:impl",":domain:app-currency",1,0],[":features:tokendetails:impl",":domain:app-currency:models",1,0],[":features:tokendetails:impl",":domain:balance-hiding",1,0],[":features:tokendetails:impl",":domain:balance-hiding:models",1,0],[":features:tokendetails:impl",":domain:card",1,0],[":features:tokendetails:impl",":domain:demo",1,0],[":features:tokendetails:impl",":domain:dynamic-addresses",1,0],[":features:tokendetails:impl",":domain:dynamic-addresses:models",1,0],[":features:tokendetails:impl",":domain:feedback",1,0],[":features:tokendetails:impl",":domain:feedback:models",1,0],[":features:tokendetails:impl",":domain:markets:models",1,0],[":features:tokendetails:impl",":domain:models",1,0],[":features:tokendetails:impl",":domain:notifications:models",1,0],[":features:tokendetails:impl",":domain:offramp",1,0],[":features:tokendetails:impl",":domain:onramp",1,0],[":features:tokendetails:impl",":domain:onramp:models",1,0],[":features:tokendetails:impl",":domain:stories",1,0],[":features:tokendetails:impl",":domain:stories:models",1,0],[":features:tokendetails:impl",":domain:quotes",1,0],[":features:tokendetails:impl",":domain:settings",1,0],[":features:tokendetails:impl",":domain:staking",1,0],[":features:tokendetails:impl",":domain:tokens",1,0],[":features:tokendetails:impl",":domain:tokens:models",1,0],[":features:tokendetails:impl",":domain:transaction",1,0],[":features:tokendetails:impl",":domain:transaction:models",1,0],[":features:tokendetails:impl",":domain:txhistory",1,0],[":features:tokendetails:impl",":domain:txhistory:models",1,0],[":features:tokendetails:impl",":domain:wallets",1,0],[":features:tokendetails:impl",":domain:wallets:models",1,0],[":features:tokendetails:impl",":domain:yield-supply",1,0],[":features:tokendetails:impl",":domain:yield-supply:models",1,0],[":features:tokendetails:impl",":features:swap:domain",1,0],[":features:tokendetails:impl",":features:swap:domain:api",1,0],[":features:tokendetails:impl",":features:swap:domain:models",1,0],[":features:tokendetails:impl",":features:tokendetails:api",1,0],[":features:tokendetails:impl",":features:wallet:api",1,0],[":features:tokendetails:impl",":features:staking:api",1,0],[":features:tokendetails:impl",":features:markets:api",1,0],[":features:tokendetails:impl",":features:onramp:api",1,0],[":features:tokendetails:impl",":features:push-notifications:api",1,0],[":features:tokendetails:impl",":features:swap:api",1,0],[":features:tokendetails:impl",":features:txhistory:api",1,0],[":features:tokendetails:impl",":features:send:api",1,0],[":features:tokendetails:impl",":features:token-recieve:api",1,0],[":features:tokendetails:impl",":features:yield-supply:api",1,0],[":features:tokendetails:impl",":features:common-features:api",1,0],[":features:tokendetails:api",":domain:models",1,1],[":features:tokendetails:api",":domain:tokens:models",1,0],[":features:tokendetails:api",":domain:wallets:models",1,0],[":features:qr-scanning:impl",":features:qr-scanning:api",1,0],[":features:qr-scanning:impl",":domain:qr-scanning",1,0],[":features:qr-scanning:impl",":domain:qr-scanning:models",1,0],[":features:qr-scanning:impl",":data:card",1,0],[":features:qr-scanning:api",":domain:qr-scanning:models",1,0],[":features:swap:impl",":features:common-features:api",1,0],[":features:swap:impl",":data:common",1,0],[":features:swap:impl",":domain:models",1,0],[":features:swap:impl",":domain:account",2,0],[":features:swap:impl",":domain:app-currency",1,0],[":features:swap:impl",":domain:app-currency:models",1,0],[":features:swap:impl",":domain:balance-hiding",1,0],[":features:swap:impl",":domain:balance-hiding:models",1,0],[":features:swap:impl",":domain:tokens",1,0],[":features:swap:impl",":domain:tokens:models",1,0],[":features:swap:impl",":domain:transaction",1,0],[":features:swap:impl",":domain:transaction:models",1,0],[":features:swap:impl",":domain:wallets",1,0],[":features:swap:impl",":domain:wallets:models",1,0],[":features:swap:impl",":domain:settings",1,0],[":features:swap:impl",":domain:staking",1,0],[":features:swap:impl",":domain:feedback",1,0],[":features:swap:impl",":domain:feedback:models",1,0],[":features:swap:impl",":domain:stories",1,0],[":features:swap:impl",":domain:stories:models",1,0],[":features:swap:impl",":domain:txhistory",1,0],[":features:swap:impl",":domain:txhistory:models",1,0],[":features:swap:impl",":domain:express:models",1,0],[":features:swap:impl",":domain:account:status",1,0],[":features:swap:impl",":domain:card",1,0],[":features:swap:impl",":domain:visa",1,0],[":features:swap:impl",":domain:markets",1,0],[":features:swap:impl",":domain:swap",1,0],[":features:swap:impl",":domain:swap:models",1,0],[":features:swap:impl",":features:swap:domain",1,0],[":features:swap:impl",":features:swap:domain:api",1,0],[":features:swap:impl",":features:swap:domain:models",1,0],[":features:swap:impl",":features:wallet:api",1,0],[":features:swap:impl",":features:swap:api",2,0],[":features:swap:impl",":features:send:api",1,0],[":features:swap:impl",":features:send:impl",1,0],[":features:swap:impl",":features:feed:api",1,0],[":features:swap:impl",":features:tokendetails:api",1,0],[":features:swap:impl",":features:approval:api",1,0],[":features:swap:api",":domain:models",1,1],[":features:swap:api",":domain:tokens:models",1,0],[":features:swap:api",":domain:wallets:models",1,0],[":features:swap:data",":features:swap:domain",1,0],[":features:swap:data",":features:swap:domain:models",1,0],[":features:swap:data",":features:swap:domain:api",1,0],[":features:swap:data",":domain:tokens:models",1,0],[":features:swap:data",":domain:legacy",1,0],[":features:swap:data",":domain:wallet-manager",1,0],[":features:swap:data",":domain:models",1,0],[":features:swap:data",":domain:wallets",1,0],[":features:swap:data",":domain:wallets:models",1,0],[":features:swap:data",":domain:transaction:models",1,0],[":features:swap:data",":domain:express:models",1,0],[":features:swap:data",":domain:account:status",1,0],[":features:swap:data",":domain:txhistory",1,0],[":features:swap:data",":data:common",1,0],[":features:swap:domain",":domain:swap:models",1,0],[":features:swap:domain",":domain:swap",1,0],[":features:swap:domain",":domain:app-currency",1,0],[":features:swap:domain",":domain:app-currency:models",1,0],[":features:swap:domain",":domain:card",1,0],[":features:swap:domain",":domain:demo",1,0],[":features:swap:domain",":domain:legacy",1,0],[":features:swap:domain",":domain:models",1,0],[":features:swap:domain",":domain:quotes",1,0],[":features:swap:domain",":domain:staking",1,0],[":features:swap:domain",":domain:tokens",1,0],[":features:swap:domain",":domain:tokens:models",1,0],[":features:swap:domain",":domain:transaction",1,0],[":features:swap:domain",":domain:transaction:models",1,0],[":features:swap:domain",":domain:txhistory:models",1,0],[":features:swap:domain",":domain:wallets",1,0],[":features:swap:domain",":domain:wallets:models",1,0],[":features:swap:domain",":domain:express:models",1,0],[":features:swap:domain",":domain:account",1,0],[":features:swap:domain",":domain:account:status",1,0],[":features:swap:domain",":domain:visa",1,0],[":features:swap:domain",":domain:visa:models",1,0],[":features:swap:domain",":domain:balance-hiding",1,0],[":features:swap:domain",":domain:yield-supply",1,0],[":features:swap:domain",":features:wallet:api",1,0],[":features:swap:domain",":features:swap:api",1,0],[":features:swap:domain",":features:swap:domain:api",1,0],[":features:swap:domain",":features:swap:domain:models",1,0],[":features:swap:domain",":features:send:api",1,0],[":features:swap:domain:models",":domain:models",1,1],[":features:swap:domain:models",":domain:tokens:models",1,0],[":features:swap:domain:models",":domain:transaction:models",1,0],[":features:swap:domain:api",":features:swap:domain:models",1,0],[":features:swap:domain:api",":domain:tokens:models",1,0],[":features:swap:domain:api",":domain:wallets:models",1,0],[":features:swap:domain:api",":domain:express:models",1,0],[":features:details:impl",":features:details:api",1,0],[":features:details:impl",":features:wallet:api",1,0],[":features:details:impl",":features:disclaimer:api",1,0],[":features:details:impl",":features:tester:api",1,0],[":features:details:impl",":features:create-wallet-selection:api",1,0],[":features:details:impl",":features:onboarding-v2:api",1,0],[":features:details:impl",":features:address-book:api",1,0],[":features:details:impl",":domain:models",1,0],[":features:details:impl",":domain:feedback",1,0],[":features:details:impl",":domain:feedback:models",1,0],[":features:details:impl",":domain:wallets",1,0],[":features:details:impl",":domain:wallets:models",1,0],[":features:details:impl",":domain:card",1,0],[":features:details:impl",":domain:tokens",1,0],[":features:details:impl",":domain:tokens:models",1,0],[":features:details:impl",":domain:app-currency",1,0],[":features:details:impl",":domain:app-currency:models",1,0],[":features:details:impl",":domain:wallet-connect",1,0],[":features:details:impl",":domain:balance-hiding",1,0],[":features:details:impl",":domain:balance-hiding:models",1,0],[":features:details:impl",":domain:legacy",1,0],[":features:details:impl",":domain:settings",1,0],[":features:details:impl",":domain:visa",1,0],[":features:details:api",":domain:models",1,0],[":features:create-wallet-selection:impl",":features:create-wallet-selection:api",1,0],[":features:create-wallet-selection:impl",":features:hot-wallet:api",1,0],[":features:create-wallet-selection:impl",":domain:card",1,0],[":features:create-wallet-selection:impl",":domain:settings",1,0],[":features:create-wallet-selection:impl",":domain:wallets",1,0],[":features:create-wallet-selection:impl",":domain:models",1,0],[":features:create-wallet-selection:impl",":domain:hot-wallet",1,0],[":features:create-wallet-selection:api",":domain:models",1,0],[":features:welcome:impl",":features:welcome:api",1,0],[":features:welcome:impl",":features:wallet:api",1,0],[":features:welcome:impl",":features:onboarding-v2:api",1,0],[":features:welcome:impl",":domain:app-currency:models",1,0],[":features:welcome:impl",":domain:models",1,0],[":features:welcome:impl",":domain:tokens:models",1,0],[":features:welcome:impl",":domain:wallets:models",1,0],[":features:welcome:impl",":domain:app-currency",1,0],[":features:welcome:impl",":domain:wallets",1,0],[":features:welcome:impl",":domain:card",1,0],[":features:welcome:impl",":domain:settings",1,0],[":features:welcome:api",":domain:wallets:models",1,0],[":features:common-features:impl",":features:common-features:api",1,0],[":features:common-features:impl",":features:wallet:api",1,0],[":features:common-features:impl",":features:token-recieve:api",1,0],[":features:common-features:impl",":domain:models",1,0],[":features:common-features:impl",":domain:account",1,0],[":features:common-features:impl",":domain:account:status",1,0],[":features:common-features:impl",":domain:core",1,0],[":features:common-features:impl",":domain:app-currency",1,0],[":features:common-features:impl",":domain:app-currency:models",1,0],[":features:common-features:impl",":domain:markets",1,0],[":features:common-features:impl",":domain:transaction",1,0],[":features:common-features:impl",":domain:tokens",1,0],[":features:common-features:impl",":domain:tokens:models",1,0],[":features:common-features:impl",":domain:manage-tokens",1,0],[":features:common-features:impl",":domain:manage-tokens:models",1,0],[":features:common-features:impl",":domain:balance-hiding",1,0],[":features:common-features:impl",":domain:balance-hiding:models",1,0],[":features:common-features:impl",":domain:wallets",1,0],[":features:common-features:impl",":domain:wallets:models",1,0],[":features:common-features:api",":domain:models",1,0],[":features:common-features:api",":domain:markets",1,0],[":features:common-features:api",":domain:account",1,0],[":features:onramp:impl",":features:common-features:api",1,0],[":features:onramp:impl",":features:onramp:api",1,0],[":features:onramp:impl",":features:swap:api",1,0],[":features:onramp:impl",":features:swap:domain",1,0],[":features:onramp:impl",":features:swap:domain:api",1,0],[":features:onramp:impl",":features:swap:domain:models",1,0],[":features:onramp:impl",":features:feed:api",1,0],[":features:onramp:impl",":domain:app-currency",1,0],[":features:onramp:impl",":domain:app-currency:models",1,0],[":features:onramp:impl",":domain:balance-hiding",1,0],[":features:onramp:impl",":domain:balance-hiding:models",1,0],[":features:onramp:impl",":domain:card",1,0],[":features:onramp:impl",":domain:demo",1,0],[":features:onramp:impl",":domain:models",1,0],[":features:onramp:impl",":domain:offramp",1,0],[":features:onramp:impl",":domain:onramp",1,0],[":features:onramp:impl",":domain:tokens",1,0],[":features:onramp:impl",":domain:tokens:models",1,0],[":features:onramp:impl",":domain:wallets",1,0],[":features:onramp:impl",":domain:wallets:models",1,0],[":features:onramp:impl",":domain:settings",1,0],[":features:onramp:impl",":domain:transaction:models",1,0],[":features:onramp:impl",":domain:account:status",1,0],[":features:onramp:impl",":domain:app-theme",1,0],[":features:onramp:impl",":domain:app-theme:models",1,0],[":features:onramp:impl",":data:common",1,0],[":features:onramp:impl",":domain:markets",1,0],[":features:onramp:api",":domain:onramp:models",1,0],[":features:onramp:api",":domain:tokens:models",1,0],[":features:onramp:api",":domain:wallets:models",1,0],[":features:walletconnect:impl",":features:common-features:api",1,0],[":features:walletconnect:impl",":features:wallet:api",1,0],[":features:walletconnect:impl",":features:walletconnect:api",1,0],[":features:walletconnect:impl",":features:send:api",1,0],[":features:walletconnect:impl",":domain:account",1,0],[":features:walletconnect:impl",":domain:account:status",1,0],[":features:walletconnect:impl",":domain:app-currency:models",1,0],[":features:walletconnect:impl",":domain:balance-hiding:models",1,0],[":features:walletconnect:impl",":domain:blockaid:models",1,0],[":features:walletconnect:impl",":domain:models",1,0],[":features:walletconnect:impl",":domain:qr-scanning:models",1,0],[":features:walletconnect:impl",":domain:tokens:models",1,0],[":features:walletconnect:impl",":domain:transaction:models",1,0],[":features:walletconnect:impl",":domain:wallets:models",1,0],[":features:walletconnect:impl",":domain:wallet-connect",1,0],[":features:walletconnect:impl",":domain:wallet-connect:models",1,0],[":features:walletconnect:impl",":domain:app-currency",1,0],[":features:walletconnect:impl",":domain:balance-hiding",1,0],[":features:walletconnect:impl",":domain:legacy",1,0],[":features:walletconnect:impl",":domain:qr-scanning",1,0],[":features:walletconnect:impl",":domain:tokens",1,0],[":features:walletconnect:impl",":domain:transaction",1,0],[":features:walletconnect:impl",":domain:wallets",1,0],[":features:walletconnect:impl",":data:card",1,0],[":features:walletconnect:api",":domain:models",1,0],[":features:stories:impl",":features:stories:api",1,0],[":features:stories:impl",":domain:stories",1,0],[":features:stories:impl",":domain:stories:models",1,0],[":features:onboarding-v2:impl",":features:onboarding-v2:api",1,0],[":features:onboarding-v2:impl",":features:manage-tokens:api",1,0],[":features:onboarding-v2:impl",":features:biometry:api",1,0],[":features:onboarding-v2:impl",":features:push-notifications:api",1,0],[":features:onboarding-v2:impl",":features:hot-wallet:api",1,0],[":features:onboarding-v2:impl",":features:token-recieve:api",1,0],[":features:onboarding-v2:impl",":domain:account",1,0],[":features:onboarding-v2:impl",":domain:models",1,0],[":features:onboarding-v2:impl",":domain:feedback",1,0],[":features:onboarding-v2:impl",":domain:feedback:models",1,0],[":features:onboarding-v2:impl",":domain:core",1,0],[":features:onboarding-v2:impl",":domain:card",1,0],[":features:onboarding-v2:impl",":domain:wallets",1,0],[":features:onboarding-v2:impl",":domain:wallets:models",1,0],[":features:onboarding-v2:impl",":domain:legacy",1,0],[":features:onboarding-v2:impl",":domain:settings",1,0],[":features:onboarding-v2:impl",":domain:onboarding",1,0],[":features:onboarding-v2:impl",":domain:visa",1,0],[":features:onboarding-v2:impl",":domain:tokens",1,0],[":features:onboarding-v2:impl",":domain:tokens:models",1,0],[":features:onboarding-v2:impl",":domain:onramp",1,0],[":features:onboarding-v2:impl",":domain:transaction",1,0],[":features:onboarding-v2:impl",":domain:staking",1,0],[":features:onboarding-v2:api",":domain:models",1,0],[":features:promo-banners:impl",":features:promo-banners:api",1,0],[":features:promo-banners:impl",":domain:common",1,0],[":features:promo-banners:impl",":domain:models",1,0],[":features:push-notifications:impl",":domain:settings",1,0],[":features:push-notifications:impl",":domain:notifications",1,0],[":features:push-notifications:impl",":domain:push-notification-preferences",1,0],[":features:push-notifications:impl",":domain:common",1,0],[":features:push-notifications:impl",":domain:account",1,0],[":features:push-notifications:impl",":domain:models",1,0],[":features:push-notifications:impl",":features:push-notifications:api",1,0],[":features:push-notifications:impl",":features:push-notification-settings:api",1,0],[":features:swap-v2:impl",":features:swap-v2:api",1,0],[":features:swap-v2:impl",":features:manage-tokens:api",1,0],[":features:swap-v2:impl",":features:send:api",1,0],[":features:swap-v2:impl",":features:common-features:api",1,0],[":features:swap-v2:impl",":domain:models",1,0],[":features:swap-v2:impl",":domain:wallets:models",1,0],[":features:swap-v2:impl",":domain:wallets",1,0],[":features:swap-v2:impl",":domain:tokens:models",1,0],[":features:swap-v2:impl",":domain:tokens",1,0],[":features:swap-v2:impl",":domain:card",1,0],[":features:swap-v2:impl",":domain:app-currency:models",1,0],[":features:swap-v2:impl",":domain:app-currency",1,0],[":features:swap-v2:impl",":domain:express:models",1,0],[":features:swap-v2:impl",":domain:swap:models",1,0],[":features:swap-v2:impl",":domain:swap",1,0],[":features:swap-v2:impl",":domain:manage-tokens:models",1,0],[":features:swap-v2:impl",":domain:manage-tokens",1,0],[":features:swap-v2:impl",":domain:transaction:models",1,0],[":features:swap-v2:impl",":domain:transaction",1,0],[":features:swap-v2:impl",":domain:legacy",1,0],[":features:swap-v2:impl",":domain:balance-hiding:models",1,0],[":features:swap-v2:impl",":domain:balance-hiding",1,0],[":features:swap-v2:impl",":domain:settings",1,0],[":features:swap-v2:impl",":domain:txhistory:models",1,0],[":features:swap-v2:impl",":domain:txhistory",1,0],[":features:swap-v2:impl",":domain:notifications",1,0],[":features:swap-v2:impl",":domain:feedback:models",1,0],[":features:swap-v2:impl",":domain:feedback",1,0],[":features:swap-v2:impl",":domain:account",1,0],[":features:swap-v2:impl",":domain:account:status",1,0],[":features:swap-v2:api",":features:send:api",1,1],[":features:swap-v2:api",":domain:wallets:models",1,0],[":features:swap-v2:api",":domain:express:models",1,0],[":features:swap-v2:api",":domain:swap:models",1,0],[":features:swap-v2:api",":domain:manage-tokens:models",1,0],[":features:swap-v2:api",":domain:models",1,0],[":features:swap-v2:api",":domain:tokens:models",1,0],[":features:swap-v2:api",":domain:app-currency:models",1,0],[":features:manage-tokens:impl",":features:manage-tokens:api",1,0],[":features:manage-tokens:impl",":features:swap-v2:api",1,0],[":features:manage-tokens:impl",":features:common-features:api",1,0],[":features:manage-tokens:impl",":domain:account:status",1,0],[":features:manage-tokens:impl",":domain:account",1,0],[":features:manage-tokens:impl",":domain:card",1,0],[":features:manage-tokens:impl",":domain:legacy",1,0],[":features:manage-tokens:impl",":domain:manage-tokens",1,0],[":features:manage-tokens:impl",":domain:tokens",1,0],[":features:manage-tokens:impl",":domain:tokens:models",1,0],[":features:manage-tokens:impl",":domain:wallets",1,0],[":features:manage-tokens:impl",":domain:wallets:models",1,0],[":features:manage-tokens:impl",":domain:swap:models",1,0],[":features:manage-tokens:impl",":domain:markets:models",1,0],[":features:manage-tokens:impl",":domain:notifications",1,0],[":features:manage-tokens:impl",":domain:dynamic-addresses",1,0],[":features:manage-tokens:api",":domain:models",1,0],[":features:manage-tokens:api",":domain:wallets:models",1,0],[":features:manage-tokens:api",":domain:manage-tokens:models",1,0],[":features:markets:impl",":features:markets:api",1,1],[":features:markets:impl",":features:onramp:api",1,1],[":features:markets:impl",":features:send:api",1,1],[":features:markets:impl",":features:token-recieve:api",1,1],[":features:markets:impl",":features:wallet:api",1,1],[":features:markets:impl",":features:account:api",1,1],[":features:markets:impl",":data:common",1,0],[":features:markets:impl",":domain:account",1,0],[":features:markets:impl",":domain:account:status",1,0],[":features:markets:impl",":domain:app-currency",1,0],[":features:markets:impl",":domain:app-currency:models",1,0],[":features:markets:impl",":domain:balance-hiding",1,0],[":features:markets:impl",":domain:balance-hiding:models",1,0],[":features:markets:impl",":domain:card",1,0],[":features:markets:impl",":domain:demo",1,0],[":features:markets:impl",":domain:feedback",1,0],[":features:markets:impl",":domain:feedback:models",1,0],[":features:markets:impl",":domain:manage-tokens",1,0],[":features:markets:impl",":domain:markets",1,0],[":features:markets:impl",":domain:offramp",1,0],[":features:markets:impl",":domain:onramp:models",1,0],[":features:markets:impl",":domain:staking:models",1,0],[":features:markets:impl",":domain:staking",1,0],[":features:markets:impl",":domain:tokens",1,0],[":features:markets:impl",":domain:tokens:models",1,0],[":features:markets:impl",":domain:wallets",1,0],[":features:markets:impl",":domain:wallets:models",1,0],[":features:markets:impl",":domain:settings",1,0],[":features:markets:impl",":domain:notifications:models",1,0],[":features:markets:impl",":domain:transaction",1,0],[":features:markets:impl",":domain:yield-supply:models",1,0],[":features:markets:impl",":domain:yield-supply",1,0],[":features:markets:api",":domain:core",1,0],[":features:markets:api",":domain:tokens:models",1,0],[":features:markets:api",":domain:app-currency:models",1,0],[":features:markets:api",":domain:markets:models",1,0],[":features:feed:impl",":features:feed:api",1,1],[":features:feed:impl",":features:onramp:api",1,1],[":features:feed:impl",":features:send:api",1,1],[":features:feed:impl",":features:token-recieve:api",1,1],[":features:feed:impl",":features:wallet:api",1,1],[":features:feed:impl",":features:account:api",1,1],[":features:feed:impl",":features:common-features:api",1,1],[":features:feed:impl",":features:promo-banners:api",1,0],[":features:feed:impl",":data:common",1,0],[":features:feed:impl",":domain:account",1,0],[":features:feed:impl",":domain:account:status",1,0],[":features:feed:impl",":domain:app-currency",1,0],[":features:feed:impl",":domain:app-currency:models",1,0],[":features:feed:impl",":domain:balance-hiding",1,0],[":features:feed:impl",":domain:balance-hiding:models",1,0],[":features:feed:impl",":domain:card",1,0],[":features:feed:impl",":domain:demo",1,0],[":features:feed:impl",":domain:feedback",1,0],[":features:feed:impl",":domain:feedback:models",1,0],[":features:feed:impl",":domain:manage-tokens",1,0],[":features:feed:impl",":domain:markets",1,0],[":features:feed:impl",":domain:offramp",1,0],[":features:feed:impl",":domain:onramp:models",1,0],[":features:feed:impl",":domain:staking:models",1,0],[":features:feed:impl",":domain:tokens",1,0],[":features:feed:impl",":domain:tokens:models",1,0],[":features:feed:impl",":domain:wallets",1,0],[":features:feed:impl",":domain:wallets:models",1,0],[":features:feed:impl",":domain:settings",1,0],[":features:feed:impl",":domain:notifications:models",1,0],[":features:feed:impl",":domain:transaction",1,0],[":features:feed:impl",":domain:news",1,0],[":features:feed:impl",":domain:yield-supply:models",1,0],[":features:feed:impl",":domain:yield-supply",1,0],[":features:feed:impl",":domain:earn",1,0],[":features:feed:impl",":domain:search",1,0],[":features:feed:api",":features:account:api",1,1],[":features:feed:api",":domain:core",1,0],[":features:feed:api",":domain:models",1,0],[":features:feed:api",":domain:tokens:models",1,0],[":features:feed:api",":domain:app-currency:models",1,0],[":features:feed:api",":domain:markets:models",1,0],[":features:staking:impl",":domain:tokens",1,0],[":features:staking:impl",":domain:tokens:models",1,0],[":features:staking:impl",":domain:wallets",1,0],[":features:staking:impl",":domain:wallets:models",1,0],[":features:staking:impl",":domain:staking",1,0],[":features:staking:impl",":domain:balance-hiding",1,0],[":features:staking:impl",":domain:balance-hiding:models",1,0],[":features:staking:impl",":domain:app-currency",1,0],[":features:staking:impl",":domain:app-currency:models",1,0],[":features:staking:impl",":domain:legacy",1,0],[":features:staking:impl",":domain:models",1,0],[":features:staking:impl",":domain:transaction",1,0],[":features:staking:impl",":domain:transaction:models",1,0],[":features:staking:impl",":domain:txhistory",1,0],[":features:staking:impl",":domain:txhistory:models",1,0],[":features:staking:impl",":domain:feedback",1,0],[":features:staking:impl",":domain:feedback:models",1,0],[":features:staking:impl",":domain:notifications:models",1,0],[":features:staking:impl",":domain:account",1,0],[":features:staking:impl",":domain:account:status",1,0],[":features:staking:impl",":features:send:api",1,0],[":features:staking:impl",":features:staking:api",1,0],[":features:staking:impl",":features:txhistory:api",1,0],[":features:staking:impl",":features:approval:api",1,0],[":features:staking:api",":domain:models",1,1],[":features:staking:api",":domain:staking",1,0],[":features:staking:api",":domain:tokens:models",1,0],[":features:staking:api",":domain:wallets:models",1,0],[":features:address-book:impl",":features:address-book:api",1,0],[":features:address-book:impl",":domain:account",1,0],[":features:address-book:impl",":domain:address-book",1,0],[":features:address-book:impl",":domain:models",1,0],[":features:address-book:api",":domain:models",1,0],[":features:wallet:impl",":domain:account",1,0],[":features:wallet:impl",":domain:account:status",1,0],[":features:wallet:impl",":domain:analytics",1,0],[":features:wallet:impl",":domain:app-currency",1,0],[":features:wallet:impl",":domain:app-currency:models",1,0],[":features:wallet:impl",":domain:balance-hiding",1,0],[":features:wallet:impl",":domain:balance-hiding:models",1,0],[":features:wallet:impl",":domain:card",1,0],[":features:wallet:impl",":domain:wallet-manager",1,0],[":features:wallet:impl",":domain:demo",1,0],[":features:wallet:impl",":domain:feedback",1,0],[":features:wallet:impl",":domain:feedback:models",1,0],[":features:wallet:impl",":domain:legacy",1,0],[":features:wallet:impl",":domain:markets:models",1,0],[":features:wallet:impl",":domain:models",1,0],[":features:wallet:impl",":domain:networks",1,0],[":features:wallet:impl",":domain:qr-scanning",1,0],[":features:wallet:impl",":domain:qr-scanning:models",1,0],[":features:wallet:impl",":domain:wallet-connect",1,0],[":features:wallet:impl",":domain:wallet-connect:models",1,0],[":features:wallet:impl",":domain:nft",1,0],[":features:wallet:impl",":domain:nft:models",1,0],[":features:wallet:impl",":domain:hot-wallet",1,0],[":features:wallet:impl",":domain:offramp",1,0],[":features:wallet:impl",":domain:onramp",1,0],[":features:wallet:impl",":domain:onramp:models",1,0],[":features:wallet:impl",":domain:stories",1,0],[":features:wallet:impl",":domain:stories:models",1,0],[":features:wallet:impl",":domain:quotes",1,0],[":features:wallet:impl",":domain:settings",1,0],[":features:wallet:impl",":domain:staking",1,0],[":features:wallet:impl",":domain:staking:models",1,0],[":features:wallet:impl",":domain:tokens",1,0],[":features:wallet:impl",":domain:tokens:models",1,0],[":features:wallet:impl",":domain:txhistory",1,0],[":features:wallet:impl",":domain:txhistory:models",1,0],[":features:wallet:impl",":domain:visa",1,0],[":features:wallet:impl",":domain:wallets",1,0],[":features:wallet:impl",":domain:wallets:models",1,0],[":features:wallet:impl",":domain:notifications",1,0],[":features:wallet:impl",":domain:push-notification-preferences",1,0],[":features:wallet:impl",":domain:transaction",1,0],[":features:wallet:impl",":domain:yield-supply",1,0],[":features:wallet:impl",":domain:yield-supply:models",1,0],[":features:wallet:impl",":domain:app-theme",1,0],[":features:wallet:impl",":domain:app-theme:models",1,0],[":features:wallet:impl",":domain:assetsdiscovery",1,0],[":features:wallet:impl",":features:common-features:api",1,0],[":features:wallet:impl",":features:account:api",1,0],[":features:wallet:impl",":features:details:api",1,0],[":features:wallet:impl",":features:hot-wallet:api",1,0],[":features:wallet:impl",":features:manage-tokens:api",1,0],[":features:wallet:impl",":features:markets:api",1,0],[":features:wallet:impl",":features:onboarding-v2:api",1,0],[":features:wallet:impl",":features:onramp:api",1,0],[":features:wallet:impl",":features:push-notifications:api",1,0],[":features:wallet:impl",":features:push-notification-settings:api",1,0],[":features:wallet:impl",":features:swap:api",1,0],[":features:wallet:impl",":features:tester:api",1,0],[":features:wallet:impl",":features:tokendetails:api",1,0],[":features:wallet:impl",":features:wallet:api",1,0],[":features:wallet:impl",":features:wallet-settings:api",1,0],[":features:wallet:impl",":features:biometry:api",1,0],[":features:wallet:impl",":features:nft:api",1,0],[":features:wallet:impl",":features:send:api",1,0],[":features:wallet:impl",":features:kyc:api",1,0],[":features:wallet:impl",":features:token-recieve:api",1,0],[":features:wallet:impl",":features:yield-supply:api",1,0],[":features:wallet:impl",":features:feed:api",1,0],[":features:wallet:impl",":features:promo-banners:api",1,0],[":features:wallet:impl",":features:tangempay:main:api",1,0],[":features:wallet:impl",":features:tangempay:details:api",1,0],[":features:wallet:impl",":features:virtual-accounts:main:api",1,0],[":features:wallet:api",":domain:models",1,0],[":features:wallet:api",":domain:visa:models",1,0],[":features:tester:impl",":domain:account",1,0],[":features:tester:impl",":domain:card",1,0],[":features:tester:impl",":domain:feedback",1,0],[":features:tester:impl",":domain:markets:models",1,0],[":features:tester:impl",":domain:markets",1,0],[":features:tester:impl",":domain:manage-tokens:models",1,0],[":features:tester:impl",":domain:manage-tokens",1,0],[":features:tester:impl",":domain:wallets:models",1,0],[":features:tester:impl",":domain:wallets",1,0],[":features:tester:impl",":domain:feedback:models",1,0],[":features:tester:impl",":domain:settings",1,0],[":features:tester:impl",":data:common",1,0],[":features:tester:impl",":features:tester:api",1,0],[":features:tester:impl",":features:push-notifications:api",1,0],[":features:tester:impl",":features:survey:api",1,0],[":features:biometry:impl",":features:biometry:api",1,1],[":features:biometry:impl",":features:hot-wallet:api",1,0],[":features:biometry:impl",":domain:wallets",1,0],[":features:biometry:impl",":domain:models",1,1],[":features:biometry:impl",":domain:settings",1,0],[":features:biometry:impl",":domain:card",1,0],[":features:account:impl",":features:account:api",1,0],[":features:account:impl",":features:wallet:api",1,0],[":features:account:impl",":domain:models",1,0],[":features:account:impl",":domain:account",1,0],[":features:account:impl",":domain:account:status",1,0],[":features:account:impl",":domain:core",1,0],[":features:account:impl",":domain:app-currency",1,0],[":features:account:impl",":domain:app-currency:models",1,0],[":features:account:impl",":domain:tokens",1,0],[":features:account:impl",":domain:tokens:models",1,0],[":features:account:impl",":domain:balance-hiding",1,0],[":features:account:impl",":domain:balance-hiding:models",1,0],[":features:account:impl",":domain:wallets",1,0],[":features:account:impl",":domain:wallets:models",1,0],[":features:account:api",":domain:models",1,0],[":features:account:api",":domain:core",1,0],[":features:account:api",":domain:app-currency:models",1,0],[":features:account:api",":domain:tokens",1,0],[":features:account:api",":domain:tokens:models",1,0],[":features:account:api",":domain:account",1,0],[":features:hot-wallet:impl",":features:hot-wallet:api",1,0],[":features:hot-wallet:impl",":features:onboarding-v2:api",1,0],[":features:hot-wallet:impl",":features:push-notifications:api",1,0],[":features:hot-wallet:impl",":domain:card",1,0],[":features:hot-wallet:impl",":domain:models",2,0],[":features:hot-wallet:impl",":domain:wallets",1,0],[":features:hot-wallet:impl",":domain:wallets:models",1,0],[":features:hot-wallet:impl",":domain:settings",1,0],[":features:hot-wallet:impl",":domain:feedback",1,0],[":features:hot-wallet:impl",":domain:feedback:models",1,0],[":features:hot-wallet:impl",":domain:hot-wallet",1,0],[":features:hot-wallet:impl",":domain:assetsdiscovery",1,0],[":features:hot-wallet:api",":domain:models",1,0],[":features:hot-wallet:api",":domain:wallets",1,0],[":features:hot-wallet:api",":domain:wallets:models",1,0],[":features:survey:impl",":features:survey:api",1,0],[":features:survey:impl",":domain:common",1,0],[":features:survey:impl",":domain:models",1,0],[":features:survey:impl",":domain:wallets",1,0],[":features:survey:impl",":domain:wallets:models",1,0],[":features:send:impl",":features:send:api",1,0],[":features:send:impl",":features:txhistory:api",1,0],[":features:send:impl",":features:nft:api",1,0],[":features:send:impl",":features:swap-v2:api",1,0],[":features:send:impl",":features:manage-tokens:api",1,0],[":features:send:impl",":domain:models",1,0],[":features:send:impl",":domain:legacy",1,0],[":features:send:impl",":domain:offramp",1,0],[":features:send:impl",":domain:card",1,0],[":features:send:impl",":domain:tokens:models",1,0],[":features:send:impl",":domain:tokens",1,0],[":features:send:impl",":domain:wallets:models",1,0],[":features:send:impl",":domain:wallets",1,0],[":features:send:impl",":domain:app-currency:models",1,0],[":features:send:impl",":domain:app-currency",1,0],[":features:send:impl",":domain:transaction:models",1,0],[":features:send:impl",":domain:transaction",2,0],[":features:send:impl",":domain:txhistory:models",1,0],[":features:send:impl",":domain:txhistory",2,0],[":features:send:impl",":domain:qr-scanning:models",1,0],[":features:send:impl",":domain:qr-scanning",1,0],[":features:send:impl",":domain:settings",1,0],[":features:send:impl",":domain:feedback",1,0],[":features:send:impl",":domain:feedback:models",1,0],[":features:send:impl",":domain:balance-hiding:models",1,0],[":features:send:impl",":domain:balance-hiding",1,0],[":features:send:impl",":domain:nft:models",1,0],[":features:send:impl",":domain:nft",1,0],[":features:send:impl",":domain:notifications",1,0],[":features:send:impl",":domain:swap:models",1,0],[":features:send:impl",":domain:account",1,0],[":features:send:impl",":domain:account:status",1,0],[":features:send:api",":domain:transaction",1,0],[":features:send:api",":domain:models",1,1],[":features:send:api",":domain:app-currency:models",1,0],[":features:send:api",":domain:nft:models",1,0],[":features:send:api",":domain:tokens:models",1,0],[":features:send:api",":domain:transaction:models",1,0],[":features:send:api",":domain:wallets:models",1,0],[":features:send:api",":domain:staking:models",1,0],[":features:virtual-accounts:details:impl",":features:virtual-accounts:details:api",1,0],[":features:virtual-accounts:main:impl",":features:virtual-accounts:main:api",1,0],[":features:virtual-accounts:onboarding:impl",":features:virtual-accounts:onboarding:api",1,0],[":features:tangempay:details:impl",":features:tangempay:details:api",1,0],[":features:tangempay:details:impl",":features:token-recieve:api",1,0],[":features:tangempay:details:impl",":features:txhistory:api",1,0],[":features:tangempay:details:impl",":features:tokendetails:api",1,0],[":features:tangempay:details:impl",":domain:balance-hiding",1,0],[":features:tangempay:details:impl",":domain:balance-hiding:models",1,0],[":features:tangempay:details:impl",":domain:feedback",1,0],[":features:tangempay:details:impl",":domain:feedback:models",1,0],[":features:tangempay:details:impl",":domain:models",1,0],[":features:tangempay:details:impl",":domain:onramp:models",1,0],[":features:tangempay:details:impl",":domain:visa",1,0],[":features:tangempay:details:impl",":domain:visa:models",1,0],[":features:tangempay:details:impl",":domain:wallets",1,0],[":features:tangempay:details:api",":domain:models",1,0],[":features:tangempay:details:api",":domain:visa:models",1,0],[":features:tangempay:main:impl",":features:tangempay:main:api",1,0],[":features:tangempay:onboarding:impl",":features:tangempay:onboarding:api",1,0],[":features:tangempay:onboarding:impl",":features:tangempay:details:api",1,0],[":features:tangempay:onboarding:impl",":features:kyc:api",1,0],[":features:tangempay:onboarding:impl",":features:wallet:api",1,0],[":features:tangempay:onboarding:impl",":features:hot-wallet:api",1,0],[":features:tangempay:onboarding:impl",":domain:appsflyer",1,0],[":features:tangempay:onboarding:impl",":domain:visa",1,0],[":features:tangempay:onboarding:impl",":domain:wallets",1,0],[":features:tangempay:onboarding:impl",":domain:wallets:models",1,0],[":features:tangempay:onboarding:impl",":domain:hot-wallet",1,0],[":features:tangempay:onboarding:impl",":data:visa",1,0],[":features:tangempay:onboarding:api",":domain:models",1,0],[":features:approval:impl",":features:approval:api",1,0],[":features:approval:impl",":features:send:api",1,0],[":features:approval:impl",":domain:models",1,0],[":features:approval:impl",":domain:wallets",1,0],[":features:approval:impl",":domain:wallets:models",1,0],[":features:approval:impl",":domain:transaction:models",1,0],[":features:approval:impl",":domain:transaction",1,0],[":features:approval:api",":domain:models",1,0],[":features:approval:api",":domain:wallets:models",1,0],[":features:push-notification-settings:impl",":features:push-notification-settings:api",1,0],[":features:push-notification-settings:impl",":features:push-notifications:api",1,0],[":features:push-notification-settings:impl",":features:wallet-settings:api",1,0],[":features:push-notification-settings:impl",":domain:models",1,0],[":features:push-notification-settings:impl",":domain:account",1,0],[":features:push-notification-settings:impl",":domain:push-notification-preferences",1,0],[":features:push-notification-settings:api",":domain:models",1,0],[":data:transaction",":data:common",1,0],[":data:transaction",":domain:legacy",1,0],[":data:transaction",":domain:wallet-manager",1,0],[":data:transaction",":domain:wallets:models",1,0],[":data:transaction",":domain:tokens:models",1,0],[":data:transaction",":domain:transaction:models",1,0],[":data:transaction",":domain:transaction",1,0],[":data:transaction",":domain:demo",1,0],[":data:transaction",":features:send:api",1,0],[":data:settings",":domain:balance-hiding:models",1,0],[":data:settings",":domain:settings",1,0],[":data:dynamic-addresses",":data:common",1,0],[":data:dynamic-addresses",":domain:account",1,0],[":data:dynamic-addresses",":domain:common",1,0],[":data:dynamic-addresses",":domain:dynamic-addresses",1,0],[":data:dynamic-addresses",":domain:dynamic-addresses:models",1,0],[":data:dynamic-addresses",":domain:models",1,0],[":data:dynamic-addresses",":domain:wallet-manager",1,0],[":data:app-theme",":domain:app-theme",1,0],[":data:app-theme",":domain:app-theme:models",1,0],[":data:yield-supply",":domain:yield-supply",1,0],[":data:yield-supply",":domain:yield-supply:models",1,0],[":data:yield-supply",":domain:wallet-manager",1,0],[":data:yield-supply",":domain:legacy",1,0],[":data:yield-supply",":domain:txhistory:models",1,0],[":data:txhistory",":data:common",1,0],[":data:txhistory",":domain:legacy",1,0],[":data:txhistory",":domain:common",1,0],[":data:txhistory",":domain:wallet-manager",1,0],[":data:txhistory",":domain:models",1,0],[":data:txhistory",":domain:tokens:models",1,0],[":data:txhistory",":domain:txhistory",1,0],[":data:txhistory",":domain:txhistory:models",1,0],[":data:txhistory",":domain:express:models",1,0],[":data:txhistory",":domain:wallets:models",1,0],[":data:txhistory",":domain:wallets",1,0],[":data:txhistory",":domain:account",1,0],[":data:txhistory",":domain:account:status",1,0],[":data:push-notification-preferences",":domain:push-notification-preferences",1,0],[":data:push-notification-preferences",":domain:models",1,0],[":data:card",":domain:card",1,0],[":data:card",":domain:models",1,0],[":data:nft",":data:common",1,0],[":data:nft",":domain:card",1,0],[":data:nft",":domain:common",1,0],[":data:nft",":domain:models",1,0],[":data:nft",":domain:nft",1,0],[":data:nft",":domain:nft:models",1,0],[":data:nft",":domain:tokens:models",1,0],[":data:nft",":domain:wallet-manager",1,0],[":data:nft",":domain:wallets:models",1,0],[":data:nft",":domain:legacy",1,0],[":data:nft",":features:nft:api",1,0],[":data:quotes",":data:common",1,0],[":data:quotes",":domain:models",1,1],[":data:quotes",":domain:quotes",1,1],[":data:wallet-manager",":domain:wallets",1,0],[":data:wallet-manager",":domain:wallet-manager",1,0],[":data:wallet-manager",":domain:demo",1,0],[":data:wallet-manager",":domain:card",1,0],[":data:wallet-manager",":domain:transaction",1,0],[":data:wallet-manager",":domain:models",1,1],[":data:wallet-manager",":domain:wallets:models",1,0],[":data:wallet-manager",":domain:tokens:models",1,0],[":data:wallet-manager",":domain:txhistory:models",1,0],[":data:wallet-manager",":domain:transaction:models",1,0],[":data:express",":data:common",1,0],[":data:express",":domain:common",1,0],[":data:express",":domain:express:models",1,0],[":data:express",":domain:express",1,0],[":data:express",":domain:wallets:models",1,0],[":data:express",":domain:txhistory",1,0],[":data:express",":domain:models",1,1],[":data:payment",":data:common",1,0],[":data:payment",":data:wallets",1,0],[":data:payment",":domain:payment",1,0],[":data:payment",":domain:payment:models",1,0],[":data:payment",":domain:wallets",1,0],[":data:payment",":domain:models",1,0],[":data:payment",":domain:common",1,0],[":data:payment",":domain:legacy",1,0],[":data:qr-scanning",":domain:models",1,0],[":data:qr-scanning",":domain:qr-scanning",1,0],[":data:qr-scanning",":domain:qr-scanning:models",1,0],[":data:qr-scanning",":domain:tokens:models",1,0],[":data:blockaid",":data:common",1,0],[":data:blockaid",":domain:models",1,0],[":data:blockaid",":domain:blockaid",1,0],[":data:blockaid",":domain:blockaid:models",1,0],[":data:app-currency",":domain:core",1,0],[":data:app-currency",":domain:app-currency",1,0],[":data:app-currency",":domain:app-currency:models",1,0],[":data:app-currency",":data:common",1,0],[":data:swap",":data:common",1,0],[":data:swap",":data:express",1,0],[":data:swap",":domain:express:models",1,0],[":data:swap",":domain:express",1,0],[":data:swap",":domain:swap:models",1,0],[":data:swap",":domain:swap",1,0],[":data:swap",":domain:wallets:models",1,0],[":data:swap",":domain:wallets",1,0],[":data:swap",":domain:tokens:models",1,0],[":data:swap",":domain:tokens",1,0],[":data:swap",":domain:legacy",1,0],[":data:swap",":domain:models",1,0],[":data:swap",":domain:quotes",1,0],[":data:swap",":domain:networks",1,0],[":data:swap",":domain:staking:models",1,0],[":data:swap",":domain:staking",1,0],[":data:swap",":domain:account",1,0],[":data:earn",":data:common",1,0],[":data:earn",":domain:earn",1,0],[":data:earn",":domain:common",1,0],[":data:earn",":domain:account:status",1,0],[":data:wallet-connect",":domain:account",1,0],[":data:wallet-connect",":domain:account:status",1,0],[":data:wallet-connect",":domain:wallet-connect",1,0],[":data:wallet-connect",":domain:wallet-connect:models",1,0],[":data:wallet-connect",":domain:transaction",1,0],[":data:wallet-connect",":domain:transaction:models",1,0],[":data:wallet-connect",":domain:wallets",1,0],[":data:wallet-connect",":domain:wallets:models",1,0],[":data:wallet-connect",":domain:tokens",1,0],[":data:wallet-connect",":domain:tokens:models",1,0],[":data:wallet-connect",":domain:models",1,0],[":data:wallet-connect",":domain:legacy",1,0],[":data:wallet-connect",":domain:wallet-manager",1,0],[":data:wallet-connect",":data:common",1,0],[":data:wallet-connect",":domain:blockaid",1,0],[":data:wallet-connect",":domain:blockaid:models",1,0],[":data:visa",":data:common",1,0],[":data:visa",":data:wallets",1,0],[":data:visa",":domain:visa",1,0],[":data:visa",":domain:card",1,0],[":data:visa",":domain:wallets",1,0],[":data:visa",":domain:legacy",2,0],[":data:visa",":domain:models",1,0],[":data:visa",":domain:wallets:models",1,0],[":data:visa",":domain:app-currency:models",1,0],[":data:visa",":domain:tokens:models",1,0],[":data:visa",":domain:tokens",1,0],[":data:visa",":domain:networks",1,0],[":data:visa",":domain:wallet-manager",1,0],[":data:visa",":domain:quotes",1,0],[":data:visa",":domain:common",1,0],[":data:visa",":features:swap:domain",1,0],[":data:balance-hiding",":domain:balance-hiding",1,0],[":data:balance-hiding",":domain:balance-hiding:models",1,0],[":data:feedback",":features:hot-wallet:api",1,0],[":data:feedback",":domain:feedback",1,0],[":data:feedback",":domain:feedback:models",1,0],[":data:feedback",":domain:legacy",1,0],[":data:feedback",":domain:card",1,0],[":data:feedback",":domain:models",1,0],[":data:feedback",":domain:wallets",1,0],[":data:feedback",":domain:wallets:models",1,0],[":data:search",":data:common",1,0],[":data:search",":domain:search",1,0],[":data:search",":domain:common",1,0],[":data:search",":domain:account:status",1,0],[":data:search",":domain:markets:models",1,0],[":data:search",":domain:wallets",1,0],[":data:search",":domain:app-currency",1,0],[":data:onramp",":data:common",1,0],[":data:onramp",":domain:account",1,0],[":data:onramp",":domain:onramp",1,0],[":data:onramp",":domain:legacy",1,0],[":data:onramp",":domain:card",1,0],[":data:onramp",":domain:wallet-manager",1,0],[":data:onramp",":domain:app-theme:models",1,0],[":data:onramp",":domain:models",1,0],[":data:onramp",":domain:express:models",1,0],[":data:onramp",":domain:txhistory",1,0],[":data:networks",":data:common",1,0],[":data:networks",":data:dynamic-addresses",1,0],[":data:networks",":domain:card",1,0],[":data:networks",":domain:common",1,0],[":data:networks",":domain:legacy",1,0],[":data:networks",":domain:models",1,0],[":data:networks",":domain:networks",1,0],[":data:networks",":domain:wallet-manager",1,0],[":data:common",":domain:account",1,0],[":data:common",":domain:demo",1,0],[":data:common",":domain:legacy",1,0],[":data:common",":domain:card",1,0],[":data:common",":domain:models",1,0],[":data:common",":domain:tokens:models",1,0],[":data:common",":domain:wallets:models",1,0],[":data:common",":domain:express:models",1,0],[":data:common",":domain:networks",1,0],[":data:common",":domain:wallet-manager",1,0],[":data:common",":domain:wallets",1,0],[":data:stories",":domain:stories",1,0],[":data:stories",":domain:stories:models",1,0],[":data:stories",":domain:models",1,1],[":data:stories",":domain:wallets:models",1,0],[":data:stories",":features:referral:domain",1,0],[":data:news",":data:common",1,0],[":data:news",":domain:news",1,0],[":data:manage-tokens",":domain:account",1,0],[":data:manage-tokens",":domain:demo",1,0],[":data:manage-tokens",":domain:models",1,0],[":data:manage-tokens",":domain:manage-tokens",1,0],[":data:manage-tokens",":domain:card",1,0],[":data:manage-tokens",":domain:wallets",1,0],[":data:manage-tokens",":domain:tokens:models",1,0],[":data:manage-tokens",":domain:wallets:models",1,0],[":data:manage-tokens",":domain:legacy",2,0],[":data:manage-tokens",":data:common",1,0],[":data:manage-tokens",":data:tokens",1,0],[":data:markets",":domain:legacy",1,0],[":data:markets",":domain:markets",1,0],[":data:markets",":domain:models",1,0],[":data:markets",":domain:tokens:models",1,0],[":data:markets",":domain:tokens",1,0],[":data:markets",":data:common",1,0],[":data:staking",":data:common",1,0],[":data:staking",":domain:tokens:models",1,0],[":data:staking",":domain:staking",1,0],[":data:staking",":domain:wallets",1,0],[":data:staking",":domain:wallets:models",1,0],[":data:staking",":domain:legacy",1,0],[":data:staking",":domain:wallet-manager",1,0],[":data:staking",":domain:card",1,0],[":data:staking",":domain:models",1,0],[":data:staking",":features:staking:api",1,0],[":data:address-book",":domain:address-book",1,0],[":data:address-book",":domain:common",1,0],[":data:address-book",":domain:models",1,0],[":data:assetsdiscovery",":domain:assetsdiscovery",1,1],[":data:assetsdiscovery",":domain:tokens",1,0],[":data:assetsdiscovery",":domain:tokens:models",1,0],[":data:assetsdiscovery",":domain:models",1,0],[":data:assetsdiscovery",":domain:wallet-manager",1,0],[":data:assetsdiscovery",":domain:wallets",1,0],[":data:assetsdiscovery",":data:common",1,0],[":data:assetsdiscovery",":data:wallet-manager",1,0],[":data:wallets",":data:common",1,0],[":data:wallets",":domain:account",1,0],[":data:wallets",":domain:card",1,0],[":data:wallets",":domain:dynamic-addresses",1,0],[":data:wallets",":domain:models",1,0],[":data:wallets",":domain:tokens:models",1,0],[":data:wallets",":domain:wallets",1,0],[":data:wallets",":domain:wallets:models",1,0],[":data:wallets",":domain:settings",1,0],[":data:account",":features:virtual-accounts:details:api",1,0],[":data:account",":domain:account",1,1],[":data:account",":domain:card",1,1],[":data:account",":domain:common",1,1],[":data:account",":domain:models",1,1],[":data:account",":domain:tokens",1,1],[":data:account",":domain:wallets",1,1],[":data:account",":domain:visa",1,1],[":data:account",":data:common",1,0],[":data:hot-wallet",":domain:hot-wallet",1,0],[":data:hot-wallet",":domain:models",1,0],[":data:appsflyer",":domain:appsflyer",1,0],[":data:tokens",":data:common",1,0],[":data:tokens",":data:networks",1,0],[":data:tokens",":domain:account",1,0],[":data:tokens",":domain:card",1,0],[":data:tokens",":domain:common",1,0],[":data:tokens",":domain:core",1,0],[":data:tokens",":domain:demo",1,0],[":data:tokens",":domain:express",1,0],[":data:tokens",":domain:legacy",1,0],[":data:tokens",":domain:models",1,0],[":data:tokens",":domain:staking",1,0],[":data:tokens",":domain:staking:models",1,0],[":data:tokens",":domain:tokens",1,0],[":data:tokens",":domain:tokens:models",1,0],[":data:tokens",":domain:txhistory:models",1,0],[":data:tokens",":domain:wallet-manager",1,0],[":data:tokens",":domain:transaction",1,0],[":data:tokens",":domain:wallets:models",1,0],[":data:tokens",":features:send:api",1,0],[":data:notifications",":domain:notifications:models",1,0],[":data:notifications",":domain:notifications",1,0],[":data:onboarding",":domain:onboarding",1,0],[":data:onboarding",":domain:models",1,0],[":data:analytics",":domain:analytics",1,0],[":data:analytics",":domain:models",1,0],[":data:analytics",":domain:wallets:models",1,0],[":data:analytics",":data:common",1,0],[":domain:demo",":domain:demo:models",1,1],[":domain:transaction",":domain:account:status",1,0],[":domain:transaction",":domain:common",1,0],[":domain:transaction",":domain:dynamic-addresses",1,0],[":domain:transaction",":domain:dynamic-addresses:models",1,0],[":domain:transaction",":domain:models",1,0],[":domain:transaction",":domain:legacy",1,0],[":domain:transaction",":domain:wallet-manager",1,0],[":domain:transaction",":domain:wallets:models",1,0],[":domain:transaction",":domain:tokens",1,0],[":domain:transaction",":domain:tokens:models",1,0],[":domain:transaction",":domain:transaction:models",1,0],[":domain:transaction",":domain:demo",1,0],[":domain:transaction",":domain:card",1,0],[":domain:transaction",":domain:notifications",1,0],[":domain:transaction",":domain:networks",1,1],[":domain:settings",":domain:balance-hiding:models",1,0],[":domain:settings",":domain:wallets:models",1,0],[":domain:dynamic-addresses",":domain:core",1,1],[":domain:dynamic-addresses",":domain:dynamic-addresses:models",1,1],[":domain:dynamic-addresses",":domain:models",1,0],[":domain:dynamic-addresses",":domain:wallet-manager",1,0],[":domain:dynamic-addresses",":domain:wallets",1,0],[":domain:app-theme",":domain:core",1,0],[":domain:app-theme",":domain:app-theme:models",1,0],[":domain:yield-supply",":domain:account:status",1,0],[":domain:yield-supply",":domain:models",1,0],[":domain:yield-supply",":domain:yield-supply:models",1,0],[":domain:yield-supply",":domain:transaction:models",1,0],[":domain:yield-supply",":domain:transaction",1,0],[":domain:yield-supply",":domain:legacy",1,0],[":domain:yield-supply",":domain:blockaid:models",1,0],[":domain:yield-supply",":domain:blockaid",1,0],[":domain:yield-supply",":domain:quotes",1,0],[":domain:yield-supply",":domain:tokens",1,0],[":domain:yield-supply",":domain:app-currency:models",1,0],[":domain:yield-supply:models",":domain:models",1,1],[":domain:txhistory",":domain:core",1,0],[":domain:txhistory",":domain:express:models",1,1],[":domain:txhistory",":domain:models",1,0],[":domain:txhistory",":domain:tokens:models",1,0],[":domain:txhistory",":domain:txhistory:models",1,0],[":domain:txhistory",":domain:wallets:models",1,0],[":domain:txhistory",":domain:visa:models",1,0],[":domain:push-notification-preferences",":domain:models",1,0],[":domain:card",":domain:demo",1,0],[":domain:card",":domain:core",1,0],[":domain:card",":domain:legacy",1,0],[":domain:card",":domain:wallet-manager",1,0],[":domain:card",":domain:models",1,0],[":domain:card",":domain:tokens:models",1,0],[":domain:card",":domain:wallets:models",1,0],[":domain:card",":domain:visa:models",1,0],[":domain:nft",":domain:core",1,0],[":domain:nft",":domain:account",1,0],[":domain:nft",":domain:models",1,0],[":domain:nft",":domain:networks",1,0],[":domain:nft",":domain:nft:models",1,0],[":domain:nft",":domain:quotes",1,0],[":domain:nft",":domain:tokens",1,0],[":domain:nft",":domain:tokens:models",1,0],[":domain:nft",":domain:wallets",1,0],[":domain:nft",":domain:wallets:models",1,0],[":domain:nft:models",":domain:core",1,0],[":domain:nft:models",":domain:models",1,0],[":domain:nft:models",":domain:tokens:models",1,0],[":domain:quotes",":domain:core",1,1],[":domain:quotes",":domain:models",1,1],[":domain:wallet-manager",":domain:wallet-manager:models",1,1],[":domain:wallet-manager",":domain:models",1,1],[":domain:wallet-manager",":domain:core",1,0],[":domain:wallet-manager",":domain:demo:models",1,0],[":domain:wallet-manager",":domain:wallets:models",1,0],[":domain:wallet-manager",":domain:tokens:models",1,0],[":domain:wallet-manager",":domain:app-currency:models",1,0],[":domain:wallet-manager",":domain:transaction:models",1,0],[":domain:wallet-manager",":domain:txhistory:models",1,0],[":domain:wallet-manager:models",":domain:models",1,0],[":domain:express",":domain:express:models",1,1],[":domain:express",":domain:models",1,1],[":domain:express:models",":domain:tokens:models",1,0],[":domain:payment",":domain:models",1,1],[":domain:payment",":domain:payment:models",1,0],[":domain:payment:models",":domain:models",1,0],[":domain:qr-scanning",":domain:models",1,1],[":domain:qr-scanning",":domain:account",1,0],[":domain:qr-scanning",":domain:common",1,0],[":domain:qr-scanning",":domain:networks",1,0],[":domain:qr-scanning",":domain:qr-scanning:models",1,0],[":domain:qr-scanning",":domain:tokens:models",1,0],[":domain:qr-scanning:models",":domain:models",1,0],[":domain:blockaid",":domain:models",1,0],[":domain:blockaid",":domain:core",1,0],[":domain:blockaid",":domain:blockaid:models",1,0],[":domain:app-currency",":domain:core",1,0],[":domain:app-currency",":domain:app-currency:models",1,0],[":domain:swap",":domain:models",1,0],[":domain:swap",":domain:express:models",1,0],[":domain:swap",":domain:swap:models",1,0],[":domain:swap",":domain:wallets:models",1,0],[":domain:swap",":domain:tokens:models",1,0],[":domain:swap:models",":domain:models",1,0],[":domain:swap:models",":domain:express:models",1,0],[":domain:swap:models",":domain:tokens:models",1,0],[":domain:legacy",":domain:core",1,0],[":domain:legacy",":domain:demo",1,0],[":domain:legacy",":domain:models",1,0],[":domain:legacy",":domain:tokens:models",1,0],[":domain:legacy",":domain:transaction:models",1,0],[":domain:legacy",":domain:txhistory:models",1,0],[":domain:legacy",":domain:wallets:models",1,0],[":domain:earn",":domain:core",1,1],[":domain:earn",":domain:models",1,1],[":domain:earn",":domain:account",1,0],[":domain:earn",":domain:common",1,0],[":domain:wallet-connect",":domain:blockaid:models",1,0],[":domain:wallet-connect",":domain:core",1,0],[":domain:wallet-connect",":domain:models",1,0],[":domain:wallet-connect",":domain:tokens:models",1,0],[":domain:wallet-connect",":domain:wallets:models",1,0],[":domain:wallet-connect",":domain:wallet-connect:models",1,0],[":domain:wallet-connect",":domain:transaction",1,0],[":domain:wallet-connect",":domain:transaction:models",1,0],[":domain:wallet-connect:models",":domain:models",1,0],[":domain:wallet-connect:models",":domain:wallets:models",1,0],[":domain:wallet-connect:models",":domain:tokens:models",1,0],[":domain:wallet-connect:models",":domain:blockaid:models",1,0],[":domain:wallet-connect:models",":domain:transaction:models",1,0],[":domain:models",":domain:core",1,1],[":domain:visa",":domain:models",1,1],[":domain:visa",":domain:visa:models",1,1],[":domain:visa",":domain:app-currency:models",1,0],[":domain:visa",":domain:core",1,0],[":domain:visa",":domain:tokens:models",1,0],[":domain:visa",":domain:wallets:models",1,0],[":domain:visa:models",":domain:models",1,0],[":domain:balance-hiding",":domain:core",1,0],[":domain:balance-hiding",":domain:settings",1,0],[":domain:balance-hiding",":domain:balance-hiding:models",1,0],[":domain:feedback",":domain:models",1,0],[":domain:feedback",":domain:wallets:models",1,0],[":domain:feedback",":domain:visa:models",1,0],[":domain:feedback",":domain:feedback:models",1,0],[":domain:feedback:models",":domain:models",1,0],[":domain:feedback:models",":domain:wallets:models",1,0],[":domain:feedback:models",":domain:visa:models",1,0],[":domain:search",":domain:core",1,1],[":domain:search",":domain:models",1,1],[":domain:search",":domain:common",1,0],[":domain:search",":domain:markets:models",1,0],[":domain:search",":domain:wallets",1,0],[":domain:search",":domain:app-currency",1,0],[":domain:search",":domain:account",1,0],[":domain:search",":domain:account:status",1,0],[":domain:onramp",":domain:onramp:models",1,1],[":domain:onramp",":domain:tokens:models",1,1],[":domain:onramp",":domain:wallets:models",1,1],[":domain:onramp",":domain:core",1,1],[":domain:onramp",":domain:settings",1,1],[":domain:onramp",":domain:stories",1,0],[":domain:onramp:models",":domain:models",1,1],[":domain:onramp:models",":domain:core",1,0],[":domain:onramp:models",":domain:tokens:models",1,0],[":domain:onramp:models",":domain:wallets:models",1,0],[":domain:networks",":domain:core",1,1],[":domain:networks",":domain:models",1,1],[":domain:networks",":domain:wallets:models",1,1],[":domain:common",":domain:models",1,1],[":domain:stories",":domain:models",1,0],[":domain:stories",":domain:stories:models",1,0],[":domain:stories",":domain:settings",1,0],[":domain:stories",":domain:wallets:models",1,0],[":domain:news",":domain:core",1,1],[":domain:news",":domain:models",1,1],[":domain:manage-tokens",":domain:core",1,1],[":domain:manage-tokens",":domain:manage-tokens:models",1,1],[":domain:manage-tokens",":domain:networks",1,1],[":domain:manage-tokens",":domain:quotes",1,1],[":domain:manage-tokens",":domain:wallet-manager",1,1],[":domain:manage-tokens",":domain:wallets:models",1,0],[":domain:manage-tokens",":domain:tokens:models",1,0],[":domain:manage-tokens",":domain:staking",1,0],[":domain:manage-tokens",":domain:tokens",1,0],[":domain:manage-tokens",":domain:card",1,0],[":domain:manage-tokens",":domain:wallets",1,0],[":domain:manage-tokens",":domain:legacy",1,0],[":domain:manage-tokens:models",":domain:models",1,0],[":domain:manage-tokens:models",":domain:tokens:models",1,0],[":domain:markets",":domain:app-currency:models",1,1],[":domain:markets",":domain:card",1,1],[":domain:markets",":domain:core",1,1],[":domain:markets",":domain:legacy",1,1],[":domain:markets",":domain:markets:models",1,1],[":domain:markets",":domain:models",1,1],[":domain:markets",":domain:networks",1,1],[":domain:markets",":domain:staking",1,1],[":domain:markets",":domain:quotes",1,1],[":domain:markets",":domain:wallet-manager",1,1],[":domain:markets",":domain:wallets",1,1],[":domain:markets",":domain:wallets:models",1,1],[":domain:markets",":domain:stories",1,1],[":domain:markets",":domain:tokens:models",1,0],[":domain:markets",":domain:tokens",1,0],[":domain:markets",":domain:settings",1,0],[":domain:markets:models",":domain:models",1,1],[":domain:markets:models",":domain:tokens:models",1,1],[":domain:markets:models",":domain:core",1,0],[":domain:staking",":domain:staking:models",1,1],[":domain:staking",":domain:core",1,1],[":domain:staking",":domain:legacy",1,0],[":domain:staking",":domain:wallet-manager",1,0],[":domain:staking",":domain:models",1,0],[":domain:staking",":domain:tokens:models",1,0],[":domain:staking",":domain:wallets:models",1,0],[":domain:staking:models",":domain:core",1,0],[":domain:staking:models",":domain:models",1,0],[":domain:offramp",":domain:core",1,1],[":domain:offramp",":domain:models",1,1],[":domain:address-book",":domain:core",1,1],[":domain:address-book",":domain:models",1,1],[":domain:address-book",":domain:transaction",1,0],[":domain:address-book",":domain:tokens",1,0],[":domain:assetsdiscovery",":domain:core",1,1],[":domain:assetsdiscovery",":domain:models",1,0],[":domain:assetsdiscovery",":domain:account:status",1,0],[":domain:wallets",":domain:core",1,1],[":domain:wallets",":domain:common",1,1],[":domain:wallets",":domain:legacy",1,0],[":domain:wallets",":domain:wallet-manager",1,0],[":domain:wallets",":domain:account",1,0],[":domain:wallets",":domain:models",1,0],[":domain:wallets",":domain:tokens",1,0],[":domain:wallets",":domain:card",1,0],[":domain:wallets",":domain:tokens:models",1,0],[":domain:wallets",":domain:wallets:models",1,0],[":domain:wallets",":domain:notifications:models",1,0],[":domain:wallets",":domain:demo:models",1,0],[":domain:wallets",":domain:hot-wallet",1,0],[":domain:wallets",":domain:qr-scanning",1,0],[":domain:wallets",":domain:qr-scanning:models",1,0],[":domain:wallets:models",":domain:models",1,0],[":domain:account",":domain:common",1,1],[":domain:account",":domain:core",1,1],[":domain:account",":domain:models",1,1],[":domain:account",":domain:wallets:models",1,1],[":domain:account",":domain:yield-supply:models",1,1],[":domain:account:status",":domain:account",1,1],[":domain:account:status",":domain:card",1,1],[":domain:account:status",":domain:core",1,1],[":domain:account:status",":domain:common",1,1],[":domain:account:status",":domain:express",1,1],[":domain:account:status",":domain:quotes",1,1],[":domain:account:status",":domain:models",1,1],[":domain:account:status",":domain:networks",1,1],[":domain:account:status",":domain:nft",1,1],[":domain:account:status",":domain:referral",1,1],[":domain:account:status",":domain:staking",1,1],[":domain:account:status",":domain:tokens",1,1],[":domain:account:status",":domain:tokens:models",1,1],[":domain:account:status",":domain:visa",1,1],[":domain:account:status",":domain:wallet-manager",1,1],[":domain:account:status",":domain:wallets",1,1],[":domain:hot-wallet",":domain:core",1,0],[":domain:hot-wallet",":domain:models",1,0],[":domain:hot-wallet",":domain:wallets:models",1,0],[":domain:tokens",":domain:core",1,1],[":domain:tokens",":domain:common",1,0],[":domain:tokens",":domain:card",1,0],[":domain:tokens",":domain:express",1,0],[":domain:tokens",":domain:models",1,0],[":domain:tokens",":domain:legacy",1,0],[":domain:tokens",":domain:wallet-manager",1,0],[":domain:tokens",":domain:staking",1,0],[":domain:tokens",":domain:visa",1,0],[":domain:tokens",":domain:tokens:models",1,0],[":domain:tokens",":domain:txhistory:models",1,0],[":domain:tokens",":domain:transaction:models",1,0],[":domain:tokens",":domain:wallets:models",1,0],[":domain:tokens",":domain:app-currency:models",1,0],[":domain:tokens",":domain:onramp:models",1,0],[":domain:tokens",":domain:settings",1,0],[":domain:tokens",":features:swap:domain:api",1,0],[":domain:tokens",":features:swap:domain:models",1,0],[":domain:tokens",":domain:stories:models",1,0],[":domain:tokens",":domain:stories",1,0],[":domain:tokens",":domain:networks",1,0],[":domain:tokens",":domain:quotes",1,0],[":domain:tokens",":domain:yield-supply:models",1,0],[":domain:tokens",":features:staking:api",1,0],[":domain:tokens",":features:markets:api",1,0],[":domain:tokens",":features:swap:api",1,0],[":domain:tokens",":features:virtual-accounts:details:api",1,0],[":domain:tokens:models",":domain:models",1,0],[":domain:tokens:models",":domain:txhistory:models",1,0],[":domain:tokens:models",":domain:staking:models",1,0],[":domain:tokens:models",":domain:stories:models",1,0],[":domain:notifications",":domain:core",1,0],[":domain:notifications",":domain:models",1,0],[":domain:notifications",":domain:notifications:models",1,0],[":domain:notifications",":domain:wallets:models",1,0],[":domain:notifications",":domain:tokens:models",1,0],[":domain:onboarding",":domain:models",1,0],[":domain:analytics",":domain:core",1,0],[":domain:analytics",":domain:models",1,0],[":domain:analytics",":domain:wallets:models",1,0]]} +``` + + + +```json +{"n":[["AccessCodeRecovery","AccessCodeRecovery","onboarding",1,0],["AccountDetails","AccountDetails","portfolio",1,0],["AddExistingWallet","AddExistingWallet","onboarding",1,0],["AddressBook","AddressBook","settings",1,0],["AppCurrencySelector","AppCurrencySelector","settings",1,0],["AppSettings","AppSettings","settings",2,0],["ArchivedAccountList","ArchivedAccountList","portfolio",1,0],["BuyCrypto","BuyCrypto","tokenaction",1,0],["CardSettings","CardSettings","settings",1,0],["ChooseManagedTokens","ChooseManagedTokens","portfolio",1,0],["CreateAccount","CreateAccount","portfolio",1,0],["CreateHardwareWallet","CreateHardwareWallet","onboarding",2,0],["CreateMobileWallet","CreateMobileWallet","onboarding",2,0],["CreateWalletBackup","CreateWalletBackup","onboarding",3,0],["CreateWalletSelection","CreateWalletSelection","onboarding",2,2],["CreateWalletStart","CreateWalletStart","onboarding",1,3],["CurrencyDetails","CurrencyDetails","portfolio",6,5],["Details","Details","settings",2,10],["DetailsSecurity","DetailsSecurity","settings",1,0],["Disclaimer","Disclaimer","entry",4,2],["Earn","Earn","markets",0,6],["EditAccount","EditAccount","portfolio",0,0],["ForgetWallet","ForgetWallet","settings",1,0],["Home","Home","entry",8,3],["Initial","Initial","entry",0,9],["Kyc","Kyc","tangempay",0,0],["ManageTokens","ManageTokens","portfolio",2,1],["Markets","Markets","markets",1,1],["MarketsTokenDetails","MarketsTokenDetails","markets",3,0],["NFT","NFT","wallet",1,1],["NFTSend","NFTSend","wallet",1,0],["News","News","markets",1,0],["NewsDetails","NewsDetails","markets",1,0],["Onboarding","Onboarding","onboarding",6,3],["Onramp","Onramp","tokenaction",3,3],["OnrampSuccess","OnrampSuccess","tokenaction",0,0],["PushNotification","PushNotification","wallet",1,0],["PushNotificationSettings","PushNotificationSettings","wallet",1,0],["QrScanning","QrScanning","misc",3,0],["ReferralProgram","ReferralProgram","settings",1,0],["ResetToFactory","ResetToFactory","settings",1,0],["SellCrypto","SellCrypto","tokenaction",1,0],["Send","Send","tokenaction",1,2],["SendEntryPoint","SendEntryPoint","tokenaction",2,0],["Staking","Staking","tokenaction",3,1],["Stories","Stories","wallet",2,0],["Survey","Survey","misc",0,0],["Swap","Swap","tokenaction",6,1],["TangemPayDetails","TangemPayDetails","tangempay",2,0],["TangemPayHotWalletOnboarding","TangemPayHotWalletOnboarding","tangempay",0,0],["TangemPayOnboarding","TangemPayOnboarding","tangempay",2,6],["UpdateAccessCode","UpdateAccessCode","onboarding",1,0],["UpgradeWallet","UpgradeWallet","onboarding",0,11],["Usedesk","Usedesk","misc",1,0],["ViewPhrase","ViewPhrase","onboarding",2,0],["Wallet","Wallet","wallet",9,14],["WalletActivation","WalletActivation","onboarding",1,0],["WalletBackup","WalletBackup","onboarding",1,0],["WalletConnectSessions","WalletConnectSessions","settings",1,1],["WalletHardwareBackup","WalletHardwareBackup","onboarding",2,0],["WalletSettings","WalletSettings","settings",1,15],["Welcome","Welcome","entry",1,3],["YieldSupplyEntry","YieldSupplyEntry","tokenaction",3,2],["AppShell","App shell","shell",0,7]],"e":[["Initial","AppSettings",1,0],["Initial","Wallet",4,0],["Initial","Welcome",2,0],["Initial","DetailsSecurity",1,0],["Initial","AccessCodeRecovery",1,0],["Initial","Onboarding",2,0],["Initial","ResetToFactory",1,0],["Initial","Home",1,0],["Initial","AppCurrencySelector",1,0],["Home","ManageTokens",1,0],["Home","CreateWalletStart",1,0],["Home","Wallet",2,0],["CreateWalletStart","CreateMobileWallet",2,0],["CreateWalletStart","Wallet",3,0],["CreateWalletStart","Onboarding",1,0],["YieldSupplyEntry","CurrencyDetails",2,0],["YieldSupplyEntry","Stories",2,0],["WalletSettings","AccountDetails",1,0],["WalletSettings","ArchivedAccountList",1,0],["WalletSettings","CreateAccount",1,0],["WalletSettings","ManageTokens",1,0],["WalletSettings","PushNotificationSettings",1,0],["WalletSettings","Home",1,0],["WalletSettings","Onboarding",1,0],["WalletSettings","ReferralProgram",1,0],["WalletSettings","UpdateAccessCode",1,0],["WalletSettings","WalletHardwareBackup",1,0],["WalletSettings","WalletBackup",1,0],["WalletSettings","CardSettings",1,0],["WalletSettings","CreateWalletBackup",2,0],["WalletSettings","ForgetWallet",2,0],["WalletSettings","ViewPhrase",1,0],["Disclaimer","PushNotification",1,0],["Disclaimer","Home",1,0],["NFT","NFTSend",1,0],["CurrencyDetails","Wallet",2,0],["CurrencyDetails","Onramp",3,0],["CurrencyDetails","SendEntryPoint",1,0],["CurrencyDetails","Swap",2,0],["CurrencyDetails","Staking",1,0],["Swap","Stories",1,0],["Details","WalletConnectSessions",4,0],["Details","AddressBook",2,0],["Details","Wallet",1,0],["Details","Onboarding",2,0],["Details","TangemPayOnboarding",2,0],["Details","Usedesk",2,0],["Details","AppSettings",1,0],["Details","Disclaimer",1,0],["Details","CreateWalletSelection",1,0],["Details","WalletSettings",2,0],["CreateWalletSelection","CreateMobileWallet",1,0],["CreateWalletSelection","CreateHardwareWallet",1,0],["Welcome","Home",1,0],["Welcome","Wallet",6,0],["Welcome","CreateWalletSelection",1,0],["AppShell","CurrencyDetails",1,0],["Onramp","Swap",1,0],["Onramp","SellCrypto",1,0],["Onramp","BuyCrypto",1,0],["WalletConnectSessions","QrScanning",1,0],["Onboarding","Wallet",7,0],["Onboarding","Home",1,0],["Onboarding","Disclaimer",1,0],["AppShell","ChooseManagedTokens",1,0],["ManageTokens","Swap",1,0],["Markets","MarketsTokenDetails",1,0],["Earn","YieldSupplyEntry",2,0],["Earn","News",5,0],["Earn","NewsDetails",4,0],["Earn","CurrencyDetails",5,0],["Earn","Markets",4,0],["Earn","MarketsTokenDetails",2,0],["Staking","CurrencyDetails",1,0],["Wallet","Details",1,0],["Wallet","Onboarding",1,0],["Wallet","CurrencyDetails",1,0],["Wallet","Home",1,0],["Wallet","NFT",1,0],["Wallet","TangemPayOnboarding",1,0],["Wallet","TangemPayDetails",1,0],["Wallet","YieldSupplyEntry",1,0],["Wallet","QrScanning",1,0],["Wallet","Send",1,0],["Wallet","Onramp",1,0],["Wallet","MarketsTokenDetails",1,0],["Wallet","Staking",1,0],["Wallet","Swap",1,0],["UpgradeWallet","ViewPhrase",3,0],["UpgradeWallet","WalletActivation",2,0],["UpgradeWallet","WalletHardwareBackup",2,0],["UpgradeWallet","Disclaimer",1,0],["UpgradeWallet","AddExistingWallet",1,0],["UpgradeWallet","Wallet",4,0],["UpgradeWallet","CreateWalletBackup",1,0],["UpgradeWallet","CreateHardwareWallet",1,0],["UpgradeWallet","Onboarding",1,0],["UpgradeWallet","Details",1,0],["UpgradeWallet","Home",1,0],["Send","QrScanning",1,0],["Send","CurrencyDetails",1,0],["TangemPayOnboarding","Swap",3,0],["TangemPayOnboarding","Disclaimer",2,0],["TangemPayOnboarding","Home",2,0],["TangemPayOnboarding","CreateWalletBackup",2,0],["TangemPayOnboarding","Wallet",5,0],["TangemPayOnboarding","TangemPayDetails",1,0],["AppShell","Onramp",1,0],["AppShell","Swap",1,0],["AppShell","SendEntryPoint",2,0],["AppShell","Staking",1,0],["AppShell","YieldSupplyEntry",1,0]]} +``` + + + +```json +{"AccessCodeRecovery":{"path":"/access_code_recovery","owner":"features:onboarding-v2","group":"onboarding","total":2,"refs":[["app",2]]},"AccountDetails":{"path":"/account_details/${account.accountId.value}","owner":"features:account","group":"portfolio","total":2,"refs":[["app",1],["features:wallet-settings",1]]},"AddExistingWallet":{"path":"/add_existing_wallet","owner":"features:onboarding-v2","group":"onboarding","total":2,"refs":[["app",1],["features:hot-wallet",1]]},"AddressBook":{"path":"/address_book/${addressBookOpenMode.address}-${addressBookOpenMode.networkId}","owner":"features:address-book","group":"settings","total":4,"refs":[["features:details",2],["app",1],["common:routing",1]]},"AppCurrencySelector":{"path":"/app_currency_selector","owner":"features:wallet-settings","group":"settings","total":2,"refs":[["app",2]]},"AppSettings":{"path":"/app_settings","owner":"features:details","group":"settings","total":5,"refs":[["app",4],["features:details",1]]},"ArchivedAccountList":{"path":"/archived_account/${userWalletId.stringValue}","owner":"features:account","group":"portfolio","total":2,"refs":[["app",1],["features:wallet-settings",1]]},"BuyCrypto":{"path":"/buy_crypto/${userWalletId.stringValue}","owner":"features:onramp","group":"tokenaction","total":3,"refs":[["app",1],["features:onramp",1],["features:wallet",1]]},"CardSettings":{"path":"/card_settings/${userWalletId.stringValue}","owner":"features:details","group":"settings","total":2,"refs":[["app",1],["features:wallet-settings",1]]},"ChooseManagedTokens":{"path":"/$source/choose_managed_tokens/$userWalletId/${initialCurrency.id.value}","owner":"features:manage-tokens","group":"portfolio","total":3,"refs":[["features:swap-v2",2],["app",1]]},"CreateAccount":{"path":"/create_account/${userWalletId.stringValue}","owner":"features:account","group":"portfolio","total":2,"refs":[["app",1],["features:wallet-settings",1]]},"CreateHardwareWallet":{"path":"/create_hardware_wallet","owner":"features:onboarding-v2","group":"onboarding","total":3,"refs":[["app",1],["features:create-wallet-selection",1],["features:hot-wallet",1]]},"CreateMobileWallet":{"path":"/create_mobile_wallet","owner":"features:hot-wallet","group":"onboarding","total":4,"refs":[["features:create-wallet-start",2],["app",1],["features:create-wallet-selection",1]]},"CreateWalletBackup":{"path":"/create_wallet_backup/${userWalletId.stringValue}","owner":"features:onboarding-v2","group":"onboarding","total":6,"refs":[["features:wallet-settings",2],["features:tangempay",2],["app",1],["features:hot-wallet",1]]},"CreateWalletSelection":{"path":"/create_wallet_selection","owner":"features:create-wallet-selection","group":"onboarding","total":3,"refs":[["app",1],["features:details",1],["features:welcome",1]]},"CreateWalletStart":{"path":"/create_wallet_start","owner":"features:create-wallet-start","group":"onboarding","total":8,"refs":[["app",5],["features:home",3]]},"CurrencyDetails":{"path":"/currency_details/${userWalletId.stringValue}/${currency.id.value}","owner":"features:tokendetails","group":"portfolio","total":18,"refs":[["features:feed",5],["features:tokendetails",4],["features:yield-supply",2],["features:swap",2],["app",1],["features:common-features",1],["features:staking",1],["features:wallet",1],["features:send",1]]},"Details":{"path":"/details/${userWalletId.stringValue}","owner":"features:details","group":"settings","total":3,"refs":[["app",1],["features:wallet",1],["features:hot-wallet",1]]},"DetailsSecurity":{"path":"/details/security","owner":"features:details","group":"settings","total":2,"refs":[["app",2]]},"Disclaimer":{"path":"/disclaimer${if (isTosAccepted) \"/tos_accepted\" else \"\"}","owner":"features:disclaimer","group":"entry","total":8,"refs":[["app",2],["features:tangempay",2],["features:details",1],["features:walletconnect",1],["features:onboarding-v2",1],["features:hot-wallet",1]]},"Earn":{"path":"/earn","owner":"features:feed","group":"markets","total":16,"refs":[["features:feed",15],["app",1]]},"EditAccount":{"path":"/edit_account/${account.accountId.value}","owner":"features:account","group":"portfolio","total":2,"refs":[["app",1],["features:account",1]]},"ForgetWallet":{"path":"/forget_wallet/${userWalletId.stringValue}","owner":"features:wallet-settings","group":"settings","total":3,"refs":[["features:wallet-settings",2],["app",1]]},"Home":{"path":"/home","owner":"features:home","group":"entry","total":14,"refs":[["app",6],["features:tangempay",2],["features:wallet-settings",1],["features:disclaimer",1],["features:welcome",1],["features:onboarding-v2",1],["features:wallet",1],["features:hot-wallet",1]]},"Initial":{"path":"/initial","owner":"app","group":"entry","total":6,"refs":[["app",5],["features:walletconnect",1]]},"Kyc":{"path":"/kyc","owner":"features:kyc","group":"tangempay","total":2,"refs":[["app",1],["features:tangempay",1]]},"ManageTokens":{"path":"${source.name.lowercase()}/manage_tokens/${accountId?.value}","owner":"features:manage-tokens","group":"portfolio","total":18,"refs":[["app",5],["features:wallet",5],["features:account",4],["features:home",2],["features:wallet-settings",2]]},"Markets":{"path":"/markets","owner":"features:markets","group":"markets","total":5,"refs":[["features:feed",4],["app",1]]},"MarketsTokenDetails":{"path":"/markets_token_details/${token.id}/$shouldShowPortfolio","owner":"features:markets","group":"markets","total":7,"refs":[["features:markets",2],["features:feed",2],["features:wallet",2],["app",1]]},"NFT":{"path":"/nft/${userWalletId.stringValue}","owner":"features:nft","group":"wallet","total":2,"refs":[["app",1],["features:wallet",1]]},"NFTSend":{"path":"/send/nft/${userWalletId.stringValue}/$nftCollectionName/${nftAsset.id}","owner":"features:nft","group":"wallet","total":2,"refs":[["app",1],["features:nft",1]]},"News":{"path":"/news","owner":"features:feed","group":"markets","total":6,"refs":[["features:feed",5],["app",1]]},"NewsDetails":{"path":"/news_details/$newsId","owner":"features:feed","group":"markets","total":5,"refs":[["features:feed",4],["app",1]]},"Onboarding":{"path":"/onboarding_v2/$mode","owner":"features:onboarding-v2","group":"onboarding","total":38,"refs":[["app",21],["features:create-wallet-start",4],["features:details",4],["features:wallet",3],["features:wallet-settings",2],["features:welcome",2],["features:hot-wallet",2]]},"Onramp":{"path":"/onramp/${userWalletId.stringValue}/${currency.symbol}","owner":"features:onramp","group":"tokenaction","total":9,"refs":[["features:tokendetails",3],["features:onramp",3],["app",1],["features:wallet",1],["common:ui-markets",1]]},"OnrampSuccess":{"path":"/onramp/success/$txId","owner":"features:onramp","group":"tokenaction","total":4,"refs":[["features:onramp",3],["app",1]]},"PushNotification":{"path":"/push_notification","owner":"features:push-notifications","group":"wallet","total":18,"refs":[["features:push-notifications",8],["app",4],["features:disclaimer",2],["features:hot-wallet",2],["features:onboarding-v2",1],["features:wallet",1]]},"PushNotificationSettings":{"path":"/push_notification_settings/${userWalletId.stringValue}","owner":"features:push-notification-settings","group":"wallet","total":2,"refs":[["app",1],["features:wallet-settings",1]]},"QrScanning":{"path":"/$source/qr_scanning${source.path}","owner":"features:qr-scanning","group":"misc","total":11,"refs":[["app",5],["features:walletconnect",2],["features:wallet",2],["features:send",2]]},"ReferralProgram":{"path":"/referral_program","owner":"features:referral","group":"settings","total":3,"refs":[["app",1],["features:referral",1],["features:wallet-settings",1]]},"ResetToFactory":{"path":"/reset_to_factory","owner":"features:details","group":"settings","total":2,"refs":[["app",2]]},"SellCrypto":{"path":"/sell_crypto/${userWalletId.stringValue}","owner":"features:onramp","group":"tokenaction","total":3,"refs":[["app",1],["features:onramp",1],["features:wallet",1]]},"Send":{"path":"/send/${userWalletId.stringValue}/${currency.id.value}?","owner":"features:send","group":"tokenaction","total":12,"refs":[["features:wallet",7],["app",4],["features:send",1]]},"SendEntryPoint":{"path":"/send_entry_point/${userWalletId.stringValue}/${currency.id.value}?","owner":"features:send","group":"tokenaction","total":5,"refs":[["features:tokendetails",2],["common:ui-markets",2],["app",1]]},"Staking":{"path":"/staking/${userWalletId.stringValue}/${cryptoCurrency.id.value}/${integrationId.value}","owner":"features:staking","group":"tokenaction","total":5,"refs":[["app",1],["features:tokendetails",1],["features:staking",1],["features:wallet",1],["common:ui-markets",1]]},"Stories":{"path":"/stories$storyId","owner":"features:stories","group":"wallet","total":7,"refs":[["app",2],["features:yield-supply",2],["features:swap",1],["features:walletconnect",1],["features:wallet",1]]},"Survey":{"path":"/survey","owner":"features:survey","group":"misc","total":4,"refs":[["features:survey",3],["app",1]]},"Swap":{"path":"/swap","owner":"features:swap","group":"tokenaction","total":38,"refs":[["common:ui-markets",14],["features:tangempay",9],["features:tokendetails",6],["app",4],["features:manage-tokens",2],["features:wallet",2],["features:onramp",1]]},"TangemPayDetails":{"path":"/tangem_pay_details/${status.account}","owner":"features:tangempay","group":"tangempay","total":3,"refs":[["app",1],["features:wallet",1],["features:tangempay",1]]},"TangemPayHotWalletOnboarding":{"path":"/tangem_pay_hot_wallet_onboarding","owner":"features:tangempay","group":"tangempay","total":2,"refs":[["app",2]]},"TangemPayOnboarding":{"path":"/tangem_pay_onboarding/$mode","owner":"features:tangempay","group":"tangempay","total":20,"refs":[["app",6],["features:wallet",6],["features:details",4],["features:tangempay",4]]},"UpdateAccessCode":{"path":"/update_access_code/${userWalletId.stringValue}","owner":"features:onboarding-v2","group":"onboarding","total":4,"refs":[["features:wallet-settings",2],["app",1],["features:tangempay",1]]},"UpgradeWallet":{"path":"/upgrade_wallet/${userWalletId.stringValue}","owner":"features:hot-wallet","group":"onboarding","total":4,"refs":[["features:hot-wallet",3],["app",1]]},"Usedesk":{"path":"/usedesk/${walletMetaInfo.userWalletId}","owner":"features:usedesk","group":"misc","total":3,"refs":[["features:details",2],["app",1]]},"ViewPhrase":{"path":"/view_seed_phrase/${userWalletId.stringValue}","owner":"features:onboarding-v2","group":"onboarding","total":5,"refs":[["features:hot-wallet",3],["app",1],["features:wallet-settings",1]]},"Wallet":{"path":"/wallet","owner":"features:wallet","group":"wallet","total":58,"refs":[["app",23],["features:welcome",7],["features:onboarding-v2",7],["features:tangempay",5],["features:create-wallet-start",4],["features:hot-wallet",4],["features:home",2],["features:tokendetails",2],["features:details",2],["features:wallet",2]]},"WalletActivation":{"path":"/wallet_activation/${userWalletId.stringValue}","owner":"features:tangempay","group":"onboarding","total":3,"refs":[["features:hot-wallet",2],["app",1]]},"WalletBackup":{"path":"/wallet_backup/${userWalletId.stringValue}/$isColdWalletOptionShown","owner":"features:onboarding-v2","group":"onboarding","total":3,"refs":[["app",1],["features:wallet-settings",1],["features:wallet",1]]},"WalletConnectSessions":{"path":"/wallet_connect_sessions","owner":"features:walletconnect","group":"settings","total":5,"refs":[["features:details",4],["app",1]]},"WalletHardwareBackup":{"path":"/wallet_hardware_backup/${userWalletId.stringValue}","owner":"features:onboarding-v2","group":"onboarding","total":4,"refs":[["features:hot-wallet",2],["app",1],["features:wallet-settings",1]]},"WalletSettings":{"path":"/wallet_settings/${userWalletId.stringValue}","owner":"features:wallet-settings","group":"settings","total":4,"refs":[["features:details",2],["app",1],["features:hot-wallet",1]]},"Welcome":{"path":"/welcome","owner":"features:welcome","group":"entry","total":7,"refs":[["app",6],["features:walletconnect",1]]},"YieldSupplyEntry":{"path":"/yield_supply_entry/${userWalletId.stringValue}/${cryptoCurrency.symbol}","owner":"features:yield-supply","group":"tokenaction","total":7,"refs":[["features:yield-supply",2],["features:feed",2],["app",1],["features:wallet",1],["common:ui-markets",1]]}} +``` + + + \ No newline at end of file diff --git a/.claude/skills/navigation-graph/SKILL.md b/.claude/skills/navigation-graph/SKILL.md new file mode 100644 index 0000000000..c22e57cfd6 --- /dev/null +++ b/.claude/skills/navigation-graph/SKILL.md @@ -0,0 +1,59 @@ +--- +name: navigation-graph +description: Refresh the app's navigation/dependency graph from live code and rebuild the interactive visualization. Scans features/domain Gradle project dependencies and every AppRoute usage, updates the generated regions of .claude/docs/navigation-graph.md (preserving hand-written prose and the curated config), then renders .claude/docs/module-connectivity.html (Area / Module / Screens views with team overlays). Use when the user asks to update/regenerate/refresh the navigation graph, screen map, module connectivity diagram or module-connectivity.html, after adding/removing AppRoute screens or feature/domain modules, or to add/recolor a team. Triggers: "update the navigation graph", "regenerate module-connectivity.html", "refresh the screen map", "rebuild the module connectivity diagram", "add a team to the graph". +allowed-tools: Bash, Read, Edit, Grep, Glob +--- + +Refresh and rebuild the app's connectivity visualization. The data flows **code → doc → HTML**: + +``` +features/domain/data build.gradle.kts deps ─┐ +AppRoute.kt + every AppRoute.X usage ─┴─▶ build_graph.py ─▶ .claude/docs/navigation-graph.md + │ (data + curated config blocks) + ▼ + render_html.py ─▶ .claude/docs/module-connectivity.html +``` + +`navigation-graph.md` is the source of truth. Its hand-written prose (sections 1–4: route tables, edges-with-triggers, nested routes, deep links) is **owned by humans and never overwritten**. The skill manages only the region between `` and ``, which holds: +- an **editable CONFIG json block** — functional groups (label/color/grid anchor), per-screen group + owner, and teams. Preserved across refreshes (human edits win). +- **auto data blocks** — `areaGraph`, `moduleGraph`, `screensGraph`, `screensMeta`. Overwritten from code every run. + +## To refresh after a code change (the common case) + +Run both scripts from anywhere in the repo (they locate the root via `settings.gradle.kts`). The scan walks the whole tree — expect ~10–20s. + +```bash +python3 .claude/skills/navigation-graph/scripts/build_graph.py +python3 .claude/skills/navigation-graph/scripts/render_html.py +``` + +Then report to the user: the printed counts, **any `⚠ NEW screen` warnings**, and that `.claude/docs/module-connectivity.html` is a self-contained file they can open in a browser. + +**If `build_graph.py` prints `⚠ NEW screen …` warnings:** a route was added to `AppRoute.kt` but isn't classified. The new screen was defaulted to group `misc` / owner `app`. Edit the CONFIG block in `.claude/docs/navigation-graph.md` to set its real `screenGroups[Name]` and `screenOwners[Name]`, then re-run both scripts. Pick the group/owner by reading where the route lives and is pushed from. Removed routes are reported as "no longer in code" and are harmless (kept in config for when they return). + +## To change grouping, owners, colors, or teams + +Edit the **CONFIG** json block inside `.claude/docs/navigation-graph.md` (between `` and `:END`), then run `render_html.py` (no need to re-scan code unless code changed): +- **`groups`** — add/rename a functional group or change its `color` / `label` / grid `anchor` (`x`,`y` are 0–1 fractions of the canvas). +- **`screenGroups` / `screenOwners`** — reassign a screen. +- **`teams`** — add a team object `{ "id", "name", "color", "roots": [...module path prefixes...], "screens": [...AppRoute names...] }`. `roots` drive the overlay in Area/Module views (e.g. `"features:onramp"`, `"domain:staking"`, `"data:swap"`); `screens` drive it in the Screens view (e.g. `"Send"`). A module/screen may be matched by either. The overlay (hull + rings, optional cluster force) and per-view member counts wire up automatically. + +After editing CONFIG, re-run `render_html.py`. If you changed code-derived facts, run `build_graph.py` first. + +## What gets extracted (and what's inferred) + +- **Dependency edges** (Area/Module): only project (`projects.*`) deps among `features/*`, `domain/*` and `data/*` — external libraries excluded. Area view merges each area's `api`/`impl`/`models`. The Data-layer toggle in the HTML appears only when data modules are present. +- **Screen edges** (Screens): the **target** is exact — the `AppRoute.X` argument of a `push`/`replaceCurrent`/`replaceAll`/`popTo` call. The **source** is the navigating file's feature module collapsed to that feature's main screen (eponymous screen if one exists, else most-referenced), so intra-feature hops are merged. Calls from shared UI / app root attach to a synthetic **App shell** node. Group/owner come from the curated CONFIG. + +## Files + +``` +.claude/skills/navigation-graph/ + SKILL.md + assets/template.html # parameterized HTML (8 inject points: 4 data blocks + teams + group colors/labels/anchors) + assets/config.seed.json # initial curation; used only when the doc has no CONFIG block yet + scripts/build_graph.py # code -> navigation-graph.md (managed region) + scripts/render_html.py # navigation-graph.md -> module-connectivity.html +``` + +Outputs live in `.claude/docs/`. The scripts are idempotent — re-running never duplicates the managed region. Do not hand-edit the auto data blocks (they're regenerated); edit CONFIG instead. After regenerating, sanity-check the HTML by extracting its ` diff --git a/.claude/skills/navigation-graph/scripts/build_graph.py b/.claude/skills/navigation-graph/scripts/build_graph.py new file mode 100644 index 0000000000..ff1a94c5a3 --- /dev/null +++ b/.claude/skills/navigation-graph/scripts/build_graph.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python3 +""" +build_graph.py — scan the live codebase and refresh the managed regions of +.claude/docs/navigation-graph.md (the source of truth for module-connectivity.html). + +What it extracts from code: + - features/domain/data Gradle project dependencies -> area graph + module graph + - AppRoute screens + every `AppRoute.X` reference -> screen navigation graph + +What it preserves: + - all hand-written prose ABOVE the marker + - the curated CONFIG block (groups / owners / teams) inside the managed region + +Run from anywhere inside the repo: python3 build_graph.py +Then render the HTML: python3 render_html.py +""" +import os, re, json, sys +from collections import defaultdict + +# ---------------------------------------------------------------- paths +def find_root(start): + d = os.path.abspath(start) + while d != os.path.dirname(d): + if os.path.exists(os.path.join(d, "settings.gradle.kts")): + return d + d = os.path.dirname(d) + sys.exit("ERROR: could not locate repo root (settings.gradle.kts not found).") + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +ROOT = find_root(os.getcwd()) +SKILL = os.path.dirname(SCRIPT_DIR) +DOCS = os.path.join(ROOT, ".claude", "docs") +MD = os.path.join(DOCS, "navigation-graph.md") +SEED = os.path.join(SKILL, "assets", "config.seed.json") +APPROUTE = os.path.join(ROOT, "common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt") + +# ---------------------------------------------------------------- managed-region helpers +BEGIN = "" +END = "" + +def block(text, key): + """Return the JSON string inside the markers, or None.""" + m = re.search(r"\s*```json\s*(.*?)\s*```\s*" + % (re.escape(key), re.escape(key)), text, re.S) + return m.group(1) if m else None + +def wrap(key, payload): + return f"\n```json\n{payload}\n```\n" + +# ---------------------------------------------------------------- gradle dependency scan +def camel_to_kebab(s): return re.sub(r'(? 1 and p[0] in SCOPED_LAYERS else None + +def build_area_graph(modules, edges): + agg = defaultdict(lambda: {"w": 0, "api": False}) + for s, d, c in edges: + if not (is_scoped(s) and is_scoped(d)): continue + sa, da = area_of(s), area_of(d) + if not sa or not da or sa == da: continue + agg[(sa, da)]["w"] += 1 + if c == 'api': agg[(sa, da)]["api"] = True + indeg, outdeg = defaultdict(int), defaultdict(int) + for (s, d) in agg: outdeg[s] += 1; indeg[d] += 1 + nodes = sorted({n for e in agg for n in e}) + N = [[n, n.split(':')[1], n.split(':')[0], indeg[n], outdeg[n]] for n in nodes] + E = [[s, d, v["w"], 1 if v["api"] else 0] for (s, d), v in agg.items()] + return {"n": N, "e": E} + +def build_module_graph(modules, edges): + agg = defaultdict(lambda: {"w": 0, "api": False}) + for s, d, c in edges: + if not (is_scoped(s) and is_scoped(d)) or s == d: continue + agg[(s, d)]["w"] += 1 + if c == 'api': agg[(s, d)]["api"] = True + indeg, outdeg = defaultdict(int), defaultdict(int) + for (s, d) in agg: outdeg[s] += 1; indeg[d] += 1 + conn = {n for e in agg for n in e} + N = [[m, ':'.join(m.split(':')[2:]), m.split(':')[1], indeg[m], outdeg[m]] + for m in sorted(conn)] + E = [[s, d, v["w"], 1 if v["api"] else 0] for (s, d), v in agg.items()] + return {"n": N, "e": E} + +# ---------------------------------------------------------------- AppRoute scan +REF_RE = re.compile(r'AppRoute\.([A-Z][A-Za-z0-9]+)') +DECL_RE = re.compile(r'^ (?:data )?(?:object|class) (\w+)', re.M) + +def read_kotlin_string(text, i): + """text[i] must be '"'. Return (content, index_after_closing_quote), handling \\" + escapes and ${...} template expressions (nested braces/strings copied verbatim) so a + path with string templates isn't truncated at the first inner quote.""" + out, j = [], i + 1 + while j < len(text): + c = text[j] + if c == '\\': + out.append(text[j:j + 2]); j += 2; continue + if c == '"': + return ''.join(out), j + 1 + if c == '$' and j + 1 < len(text) and text[j + 1] == '{': + out.append('${'); j += 2; depth = 1 + while j < len(text) and depth > 0: + ck = text[j] + if ck == '"': + s, j = read_kotlin_string(text, j); out.append('"' + s + '"'); continue + if ck == '{': depth += 1 + elif ck == '}': depth -= 1 + if depth > 0: out.append(ck) + j += 1 + out.append('}'); continue + out.append(c); j += 1 + return ''.join(out), j + +def extract_path(block): + """First string literal of the `path = …` argument within one screen's source block. + Block-scoped (so multi-line `AppRoute(` blocks resolve) and template-aware (so paths + aren't truncated); for a non-literal RHS (e.g. `path = when {…}`) it takes the first + branch literal. Returns None when the block has no `path =`.""" + m = re.search(r'\bpath\s*=\s*', block) + if not m: return None + i = m.end() + while i < len(block) and block[i] in ' \t\r\n': i += 1 + if i < len(block) and block[i] == '"': + return read_kotlin_string(block, i)[0] + q = block.find('"', i) + return read_kotlin_string(block, q)[0] if q != -1 else None + +def scan_routes(): + src = open(APPROUTE, encoding='utf-8').read() + decls = [(m.group(1), m.start()) for m in DECL_RE.finditer(src)] + screens = {name for name, _ in decls} + paths = {} + for idx, (name, start) in enumerate(decls): + end = decls[idx + 1][1] if idx + 1 < len(decls) else len(src) + p = extract_path(src[start:end]) + if p is not None: paths.setdefault(name, p) + usage = defaultdict(lambda: defaultdict(int)) + nav = defaultdict(int) + def area(fp): + parts = os.path.relpath(fp, ROOT).split(os.sep) + top = parts[0] + return f"{top}:{parts[1]}" if top in ('features','domain','data','core','common','libs') and len(parts) > 1 else top + for dp, _, fns in os.walk(ROOT): + if '/build/' in dp or '/.git' in dp or '/.gradle' in dp: continue + for fn in fns: + if not fn.endswith('.kt'): continue + fp = os.path.join(dp, fn) + if os.path.samefile(fp, APPROUTE) if os.path.exists(APPROUTE) else False: continue + try: txt = open(fp, encoding='utf-8', errors='ignore').read() + except Exception: continue + if 'AppRoute.' not in txt: continue + a = area(fp) + for m in REF_RE.finditer(txt): + if m.group(1) in screens: usage[m.group(1)][a] += 1 + for nm in re.finditer(r'\b(push|replaceCurrent|replaceAll|popTo)\s*\(', txt): + r2 = REF_RE.search(txt[nm.end():nm.end()+160]) + if r2 and r2.group(1) in screens: nav[(a, r2.group(1))] += 1 + return screens, paths, usage, nav + +def build_screen_graph(screens, paths, usage, nav, cfg): + total = {s: sum(usage[s].values()) for s in screens} + sg, so = cfg["screenGroups"], cfg["screenOwners"] + owned = defaultdict(list) + for s in sorted(screens): owned[so.get(s, cfg["defaultOwner"])].append(s) + def fnorm(a): return a.split(':')[-1].replace('-', '').lower() + def main_of(a, ss): + epon = [s for s in ss if s.lower() == fnorm(a)] + return epon[0] if epon else max(ss, key=lambda x: (total[x], x)) # deterministic tie-break + main = {a: main_of(a, ss) for a, ss in owned.items()} + APPSHELL = 'AppShell' + edge = defaultdict(int) + for (a, t), c in nav.items(): + src = main.get(a, APPSHELL) + if src == t: continue + edge[(src, t)] += c + use_shell = any(s == APPSHELL for s, _ in edge) + indeg, outdeg = defaultdict(int), defaultdict(int) + for (s, t) in edge: outdeg[s] += 1; indeg[t] += 1 + N = [[s, s, sg.get(s, cfg["defaultGroup"]), indeg[s], outdeg[s]] for s in sorted(screens)] + if use_shell: N.append([APPSHELL, 'App shell', 'shell', indeg[APPSHELL], outdeg[APPSHELL]]) + E = [[s, t, w, 0] for (s, t), w in edge.items()] + meta = {} + for s in sorted(screens): + meta[s] = {"path": paths.get(s, ""), "owner": so.get(s, cfg["defaultOwner"]), + "group": sg.get(s, cfg["defaultGroup"]), "total": total[s], + "refs": sorted(usage[s].items(), key=lambda kv: -kv[1])} + return {"n": N, "e": E}, meta + +# ---------------------------------------------------------------- main +def main(): + if not os.path.exists(APPROUTE): + sys.exit(f"ERROR: AppRoute.kt not found at {APPROUTE}") + old = open(MD, encoding='utf-8').read() if os.path.exists(MD) else "" + + # config: prefer the one already in the doc (human edits win), else seed + cfg_str = block(old, "config") + cfg = json.loads(cfg_str) if cfg_str else json.load(open(SEED)) + + modules, dep_edges = scan_modules() + area_g = build_area_graph(modules, dep_edges) + mod_g = build_module_graph(modules, dep_edges) + screens, paths, usage, nav = scan_routes() + + # reconcile config with the screens actually present in code + warns = [] + for s in sorted(screens): + if s not in cfg["screenGroups"]: + cfg["screenGroups"][s] = cfg["defaultGroup"]; warns.append(f"NEW screen '{s}': group defaulted to '{cfg['defaultGroup']}' — set it in CONFIG") + if s not in cfg["screenOwners"]: + cfg["screenOwners"][s] = cfg["defaultOwner"]; warns.append(f"NEW screen '{s}': owner defaulted to '{cfg['defaultOwner']}' — set it in CONFIG") + stale = [s for s in cfg["screenGroups"] if s not in screens] + + screen_g, screen_meta = build_screen_graph(screens, paths, usage, nav, cfg) + + inv_area = sum(1 for s, t, w, a in area_g["e"] if t.startswith('features:') and not s.startswith('features:')) + + cj = lambda o: json.dumps(o, separators=(',', ':')) + summary = ( + f"_Auto-generated from code by the `navigation-graph` skill. Edit only the CONFIG block below._\n\n" + f"- **Screens (AppRoute):** {len(screens)} · screen-nav edges {len(screen_g['e'])}\n" + f"- **Area graph:** {len(area_g['n'])} feature/domain/data areas · {len(area_g['e'])} dependency edges · {inv_area} inverted (domain/data→features)\n" + f"- **Module graph:** {len(mod_g['n'])} modules · {len(mod_g['e'])} edges\n" + ) + if warns: summary += "\n**Action needed:**\n" + "\n".join(f"- {w}" for w in warns) + "\n" + if stale: summary += f"\n_Config has {len(stale)} screen(s) no longer in code (kept, harmless): {', '.join(stale)}_\n" + + managed = "\n\n".join([ + BEGIN, + "## Connectivity data (generated)\n\n" + summary, + "### Curated config (editable — preserved across refreshes)\n\n" + wrap("config", json.dumps(cfg, indent=2)), + "### Graph data (auto — overwritten every refresh; do not hand-edit)\n\n" + + wrap("areaGraph", cj(area_g)) + "\n\n" + wrap("moduleGraph", cj(mod_g)) + "\n\n" + + wrap("screensGraph", cj(screen_g)) + "\n\n" + wrap("screensMeta", cj(screen_meta)), + END, + ]) + + if BEGIN in old and END in old: + head = old[:old.index(BEGIN)].rstrip() + "\n\n" + tail = old[old.index(END) + len(END):] + new = head + managed + tail + elif old.strip(): + new = old.rstrip() + "\n\n" + managed + "\n" + else: + new = "# Navigation Graph\n\n" + managed + "\n" + + os.makedirs(DOCS, exist_ok=True) + open(MD, "w", encoding='utf-8').write(new) + print(f"✓ updated {os.path.relpath(MD, ROOT)}") + print(f" screens={len(screens)} screen-edges={len(screen_g['e'])} | " + f"areas={len(area_g['n'])}/{len(area_g['e'])} | modules={len(mod_g['n'])}/{len(mod_g['e'])}") + for w in warns: print(" ⚠ " + w) + print("Next: python3 " + os.path.join(SCRIPT_DIR, "render_html.py")) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/.claude/skills/navigation-graph/scripts/render_html.py b/.claude/skills/navigation-graph/scripts/render_html.py new file mode 100644 index 0000000000..baeab3d348 --- /dev/null +++ b/.claude/skills/navigation-graph/scripts/render_html.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +""" +render_html.py — build .claude/docs/module-connectivity.html from the data blocks +in .claude/docs/navigation-graph.md (which build_graph.py refreshes from code). + +Run AFTER build_graph.py: python3 render_html.py +""" +import os, re, json, sys + +def find_root(start): + d = os.path.abspath(start) + while d != os.path.dirname(d): + if os.path.exists(os.path.join(d, "settings.gradle.kts")): + return d + d = os.path.dirname(d) + sys.exit("ERROR: could not locate repo root (settings.gradle.kts not found).") + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +ROOT = find_root(os.getcwd()) +SKILL = os.path.dirname(SCRIPT_DIR) +MD = os.path.join(ROOT, ".claude", "docs", "navigation-graph.md") +TEMPLATE = os.path.join(SKILL, "assets", "template.html") +OUT = os.path.join(ROOT, ".claude", "docs", "module-connectivity.html") + +def block(text, key): + m = re.search(r"\s*```json\s*(.*?)\s*```\s*" + % (re.escape(key), re.escape(key)), text, re.S) + if not m: + sys.exit(f"ERROR: data block '{key}' not found in {MD}. Run build_graph.py first.") + return json.loads(m.group(1)) + +def main(): + if not os.path.exists(MD): sys.exit(f"ERROR: {MD} not found. Run build_graph.py first.") + if not os.path.exists(TEMPLATE): sys.exit(f"ERROR: template missing at {TEMPLATE}") + text = open(MD, encoding='utf-8').read() + cfg = block(text, "config") + area = block(text, "areaGraph") + mod = block(text, "moduleGraph") + screens = block(text, "screensGraph") + smeta = block(text, "screensMeta") + + groups = cfg["groups"] + group_colors = {g: groups[g]["color"] for g in groups} + group_labels = {g: groups[g]["label"] for g in groups} + group_anchors = {g: groups[g]["anchor"] for g in groups} + + cj = lambda o: json.dumps(o, separators=(',', ':')) + tpl = open(TEMPLATE, encoding='utf-8').read() + repl = { + "/*__AREA_DATA__*/null": cj(area), + "/*__MODULE_DATA__*/null": cj(mod), + "/*__SCREENS_DATA__*/null": cj(screens), + "/*__SCREENS_META__*/null": cj(smeta), + "/*__TEAMS__*/[]": cj(cfg["teams"]), + "/*__GROUP_COLORS__*/{}": cj(group_colors), + "/*__GROUP_LABELS__*/{}": cj(group_labels), + "/*__GROUP_ANCHORS__*/{}": cj(group_anchors), + } + out = tpl + for k, v in repl.items(): + if k not in out: sys.exit(f"ERROR: placeholder '{k}' missing in template — template/skill version mismatch.") + out = out.replace(k, v) + for ph in ("__AREA_DATA__", "__MODULE_DATA__", "__SCREENS_DATA__", "__SCREENS_META__", + "__TEAMS__", "__GROUP_COLORS__", "__GROUP_LABELS__", "__GROUP_ANCHORS__"): + if "/*" + ph + "*/" in out: sys.exit(f"ERROR: placeholder {ph} left unreplaced.") + open(OUT, "w", encoding='utf-8').write(out) + print(f"✓ wrote {os.path.relpath(OUT, ROOT)} ({len(out)//1024} KB, self-contained)") + print(f" area {len(area['n'])}n/{len(area['e'])}e · module {len(mod['n'])}n/{len(mod['e'])}e · " + f"screens {len(screens['n'])}n/{len(screens['e'])}e · teams {len(cfg['teams'])}") + print(f" open: {OUT}") + +if __name__ == "__main__": + main() \ No newline at end of file From f58dec08f890bfffbee5b67c4a45ff6e62efc110 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 22 Jun 2026 12:07:09 +0000 Subject: [PATCH 014/210] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 9f936c4349..074e02bb80 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,9 +5,9 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-6.0-1578" +tangemBlockchainSdk = "develop-1567" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-6.0-626" +tangemCardSdk = "develop-624" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "tangem-master-21" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ From da1b0477e0af1bdb672232626605818c29d574bc Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 22 Jun 2026 16:36:07 +0400 Subject: [PATCH 015/210] Updated on 2026-08-14 --- CLAUDE.md | 2 +- core/config-toggles/CLAUDE.md | 70 +++++++++++++++++++ .../configs/feature_toggles_config.json | 4 ++ .../features/home/api/HomeFeatureToggles.kt | 6 ++ features/home/impl/build.gradle.kts | 1 + .../home/impl/DefaultHomeFeatureToggles.kt | 13 ++++ .../home/impl/di/HomeFeatureModule.kt | 15 ++++ .../features/home/impl/model/HomeModel.kt | 3 + .../features/home/impl/ui/state/HomeUM.kt | 1 + 9 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 core/config-toggles/CLAUDE.md create mode 100644 features/home/api/src/main/kotlin/com/tangem/features/home/api/HomeFeatureToggles.kt create mode 100644 features/home/impl/src/main/kotlin/com/tangem/features/home/impl/DefaultHomeFeatureToggles.kt diff --git a/CLAUDE.md b/CLAUDE.md index d093519ee6..0dc488bd3f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,7 +102,7 @@ The app uses [Decompose](https://github.com/arkivanov/Decompose) for lifecycle-a - **Async:** Kotlin Coroutines + Flow. Inject `CoroutineDispatcherProvider` (from `core/utils`) instead of using `Dispatchers.*` directly — provides `main`, `mainImmediate`, `io`, `default`, `single` - **Error handling:** Arrow's `Either` pattern throughout domain/data layers. `DataError` sealed hierarchy for domain errors. See `domain/core/CLAUDE.md` for the LCE pattern - **Analytics:** `AnalyticsEvent(category, event, params)` in `core/analytics/models/`. Feature events are sealed class hierarchies extending `AnalyticsEvent`. Send via injected `AnalyticsEventHandler` -- **Feature toggles:** `FeatureTogglesManager` in `core/config-toggles/`. Toggles are defined in `core/config-toggles/src/main/assets/configs/feature_toggles_config.json` and auto-generated into a `FeatureToggles` enum by the convention plugin at build time. Each feature module exposes its own `XxxFeatureToggles` interface (in `api/`) with a `DefaultXxxFeatureToggles` implementation (in `impl/`) that delegates to `FeatureTogglesManager` +- **Feature toggles:** `FeatureTogglesManager` in `core/config-toggles/`. See `core/config-toggles/CLAUDE.md`. - **Supported languages:** `SupportedLanguages` in `core/utils/` defines the app's supported locales: en, ru, de, fr, it, ja, uk, zh, es. `getCurrentSupportedLanguageCode()` returns the device locale if supported, otherwise falls back to English. Used by API calls that accept a language parameter ### Build System diff --git a/core/config-toggles/CLAUDE.md b/core/config-toggles/CLAUDE.md new file mode 100644 index 0000000000..0d2107635e --- /dev/null +++ b/core/config-toggles/CLAUDE.md @@ -0,0 +1,70 @@ +# core/config-toggles + +Feature toggles (and the related excluded-blockchains toggles). Toggles gate +features by app version; the JSON config is the source of truth and the +`FeatureToggles` enum is generated from it at build time. + +## How it works + +- **Config:** `src/main/assets/configs/feature_toggles_config.json` — a JSON array + of `{ "name": , "version": }` (`ConfigToggle`). +- The **convention plugin** generates the `FeatureToggles` enum (one entry per + `name`) at build time. Reference it as `FeatureToggles.`. +- **Entry point:** `FeatureTogglesManager.isFeatureEnabled(FeatureToggles.X)`. + - `ProdFeatureTogglesManager` (release): a toggle is enabled when the app + version `>=` its `version`. + - `DevFeatureTogglesManager` (tester builds, `BuildConfig.TESTER_MENU_ENABLED`): + runtime-toggleable via the Tester Menu. +- **`version` semantics:** + - `"undefined"` (`DISABLED_FEATURE_TOGGLE_VERSION`) → OFF in prod; can only be + flipped ON via the Tester Menu / dev builds. Use this while a feature is in + development. + - `"X.Y"` (e.g. `5.40`) → ON in prod from that app version onward + (`currentVersion >= localVersion`, see `VersionAvailabilityContract`). + +## Naming convention (ENFORCED by a test) + +- A toggle `name` MUST match `^(AND|TWI)_\d+(?:_[A-Z0-9]+)+$` — start with the + Jira ticket id (`AND_` for Android tickets, `TWI_` for idea tickets), + then an `UPPER_SNAKE_CASE` suffix. Example: `AND_15901_STORIES_CONTAINER_ENABLED`. +- Enforced by `FeatureTogglesNamingConventionTest`. Legacy toggles that predate + the rule are whitelisted in its `EXCLUDED_TOGGLES_LIST` — do **not** add new + names there without an explicit reason. +- The Kotlin interface property stays human-readable **without** the ticket id: + `isStoriesContainerEnabled`. + +## Per-feature toggles & how to add one + +Each feature owns its toggles — feature code reads them through its own +interface, never `FeatureTogglesManager` directly: + +- `api/`: `XxxFeatureToggles` interface — `val isYyyEnabled: Boolean`. +- `impl/`: `DefaultXxxFeatureToggles(featureTogglesManager)` exposes each toggle as + a **getter-backed property**, not a stored value — so it is re-evaluated on every + read (required for runtime toggling via the Tester Menu): + + ```kotlin + override val isYyyEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.AND__YYY) + ``` + + Never `val isYyyEnabled = featureTogglesManager.isFeatureEnabled(...)` (evaluated + once at construction). +- DI: a `@Provides @Singleton` in the feature's Hilt module returning the interface. + +To add a toggle: + +1. Add `{ "name": "AND__FOO_ENABLED", "version": "undefined" }` to the config + JSON (the enum is regenerated at build). +2. Add `val isFooEnabled` to the feature's `XxxFeatureToggles` and map it in + `DefaultXxxFeatureToggles` (create the interface/impl/DI provider if the + feature has none yet). +3. Gate code on `xxxFeatureToggles.isFooEnabled`. + +## Removing (cleanup) + +When a toggle ships at 100%, set its `version` to the release and run the +`cleanup-feature-toggles` skill — it removes the JSON entry, the interface/impl +members, inlines `true`, and drops dead branches. Mark code that must be deleted +together with a toggle using `@RemoveWithToggle("AND__FOO_ENABLED")` +(`com.tangem.utils.annotations.RemoveWithToggle`); the cleanup skill picks it up. \ No newline at end of file diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index e0464082f0..a9cc20669f 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -1,4 +1,8 @@ [ + { + "name": "AND_15901_STORIES_CONTAINER_ENABLED", + "version": "undefined" + }, { "name": "NEW_CARD_SCANNING_ENABLED", "version": "undefined" diff --git a/features/home/api/src/main/kotlin/com/tangem/features/home/api/HomeFeatureToggles.kt b/features/home/api/src/main/kotlin/com/tangem/features/home/api/HomeFeatureToggles.kt new file mode 100644 index 0000000000..c991bd16d5 --- /dev/null +++ b/features/home/api/src/main/kotlin/com/tangem/features/home/api/HomeFeatureToggles.kt @@ -0,0 +1,6 @@ +package com.tangem.features.home.api + +interface HomeFeatureToggles { + + val isStoriesContainerEnabled: Boolean +} \ No newline at end of file diff --git a/features/home/impl/build.gradle.kts b/features/home/impl/build.gradle.kts index afa7941725..60bb2f652f 100644 --- a/features/home/impl/build.gradle.kts +++ b/features/home/impl/build.gradle.kts @@ -23,6 +23,7 @@ dependencies { implementation(projects.core.analytics.models) implementation(projects.core.navigation) implementation(projects.core.utils) + implementation(projects.core.configToggles) /** Common */ implementation(projects.common.routing) diff --git a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/DefaultHomeFeatureToggles.kt b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/DefaultHomeFeatureToggles.kt new file mode 100644 index 0000000000..81085edfe0 --- /dev/null +++ b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/DefaultHomeFeatureToggles.kt @@ -0,0 +1,13 @@ +package com.tangem.features.home.impl + +import com.tangem.core.configtoggle.FeatureToggles +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.features.home.api.HomeFeatureToggles + +internal class DefaultHomeFeatureToggles( + private val featureTogglesManager: FeatureTogglesManager, +) : HomeFeatureToggles { + + override val isStoriesContainerEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.AND_15901_STORIES_CONTAINER_ENABLED) +} \ No newline at end of file diff --git a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/di/HomeFeatureModule.kt b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/di/HomeFeatureModule.kt index cb6516bc9b..5b764ba1c6 100644 --- a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/di/HomeFeatureModule.kt +++ b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/di/HomeFeatureModule.kt @@ -1,12 +1,16 @@ package com.tangem.features.home.impl.di +import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.core.decompose.di.ModelComponent import com.tangem.core.decompose.model.Model import com.tangem.features.home.api.HomeComponent +import com.tangem.features.home.api.HomeFeatureToggles import com.tangem.features.home.impl.DefaultHomeComponent +import com.tangem.features.home.impl.DefaultHomeFeatureToggles import com.tangem.features.home.impl.model.HomeModel import dagger.Binds import dagger.Module +import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import dagger.multibindings.ClassKey @@ -22,6 +26,17 @@ internal interface ComponentModule { fun bindComponent(factory: DefaultHomeComponent.Factory): HomeComponent.Factory } +@Module +@InstallIn(SingletonComponent::class) +internal object HomeFeatureTogglesModule { + + @Provides + @Singleton + fun provideHomeFeatureToggles(featureTogglesManager: FeatureTogglesManager): HomeFeatureToggles { + return DefaultHomeFeatureToggles(featureTogglesManager) + } +} + @Module @InstallIn(ModelComponent::class) internal interface ModelModule { diff --git a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt index 147e146fa6..86f2175a22 100644 --- a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt +++ b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt @@ -34,6 +34,7 @@ import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase import com.tangem.domain.wallets.usecase.SaveWalletUseCase import com.tangem.feature.referral.domain.ShouldShowMobileWalletPromoUseCase import com.tangem.features.home.api.HomeComponent +import com.tangem.features.home.api.HomeFeatureToggles import com.tangem.features.home.impl.ui.state.HomeUM import com.tangem.features.home.impl.ui.state.Stories import com.tangem.features.home.impl.ui.state.getRestrictedStories @@ -67,6 +68,7 @@ internal class HomeModel @Inject constructor( private val urlOpener: UrlOpener, private val userWalletsListRepository: UserWalletsListRepository, private val shouldShowMobileWalletPromoUseCase: ShouldShowMobileWalletPromoUseCase, + private val homeFeatureToggles: HomeFeatureToggles, @GlobalUiMessageSender private val uiMessageSender: UiMessageSender, ) : Model() { @@ -77,6 +79,7 @@ internal class HomeModel @Inject constructor( private val _uiState = MutableStateFlow( HomeUM( scanInProgress = false, + isStoriesContainerEnabled = homeFeatureToggles.isStoriesContainerEnabled, stories = getRestrictedStories().toImmutableList(), onShopClick = ::onShopClick, onSearchTokensClick = ::onSearchTokensClick, diff --git a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/state/HomeUM.kt b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/state/HomeUM.kt index 924ca985fb..bdc99712d2 100644 --- a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/state/HomeUM.kt +++ b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/state/HomeUM.kt @@ -4,6 +4,7 @@ import kotlinx.collections.immutable.ImmutableList data class HomeUM( val scanInProgress: Boolean, + val isStoriesContainerEnabled: Boolean, val stories: ImmutableList, val onShopClick: () -> Unit, val onSearchTokensClick: () -> Unit, From 71764362260e8157c2e54b4e135311e5c6e585dd Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 22 Jun 2026 18:12:31 +0100 Subject: [PATCH 016/210] Updated on 2026-08-14 --- .../DefaultAddressBookRepositoryTest.kt | 4 ++- .../domain/addressbook/model/Contact.kt | 2 ++ .../usecase/CreateContactUseCase.kt | 4 ++- .../usecase/UpdateContactUseCase.kt | 2 ++ .../crypto/AddressBookCipherTest.kt | 32 ++++++++++++++++--- .../usecase/CreateContactUseCaseTest.kt | 14 ++++++-- .../usecase/GetContactsUseCaseTest.kt | 2 ++ .../usecase/GetVerifiedContactsUseCaseTest.kt | 2 ++ .../usecase/SignAddressEntriesUseCaseTest.kt | 2 ++ .../usecase/UpdateContactUseCaseTest.kt | 13 +++++--- .../usecase/ValidateContactNameUseCaseTest.kt | 2 ++ .../VerifyAddressEntriesUseCaseTest.kt | 2 ++ 12 files changed, 68 insertions(+), 13 deletions(-) diff --git a/data/address-book/src/test/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepositoryTest.kt b/data/address-book/src/test/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepositoryTest.kt index e796387263..233b709cfa 100644 --- a/data/address-book/src/test/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepositoryTest.kt +++ b/data/address-book/src/test/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepositoryTest.kt @@ -195,10 +195,12 @@ internal class DefaultAddressBookRepositoryTest { assertThat(result).isEqualTo(bob) } - private fun createContact(id: String, name: String): Contact = Contact( + private fun createContact(id: String, name: String, iconColor: String = "KekColor"): Contact = Contact( id = ContactId(id), walletId = UserWalletId(WALLET_A), name = ContactName(name).getOrNull()!!, + icon = "", + iconColor = iconColor, createdAt = TIMESTAMP, updatedAt = TIMESTAMP, addressEntries = emptyList(), diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/Contact.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/Contact.kt index 2cf5cae408..8697b62900 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/Contact.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/Contact.kt @@ -15,6 +15,8 @@ data class Contact( val id: ContactId, val walletId: UserWalletId, val name: ContactName, + val icon: String, + val iconColor: String, val createdAt: String, val updatedAt: String, val addressEntries: List, diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCase.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCase.kt index 45d623fe55..3cab6232ed 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCase.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCase.kt @@ -25,10 +25,10 @@ class CreateContactUseCase( private val timestampProvider: IsoTimestampProvider, ) { - @Suppress("LongParameterList") suspend operator fun invoke( userWallet: UserWallet, name: String, + iconColor: String, network: Network, addressEntries: List, ): Either = either { @@ -42,6 +42,8 @@ class CreateContactUseCase( id = ContactId(UUID.randomUUID().toString()), walletId = userWalletId, name = validName, + icon = "", + iconColor = iconColor, createdAt = now, updatedAt = now, addressEntries = addressEntries, diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCase.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCase.kt index 3a6ed87bf1..113a870f0f 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCase.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCase.kt @@ -21,6 +21,7 @@ class UpdateContactUseCase( userWallet: UserWallet, contact: Contact, name: String, + iconColor: String, addressEntries: List, ): Either = either { val validName = ContactName(name) @@ -29,6 +30,7 @@ class UpdateContactUseCase( val updated = contact.copy( name = validName, + iconColor = iconColor, addressEntries = addressEntries, updatedAt = timestampProvider.now(), ) diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/crypto/AddressBookCipherTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/crypto/AddressBookCipherTest.kt index 3f6371f5a5..b8cbeedb8d 100644 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/crypto/AddressBookCipherTest.kt +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/crypto/AddressBookCipherTest.kt @@ -36,8 +36,16 @@ internal class AddressBookCipherTest { fun `GIVEN multi-contact book WHEN encrypt then decrypt THEN original book is restored`() { // Arrange val book = addressBook( - contact("Alice", entry("addr-1", "0xabc", memo = "memo")), - contact("Bob", entry("addr-2", "0xdef", memo = null)), + contact( + name = "Alice", + iconColor = "TestColor1", + entries = arrayOf(entry("addr-1", "0xabc", memo = "memo")), + ), + contact( + name = "Bob", + iconColor = "TestColor2", + entries = arrayOf(entry("addr-2", "0xdef", memo = null)), + ), ) // Act @@ -64,7 +72,13 @@ internal class AddressBookCipherTest { @Test fun `GIVEN a book WHEN encrypt THEN blob metadata and field sizes match the spec`() { // Arrange - val book = addressBook(contact("Alice", entry("addr-1", "0xabc", memo = null))) + val book = addressBook( + contact( + name = "Alice", + iconColor = "TestColor", + entries = arrayOf(entry("addr-1", "0xabc", memo = null)), + ) + ) // Act val blob = cipher.encrypt(book, wallet, updatedAt).rightValue() @@ -94,7 +108,13 @@ internal class AddressBookCipherTest { @Test fun `GIVEN same book encrypted twice WHEN compared THEN nonce differs but both decrypt to original`() { // Arrange - val book = addressBook(contact("Alice", entry("addr-1", "0xabc", memo = null))) + val book = addressBook( + contact( + name = "Alice", + iconColor = "TestColor", + entries = arrayOf(entry("addr-1", "0xabc", memo = null)), + ) + ) // Act val first = cipher.encrypt(book, wallet, updatedAt).rightValue() @@ -222,10 +242,12 @@ internal class AddressBookCipherTest { private fun addressBook(walletId: UserWalletId): AddressBook = AddressBook(walletId = walletId, contacts = emptyList()) - private fun contact(name: String, vararg entries: AddressEntry): Contact = Contact( + private fun contact(name: String, iconColor: String, vararg entries: AddressEntry): Contact = Contact( id = ContactId("contact-$name"), walletId = wallet.walletId, name = requireNotNull(ContactName(name).getOrNull()), + icon = "", + iconColor = iconColor, createdAt = "2026-01-01T00:00:00.000Z", updatedAt = "2026-05-22T09:00:00.000Z", addressEntries = entries.toList(), diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCaseTest.kt index 60a42a7729..6059039b5e 100644 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCaseTest.kt +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCaseTest.kt @@ -80,6 +80,7 @@ class CreateContactUseCaseTest { val result = useCase( userWallet = userWallet, name = "Alice", + iconColor = "TestColor", network = network, addressEntries = addressEntries, ) @@ -102,6 +103,7 @@ class CreateContactUseCaseTest { val result = useCase( userWallet = userWallet, name = "Alice", + iconColor = "TestColor", network = network, addressEntries = addressEntries, ) @@ -112,11 +114,16 @@ class CreateContactUseCaseTest { @Test fun `duplicate name fails without persisting`() = runTest { - every { repository.getContacts(walletId) } returns flowOf(listOf(contact(name = "Alice"))) + every { repository.getContacts(walletId) } returns flowOf( + listOf( + contact(name = "Alice", iconColor = "TestColor") + ) + ) val result = useCase( userWallet = userWallet, name = "alice", + iconColor = "TestColor", network = network, addressEntries = addressEntries, ) @@ -133,6 +140,7 @@ class CreateContactUseCaseTest { val result = useCase( userWallet = userWallet, name = "", + iconColor = "TestColor", network = network, addressEntries = addressEntries, ) @@ -142,10 +150,12 @@ class CreateContactUseCaseTest { coVerify(exactly = 0) { repository.saveContact(any()) } } - private fun contact(name: String): Contact = Contact( + private fun contact(name: String, iconColor: String): Contact = Contact( id = ContactId("id-$name"), walletId = walletId, name = requireNotNull(ContactName(name).getOrNull()), + icon = "", + iconColor = iconColor, createdAt = expectedTimestamp, updatedAt = expectedTimestamp, addressEntries = listOf( diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCaseTest.kt index bb0253a442..a9f574f27d 100644 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCaseTest.kt +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCaseTest.kt @@ -90,6 +90,8 @@ class GetContactsUseCaseTest { id = ContactId("id-$name"), walletId = UserWalletId("011"), name = requireNotNull(ContactName(name).getOrNull()), + icon = "", + iconColor = "KekColor", createdAt = "2026-01-01T00:00:00.000Z", updatedAt = "2026-01-01T00:00:00.000Z", addressEntries = listOf( diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetVerifiedContactsUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetVerifiedContactsUseCaseTest.kt index 4c4e20d359..af4854afd1 100644 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetVerifiedContactsUseCaseTest.kt +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetVerifiedContactsUseCaseTest.kt @@ -109,6 +109,8 @@ class GetVerifiedContactsUseCaseTest { id = ContactId("id-$name"), walletId = walletId, name = requireNotNull(ContactName(name).getOrNull()), + icon = "", + iconColor = "KekColor", createdAt = "2026-01-01T00:00:00.000Z", updatedAt = "2026-01-01T00:00:00.000Z", addressEntries = entries, diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/SignAddressEntriesUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/SignAddressEntriesUseCaseTest.kt index da7c887d62..db38425d7f 100644 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/SignAddressEntriesUseCaseTest.kt +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/SignAddressEntriesUseCaseTest.kt @@ -124,6 +124,8 @@ class SignAddressEntriesUseCaseTest { id = ContactId("contact-1"), walletId = UserWalletId("011"), name = requireNotNull(ContactName("Alice").getOrNull()), + icon = "", + iconColor = "KekColor", createdAt = "2026-01-01T00:00:00.000Z", updatedAt = "2026-01-01T00:00:00.000Z", addressEntries = entries.toList(), diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCaseTest.kt index 7e3929dbe3..46d68f096e 100644 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCaseTest.kt +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCaseTest.kt @@ -69,7 +69,7 @@ class UpdateContactUseCaseTest { @Test fun `update preserves id and persists signed changes without checking uniqueness`() = runTest { - val existing = contact(name = "Alice") + val existing = contact(name = "Alice", iconColor = "TestColor") val saved = slot() coEvery { repository.saveContact(capture(saved)) } returns Unit @@ -77,6 +77,7 @@ class UpdateContactUseCaseTest { userWallet = userWallet, contact = existing, name = "Bob", + iconColor = "TestColor", addressEntries = updatedEntries, ) @@ -96,8 +97,9 @@ class UpdateContactUseCaseTest { val result = useCase( userWallet = userWallet, - contact = contact(name = "Alice"), + contact = contact(name = "Alice", iconColor = "TestColor"), name = "Bob", + iconColor = "TestColor", addressEntries = updatedEntries, ) @@ -109,8 +111,9 @@ class UpdateContactUseCaseTest { fun `invalid name fails without persisting`() = runTest { val result = useCase( userWallet = userWallet, - contact = contact(name = "Alice"), + contact = contact(name = "Alice", iconColor = "TestColor"), name = "", + iconColor = "TestColor", addressEntries = updatedEntries, ) @@ -119,10 +122,12 @@ class UpdateContactUseCaseTest { coVerify(exactly = 0) { repository.saveContact(any()) } } - private fun contact(name: String): Contact = Contact( + private fun contact(name: String, iconColor: String): Contact = Contact( id = ContactId("id-$name"), walletId = walletId, name = requireNotNull(ContactName(name).getOrNull()), + icon = "", + iconColor = iconColor, createdAt = originalTimestamp, updatedAt = originalTimestamp, addressEntries = listOf( diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/ValidateContactNameUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/ValidateContactNameUseCaseTest.kt index aa81fcd4c1..9b51b6df7d 100644 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/ValidateContactNameUseCaseTest.kt +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/ValidateContactNameUseCaseTest.kt @@ -64,6 +64,8 @@ class ValidateContactNameUseCaseTest { id = ContactId("id-$name"), walletId = walletId, name = requireNotNull(ContactName(name).getOrNull()), + icon = "", + iconColor = "KekColor", createdAt = "2026-01-01T00:00:00.000Z", updatedAt = "2026-01-01T00:00:00.000Z", addressEntries = listOf( diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/VerifyAddressEntriesUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/VerifyAddressEntriesUseCaseTest.kt index 858206d127..f5b8ba5afb 100644 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/VerifyAddressEntriesUseCaseTest.kt +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/VerifyAddressEntriesUseCaseTest.kt @@ -152,6 +152,8 @@ class VerifyAddressEntriesUseCaseTest { id = ContactId("contact-1"), walletId = UserWalletId("011"), name = requireNotNull(ContactName("Alice").getOrNull()), + icon = "", + iconColor = "KekColor", createdAt = "2026-01-01T00:00:00.000Z", updatedAt = "2026-01-01T00:00:00.000Z", addressEntries = entries.toList(), From 428f1076f091289f50cd652942ece92fd3be6490 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 23 Jun 2026 08:40:33 +0300 Subject: [PATCH 017/210] Updated on 2026-08-14 --- .../api/tangemTech/TangemTechApi.kt | 6 +- .../models/PushNotificationPreferenceState.kt | 10 - .../models/PushNotificationPreferencesBody.kt | 10 +- .../PushNotificationPreferencesResponse.kt | 6 +- ...etPushNotificationPreferencesRepository.kt | 121 +++++---- .../PushNotificationPreferencesConverter.kt | 10 +- .../di/PushNotificationPreferencesModule.kt | 3 - ...shNotificationPreferencesRepositoryTest.kt | 253 ++++++++++++------ .../models/PushNotificationPreference.kt | 1 - .../model/PushNotificationSettingsModel.kt | 1 - .../PushNotificationSettingsModelTest.kt | 12 +- 11 files changed, 248 insertions(+), 185 deletions(-) delete mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/PushNotificationPreferenceState.kt diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt index 7530892018..244998c682 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/TangemTechApi.kt @@ -52,16 +52,16 @@ interface TangemTechApi { @Body userTokens: UserTokensResponse, ): ApiResponse - @GET("/v1/wallets/{wallet_id}/notification-preferences") + @GET("/api/v1/notification-preferences/{wallet_id}") suspend fun getPushNotificationPreferences( @Path("wallet_id") walletId: String, ): ApiResponse - @PUT("/v1/wallets/{wallet_id}/notification-preferences") + @PUT("/api/v1/notification-preferences/{wallet_id}") suspend fun updatePushNotificationPreferences( @Path("wallet_id") walletId: String, @Body body: PushNotificationPreferencesBody, - ): ApiResponse + ): ApiResponse // region Referral /** Returns referral status by [walletId] */ diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/PushNotificationPreferenceState.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/PushNotificationPreferenceState.kt deleted file mode 100644 index 9d95473183..0000000000 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/PushNotificationPreferenceState.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.tangem.datasource.api.tangemTech.models - -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass - -@JsonClass(generateAdapter = true) -data class PushNotificationPreferenceState( - @Json(name = "isEnabled") val isEnabled: Boolean, - @Json(name = "isVisible") val isVisible: Boolean, -) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/PushNotificationPreferencesBody.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/PushNotificationPreferencesBody.kt index 01a4c76dcd..9b7ce52ebe 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/PushNotificationPreferencesBody.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/PushNotificationPreferencesBody.kt @@ -5,10 +5,10 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class PushNotificationPreferencesBody( - @Json(name = "transactionAlerts") - val areTransactionAlertsEnabled: Boolean, - @Json(name = "offersUpdates") - val areOffersUpdatesEnabled: Boolean, - @Json(name = "priceAlerts") + @Json(name = "transactionEventsEnabled") + val areTransactionEventsEnabled: Boolean, + @Json(name = "offerUpdatesEnabled") + val areOfferUpdatesEnabled: Boolean, + @Json(name = "priceAlertsEnabled") val arePriceAlertsEnabled: Boolean, ) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/PushNotificationPreferencesResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/PushNotificationPreferencesResponse.kt index 25606b8a6e..e67a3dbcb5 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/PushNotificationPreferencesResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/tangemTech/models/PushNotificationPreferencesResponse.kt @@ -5,7 +5,7 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class PushNotificationPreferencesResponse( - @Json(name = "transactionAlerts") val transactionAlerts: PushNotificationPreferenceState, - @Json(name = "offersUpdates") val offersUpdates: PushNotificationPreferenceState, - @Json(name = "priceAlerts") val priceAlerts: PushNotificationPreferenceState, + @Json(name = "transactionEventsEnabled") val areTransactionEventsEnabled: Boolean, + @Json(name = "offerUpdatesEnabled") val areOfferUpdatesEnabled: Boolean, + @Json(name = "priceAlertsEnabled") val arePriceAlertsEnabled: Boolean, ) \ No newline at end of file diff --git a/data/push-notification-preferences/src/main/kotlin/com/tangem/data/pushnotificationpreferences/DefaultWalletPushNotificationPreferencesRepository.kt b/data/push-notification-preferences/src/main/kotlin/com/tangem/data/pushnotificationpreferences/DefaultWalletPushNotificationPreferencesRepository.kt index c5b207e92b..7f8be9280c 100644 --- a/data/push-notification-preferences/src/main/kotlin/com/tangem/data/pushnotificationpreferences/DefaultWalletPushNotificationPreferencesRepository.kt +++ b/data/push-notification-preferences/src/main/kotlin/com/tangem/data/pushnotificationpreferences/DefaultWalletPushNotificationPreferencesRepository.kt @@ -1,54 +1,49 @@ package com.tangem.data.pushnotificationpreferences import arrow.core.Either +import com.tangem.data.pushnotificationpreferences.converters.PushNotificationPreferencesConverter +import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.api.tangemTech.models.PushNotificationPreferencesBody import com.tangem.datasource.local.datastore.RuntimeSharedStore -import com.tangem.datasource.local.preferences.AppPreferencesStore -import com.tangem.datasource.local.preferences.PreferencesKeys -import com.tangem.datasource.local.preferences.utils.getObjectMapSync import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pushnotificationpreferences.models.PushNotificationCategory -import com.tangem.domain.pushnotificationpreferences.models.PushNotificationPreference import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences import com.tangem.domain.pushnotificationpreferences.repository.WalletPushNotificationPreferencesRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext /** - * In-memory cache implementation of [WalletPushNotificationPreferencesRepository]. - * - * Mock-mode (current): defaults are computed locally and writes are kept in-memory only. - * Real-mode (when Variant C BE is ready): replace TODO blocks with [TangemTechApi] calls. - * - * Defaults for existing users (until BE migration runs): TX read from - * [PreferencesKeys.NOTIFICATIONS_ENABLED_STATES_KEY] (default true), Offers&Updates = true, Price Alerts = false, - * isVisible = true for all three. + * Preferences are cached in-memory (non-persistent); writes are full-replace PUTs and the server echo + * is cached as the source of truth. */ internal class DefaultWalletPushNotificationPreferencesRepository( - private val appPreferencesStore: AppPreferencesStore, - @Suppress("unused") private val tangemTechApi: TangemTechApi, + private val tangemTechApi: TangemTechApi, private val cache: RuntimeSharedStore>, private val dispatchers: CoroutineDispatcherProvider, ) : WalletPushNotificationPreferencesRepository { + private val walletMutexes = ConcurrentHashMap() + override suspend fun preload(userWalletId: UserWalletId) { - if (cache.getSyncOrNull()?.containsKey(userWalletId.stringValue) == true) return - val preferences = withContext(dispatchers.io) { - // TODO: uncomment when api is ready - // val response = tangemTechApi.getPushNotificationPreferences(userWalletId.stringValue).getOrThrow() - // PushNotificationPreferencesConverter.convert(response) - loadDefaults(userWalletId) - } - cache.update(default = emptyMap()) { current -> - if (current.containsKey(userWalletId.stringValue)) { - current - } else { - current + (userWalletId.stringValue to preferences) + if (isCached(userWalletId)) return + mutexFor(userWalletId).withLock { + if (isCached(userWalletId)) return + val preferences = fetch(userWalletId) + cache.update(default = emptyMap()) { current -> + if (current.containsKey(userWalletId.stringValue)) { + current + } else { + current + (userWalletId.stringValue to preferences) + } } } } @@ -64,9 +59,11 @@ internal class DefaultWalletPushNotificationPreferencesRepository( category: PushNotificationCategory, isEnabled: Boolean, ): Either = Either.catch { - val current = cache.getSyncOrNull()?.get(userWalletId.stringValue) ?: loadDefaults(userWalletId) - val updated = current.withCategory(category, isEnabled) - putAndCommit(userWalletId, updated) + mutexFor(userWalletId).withLock { + val current = currentOrFetch(userWalletId) + val updated = current.withCategory(category, isEnabled) + putAndCommit(userWalletId, updated) + } } override suspend fun setAllPreferences( @@ -75,39 +72,45 @@ internal class DefaultWalletPushNotificationPreferencesRepository( offersUpdates: Boolean, priceAlerts: Boolean, ): Either = Either.catch { - val current = cache.getSyncOrNull()?.get(userWalletId.stringValue) ?: loadDefaults(userWalletId) - val updated = current.copy( - transactionAlerts = current.transactionAlerts.copy(isEnabled = transactionAlerts), - offersUpdates = current.offersUpdates.copy(isEnabled = offersUpdates), - priceAlerts = current.priceAlerts.copy(isEnabled = priceAlerts), - ) - putAndCommit(userWalletId, updated) + mutexFor(userWalletId).withLock { + val current = currentOrFetch(userWalletId) + val updated = current.copy( + transactionAlerts = current.transactionAlerts.copy(isEnabled = transactionAlerts), + offersUpdates = current.offersUpdates.copy(isEnabled = offersUpdates), + priceAlerts = current.priceAlerts.copy(isEnabled = priceAlerts), + ) + putAndCommit(userWalletId, updated) + } } + // Cache, or a freshly fetched server snapshot, so a full-replace PUT never carries fabricated defaults. + private suspend fun currentOrFetch(userWalletId: UserWalletId): WalletPushNotificationPreferences = + cache.getSyncOrNull()?.get(userWalletId.stringValue) ?: fetch(userWalletId) + + private suspend fun fetch(userWalletId: UserWalletId): WalletPushNotificationPreferences = + withContext(dispatchers.io) { + val response = tangemTechApi.getPushNotificationPreferences(userWalletId.stringValue).getOrThrow() + PushNotificationPreferencesConverter.convert(response) + } + + private suspend fun isCached(userWalletId: UserWalletId): Boolean = + cache.getSyncOrNull()?.containsKey(userWalletId.stringValue) == true + + private fun mutexFor(userWalletId: UserWalletId): Mutex = + walletMutexes.computeIfAbsent(userWalletId.stringValue) { Mutex() } + private suspend fun putAndCommit(userWalletId: UserWalletId, updated: WalletPushNotificationPreferences) { - withContext(dispatchers.io) { - // TODO: uncomment when api is ready - // tangemTechApi.updatePushNotificationPreferences( - // walletId = userWalletId.stringValue, - // body = PushNotificationPreferencesBody( - // areTransactionAlertsEnabled = updated.transactionAlerts.isEnabled, - // areOffersUpdatesEnabled = updated.offersUpdates.isEnabled, - // arePriceAlertsEnabled = updated.priceAlerts.isEnabled, - // ), - // ).getOrThrow() + val applied = withContext(dispatchers.io) { + val response = tangemTechApi.updatePushNotificationPreferences( + walletId = userWalletId.stringValue, + body = PushNotificationPreferencesBody( + areTransactionEventsEnabled = updated.transactionAlerts.isEnabled, + areOfferUpdatesEnabled = updated.offersUpdates.isEnabled, + arePriceAlertsEnabled = updated.priceAlerts.isEnabled, + ), + ).getOrThrow() + PushNotificationPreferencesConverter.convert(response) } - cache.update(default = emptyMap()) { it + (userWalletId.stringValue to updated) } - } - - // TODO remove when api is ready, use api methods to load real settings - private suspend fun loadDefaults(userWalletId: UserWalletId): WalletPushNotificationPreferences { - val areTransactionAlertsEnabled = appPreferencesStore - .getObjectMapSync(PreferencesKeys.NOTIFICATIONS_ENABLED_STATES_KEY)[userWalletId.stringValue] != - false - return WalletPushNotificationPreferences( - transactionAlerts = PushNotificationPreference(isEnabled = areTransactionAlertsEnabled, isVisible = true), - offersUpdates = PushNotificationPreference(isEnabled = true, isVisible = true), - priceAlerts = PushNotificationPreference(isEnabled = false, isVisible = true), - ) + cache.update(default = emptyMap()) { it + (userWalletId.stringValue to applied) } } } \ No newline at end of file diff --git a/data/push-notification-preferences/src/main/kotlin/com/tangem/data/pushnotificationpreferences/converters/PushNotificationPreferencesConverter.kt b/data/push-notification-preferences/src/main/kotlin/com/tangem/data/pushnotificationpreferences/converters/PushNotificationPreferencesConverter.kt index 34a5e99cd3..5c2bfea3db 100644 --- a/data/push-notification-preferences/src/main/kotlin/com/tangem/data/pushnotificationpreferences/converters/PushNotificationPreferencesConverter.kt +++ b/data/push-notification-preferences/src/main/kotlin/com/tangem/data/pushnotificationpreferences/converters/PushNotificationPreferencesConverter.kt @@ -1,6 +1,5 @@ package com.tangem.data.pushnotificationpreferences.converters -import com.tangem.datasource.api.tangemTech.models.PushNotificationPreferenceState import com.tangem.datasource.api.tangemTech.models.PushNotificationPreferencesResponse import com.tangem.domain.pushnotificationpreferences.models.PushNotificationPreference import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences @@ -11,11 +10,8 @@ internal object PushNotificationPreferencesConverter : override fun convert(value: PushNotificationPreferencesResponse): WalletPushNotificationPreferences = WalletPushNotificationPreferences( - transactionAlerts = value.transactionAlerts.toDomain(), - offersUpdates = value.offersUpdates.toDomain(), - priceAlerts = value.priceAlerts.toDomain(), + transactionAlerts = PushNotificationPreference(isEnabled = value.areTransactionEventsEnabled), + offersUpdates = PushNotificationPreference(isEnabled = value.areOfferUpdatesEnabled), + priceAlerts = PushNotificationPreference(isEnabled = value.arePriceAlertsEnabled), ) - - private fun PushNotificationPreferenceState.toDomain(): PushNotificationPreference = - PushNotificationPreference(isEnabled = isEnabled, isVisible = isVisible) } \ No newline at end of file diff --git a/data/push-notification-preferences/src/main/kotlin/com/tangem/data/pushnotificationpreferences/di/PushNotificationPreferencesModule.kt b/data/push-notification-preferences/src/main/kotlin/com/tangem/data/pushnotificationpreferences/di/PushNotificationPreferencesModule.kt index b82e635254..bcdafa677d 100644 --- a/data/push-notification-preferences/src/main/kotlin/com/tangem/data/pushnotificationpreferences/di/PushNotificationPreferencesModule.kt +++ b/data/push-notification-preferences/src/main/kotlin/com/tangem/data/pushnotificationpreferences/di/PushNotificationPreferencesModule.kt @@ -3,7 +3,6 @@ package com.tangem.data.pushnotificationpreferences.di import com.tangem.data.pushnotificationpreferences.DefaultWalletPushNotificationPreferencesRepository import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.local.datastore.RuntimeSharedStore -import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.domain.pushnotificationpreferences.repository.WalletPushNotificationPreferencesRepository import com.tangem.utils.coroutines.CoroutineDispatcherProvider import dagger.Module @@ -19,11 +18,9 @@ internal object PushNotificationPreferencesModule { @Singleton @Provides fun providesWalletPushNotificationPreferencesRepository( - appPreferencesStore: AppPreferencesStore, tangemTechApi: TangemTechApi, dispatchers: CoroutineDispatcherProvider, ): WalletPushNotificationPreferencesRepository = DefaultWalletPushNotificationPreferencesRepository( - appPreferencesStore = appPreferencesStore, tangemTechApi = tangemTechApi, cache = RuntimeSharedStore(), dispatchers = dispatchers, diff --git a/data/push-notification-preferences/src/test/kotlin/com/tangem/data/pushnotificationpreferences/DefaultWalletPushNotificationPreferencesRepositoryTest.kt b/data/push-notification-preferences/src/test/kotlin/com/tangem/data/pushnotificationpreferences/DefaultWalletPushNotificationPreferencesRepositoryTest.kt index c9334a143a..20ec904473 100644 --- a/data/push-notification-preferences/src/test/kotlin/com/tangem/data/pushnotificationpreferences/DefaultWalletPushNotificationPreferencesRepositoryTest.kt +++ b/data/push-notification-preferences/src/test/kotlin/com/tangem/data/pushnotificationpreferences/DefaultWalletPushNotificationPreferencesRepositoryTest.kt @@ -1,141 +1,220 @@ package com.tangem.data.pushnotificationpreferences -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.emptyPreferences import app.cash.turbine.test import arrow.core.Either import com.google.common.truth.Truth.assertThat -import com.squareup.moshi.Moshi +import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.api.tangemTech.models.PushNotificationPreferencesBody +import com.tangem.datasource.api.tangemTech.models.PushNotificationPreferencesResponse import com.tangem.datasource.local.datastore.RuntimeSharedStore -import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pushnotificationpreferences.models.PushNotificationCategory import com.tangem.domain.pushnotificationpreferences.models.PushNotificationPreference import com.tangem.domain.pushnotificationpreferences.models.WalletPushNotificationPreferences import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.mockk -import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test class DefaultWalletPushNotificationPreferencesRepositoryTest { private val tangemTechApi: TangemTechApi = mockk() - private val preferencesDataStore: DataStore = mockk() - private val appPreferencesStore = AppPreferencesStore( - moshi = Moshi.Builder().build(), - dispatchers = TestingCoroutineDispatcherProvider(), - preferencesDataStore = preferencesDataStore, - ) private val userWalletId = UserWalletId(stringValue = "0011223344556677") private val otherWalletId = UserWalletId(stringValue = "ffeeddccbbaa9988") private val repository = DefaultWalletPushNotificationPreferencesRepository( - appPreferencesStore = appPreferencesStore, tangemTechApi = tangemTechApi, cache = RuntimeSharedStore(), dispatchers = TestingCoroutineDispatcherProvider(), ) @Test - fun `GIVEN no prior state WHEN preload THEN cache contains defaults`() = runTest { - coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences()) + fun `GIVEN server returns prefs WHEN preload THEN cache holds converted server state`() = runTest { + // Arrange + stubGet(userWalletId, transaction = true, offers = true, price = false) + // Act repository.preload(userWalletId) + // Assert repository.observePreferences(userWalletId).test { - assertThat(awaitItem()).isEqualTo(defaults(transactionAlertsEnabled = true)) + assertThat(awaitItem()).isEqualTo(prefs(transaction = true, offers = true, price = false)) } + coVerify(exactly = 1) { tangemTechApi.getPushNotificationPreferences(userWalletId.stringValue) } } @Test - fun `GIVEN preload already done WHEN preload called again THEN no-op`() = runTest { - coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences()) + fun `GIVEN already preloaded WHEN preload called again THEN no second GET`() = runTest { + // Arrange + stubGet(userWalletId, transaction = true, offers = true, price = false) + // Act + repository.preload(userWalletId) + repository.preload(userWalletId) + + // Assert + coVerify(exactly = 1) { tangemTechApi.getPushNotificationPreferences(userWalletId.stringValue) } + } + + @Test + fun `GIVEN cache miss WHEN updatePreference THEN fetches baseline AND sends full-replace PUT AND caches echo`() = + runTest { + // Arrange + stubGet(userWalletId, transaction = true, offers = true, price = false) + stubPut(userWalletId, transaction = true, offers = true, price = true) + + // Act + val result = repository.updatePreference( + userWalletId = userWalletId, + category = PushNotificationCategory.PriceAlerts, + isEnabled = true, + ) + + // Assert + assertThat(result).isInstanceOf(Either.Right::class.java) + // The full-replace body changes only the tapped category on top of the server baseline. + coVerify(exactly = 1) { + tangemTechApi.updatePushNotificationPreferences( + userWalletId.stringValue, + PushNotificationPreferencesBody( + areTransactionEventsEnabled = true, + areOfferUpdatesEnabled = true, + arePriceAlertsEnabled = true, + ), + ) + } + repository.observePreferences(userWalletId).test { + assertThat(awaitItem()).isEqualTo(prefs(transaction = true, offers = true, price = true)) + } + } + + @Test + fun `GIVEN preloaded state WHEN updatePreference THEN only the tapped category changes in the PUT body`() = runTest { + // Arrange + stubGet(userWalletId, transaction = true, offers = true, price = false) + stubPut(userWalletId, transaction = true, offers = false, price = false) + + // Act repository.preload(userWalletId) repository.updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, isEnabled = false) + + // Assert + coVerify(exactly = 1) { + tangemTechApi.updatePushNotificationPreferences( + userWalletId.stringValue, + PushNotificationPreferencesBody( + areTransactionEventsEnabled = true, + areOfferUpdatesEnabled = false, + arePriceAlertsEnabled = false, + ), + ) + } + } + + @Test + fun `GIVEN write fails WHEN updatePreference THEN returns Left`() = runTest { + // Arrange + stubGet(userWalletId, transaction = true, offers = true, price = false) + repository.preload(userWalletId) + coEvery { tangemTechApi.updatePushNotificationPreferences(any(), any()) } throws IllegalStateException("boom") + + // Act + val result = repository.updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, isEnabled = false) + + // Assert + assertThat(result).isInstanceOf(Either.Left::class.java) + } + + @Test + fun `GIVEN different wallets WHEN observed THEN each keeps its own server state`() = runTest { + // Arrange + stubGet(userWalletId, transaction = false, offers = false, price = false) + stubGet(otherWalletId, transaction = true, offers = true, price = true) + + // Assert + repository.observePreferences(userWalletId).test { + assertThat(awaitItem()).isEqualTo(prefs(transaction = false, offers = false, price = false)) + } + repository.observePreferences(otherWalletId).test { + assertThat(awaitItem()).isEqualTo(prefs(transaction = true, offers = true, price = true)) + } + } + + @Test + fun `GIVEN concurrent collectors WHEN preload races THEN a single GET is issued`() = runTest { + // Arrange + val gate = CompletableDeferred() + coEvery { tangemTechApi.getPushNotificationPreferences(userWalletId.stringValue) } coAnswers { + gate.await() + ApiResponse.Success(PushNotificationPreferencesResponse(true, true, false)) + } + + // Act + launch { repository.preload(userWalletId) } + runCurrent() + launch { repository.preload(userWalletId) } + runCurrent() + gate.complete(Unit) + advanceUntilIdle() + + // Assert + coVerify(exactly = 1) { tangemTechApi.getPushNotificationPreferences(userWalletId.stringValue) } + } + + @Test + fun `GIVEN concurrent writes WHEN updatePreference races THEN serialized so no update is lost`() = runTest { + // Arrange + stubGet(userWalletId, transaction = true, offers = true, price = false) + val gate = CompletableDeferred() + coEvery { tangemTechApi.updatePushNotificationPreferences(eq(userWalletId.stringValue), any()) } coAnswers { + val body = arg(1) + gate.await() + ApiResponse.Success( + PushNotificationPreferencesResponse( + body.areTransactionEventsEnabled, + body.areOfferUpdatesEnabled, + body.arePriceAlertsEnabled, + ), + ) + } repository.preload(userWalletId) + // Act + launch { repository.updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, isEnabled = false) } + runCurrent() + launch { repository.updatePreference(userWalletId, PushNotificationCategory.PriceAlerts, isEnabled = true) } + runCurrent() + gate.complete(Unit) + advanceUntilIdle() + + // Assert + coVerify(exactly = 2) { tangemTechApi.updatePushNotificationPreferences(eq(userWalletId.stringValue), any()) } repository.observePreferences(userWalletId).test { - val item = awaitItem() - assertThat(item.offersUpdates.isEnabled).isFalse() + assertThat(awaitItem()).isEqualTo(prefs(transaction = true, offers = false, price = true)) } } - @Test - fun `GIVEN cache miss WHEN updatePreference THEN loads defaults and applies update`() = runTest { - coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences()) - - val result = repository.updatePreference( - userWalletId = userWalletId, - category = PushNotificationCategory.PriceAlerts, - isEnabled = true, - ) - - assertThat(result).isInstanceOf(Either.Right::class.java) - repository.observePreferences(userWalletId).test { - val item = awaitItem() - assertThat(item.priceAlerts.isEnabled).isTrue() - assertThat(item.offersUpdates.isEnabled).isTrue() - assertThat(item.transactionAlerts.isEnabled).isTrue() - } + private fun stubGet(id: UserWalletId, transaction: Boolean, offers: Boolean, price: Boolean) { + coEvery { tangemTechApi.getPushNotificationPreferences(id.stringValue) } returns + ApiResponse.Success(PushNotificationPreferencesResponse(transaction, offers, price)) } - @Test - fun `GIVEN preloaded state WHEN updatePreference for each category THEN updates only that category`() = runTest { - coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences()) - - repository.preload(userWalletId) - repository.updatePreference(userWalletId, PushNotificationCategory.TransactionAlerts, isEnabled = false) - repository.updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, isEnabled = false) - repository.updatePreference(userWalletId, PushNotificationCategory.PriceAlerts, isEnabled = true) - - repository.observePreferences(userWalletId).test { - val item = awaitItem() - assertThat(item.transactionAlerts.isEnabled).isFalse() - assertThat(item.offersUpdates.isEnabled).isFalse() - assertThat(item.priceAlerts.isEnabled).isTrue() - } + private fun stubPut(id: UserWalletId, transaction: Boolean, offers: Boolean, price: Boolean) { + coEvery { tangemTechApi.updatePushNotificationPreferences(eq(id.stringValue), any()) } returns + ApiResponse.Success(PushNotificationPreferencesResponse(transaction, offers, price)) } - @Test - fun `GIVEN no subscription yet WHEN observePreferences subscribed THEN triggers preload and emits defaults`() = - runTest { - coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences()) - - repository.observePreferences(userWalletId).test { - val item = awaitItem() - assertThat(item).isEqualTo(defaults(transactionAlertsEnabled = true)) - } - } - - @Test - fun `GIVEN updates for different wallets WHEN observed independently THEN each wallet has its own state`() = - runTest { - coEvery { preferencesDataStore.data } returns flowOf(emptyPreferences()) - - repository.updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, isEnabled = false) - repository.updatePreference(otherWalletId, PushNotificationCategory.PriceAlerts, isEnabled = true) - - repository.observePreferences(userWalletId).test { - val item = awaitItem() - assertThat(item.offersUpdates.isEnabled).isFalse() - assertThat(item.priceAlerts.isEnabled).isFalse() - } - repository.observePreferences(otherWalletId).test { - val item = awaitItem() - assertThat(item.offersUpdates.isEnabled).isTrue() - assertThat(item.priceAlerts.isEnabled).isTrue() - } - } - - private fun defaults(transactionAlertsEnabled: Boolean) = WalletPushNotificationPreferences( - transactionAlerts = PushNotificationPreference(isEnabled = transactionAlertsEnabled, isVisible = true), - offersUpdates = PushNotificationPreference(isEnabled = true, isVisible = true), - priceAlerts = PushNotificationPreference(isEnabled = false, isVisible = true), + private fun prefs(transaction: Boolean, offers: Boolean, price: Boolean) = WalletPushNotificationPreferences( + transactionAlerts = PushNotificationPreference(isEnabled = transaction), + offersUpdates = PushNotificationPreference(isEnabled = offers), + priceAlerts = PushNotificationPreference(isEnabled = price), ) } \ No newline at end of file diff --git a/domain/push-notification-preferences/src/main/kotlin/com/tangem/domain/pushnotificationpreferences/models/PushNotificationPreference.kt b/domain/push-notification-preferences/src/main/kotlin/com/tangem/domain/pushnotificationpreferences/models/PushNotificationPreference.kt index 248d916d41..d5642bf159 100644 --- a/domain/push-notification-preferences/src/main/kotlin/com/tangem/domain/pushnotificationpreferences/models/PushNotificationPreference.kt +++ b/domain/push-notification-preferences/src/main/kotlin/com/tangem/domain/pushnotificationpreferences/models/PushNotificationPreference.kt @@ -2,5 +2,4 @@ package com.tangem.domain.pushnotificationpreferences.models data class PushNotificationPreference( val isEnabled: Boolean, - val isVisible: Boolean, ) \ No newline at end of file diff --git a/features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/model/PushNotificationSettingsModel.kt b/features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/model/PushNotificationSettingsModel.kt index cbc841a27c..5e4e88cda0 100644 --- a/features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/model/PushNotificationSettingsModel.kt +++ b/features/push-notification-settings/impl/src/main/kotlin/com/tangem/features/pushnotificationsettings/impl/model/PushNotificationSettingsModel.kt @@ -163,7 +163,6 @@ internal class PushNotificationSettingsModel @Inject constructor( return TOGGLE_ORDER .asSequence() .map { id -> id.spec(prefs) } - .filter { it.preference.isVisible } .map { spec -> ToggleUM( id = spec.id, diff --git a/features/push-notification-settings/impl/src/test/kotlin/com/tangem/features/pushnotificationsettings/impl/model/PushNotificationSettingsModelTest.kt b/features/push-notification-settings/impl/src/test/kotlin/com/tangem/features/pushnotificationsettings/impl/model/PushNotificationSettingsModelTest.kt index 500cfcf9b6..a747c404c4 100644 --- a/features/push-notification-settings/impl/src/test/kotlin/com/tangem/features/pushnotificationsettings/impl/model/PushNotificationSettingsModelTest.kt +++ b/features/push-notification-settings/impl/src/test/kotlin/com/tangem/features/pushnotificationsettings/impl/model/PushNotificationSettingsModelTest.kt @@ -286,14 +286,14 @@ class PushNotificationSettingsModelTest { } private fun allFalse() = WalletPushNotificationPreferences( - transactionAlerts = PushNotificationPreference(isEnabled = false, isVisible = true), - offersUpdates = PushNotificationPreference(isEnabled = false, isVisible = true), - priceAlerts = PushNotificationPreference(isEnabled = false, isVisible = true), + transactionAlerts = PushNotificationPreference(isEnabled = false), + offersUpdates = PushNotificationPreference(isEnabled = false), + priceAlerts = PushNotificationPreference(isEnabled = false), ) private fun anyOn() = WalletPushNotificationPreferences( - transactionAlerts = PushNotificationPreference(isEnabled = true, isVisible = true), - offersUpdates = PushNotificationPreference(isEnabled = false, isVisible = true), - priceAlerts = PushNotificationPreference(isEnabled = false, isVisible = true), + transactionAlerts = PushNotificationPreference(isEnabled = true), + offersUpdates = PushNotificationPreference(isEnabled = false), + priceAlerts = PushNotificationPreference(isEnabled = false), ) } \ No newline at end of file From 850c904233638cca8753e44a3ac1816805a3307a Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 23 Jun 2026 13:17:08 +0400 Subject: [PATCH 018/210] Updated on 2026-08-14 --- .../ui/components/stories/StoriesContainer.kt | 43 ++-- .../stories/model/StoriesContentConfig.kt | 9 +- features/home/impl/build.gradle.kts | 5 +- .../features/home/impl/model/HomeModel.kt | 70 +++--- .../com/tangem/features/home/impl/ui/Home.kt | 18 +- .../home/impl/ui/compose/HomeStoriesScreen.kt | 102 +++++++++ .../home/impl/ui/compose/StoriesScreenV2.kt | 2 + .../features/home/impl/ui/state/HomeUM.kt | 17 +- .../features/home/impl/model/HomeModelTest.kt | 207 ++++++++++++++++++ 9 files changed, 399 insertions(+), 74 deletions(-) create mode 100644 features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/HomeStoriesScreen.kt create mode 100644 features/home/impl/src/test/kotlin/com/tangem/features/home/impl/model/HomeModelTest.kt diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/stories/StoriesContainer.kt b/core/ui/src/main/java/com/tangem/core/ui/components/stories/StoriesContainer.kt index bc292ff0d0..acf73b18d4 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/stories/StoriesContainer.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/stories/StoriesContainer.kt @@ -51,7 +51,10 @@ inline fun StoriesContainer( ) { var watchedCounter by remember { mutableIntStateOf(1) } var isPressed by remember { mutableStateOf(value = false) } - val storyState by remember(config) { + // Key on content (stories + repeatability), not the whole config object: an unrelated change + // to the config instance (e.g. a caller folding a changing flag into it) must not recreate the + // state machine and rewind stories to the first page. + val storyState by remember(config.stories, config.isRestartable) { mutableStateOf( StoriesStepStateMachine( stories = config.stories, @@ -59,7 +62,9 @@ inline fun StoriesContainer( ), ) } - BackHandler(onBack = { config.onClose(watchedCounter) }) + if (config.isCloseButtonVisible) { + BackHandler(onBack = { config.onClose(watchedCounter) }) + } val isPaused = isPressed || isPauseStories @@ -90,22 +95,24 @@ inline fun StoriesContainer( paused = isPaused, onStepFinish = onNextClick, ) - Icon( - painter = rememberVectorPainter( - image = ImageVector.vectorResource(R.drawable.ic_close_24), - ), - tint = TangemTheme.colors.icon.constant, - contentDescription = null, - modifier = Modifier - .align(Alignment.End) - .padding(top = 14.dp, end = 16.dp) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = LocalIndication.current, - onClick = { config.onClose(watchedCounter) }, - ) - .testTag(SwapStoriesScreenTestTags.CLOSE_BUTTON), - ) + if (config.isCloseButtonVisible) { + Icon( + painter = rememberVectorPainter( + image = ImageVector.vectorResource(R.drawable.ic_close_24), + ), + tint = TangemTheme.colors.icon.constant, + contentDescription = null, + modifier = Modifier + .align(Alignment.End) + .padding(top = 14.dp, end = 16.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = LocalIndication.current, + onClick = { config.onClose(watchedCounter) }, + ) + .testTag(SwapStoriesScreenTestTags.CLOSE_BUTTON), + ) + } } } } diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/stories/model/StoriesContentConfig.kt b/core/ui/src/main/java/com/tangem/core/ui/components/stories/model/StoriesContentConfig.kt index 07e03327ab..e8beb9581b 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/stories/model/StoriesContentConfig.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/stories/model/StoriesContentConfig.kt @@ -5,13 +5,18 @@ import kotlinx.collections.immutable.ImmutableList /** * Config for stories component * - * @property stories configuration list + * @property stories configuration list * @property isRestartable indicates than stories progressions starts + * @property isCloseButtonVisible whether the top-right close button (and back handler) is shown. + * Set `false` for non-closable stories (e.g. a root intro screen); [onClose] is then irrelevant. + * @property onClose invoked with the number of watched stories when the user closes them. + * Only meaningful when [isCloseButtonVisible] is `true`; defaults to a no-op for non-closable stories. */ interface StoriesContentConfig { val stories: ImmutableList val isRestartable: Boolean - val onClose: (Int) -> Unit + val isCloseButtonVisible: Boolean get() = true + val onClose: (Int) -> Unit get() = {} } interface StoryConfig { diff --git a/features/home/impl/build.gradle.kts b/features/home/impl/build.gradle.kts index 60bb2f652f..b3051da10d 100644 --- a/features/home/impl/build.gradle.kts +++ b/features/home/impl/build.gradle.kts @@ -69,4 +69,7 @@ dependencies { /** DI */ implementation(deps.hilt.android) kapt(deps.hilt.kapt) -} \ No newline at end of file + + /** Tests */ + testImplementation(projects.test.core) +} \ No newline at end of file diff --git a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt index 86f2175a22..44a3b475ae 100644 --- a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt +++ b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt @@ -3,8 +3,6 @@ package com.tangem.features.home.impl.model import com.tangem.common.core.TangemError import com.tangem.common.core.TangemSdkError import com.tangem.common.routing.AppRoute -import com.tangem.common.routing.AppRoute.ManageTokens.Source -import com.tangem.common.routing.AppRouter import com.tangem.common.routing.entity.InitScreenLaunchMode import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsParam @@ -16,11 +14,9 @@ import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router import com.tangem.core.decompose.ui.UiMessageSender -import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.ui.message.dialog.Dialogs import com.tangem.domain.card.ScanCardProcessor import com.tangem.domain.card.analytics.IntroductionProcess -import com.tangem.domain.card.analytics.Shop import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.common.wallets.error.SaveWalletError @@ -30,11 +26,11 @@ import com.tangem.domain.settings.usercountry.GetUserCountryUseCase import com.tangem.domain.settings.usercountry.models.UserCountry import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions import com.tangem.domain.wallets.builder.ColdUserWalletBuilder -import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase import com.tangem.domain.wallets.usecase.SaveWalletUseCase import com.tangem.feature.referral.domain.ShouldShowMobileWalletPromoUseCase import com.tangem.features.home.api.HomeComponent import com.tangem.features.home.api.HomeFeatureToggles +import com.tangem.features.home.impl.ui.state.HomeStoriesConfig import com.tangem.features.home.impl.ui.state.HomeUM import com.tangem.features.home.impl.ui.state.Stories import com.tangem.features.home.impl.ui.state.getRestrictedStories @@ -60,12 +56,9 @@ internal class HomeModel @Inject constructor( private val settingsRepository: SettingsRepository, private val analyticsEventHandler: AnalyticsEventHandler, private val router: Router, - private val appRouter: AppRouter, private val getUserCountryUseCase: GetUserCountryUseCase, private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory, private val saveWalletUseCase: SaveWalletUseCase, - private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase, - private val urlOpener: UrlOpener, private val userWalletsListRepository: UserWalletsListRepository, private val shouldShowMobileWalletPromoUseCase: ShouldShowMobileWalletPromoUseCase, private val homeFeatureToggles: HomeFeatureToggles, @@ -76,18 +69,8 @@ internal class HomeModel @Inject constructor( val params = paramsContainer.require() - private val _uiState = MutableStateFlow( - HomeUM( - scanInProgress = false, - isStoriesContainerEnabled = homeFeatureToggles.isStoriesContainerEnabled, - stories = getRestrictedStories().toImmutableList(), - onShopClick = ::onShopClick, - onSearchTokensClick = ::onSearchTokensClick, - onGetStartedClick = ::onGetStartedClick, - ), - ) - - val uiState = _uiState.asStateFlow() + val uiState: StateFlow + field = MutableStateFlow(createInitialState()) init { analyticsEventHandler.send(IntroductionProcess.ScreenOpened()) @@ -99,6 +82,17 @@ internal class HomeModel @Inject constructor( } } + private fun createInitialState(): HomeUM { + val initialStories = getRestrictedStories().toImmutableList() + return HomeUM( + scanInProgress = false, + isStoriesContainerEnabled = homeFeatureToggles.isStoriesContainerEnabled, + stories = initialStories, + storiesConfig = HomeStoriesConfig(stories = initialStories), + onGetStartedClick = ::onGetStartedClick, + ) + } + private fun observeUserCountryChanges() { getUserCountryUseCase.invoke() .distinctUntilChanged() @@ -117,35 +111,21 @@ internal class HomeModel @Inject constructor( } else { Stories.entries } + .toImmutableList() - _uiState.update { - it.copy(stories = stories.toImmutableList()) + uiState.update { + it.copy(stories = stories, storiesConfig = HomeStoriesConfig(stories = stories)) } } - private fun onShopClick() { - analyticsEventHandler.send(IntroductionProcess.ButtonBuyCards()) - analyticsEventHandler.send(Shop.ScreenOpened()) - modelScope.launch { - generateBuyTangemCardLinkUseCase.invoke(null).let { urlOpener.openUrl(it) } - } - } - - private fun onSearchTokensClick() { - analyticsEventHandler.send(IntroductionProcess.ButtonTokensList()) - router.push(AppRoute.ManageTokens(Source.STORIES)) - } - private fun onGetStartedClick() { debouncer.debounce(modelScope) { - modelScope.launch { - val mode = if (shouldShowMobileWalletPromoUseCase()) { - AppRoute.CreateWalletStart.Mode.HotWallet - } else { - AppRoute.CreateWalletStart.Mode.ColdWallet - } - router.push(AppRoute.CreateWalletStart(mode = mode)) + val mode = if (shouldShowMobileWalletPromoUseCase()) { + AppRoute.CreateWalletStart.Mode.HotWallet + } else { + AppRoute.CreateWalletStart.Mode.ColdWallet } + router.push(AppRoute.CreateWalletStart(mode = mode)) } } @@ -201,13 +181,13 @@ internal class HomeModel @Inject constructor( setLoading(false) when (error) { is SaveWalletError.DataError -> TangemLogger.e("Unable to save user wallet: $error") - is SaveWalletError.WalletAlreadySaved -> appRouter.replaceAll(AppRoute.Wallet) + is SaveWalletError.WalletAlreadySaved -> router.replaceAll(AppRoute.Wallet) } }, ifRight = { setLoading(false) sendSignedInCardAnalyticsEvent(scanResponse, userWallet.isImported) - appRouter.replaceAll(AppRoute.Wallet) + router.replaceAll(AppRoute.Wallet) }, ) } @@ -224,7 +204,7 @@ internal class HomeModel @Inject constructor( } private fun setLoading(isLoading: Boolean) { - _uiState.update { it.copy(scanInProgress = isLoading) } + uiState.update { it.copy(scanInProgress = isLoading) } } private fun handleScanError(error: TangemError) { diff --git a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/Home.kt b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/Home.kt index b245ef97b4..09492c1c74 100644 --- a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/Home.kt +++ b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/Home.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.Modifier import com.tangem.core.ui.components.SystemBarsIconsDisposable import com.tangem.core.ui.res.TangemColorPalette import com.tangem.core.ui.utils.ChangeRootBackgroundColorEffect +import com.tangem.features.home.impl.ui.compose.HomeStoriesScreen import com.tangem.features.home.impl.ui.compose.StoriesScreenV2 import com.tangem.features.home.impl.ui.state.HomeUM @@ -12,11 +13,18 @@ import com.tangem.features.home.impl.ui.state.HomeUM internal fun Home(state: HomeUM, modifier: Modifier = Modifier) { SystemBarsIconsDisposable(darkIcons = false) - StoriesScreenV2( - modifier = modifier, - state = state, - onGetStartedClick = state.onGetStartedClick, - ) + if (state.isStoriesContainerEnabled) { + HomeStoriesScreen( + modifier = modifier, + state = state, + ) + } else { + StoriesScreenV2( + modifier = modifier, + state = state, + onGetStartedClick = state.onGetStartedClick, + ) + } ChangeRootBackgroundColorEffect(TangemColorPalette.Black) } \ No newline at end of file diff --git a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/HomeStoriesScreen.kt b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/HomeStoriesScreen.kt new file mode 100644 index 0000000000..279753d624 --- /dev/null +++ b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/HomeStoriesScreen.kt @@ -0,0 +1,102 @@ +package com.tangem.features.home.impl.ui.compose + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import com.tangem.core.ui.R +import com.tangem.core.ui.components.stories.StoriesContainer +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.test.StoriesScreenTestTags +import com.tangem.features.home.impl.ui.compose.content.FirstStoriesContent +import com.tangem.features.home.impl.ui.compose.content.StoriesCurrencies +import com.tangem.features.home.impl.ui.compose.content.StoriesRevolutionaryWallet +import com.tangem.features.home.impl.ui.compose.content.StoriesUltraSecureBackup +import com.tangem.features.home.impl.ui.compose.content.StoriesWalletForEveryone +import com.tangem.features.home.impl.ui.compose.content.StoriesWeb3 +import com.tangem.features.home.impl.ui.compose.views.HomeButtonsV2 +import com.tangem.features.home.impl.ui.state.HomeUM +import com.tangem.features.home.impl.ui.state.Stories + +private const val BACKGROUND_COLOR = 0xFF010101L + +/** + * Home stories built on the shared [StoriesContainer]. + * The container provides the progress bar, tap/hold navigation and pause; this screen supplies the + * per-story content, the Tangem logo and the persistent "Get Started" button. + */ +@Composable +internal fun HomeStoriesScreen(state: HomeUM, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .fillMaxSize() + .background(Color(BACKGROUND_COLOR)) + .testTag(StoriesScreenTestTags.SCREEN_CONTAINER), + ) { + StoriesContainer( + modifier = Modifier.fillMaxSize(), + config = state.storiesConfig, + isPauseStories = state.scanInProgress, + ) { story, isPaused -> + Column( + modifier = Modifier + .statusBarsPadding() + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Image( + painter = painterResource(id = R.drawable.ic_tangem_logo), + contentDescription = null, + contentScale = ContentScale.FillHeight, + modifier = Modifier + .padding( + start = TangemTheme.dimens.spacing16, + top = TangemTheme.dimens.spacing16, + ) + .height(TangemTheme.dimens.size18) + .align(Alignment.Start), + ) + when (story) { + Stories.TangemIntro -> FirstStoriesContent(isPaused = isPaused, duration = story.duration) + Stories.RevolutionaryWallet -> StoriesRevolutionaryWallet() + Stories.UltraSecureBackup -> StoriesUltraSecureBackup( + isPaused = isPaused, + stepDuration = story.duration, + ) + Stories.Currencies -> StoriesCurrencies(isPaused, story.duration) + Stories.Web3 -> StoriesWeb3(isPaused, story.duration) + Stories.WalletForEveryone -> StoriesWalletForEveryone(story.duration) + } + } + } + Column( + modifier = Modifier + .navigationBarsPadding() + .padding(bottom = TangemTheme.dimens.spacing16) + .padding(horizontal = TangemTheme.dimens.spacing16) + .align(Alignment.BottomCenter) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing12), + ) { + HomeButtonsV2( + modifier = Modifier.fillMaxWidth(), + onGetStartedClick = state.onGetStartedClick, + ) + } + } +} \ No newline at end of file diff --git a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/StoriesScreenV2.kt b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/StoriesScreenV2.kt index e3a485d2cd..77d8dcb1ea 100644 --- a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/StoriesScreenV2.kt +++ b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/StoriesScreenV2.kt @@ -27,7 +27,9 @@ import com.tangem.features.home.impl.ui.state.Stories import kotlin.math.max import com.tangem.core.ui.R import com.tangem.features.home.impl.ui.state.HomeUM +import com.tangem.utils.annotations.RemoveWithToggle +@RemoveWithToggle("AND_15901_STORIES_CONTAINER_ENABLED") @Composable internal fun StoriesScreenV2(state: HomeUM, onGetStartedClick: () -> Unit, modifier: Modifier = Modifier) { var currentStory by remember { mutableStateOf(state.firstStory) } diff --git a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/state/HomeUM.kt b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/state/HomeUM.kt index bdc99712d2..9ab0da9271 100644 --- a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/state/HomeUM.kt +++ b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/state/HomeUM.kt @@ -1,13 +1,14 @@ package com.tangem.features.home.impl.ui.state +import com.tangem.core.ui.components.stories.model.StoriesContentConfig +import com.tangem.core.ui.components.stories.model.StoryConfig import kotlinx.collections.immutable.ImmutableList data class HomeUM( val scanInProgress: Boolean, val isStoriesContainerEnabled: Boolean, val stories: ImmutableList, - val onShopClick: () -> Unit, - val onSearchTokensClick: () -> Unit, + val storiesConfig: HomeStoriesConfig, val onGetStartedClick: () -> Unit, ) { val firstStory: Stories get() = stories[0] @@ -15,7 +16,17 @@ data class HomeUM( fun stepOf(story: Stories): Int = stories.indexOf(story) } -enum class Stories(val duration: Int = 6000) { +/** + * Config for the redesigned Home stories ([StoriesContainer]). The Home intro loops forever and is + * not closable, so [isCloseButtonVisible] is `false` and [onClose] keeps its no-op default. + */ +data class HomeStoriesConfig( + override val stories: ImmutableList, + override val isRestartable: Boolean = true, + override val isCloseButtonVisible: Boolean = false, +) : StoriesContentConfig + +enum class Stories(override val duration: Int = 6000) : StoryConfig { TangemIntro, RevolutionaryWallet, UltraSecureBackup, diff --git a/features/home/impl/src/test/kotlin/com/tangem/features/home/impl/model/HomeModelTest.kt b/features/home/impl/src/test/kotlin/com/tangem/features/home/impl/model/HomeModelTest.kt new file mode 100644 index 0000000000..089e0b31b4 --- /dev/null +++ b/features/home/impl/src/test/kotlin/com/tangem/features/home/impl/model/HomeModelTest.kt @@ -0,0 +1,207 @@ +package com.tangem.features.home.impl.model + +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.common.routing.entity.InitScreenLaunchMode +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.model.MutableParamsContainer +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.navigation.Router +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.domain.card.ScanCardProcessor +import com.tangem.domain.card.repository.CardSdkConfigRepository +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.settings.repositories.SettingsRepository +import com.tangem.domain.settings.usercountry.GetUserCountryUseCase +import com.tangem.domain.settings.usercountry.models.UserCountry +import com.tangem.domain.wallets.builder.ColdUserWalletBuilder +import com.tangem.domain.wallets.usecase.SaveWalletUseCase +import com.tangem.feature.referral.domain.ShouldShowMobileWalletPromoUseCase +import com.tangem.features.home.api.HomeComponent +import com.tangem.features.home.api.HomeFeatureToggles +import com.tangem.features.home.impl.ui.state.Stories +import com.tangem.features.home.impl.ui.state.getRestrictedStories +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.* +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +internal class HomeModelTest { + + private val scanCardProcessor: ScanCardProcessor = mockk() + private val cardSdkConfigRepository: CardSdkConfigRepository = mockk(relaxed = true) + private val settingsRepository: SettingsRepository = mockk() + private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true) + private val router: Router = mockk(relaxed = true) + private val getUserCountryUseCase: GetUserCountryUseCase = mockk() + private val coldUserWalletBuilderFactory: ColdUserWalletBuilder.Factory = mockk(relaxed = true) + private val saveWalletUseCase: SaveWalletUseCase = mockk(relaxed = true) + private val userWalletsListRepository: UserWalletsListRepository = mockk(relaxed = true) + private val shouldShowMobileWalletPromoUseCase: ShouldShowMobileWalletPromoUseCase = mockk(relaxed = true) + private val homeFeatureToggles: HomeFeatureToggles = mockk() + private val uiMessageSender: UiMessageSender = mockk(relaxed = true) + + private val progressSlot = slot Unit>() + + @BeforeEach + fun setUp() { + every { homeFeatureToggles.isStoriesContainerEnabled } returns false + every { getUserCountryUseCase.invoke() } returns emptyFlow() + coEvery { settingsRepository.shouldSaveAccessCodes() } returns false + coEvery { + scanCardProcessor.scan( + analyticsSource = any(), + shouldCheckIsAlreadyActivated = any(), + cardId = any(), + onProgressStateChange = capture(progressSlot), + onWalletNotCreated = any(), + onCancel = any(), + onFailure = any(), + onSuccess = any(), + ) + } just Runs + } + + @Test + fun `GIVEN toggle enabled WHEN model created THEN isStoriesContainerEnabled is true`() = runTest { + // Arrange + every { homeFeatureToggles.isStoriesContainerEnabled } returns true + + // Act + val model = createModel(testScope = this) + advanceUntilIdle() + + // Assert + assertThat(model.uiState.value.isStoriesContainerEnabled).isTrue() + model.onDestroy() + } + + @Test + fun `GIVEN toggle disabled WHEN model created THEN isStoriesContainerEnabled is false`() = runTest { + // Arrange + every { homeFeatureToggles.isStoriesContainerEnabled } returns false + + // Act + val model = createModel(testScope = this) + advanceUntilIdle() + + // Assert + assertThat(model.uiState.value.isStoriesContainerEnabled).isFalse() + model.onDestroy() + } + + @Test + fun `GIVEN model created WHEN no country emitted THEN storiesConfig is non-closable looping and in sync`() = + runTest { + // Act + val model = createModel(testScope = this) + advanceUntilIdle() + + // Assert + val state = model.uiState.value + assertThat(state.storiesConfig.isRestartable).isTrue() + assertThat(state.storiesConfig.isCloseButtonVisible).isFalse() + assertThat(state.storiesConfig.stories).isEqualTo(state.stories) + assertThat(state.stories).containsExactlyElementsIn(getRestrictedStories()).inOrder() + model.onDestroy() + } + + @Test + fun `GIVEN FCA-restricted country WHEN model created THEN Currencies excluded and config in sync`() = runTest { + // Arrange + every { getUserCountryUseCase.invoke() } returns flowOf(UserCountry.Other(code = "GB").right()) + + // Act + val model = createModel(testScope = this) + advanceUntilIdle() + + // Assert + val state = model.uiState.value + assertThat(state.stories).containsExactlyElementsIn(getRestrictedStories()).inOrder() + assertThat(state.stories).doesNotContain(Stories.Currencies) + assertThat(state.storiesConfig.stories).isEqualTo(state.stories) + model.onDestroy() + } + + @Test + fun `GIVEN non-restricted country WHEN model created THEN all stories shown and config in sync`() = runTest { + // Arrange + every { getUserCountryUseCase.invoke() } returns flowOf(UserCountry.Russia.right()) + + // Act + val model = createModel(testScope = this) + advanceUntilIdle() + + // Assert + val state = model.uiState.value + assertThat(state.stories).containsExactlyElementsIn(Stories.entries).inOrder() + assertThat(state.storiesConfig.stories).isEqualTo(state.stories) + model.onDestroy() + } + + @Test + fun `GIVEN scan in progress WHEN loading toggles THEN storiesConfig instance is not replaced`() = runTest { + // Arrange + val model = createModel(testScope = this, launchMode = InitScreenLaunchMode.WithCardScan) + advanceUntilIdle() + val initialConfig = model.uiState.value.storiesConfig + + // Act + Assert — loading on + progressSlot.captured.invoke(true) + advanceUntilIdle() + assertThat(model.uiState.value.scanInProgress).isTrue() + assertThat(model.uiState.value.storiesConfig).isSameInstanceAs(initialConfig) + + // Act + Assert — loading off + progressSlot.captured.invoke(false) + advanceUntilIdle() + assertThat(model.uiState.value.scanInProgress).isFalse() + assertThat(model.uiState.value.storiesConfig).isSameInstanceAs(initialConfig) + + model.onDestroy() + } + + private fun createModel( + testScope: TestScope, + launchMode: InitScreenLaunchMode = InitScreenLaunchMode.Standard, + paramsContainer: ParamsContainer = MutableParamsContainer( + value = HomeComponent.Params(launchMode = launchMode), + ), + ): HomeModel { + return HomeModel( + paramsContainer = paramsContainer, + dispatchers = testScope.createTestingCoroutineDispatcherProvider(), + scanCardProcessor = scanCardProcessor, + cardSdkConfigRepository = cardSdkConfigRepository, + settingsRepository = settingsRepository, + analyticsEventHandler = analyticsEventHandler, + router = router, + getUserCountryUseCase = getUserCountryUseCase, + coldUserWalletBuilderFactory = coldUserWalletBuilderFactory, + saveWalletUseCase = saveWalletUseCase, + userWalletsListRepository = userWalletsListRepository, + shouldShowMobileWalletPromoUseCase = shouldShowMobileWalletPromoUseCase, + homeFeatureToggles = homeFeatureToggles, + uiMessageSender = uiMessageSender, + ) + } + + 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 From baea9598e0fa377ccf6b46f0a42627347ad09db0 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 23 Jun 2026 09:17:33 +0000 Subject: [PATCH 019/210] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index ef9bcda031..074e02bb80 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,9 +5,9 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-5.39-1575" +tangemBlockchainSdk = "develop-1567" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-5.39-623" +tangemCardSdk = "develop-624" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "tangem-master-21" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ From bee4019e8b7f65f59a76833c6a76a15b70efc7e7 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 23 Jun 2026 13:35:52 +0200 Subject: [PATCH 020/210] Updated on 2026-08-14 --- .../com/tangem/common/routing/AppRoute.kt | 1 + .../routing/entity/AddressBookOpenMode.kt | 9 + .../tangem/common/ui/account/AccountIconUM.kt | 2 + core/res/src/main/res/values/strings.xml | 1 + .../core/ui/components/account/AccountIcon.kt | 9 +- .../addressbook/di/AddressBookDataModule.kt | 2 +- .../domain/addressbook/model/AddressEntry.kt | 1 + .../crypto/AddressBookCipherTest.kt | 1 + .../usecase/CreateContactUseCaseTest.kt | 15 +- .../usecase/GetContactsUseCaseTest.kt | 1 + .../usecase/GetVerifiedContactsUseCaseTest.kt | 9 +- .../usecase/SignAddressEntriesUseCaseTest.kt | 1 + .../usecase/UpdateContactUseCaseTest.kt | 2 + .../usecase/ValidateContactNameUseCaseTest.kt | 1 + .../VerifyAddressEntriesUseCaseTest.kt | 1 + features/address-book/api/build.gradle.kts | 4 + .../AddressBookContactsBlockComponent.kt | 33 ++++ .../addressbook/AddressSelectorComponent.kt | 19 +++ .../addressbook/ContactSelectionTrigger.kt | 20 +++ .../features/addressbook/MatchedContact.kt | 37 ++++ .../features/addressbook/SelectedContact.kt | 25 +++ .../state/AddAddressStateController.kt | 8 +- .../DefaultAddressSelectorComponent.kt | 34 ++++ .../ui/AddressSelectorBottomSheet.kt | 159 ++++++++++++++++++ ...efaultAddressBookContactsBlockComponent.kt | 36 ++++ .../block/model/ContactsBlockModel.kt | 50 ++++++ .../state/ContactsBlockStateController.kt | 20 +++ .../UpdateContactsBlockStateTransformer.kt | 41 +++++ .../addressbook/block/ui/ContactsBlock.kt | 101 +++++++++++ .../block/ui/state/ContactsBlockUM.kt | 18 ++ .../common/AddressBookChildFactory.kt | 9 +- .../addressbook/common/ContactMatcher.kt | 43 +++++ .../common/DefaultAddressBookComponent.kt | 7 +- .../common/DefaultContactSelectionTrigger.kt | 34 ++++ .../addressbook/common/ui/ContactRow.kt | 65 +++++++ .../di/AddressBookComponentModule.kt | 27 +++ .../addressbook/di/AddressBookModelModule.kt | 6 + .../state/EditContactStateController.kt | 8 +- .../list/DefaultAddressBookListComponent.kt | 39 ++++- .../list/model/AddressBookListModel.kt | 68 +++++++- .../state/AddressBookListStateController.kt | 9 +- .../state/converter/ContactUMConverter.kt | 21 --- ...eAddressBookListInitialStateTransformer.kt | 5 +- ...ddressBookListSelectionStateTransformer.kt | 38 +++++ .../list/ui/AddressBookListScreen.kt | 143 ++++++++++++++++ .../list/ui/state/AddressBookListUM.kt | 18 +- .../addressbook/list/ui/state/ContactUM.kt | 6 +- .../addressbook/route/AddressBookRoute.kt | 21 ++- .../addressbook/common/ContactMatcherTest.kt | 100 +++++++++++ 49 files changed, 1256 insertions(+), 72 deletions(-) create mode 100644 features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/AddressBookContactsBlockComponent.kt create mode 100644 features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/AddressSelectorComponent.kt create mode 100644 features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/ContactSelectionTrigger.kt create mode 100644 features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/MatchedContact.kt create mode 100644 features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/SelectedContact.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addressselector/DefaultAddressSelectorComponent.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addressselector/ui/AddressSelectorBottomSheet.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/DefaultAddressBookContactsBlockComponent.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/model/ContactsBlockModel.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/state/ContactsBlockStateController.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/state/transformers/UpdateContactsBlockStateTransformer.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/ui/ContactsBlock.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/ui/state/ContactsBlockUM.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/ContactMatcher.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/DefaultContactSelectionTrigger.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/ui/ContactRow.kt delete mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/converter/ContactUMConverter.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListSelectionStateTransformer.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookListScreen.kt create mode 100644 features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/common/ContactMatcherTest.kt diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt index d71dbce1e9..90449eab97 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt @@ -180,6 +180,7 @@ sealed class AppRoute(val path: String) : Route { path = when (addressBookOpenMode) { is AddressBookOpenMode.WithContactCreation -> "/address_book/${addressBookOpenMode.address}-${addressBookOpenMode.networkId}" + is AddressBookOpenMode.ContactSelection -> "/address_book/select/${addressBookOpenMode.networkId}" AddressBookOpenMode.Default -> "/address_book" }, ) diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/entity/AddressBookOpenMode.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/entity/AddressBookOpenMode.kt index 7c6b6206c4..e9af666c27 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/entity/AddressBookOpenMode.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/entity/AddressBookOpenMode.kt @@ -14,4 +14,13 @@ sealed interface AddressBookOpenMode { val address: String, val networkId: String, ) : AddressBookOpenMode + + /** + * Opened from the Send flow to pick a recipient. The list is filtered by [networkId] (the current send network), + * and the chosen contact's address is delivered back via `ContactSelectionTrigger` rather than navigation. + */ + @Serializable + data class ContactSelection( + val networkId: String, + ) : AddressBookOpenMode } \ No newline at end of file diff --git a/common/ui/src/main/java/com/tangem/common/ui/account/AccountIconUM.kt b/common/ui/src/main/java/com/tangem/common/ui/account/AccountIconUM.kt index caa4e584e5..eccf95d9ec 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/account/AccountIconUM.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/account/AccountIconUM.kt @@ -1,8 +1,10 @@ package com.tangem.common.ui.account +import androidx.compose.runtime.Immutable import com.tangem.domain.models.account.CryptoPortfolioIcon.Color import com.tangem.domain.models.account.CryptoPortfolioIcon.Icon +@Immutable sealed class AccountIconUM { data class CryptoPortfolio(val value: Icon, val color: Color) : AccountIconUM() diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 3e4488d270..8277d57104 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -102,6 +102,7 @@ %d address %d addresses + Choose address Contact Contact name Copy address diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/account/AccountIcon.kt b/core/ui/src/main/java/com/tangem/core/ui/components/account/AccountIcon.kt index b9ffbb60e7..001c7069af 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/account/AccountIcon.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/account/AccountIcon.kt @@ -32,7 +32,7 @@ import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.res.TangemThemePreviewRedesign enum class AccountIconSize { - Default, Large, Medium, Small, ExtraSmall, RedesignedDefault, RedesignExtraSmall, RedesignLarge + Default, Large, Medium, Small, ExtraSmall, RedesignedDefault, RedesignExtraSmall, RedesignLarge, Contact } /** @@ -133,6 +133,7 @@ fun AccountCharIcon(char: Char, color: Color, size: AccountIconSize, modifier: M AccountIconSize.RedesignedDefault -> TangemTheme.typography2.headingSemibold28 AccountIconSize.RedesignExtraSmall -> TangemTheme.typography2.captionMedium11 AccountIconSize.RedesignLarge -> TangemTheme.typography3.heading.medium + AccountIconSize.Contact -> TangemTheme.typography3.body.medium } val textSize by animateFloatAsState( @@ -168,6 +169,7 @@ private fun AccountIconSize.iconSizeInDp(): Dp = when (this) { AccountIconSize.RedesignedDefault -> 20.dp AccountIconSize.RedesignExtraSmall -> 8.dp AccountIconSize.RedesignLarge -> 32.dp + AccountIconSize.Contact -> 20.dp } fun AccountIconSize.toBoxSize(): Dp = when (this) { @@ -179,6 +181,7 @@ fun AccountIconSize.toBoxSize(): Dp = when (this) { AccountIconSize.RedesignedDefault -> 40.dp AccountIconSize.RedesignExtraSmall -> 16.dp AccountIconSize.RedesignLarge -> 80.dp + AccountIconSize.Contact -> 40.dp } private fun AccountIconSize.boxShapeSizeInDp(): Dp = when (this) { @@ -190,6 +193,7 @@ private fun AccountIconSize.boxShapeSizeInDp(): Dp = when (this) { AccountIconSize.RedesignedDefault -> 12.dp AccountIconSize.RedesignExtraSmall -> 6.dp AccountIconSize.RedesignLarge -> 80.dp + AccountIconSize.Contact -> 100.dp } @Preview(showBackground = true) @@ -233,7 +237,8 @@ private fun Sample() { AccountIconSize.ExtraSmall -> AccountIconSize.RedesignedDefault AccountIconSize.RedesignedDefault -> AccountIconSize.RedesignExtraSmall AccountIconSize.RedesignExtraSmall -> AccountIconSize.RedesignLarge - AccountIconSize.RedesignLarge -> AccountIconSize.Default + AccountIconSize.RedesignLarge -> AccountIconSize.Contact + AccountIconSize.Contact -> AccountIconSize.Default } }) { Text("Change") } diff --git a/data/address-book/src/main/kotlin/com/tangem/data/addressbook/di/AddressBookDataModule.kt b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/di/AddressBookDataModule.kt index 24ffcd6120..ce181b3417 100644 --- a/data/address-book/src/main/kotlin/com/tangem/data/addressbook/di/AddressBookDataModule.kt +++ b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/di/AddressBookDataModule.kt @@ -19,9 +19,9 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton import kotlinx.serialization.builtins.MapSerializer import kotlinx.serialization.builtins.serializer +import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressEntry.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressEntry.kt index 4ed3388f27..616eeebe2a 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressEntry.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/model/AddressEntry.kt @@ -9,6 +9,7 @@ data class AddressEntry( val id: AddressEntryId, val address: String, val networkId: Network.RawID, + val networkName: String, val memo: String?, val signature: String, ) \ No newline at end of file diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/crypto/AddressBookCipherTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/crypto/AddressBookCipherTest.kt index b8cbeedb8d..faff49d72f 100644 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/crypto/AddressBookCipherTest.kt +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/crypto/AddressBookCipherTest.kt @@ -259,6 +259,7 @@ internal class AddressBookCipherTest { networkId = Network.RawID("ethereum"), memo = memo, signature = "", + networkName = "Ethereum", ) private fun String.flipFirstHexNibble(): String = (if (first() == '0') '1' else '0') + substring(1) diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCaseTest.kt index 6059039b5e..cf1d9fba15 100644 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCaseTest.kt +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCaseTest.kt @@ -5,23 +5,14 @@ import arrow.core.right import com.google.common.truth.Truth.assertThat import com.tangem.domain.addressbook.error.ContactNameValidationError import com.tangem.domain.addressbook.error.SaveContactError -import com.tangem.domain.addressbook.model.AddressEntry -import com.tangem.domain.addressbook.model.AddressEntryId -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.model.* import com.tangem.domain.addressbook.repository.AddressBookRepository import com.tangem.domain.addressbook.time.IsoTimestampProvider import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.transaction.error.SignHashesError -import io.mockk.clearMocks -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot +import io.mockk.* import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.BeforeEach @@ -57,6 +48,7 @@ class CreateContactUseCaseTest { networkId = networkRawId, memo = "memo", signature = "sig", + networkName = "Ethereum", ), ) @@ -165,6 +157,7 @@ class CreateContactUseCaseTest { networkId = networkRawId, memo = null, signature = "sig", + networkName = "Ethereum", ), ), ) diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCaseTest.kt index a9f574f27d..21ce51f700 100644 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCaseTest.kt +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCaseTest.kt @@ -101,6 +101,7 @@ class GetContactsUseCaseTest { networkId = Network.RawID("ethereum"), memo = null, signature = "sig", + networkName = "Ethereum", ), ), ) diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetVerifiedContactsUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetVerifiedContactsUseCaseTest.kt index af4854afd1..9ea96b80b0 100644 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetVerifiedContactsUseCaseTest.kt +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetVerifiedContactsUseCaseTest.kt @@ -3,13 +3,7 @@ package com.tangem.domain.addressbook.usecase import arrow.core.left import arrow.core.right import com.google.common.truth.Truth.assertThat -import com.tangem.domain.addressbook.model.AddressEntriesVerification -import com.tangem.domain.addressbook.model.AddressEntry -import com.tangem.domain.addressbook.model.AddressEntryId -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.model.VerifiedContact +import com.tangem.domain.addressbook.model.* import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet @@ -103,6 +97,7 @@ class GetVerifiedContactsUseCaseTest { networkId = Network.RawID("ethereum"), memo = null, signature = "sig-$id", + networkName = "Ethereum", ) private fun contact(name: String, entries: List): Contact = Contact( diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/SignAddressEntriesUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/SignAddressEntriesUseCaseTest.kt index db38425d7f..c4db011355 100644 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/SignAddressEntriesUseCaseTest.kt +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/SignAddressEntriesUseCaseTest.kt @@ -137,6 +137,7 @@ class SignAddressEntriesUseCaseTest { networkId = Network.RawID("ethereum"), memo = memo, signature = "", + networkName = "Ethereum", ) private fun expectedHash(contact: Contact, entry: AddressEntry): ByteArray { diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCaseTest.kt index 46d68f096e..6fb2cfe6aa 100644 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCaseTest.kt +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCaseTest.kt @@ -54,6 +54,7 @@ class UpdateContactUseCaseTest { networkId = networkRawId, memo = "memo", signature = "sig2", + networkName = "Ethereum", ), ) @@ -137,6 +138,7 @@ class UpdateContactUseCaseTest { networkId = networkRawId, memo = null, signature = "sig", + networkName = "Ethereum", ), ), ) diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/ValidateContactNameUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/ValidateContactNameUseCaseTest.kt index 9b51b6df7d..bcc412f11e 100644 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/ValidateContactNameUseCaseTest.kt +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/ValidateContactNameUseCaseTest.kt @@ -75,6 +75,7 @@ class ValidateContactNameUseCaseTest { networkId = Network.RawID("ethereum"), memo = null, signature = "sig", + networkName = "Ethereum", ), ), ) diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/VerifyAddressEntriesUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/VerifyAddressEntriesUseCaseTest.kt index f5b8ba5afb..04821e35ac 100644 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/VerifyAddressEntriesUseCaseTest.kt +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/VerifyAddressEntriesUseCaseTest.kt @@ -165,6 +165,7 @@ class VerifyAddressEntriesUseCaseTest { networkId = Network.RawID("ethereum"), memo = memo, signature = signature, + networkName = "Ethereum", ) private fun expectedPayload(contact: Contact, entry: AddressEntry): String = diff --git a/features/address-book/api/build.gradle.kts b/features/address-book/api/build.gradle.kts index 50ede12ea4..b70fe75e59 100644 --- a/features/address-book/api/build.gradle.kts +++ b/features/address-book/api/build.gradle.kts @@ -12,6 +12,7 @@ dependencies { /* Project - Common */ api(projects.common.routing) + implementation(projects.common.ui) /* Project - Domain */ implementation(projects.domain.models) @@ -22,4 +23,7 @@ dependencies { /* Compose */ implementation(deps.compose.runtime) + + /** Other */ + implementation(deps.kotlin.immutable.collections) } \ No newline at end of file diff --git a/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/AddressBookContactsBlockComponent.kt b/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/AddressBookContactsBlockComponent.kt new file mode 100644 index 0000000000..7c8f04d753 --- /dev/null +++ b/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/AddressBookContactsBlockComponent.kt @@ -0,0 +1,33 @@ +package com.tangem.features.addressbook + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import kotlinx.coroutines.flow.StateFlow + +/** + * The contacts block shown on the Send address-entry screen, below the "recent" block. Lists up to five contacts that + * have an address in [Params.network], filtered live by [Params.queryFlow] (the recipient-input text, matched against + * contact name or address). Hidden when there are no matching contacts. + */ +interface AddressBookContactsBlockComponent : ComposableContentComponent { + + interface Factory : ComponentFactory + + /** + * @property userWalletId the sending wallet whose address book is shown + * @property network the current send network; only contacts with an address in this network are shown + * @property queryFlow the live recipient-input text used to filter the block + * @property onContactClick invoked with the tapped contact and its network-matching entries; the host decides + * whether to apply it directly (single entry) or open the address selector (multiple entries) + * @property onSeeAllClick invoked when the user taps "See all" to open the full address book in selection mode + */ + data class Params( + val userWalletId: UserWalletId, + val network: Network, + val queryFlow: StateFlow, + val onContactClick: (MatchedContact) -> Unit, + val onSeeAllClick: () -> Unit, + ) +} \ No newline at end of file diff --git a/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/AddressSelectorComponent.kt b/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/AddressSelectorComponent.kt new file mode 100644 index 0000000000..4f0eb88c45 --- /dev/null +++ b/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/AddressSelectorComponent.kt @@ -0,0 +1,19 @@ +package com.tangem.features.addressbook + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent + +/** + * Bottom sheet shown when a picked contact has more than one address in the target network. Lets the user choose a + * concrete address; the chosen one is returned via [Params.onAddressSelected] as a [SelectedContact]. + */ +interface AddressSelectorComponent : ComposableBottomSheetComponent { + + interface Factory : ComponentFactory + + data class Params( + val contact: MatchedContact, + val onAddressSelected: (SelectedContact) -> Unit, + val onDismiss: () -> Unit, + ) +} \ No newline at end of file diff --git a/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/ContactSelectionTrigger.kt b/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/ContactSelectionTrigger.kt new file mode 100644 index 0000000000..b11d5dedad --- /dev/null +++ b/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/ContactSelectionTrigger.kt @@ -0,0 +1,20 @@ +package com.tangem.features.addressbook + +import kotlinx.coroutines.flow.SharedFlow + +/** + * Delivers a contact picked in the full address-book list (opened in selection mode) back to whatever feature + * requested the selection. The picker and the requesting feature live in independent model scopes, so a one-shot + * [SharedFlow] is used instead of a retained holder: nothing is kept after emission, so there is nothing to clear. + * + * Mirrors the `SwapChooseTokenNetworkTrigger`/`Listener` pattern. + */ +interface ContactSelectionTrigger { + + fun trigger(contact: SelectedContact) +} + +interface ContactSelectionListener { + + val resultFlow: SharedFlow +} \ No newline at end of file diff --git a/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/MatchedContact.kt b/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/MatchedContact.kt new file mode 100644 index 0000000000..f5d9b0adff --- /dev/null +++ b/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/MatchedContact.kt @@ -0,0 +1,37 @@ +package com.tangem.features.addressbook + +import com.tangem.common.ui.account.AccountIconUM +import kotlinx.collections.immutable.ImmutableList + +/** + * A contact together with its address entries that match a given network. Emitted when a contact is tapped during + * selection; the host decides what to do with it: + * - exactly one [entries] item → build a [SelectedContact] and proceed straight away; + * - more than one → open the address selector so the user picks a concrete address first. + */ + +data class MatchedContact( + val contactId: String, + val name: String, + val icon: AccountIconUM.CryptoPortfolio, + val networkId: String, + val entries: ImmutableList, +) { + + /** Resolves this contact to a concrete pick using one of its [entries]. */ + fun toSelectedContact(entry: ContactAddress): SelectedContact = SelectedContact( + contactId = contactId, + name = name, + icon = icon, + address = entry.address, + networkId = networkId, + memo = entry.memo, + ) + + /** A single network-matching address of the contact. */ + data class ContactAddress( + val address: String, + val memo: String?, + val networkName: String, + ) +} \ No newline at end of file diff --git a/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/SelectedContact.kt b/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/SelectedContact.kt new file mode 100644 index 0000000000..36e8e8cd74 --- /dev/null +++ b/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/SelectedContact.kt @@ -0,0 +1,25 @@ +package com.tangem.features.addressbook + +import com.tangem.common.ui.account.AccountIconUM + +/** + * A single resolved address-book pick. Produced once the concrete address within a contact is known — either directly + * (the contact has a single matching-network address) or after the user chose one in the address selector. + * + * Feature-agnostic: any feature that opens the address book for selection receives this result. + * + * @property contactId the id of the source [com.tangem.domain.addressbook.model.Contact] + * @property name the contact name to display + * @property icon the contact avatar (initials + color), reusing the account icon UI model + * @property address the chosen on-chain address + * @property networkId raw id of the network the address belongs to + * @property memo optional memo/destination tag (only meaningful for networks that support it) + */ +data class SelectedContact( + val contactId: String, + val name: String, + val icon: AccountIconUM.CryptoPortfolio, + val address: String, + val networkId: String, + val memo: String?, +) \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/AddAddressStateController.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/AddAddressStateController.kt index d5ecedc0de..b0f71b411c 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/AddAddressStateController.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/AddAddressStateController.kt @@ -11,19 +11,17 @@ import com.tangem.features.addressbook.addaddress.ui.state.AddressFieldUM import com.tangem.utils.transformer.Transformer import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import javax.inject.Inject @ModelScoped internal class AddAddressStateController @Inject constructor() { - private val mutableUiState: MutableStateFlow = MutableStateFlow(value = getInitialState()) - - val uiState: StateFlow get() = mutableUiState.asStateFlow() + val uiState: StateFlow + field = MutableStateFlow(value = getInitialState()) fun update(transformer: Transformer) { - mutableUiState.update(function = transformer::transform) + uiState.update(function = transformer::transform) } private fun getInitialState(): AddAddressUM = AddAddressUM( diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addressselector/DefaultAddressSelectorComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addressselector/DefaultAddressSelectorComponent.kt new file mode 100644 index 0000000000..6b94731ad1 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addressselector/DefaultAddressSelectorComponent.kt @@ -0,0 +1,34 @@ +package com.tangem.features.addressbook.addressselector + +import androidx.compose.runtime.Composable +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.features.addressbook.AddressSelectorComponent +import com.tangem.features.addressbook.addressselector.ui.AddressSelectorBottomSheet +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultAddressSelectorComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted private val params: AddressSelectorComponent.Params, +) : AddressSelectorComponent, AppComponentContext by appComponentContext { + + override fun dismiss() = params.onDismiss() + + @Composable + override fun BottomSheet() { + AddressSelectorBottomSheet( + contact = params.contact, + onAddressClick = { entry -> params.onAddressSelected(params.contact.toSelectedContact(entry)) }, + onDismiss = ::dismiss, + ) + } + + @AssistedFactory + interface Factory : AddressSelectorComponent.Factory { + override fun create( + context: AppComponentContext, + params: AddressSelectorComponent.Params, + ): DefaultAddressSelectorComponent + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addressselector/ui/AddressSelectorBottomSheet.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addressselector/ui/AddressSelectorBottomSheet.kt new file mode 100644 index 0000000000..cd305175a2 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addressselector/ui/AddressSelectorBottomSheet.kt @@ -0,0 +1,159 @@ +package com.tangem.features.addressbook.addressselector.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import com.tangem.common.ui.account.AccountIconUM +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.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.ds2.row.TangemRow +import com.tangem.core.ui.ds2.row.TangemRowText +import com.tangem.core.ui.ds2.row.TangemRowTextRole +import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.features.addressbook.MatchedContact +import com.tangem.features.addressbook.impl.R +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun AddressSelectorBottomSheet( + contact: MatchedContact, + onAddressClick: (MatchedContact.ContactAddress) -> Unit, + onDismiss: () -> Unit, +) { + TangemBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = onDismiss, + content = TangemBottomSheetConfigContent.Empty, + ), + containerColor = TangemTheme.colors3.bg.primary, + title = { + TangemTopBar( + title = resourceReference(R.string.address_book_choose_address), + type = TangemTopBarType.BottomSheet, + endContent = { + TangemButton( + iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_close_24), + onClick = onDismiss, + size = TangemButton.Size.X11, + variant = TangemButton.Variant.Material, + ) + }, + ) + }, + content = { AddressSelectorList(contact = contact, onAddressClick = onAddressClick) }, + footer = { + TangemButton( + onClick = onDismiss, + text = resourceReference(R.string.common_cancel), + variant = TangemButton.Variant.Secondary, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) + }, + ) +} + +@Composable +private fun AddressSelectorList( + contact: MatchedContact, + onAddressClick: (MatchedContact.ContactAddress) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .background( + color = TangemTheme.colors3.bg.secondary, + shape = RoundedCornerShape(20.dp), + ) + .verticalScroll(rememberScrollState()), + ) { + contact.entries.fastForEach { entry -> + AddressRow(entry = entry, onClick = { onAddressClick(entry) }) + } + } +} + +@Composable +private fun AddressRow(entry: MatchedContact.ContactAddress, onClick: () -> Unit) { + TangemRow( + verticalAlignment = TangemRowVerticalAlignment.Center, + onClick = onClick, + startSlot = { + TangemIcon( + tangemIconUM = TangemIconUM.Ident(entry.address), + modifier = Modifier + .size(40.dp) + .clip(CircleShape), + ) + }, + titleSlot = { + TangemRowText( + text = entry.address, + role = TangemRowTextRole.Title, + overflow = TextOverflow.MiddleEllipsis, + ) + }, + subtitleSlot = { + TangemRowText( + text = entry.networkName, + role = TangemRowTextRole.Subtitle, + ) + }, + ) +} + +@Composable +@Preview(showBackground = true, widthDp = 360) +private fun Preview_AddressSelectorList() { + TangemThemePreviewRedesign { + AddressSelectorList( + contact = MatchedContact( + contactId = "1", + name = "Binance", + icon = AccountIconUM.CryptoPortfolio( + value = CryptoPortfolioIcon.Icon.Letter, + color = CryptoPortfolioIcon.Color.Azure, + ), + networkId = "ethereum", + entries = persistentListOf( + MatchedContact.ContactAddress( + address = "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D", + memo = null, + networkName = "Ethereum", + ), + MatchedContact.ContactAddress( + address = "0x34B4492A412D84A6E606288f3Bd714b89135D4dE", + memo = "12345", + networkName = "Ethereum", + ), + ), + ), + onAddressClick = {}, + ) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/DefaultAddressBookContactsBlockComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/DefaultAddressBookContactsBlockComponent.kt new file mode 100644 index 0000000000..c189ace95c --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/DefaultAddressBookContactsBlockComponent.kt @@ -0,0 +1,36 @@ +package com.tangem.features.addressbook.block + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.features.addressbook.AddressBookContactsBlockComponent +import com.tangem.features.addressbook.block.model.ContactsBlockModel +import com.tangem.features.addressbook.block.ui.ContactsBlock +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultAddressBookContactsBlockComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted params: AddressBookContactsBlockComponent.Params, +) : AddressBookContactsBlockComponent, AppComponentContext by appComponentContext { + + private val model: ContactsBlockModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.state.collectAsStateWithLifecycle() + ContactsBlock(state = state, modifier = modifier) + } + + @AssistedFactory + interface Factory : AddressBookContactsBlockComponent.Factory { + override fun create( + context: AppComponentContext, + params: AddressBookContactsBlockComponent.Params, + ): DefaultAddressBookContactsBlockComponent + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/model/ContactsBlockModel.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/model/ContactsBlockModel.kt new file mode 100644 index 0000000000..1aa457aced --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/model/ContactsBlockModel.kt @@ -0,0 +1,50 @@ +package com.tangem.features.addressbook.block.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.domain.addressbook.usecase.GetContactsUseCase +import com.tangem.features.addressbook.AddressBookContactsBlockComponent +import com.tangem.features.addressbook.block.state.ContactsBlockStateController +import com.tangem.features.addressbook.block.state.transformers.UpdateContactsBlockStateTransformer +import com.tangem.features.addressbook.block.ui.state.ContactsBlockUM +import com.tangem.features.addressbook.common.ContactMatcher +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +@ModelScoped +internal class ContactsBlockModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val stateController: ContactsBlockStateController, + getContactsUseCase: GetContactsUseCase, +) : Model() { + + private val params = paramsContainer.require() + + val state: StateFlow get() = stateController.uiState + + init { + params.queryFlow + .flatMapLatest { query -> getContactsUseCase(query = query, userWalletId = params.userWalletId) } + .onEach { contacts -> + val matched = ContactMatcher.match(contacts = contacts, networkId = params.network.rawId) + stateController.update( + UpdateContactsBlockStateTransformer( + matched = matched, + onSeeAllClick = params.onSeeAllClick, + onContactClick = params.onContactClick, + ), + ) + } + .flowOn(dispatchers.default) + .launchIn(modelScope) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/state/ContactsBlockStateController.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/state/ContactsBlockStateController.kt new file mode 100644 index 0000000000..a0367bbd82 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/state/ContactsBlockStateController.kt @@ -0,0 +1,20 @@ +package com.tangem.features.addressbook.block.state + +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.features.addressbook.block.ui.state.ContactsBlockUM +import com.tangem.utils.transformer.Transformer +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject + +@ModelScoped +internal class ContactsBlockStateController @Inject constructor() { + + val uiState: StateFlow + field = MutableStateFlow(value = ContactsBlockUM.Hidden) + + fun update(transformer: Transformer) { + uiState.update(function = transformer::transform) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/state/transformers/UpdateContactsBlockStateTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/state/transformers/UpdateContactsBlockStateTransformer.kt new file mode 100644 index 0000000000..c267bfd65d --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/state/transformers/UpdateContactsBlockStateTransformer.kt @@ -0,0 +1,41 @@ +package com.tangem.features.addressbook.block.state.transformers + +import com.tangem.features.addressbook.MatchedContact +import com.tangem.features.addressbook.block.ui.state.ContactsBlockUM +import com.tangem.features.addressbook.list.ui.state.ContactUM +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.toImmutableList + +/** Builds the Send contacts block from the network-matching contacts; an empty result hides the block. */ +internal class UpdateContactsBlockStateTransformer( + private val matched: List, + private val onSeeAllClick: () -> Unit, + private val onContactClick: (MatchedContact) -> Unit, +) : Transformer { + + override fun transform(prevState: ContactsBlockUM): ContactsBlockUM { + return if (matched.isEmpty()) { + ContactsBlockUM.Hidden + } else { + ContactsBlockUM.Content( + contacts = matched + .take(MAX_CONTACTS) + .map { it.toRowUM() }.toImmutableList(), + onSeeAllClick = onSeeAllClick, + shouldShowSeeAll = matched.size > MAX_CONTACTS, + ) + } + } + + private fun MatchedContact.toRowUM(): ContactUM = ContactUM( + id = contactId, + name = name, + icon = icon, + networkAddressCount = entries.size, + onClick = { onContactClick(this) }, + ) + + private companion object { + const val MAX_CONTACTS = 5 + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/ui/ContactsBlock.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/ui/ContactsBlock.kt new file mode 100644 index 0000000000..ca1526a0a5 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/ui/ContactsBlock.kt @@ -0,0 +1,101 @@ +package com.tangem.features.addressbook.block.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.common.ui.account.AccountIconUM +import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.features.addressbook.block.ui.state.ContactsBlockUM +import com.tangem.features.addressbook.common.ui.ContactRow +import com.tangem.features.addressbook.list.ui.state.ContactUM +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun ContactsBlock(state: ContactsBlockUM, modifier: Modifier = Modifier) { + if (state !is ContactsBlockUM.Content) return + Column( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(TangemTheme.colors3.bg.secondary), + ) { + Header(onSeeAllClick = state.onSeeAllClick, shouldShowSeeAll = state.shouldShowSeeAll) + state.contacts.forEach { contact -> + ContactRow(contact = contact) + } + } +} + +@Composable +private fun Header(onSeeAllClick: () -> Unit, shouldShowSeeAll: Boolean) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(top = 16.dp, bottom = 4.dp), + ) { + Text( + text = stringResourceSafe(R.string.address_book_title), + color = TangemTheme.colors3.text.secondary, + style = TangemTheme.typography3.caption.medium, + modifier = Modifier.weight(1f), + ) + if (shouldShowSeeAll) { + Text( + text = stringResourceSafe(R.string.common_view_all), + color = TangemTheme.colors3.text.brand, + style = TangemTheme.typography3.caption.medium, + modifier = Modifier.clickable(onClick = onSeeAllClick), + ) + } + } +} + +@Composable +@Preview(showBackground = true, widthDp = 360) +private fun Preview_ContactsBlock() { + TangemThemePreviewRedesign { + ContactsBlock( + state = ContactsBlockUM.Content( + contacts = persistentListOf( + ContactUM( + id = "1", + name = "Binance", + icon = AccountIconUM.CryptoPortfolio( + value = CryptoPortfolioIcon.Icon.Letter, + color = CryptoPortfolioIcon.Color.Azure, + ), + networkAddressCount = 1, + onClick = {}, + ), + ContactUM( + id = "2", + name = "Alice", + icon = AccountIconUM.CryptoPortfolio( + value = CryptoPortfolioIcon.Icon.Letter, + color = CryptoPortfolioIcon.Color.UFOGreen, + ), + networkAddressCount = 3, + onClick = {}, + ), + ), + onSeeAllClick = {}, + shouldShowSeeAll = true, + ), + ) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/ui/state/ContactsBlockUM.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/ui/state/ContactsBlockUM.kt new file mode 100644 index 0000000000..acf334bbb9 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/ui/state/ContactsBlockUM.kt @@ -0,0 +1,18 @@ +package com.tangem.features.addressbook.block.ui.state + +import androidx.compose.runtime.Immutable +import com.tangem.features.addressbook.list.ui.state.ContactUM +import kotlinx.collections.immutable.ImmutableList + +/** UI state of the Send contacts block. [Hidden] is rendered as nothing (no matching contacts / feature off). */ +@Immutable +internal sealed interface ContactsBlockUM { + + data object Hidden : ContactsBlockUM + + data class Content( + val shouldShowSeeAll: Boolean, + val contacts: ImmutableList, + val onSeeAllClick: () -> Unit, + ) : ContactsBlockUM +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookChildFactory.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookChildFactory.kt index 3283d11e76..d07a64c7f5 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookChildFactory.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookChildFactory.kt @@ -3,6 +3,7 @@ package com.tangem.features.addressbook.common import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.domain.addressbook.model.ContactId +import com.tangem.features.addressbook.AddressSelectorComponent import com.tangem.features.addressbook.addaddress.DefaultAddAddressComponent import com.tangem.features.addressbook.editcontact.DefaultEditContactComponent import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress @@ -15,19 +16,23 @@ import javax.inject.Inject * Builds the child screens of the address book feature for a given [AddressBookRoute], wiring their callbacks to the * container's [AddressBookClickIntents]. Mirrors the `FeedEntryChildFactory` pattern used by the feed feature. */ -internal class AddressBookChildFactory @Inject constructor() { +internal class AddressBookChildFactory @Inject constructor( + private val addressSelectorFactory: AddressSelectorComponent.Factory, +) { fun createChild( route: AddressBookRoute, context: AppComponentContext, clickIntents: AddressBookClickIntents, ): ComposableContentComponent = when (route) { - AddressBookRoute.List -> DefaultAddressBookListComponent( + is AddressBookRoute.List -> DefaultAddressBookListComponent( appComponentContext = context, params = DefaultAddressBookListComponent.Params( + mode = route.mode, onContactClick = { clickIntents.onContactClick(ContactId(it)) }, onAddContactClick = clickIntents::onAddContactClick, ), + addressSelectorFactory = addressSelectorFactory, ) is AddressBookRoute.EditContact -> DefaultEditContactComponent( appComponentContext = context, diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/ContactMatcher.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/ContactMatcher.kt new file mode 100644 index 0000000000..c044f8e480 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/ContactMatcher.kt @@ -0,0 +1,43 @@ +package com.tangem.features.addressbook.common + +import com.tangem.common.ui.account.AccountIconUM +import com.tangem.domain.addressbook.model.Contact +import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.features.addressbook.MatchedContact +import kotlinx.collections.immutable.toImmutableList + +/** + * Maps contacts to [MatchedContact]s for a given [networkId], keeping only those that have at least one address in that + * network (with just the matching entries). Name/address query filtering is done upstream by `GetContactsUseCase`. + */ +internal object ContactMatcher { + + private val DEFAULT_ICON_COLOR = CryptoPortfolioIcon.Color.Azure + + fun match(contacts: List, networkId: String): List { + return contacts.mapNotNull { contact -> + val entries = contact.addressEntries.filter { it.networkId.value == networkId } + if (entries.isEmpty()) return@mapNotNull null + + MatchedContact( + contactId = contact.id.value, + name = contact.name.value, + icon = AccountIconUM.CryptoPortfolio( + value = CryptoPortfolioIcon.Icon.Letter, + color = contact.resolveIconColor(), + ), + networkId = networkId, + entries = entries.map { entry -> + MatchedContact.ContactAddress( + address = entry.address, + memo = entry.memo, + networkName = entry.networkName, + ) + }.toImmutableList(), + ) + } + } + + private fun Contact.resolveIconColor(): CryptoPortfolioIcon.Color = + CryptoPortfolioIcon.Color.entries.firstOrNull { it.name == iconColor } ?: DEFAULT_ICON_COLOR +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/DefaultAddressBookComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/DefaultAddressBookComponent.kt index 462aec3631..5066885b29 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/DefaultAddressBookComponent.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/DefaultAddressBookComponent.kt @@ -96,9 +96,12 @@ internal class DefaultAddressBookComponent @AssistedInject constructor( } private fun initialStack(): List = when (val mode = params.addressBookOpenMode) { - AddressBookOpenMode.Default -> listOf(AddressBookRoute.List) + AddressBookOpenMode.Default -> listOf(AddressBookRoute.List()) + is AddressBookOpenMode.ContactSelection -> listOf( + AddressBookRoute.List(mode = AddressBookRoute.ListMode.Selector(networkId = mode.networkId)), + ) is AddressBookOpenMode.WithContactCreation -> listOf( - AddressBookRoute.List, + AddressBookRoute.List(), // Address + network are already known, so open the new contact with that address attached — no AddAddress. AddressBookRoute.EditContact( predefinedAddress = mode.address, diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/DefaultContactSelectionTrigger.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/DefaultContactSelectionTrigger.kt new file mode 100644 index 0000000000..115b418695 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/DefaultContactSelectionTrigger.kt @@ -0,0 +1,34 @@ +package com.tangem.features.addressbook.common + +import com.tangem.features.addressbook.ContactSelectionListener +import com.tangem.features.addressbook.ContactSelectionTrigger +import com.tangem.features.addressbook.SelectedContact +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * One-shot delivery of a contact picked on the full address-book list back to the Send flow. + * + * Implements both [ContactSelectionTrigger] (the list emits) and [ContactSelectionListener] (Send collects). The flow + * is no-replay with a 1-item buffer so [trigger] is non-blocking ([tryEmit]) — the picker can always close even if the + * collector is momentarily absent; nothing is retained for late subscribers, so there is no stale value to clear. + */ +@Singleton +internal class DefaultContactSelectionTrigger @Inject constructor() : + ContactSelectionTrigger, + ContactSelectionListener { + + private val mutableResultFlow = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + override val resultFlow: SharedFlow = mutableResultFlow + + override fun trigger(contact: SelectedContact) { + mutableResultFlow.tryEmit(contact) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/ui/ContactRow.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/ui/ContactRow.kt new file mode 100644 index 0000000000..20855e8bb0 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/ui/ContactRow.kt @@ -0,0 +1,65 @@ +package com.tangem.features.addressbook.common.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.common.ui.account.AccountIcon +import com.tangem.common.ui.account.AccountIconUM +import com.tangem.core.ui.R +import com.tangem.core.ui.components.account.AccountIconSize +import com.tangem.core.ui.ds2.row.* +import com.tangem.core.ui.extensions.pluralStringResourceSafe +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.features.addressbook.list.ui.state.ContactUM + +@Composable +internal fun ContactRow(contact: ContactUM) { + TangemRow( + onClick = contact.onClick, + verticalAlignment = TangemRowVerticalAlignment.Center, + contentLead = TangemRowContentLead.Start, + startSlot = { + AccountIcon( + name = stringReference(contact.name), + icon = contact.icon, + size = AccountIconSize.Contact, + ) + }, + titleSlot = { + TangemRowText( + text = contact.name, + role = TangemRowTextRole.Title, + ) + }, + subtitleSlot = { + TangemRowText( + text = pluralStringResourceSafe( + R.plurals.address_book_addresses, + contact.networkAddressCount, + contact.networkAddressCount, + ), + role = TangemRowTextRole.Subtitle, + ) + }, + ) +} + +@Composable +@Preview(showBackground = true, widthDp = 360) +private fun Preview_ContactRow() { + TangemThemePreviewRedesign { + ContactRow( + ContactUM( + id = "1", + name = "Binance", + icon = AccountIconUM.CryptoPortfolio( + value = CryptoPortfolioIcon.Icon.Letter, + color = CryptoPortfolioIcon.Color.Azure, + ), + networkAddressCount = 1, + onClick = {}, + ), + ) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookComponentModule.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookComponentModule.kt index 868ed604e7..30378a73a6 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookComponentModule.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookComponentModule.kt @@ -1,7 +1,14 @@ package com.tangem.features.addressbook.di import com.tangem.features.addressbook.AddressBookComponent +import com.tangem.features.addressbook.AddressBookContactsBlockComponent +import com.tangem.features.addressbook.AddressSelectorComponent +import com.tangem.features.addressbook.ContactSelectionListener +import com.tangem.features.addressbook.ContactSelectionTrigger +import com.tangem.features.addressbook.addressselector.DefaultAddressSelectorComponent +import com.tangem.features.addressbook.block.DefaultAddressBookContactsBlockComponent import com.tangem.features.addressbook.common.DefaultAddressBookComponent +import com.tangem.features.addressbook.common.DefaultContactSelectionTrigger import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -15,4 +22,24 @@ internal interface AddressBookComponentModule { @Binds @Singleton fun bindAddressBookComponentFactory(factory: DefaultAddressBookComponent.Factory): AddressBookComponent.Factory + + @Binds + @Singleton + fun bindContactsBlockComponentFactory( + factory: DefaultAddressBookContactsBlockComponent.Factory, + ): AddressBookContactsBlockComponent.Factory + + @Binds + @Singleton + fun bindAddressSelectorComponentFactory( + factory: DefaultAddressSelectorComponent.Factory, + ): AddressSelectorComponent.Factory + + @Binds + @Singleton + fun bindContactSelectionTrigger(impl: DefaultContactSelectionTrigger): ContactSelectionTrigger + + @Binds + @Singleton + fun bindContactSelectionListener(impl: DefaultContactSelectionTrigger): ContactSelectionListener } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookModelModule.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookModelModule.kt index 8d81fd327a..9b21153da9 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookModelModule.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookModelModule.kt @@ -3,6 +3,7 @@ package com.tangem.features.addressbook.di import com.tangem.core.decompose.di.ModelComponent import com.tangem.core.decompose.model.Model import com.tangem.features.addressbook.addaddress.model.AddAddressModel +import com.tangem.features.addressbook.block.model.ContactsBlockModel import com.tangem.features.addressbook.list.model.AddressBookListModel import com.tangem.features.addressbook.editcontact.model.EditContactModel import dagger.Binds @@ -20,6 +21,11 @@ internal interface AddressBookModelModule { @ClassKey(AddressBookListModel::class) fun bindAddressBookModel(model: AddressBookListModel): Model + @Binds + @IntoMap + @ClassKey(ContactsBlockModel::class) + fun bindContactsBlockModel(model: ContactsBlockModel): Model + @Binds @IntoMap @ClassKey(EditContactModel::class) diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/EditContactStateController.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/EditContactStateController.kt index 566148a41d..bd75ec37da 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/EditContactStateController.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/state/EditContactStateController.kt @@ -12,19 +12,17 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import javax.inject.Inject @ModelScoped internal class EditContactStateController @Inject constructor() { - private val mutableUiState: MutableStateFlow = MutableStateFlow(value = getInitialState()) - - val uiState: StateFlow get() = mutableUiState.asStateFlow() + val uiState: StateFlow + field = MutableStateFlow(value = getInitialState()) fun update(transformer: Transformer) { - mutableUiState.update(function = transformer::transform) + uiState.update(function = transformer::transform) } private fun getInitialState(): EditContactUM { diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/DefaultAddressBookListComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/DefaultAddressBookListComponent.kt index 6214fdbc75..4f02d5303e 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/DefaultAddressBookListComponent.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/DefaultAddressBookListComponent.kt @@ -5,35 +5,72 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.slot.childSlot +import com.arkivanov.decompose.router.slot.dismiss import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.addressbook.AddressSelectorComponent import com.tangem.features.addressbook.list.model.AddressBookListModel import com.tangem.features.addressbook.list.ui.AddressBookEmptyScreen +import com.tangem.features.addressbook.list.ui.AddressBookListScreen import com.tangem.features.addressbook.list.ui.state.AddressBookListUM +import com.tangem.features.addressbook.route.AddressBookRoute internal class DefaultAddressBookListComponent( appComponentContext: AppComponentContext, params: Params, + addressSelectorFactory: AddressSelectorComponent.Factory, ) : ComposableContentComponent, AppComponentContext by appComponentContext { private val model: AddressBookListModel = getOrCreateModel(params) + private val selectorSlot = childSlot( + source = model.selectorNavigation, + serializer = null, + key = "address_selector_slot", + handleBackButton = true, + childFactory = { contact, componentContext -> + addressSelectorFactory.create( + context = childByContext(componentContext), + params = AddressSelectorComponent.Params( + contact = contact, + onAddressSelected = model::deliverSelection, + onDismiss = { model.selectorNavigation.dismiss() }, + ), + ) + }, + ) + @Composable override fun Content(modifier: Modifier) { val state by model.state.collectAsStateWithLifecycle() + val selector by selectorSlot.subscribeAsState() when (val addressBookListUM = state) { is AddressBookListUM.Empty -> AddressBookEmptyScreen( onAddContactClick = addressBookListUM.onAddClick, onBackClick = router::pop, modifier = modifier.background(TangemTheme.colors3.bg.primary), ) - is AddressBookListUM.AddressList -> TODO("[REDACTED_TASK_KEY]") + is AddressBookListUM.Content -> AddressBookListScreen( + state = addressBookListUM, + onBackClick = router::pop, + modifier = modifier.background(TangemTheme.colors3.bg.primary), + ) } + selector.child?.instance?.BottomSheet() } + /** + * @property mode Default (management) or Selector (pick a contact for a network) + * @property onContactClick management mode — opens the contact editor (TODO [REDACTED_TASK_KEY]) + * @property onAddContactClick opens the new-contact editor + */ data class Params( + val mode: AddressBookRoute.ListMode, val onContactClick: (String) -> Unit, val onAddContactClick: () -> Unit, ) diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt index 6d269a7616..e53a5d03d8 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt @@ -1,30 +1,92 @@ package com.tangem.features.addressbook.list.model +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.arkivanov.decompose.router.slot.dismiss 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.navigation.Router +import com.tangem.domain.addressbook.usecase.GetContactsUseCase +import com.tangem.features.addressbook.ContactSelectionTrigger +import com.tangem.features.addressbook.MatchedContact +import com.tangem.features.addressbook.SelectedContact +import com.tangem.features.addressbook.common.ContactMatcher import com.tangem.features.addressbook.list.DefaultAddressBookListComponent import com.tangem.features.addressbook.list.state.AddressBookListStateController import com.tangem.features.addressbook.list.state.transformers.UpdateAddressBookListInitialStateTransformer +import com.tangem.features.addressbook.list.state.transformers.UpdateAddressBookListSelectionStateTransformer import com.tangem.features.addressbook.list.ui.state.AddressBookListUM +import com.tangem.features.addressbook.route.AddressBookRoute import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import javax.inject.Inject +/** + * Backs the contacts list. The list content is the same however the address book was opened — the open + * [AddressBookRoute.ListMode] only decides what tapping a contact does: + * - [AddressBookRoute.ListMode.Default]: browse / manage contacts (full UI is TODO [REDACTED_TASK_KEY]). + * - [AddressBookRoute.ListMode.Selector]: pick a recipient for the given network — a single matching address is + * returned right away, several open the address selector first. + */ @ModelScoped internal class AddressBookListModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, private val stateController: AddressBookListStateController, + private val router: Router, + private val contactSelectionTrigger: ContactSelectionTrigger, + private val getContactsUseCase: GetContactsUseCase, ) : Model() { private val params = paramsContainer.require() val state: StateFlow get() = stateController.uiState + /** Address-selector bottom sheet, shown when a picked contact has more than one address in the target network. */ + val selectorNavigation = SlotNavigation() + init { - stateController.update( - UpdateAddressBookListInitialStateTransformer(onAddContactClick = params.onAddContactClick), - ) + when (val mode = params.mode) { + // Browse/manage: full list UI is TODO [REDACTED_TASK_KEY]. + AddressBookRoute.ListMode.Default -> stateController.update( + UpdateAddressBookListInitialStateTransformer(onAddContactClick = params.onAddContactClick), + ) + // Pick a recipient: same list, the tap returns the chosen address. + is AddressBookRoute.ListMode.Selector -> observeSelectionContacts(networkId = mode.networkId) + } + } + + private fun observeSelectionContacts(networkId: String) { + getContactsUseCase(query = "") + .onEach { contacts -> + stateController.update( + UpdateAddressBookListSelectionStateTransformer( + matched = ContactMatcher.match(contacts = contacts, networkId = networkId), + onAddContactClick = params.onAddContactClick, + onContactClick = ::onPickContact, + ), + ) + } + .flowOn(dispatchers.default) + .launchIn(modelScope) + } + + private fun onPickContact(contact: MatchedContact) { + val singleEntry = contact.entries.singleOrNull() + if (singleEntry != null) { + deliverSelection(contact.toSelectedContact(singleEntry)) + } else { + selectorNavigation.activate(contact) + } + } + + fun deliverSelection(contact: SelectedContact) { + contactSelectionTrigger.trigger(contact) + selectorNavigation.dismiss() + router.pop() } } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/AddressBookListStateController.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/AddressBookListStateController.kt index f42ef0cd71..8b7799c8f9 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/AddressBookListStateController.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/AddressBookListStateController.kt @@ -5,20 +5,17 @@ import com.tangem.features.addressbook.list.ui.state.AddressBookListUM import com.tangem.utils.transformer.Transformer import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import javax.inject.Inject @ModelScoped internal class AddressBookListStateController @Inject constructor() { - private val mutableUiState: MutableStateFlow = - MutableStateFlow(value = getInitialState()) - - val uiState: StateFlow get() = mutableUiState.asStateFlow() + val uiState: StateFlow + field = MutableStateFlow(value = getInitialState()) fun update(transformer: Transformer) { - mutableUiState.update(function = transformer::transform) + uiState.update(function = transformer::transform) } private fun getInitialState(): AddressBookListUM = AddressBookListUM.Empty(onAddClick = {}) diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/converter/ContactUMConverter.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/converter/ContactUMConverter.kt deleted file mode 100644 index e50783cb34..0000000000 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/converter/ContactUMConverter.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.tangem.features.addressbook.list.state.converter - -import com.tangem.domain.addressbook.model.Contact -import com.tangem.features.addressbook.list.ui.state.ContactUM -import com.tangem.utils.converter.Converter - -/** - * Maps a domain [Contact] to its UI representation [ContactUM]. - * - * TODO AddressBook ([REDACTED_TASK_KEY]): wire into [com.tangem.features.addressbook.list.model.AddressBookListModel] when the contacts list - * is loaded from the repository and the [com.tangem.features.addressbook.list.ui.state.AddressBookListUM.AddressList] - * screen is implemented. - */ -internal class ContactUMConverter : Converter { - - override fun convert(value: Contact): ContactUM = ContactUM( - id = value.id.value, - name = value.name.value, - addressCount = value.addressEntries.size, - ) -} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListInitialStateTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListInitialStateTransformer.kt index 8191d0f1b5..db17c8e2e3 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListInitialStateTransformer.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListInitialStateTransformer.kt @@ -1,6 +1,7 @@ package com.tangem.features.addressbook.list.state.transformers import com.tangem.features.addressbook.list.ui.state.AddressBookListUM +import com.tangem.features.addressbook.list.ui.state.ContentMode import com.tangem.utils.transformer.Transformer /** @@ -13,7 +14,9 @@ internal class UpdateAddressBookListInitialStateTransformer( override fun transform(prevState: AddressBookListUM): AddressBookListUM { return when (prevState) { is AddressBookListUM.Empty -> prevState.copy(onAddClick = onAddContactClick) - is AddressBookListUM.AddressList -> prevState + is AddressBookListUM.Content -> prevState.copy( + contentMode = ContentMode.Default(onAddClick = onAddContactClick), + ) } } } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListSelectionStateTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListSelectionStateTransformer.kt new file mode 100644 index 0000000000..fb09fff616 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListSelectionStateTransformer.kt @@ -0,0 +1,38 @@ +package com.tangem.features.addressbook.list.state.transformers + +import com.tangem.features.addressbook.MatchedContact +import com.tangem.features.addressbook.list.ui.state.AddressBookListUM +import com.tangem.features.addressbook.list.ui.state.ContactUM +import com.tangem.features.addressbook.list.ui.state.ContentMode +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.toImmutableList + +/** + * Builds the contacts list from the [matched] contacts. An empty result falls back to [AddressBookListUM.Empty] so the + * user can still add a contact. + */ +internal class UpdateAddressBookListSelectionStateTransformer( + private val matched: List, + private val onAddContactClick: () -> Unit, + private val onContactClick: (MatchedContact) -> Unit, +) : Transformer { + + override fun transform(prevState: AddressBookListUM): AddressBookListUM { + return if (matched.isEmpty()) { + AddressBookListUM.Empty(onAddClick = onAddContactClick) + } else { + AddressBookListUM.Content( + contacts = matched.map { it.toContactUM() }.toImmutableList(), + contentMode = ContentMode.Select, + ) + } + } + + private fun MatchedContact.toContactUM(): ContactUM = ContactUM( + id = contactId, + name = name, + icon = icon, + networkAddressCount = entries.size, + onClick = { onContactClick(this) }, + ) +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookListScreen.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookListScreen.kt new file mode 100644 index 0000000000..b338c678e9 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookListScreen.kt @@ -0,0 +1,143 @@ +package com.tangem.features.addressbook.list.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.common.ui.account.AccountIconUM +import com.tangem.core.ui.R +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds.topbar.TangemTopBar +import com.tangem.core.ui.ds2.button.TangemButton +import com.tangem.core.ui.extensions.resourceReference +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_chevron_left_20 +import com.tangem.core.ui.res.generated.icons.ic_cross_20 +import com.tangem.core.ui.res.generated.icons.ic_sign_plus_20 +import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.features.addressbook.common.ui.ContactRow +import com.tangem.features.addressbook.list.ui.state.AddressBookListUM +import com.tangem.features.addressbook.list.ui.state.ContactUM +import com.tangem.features.addressbook.list.ui.state.ContentMode +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun AddressBookListScreen( + state: AddressBookListUM.Content, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + TangemTopBar( + modifier = Modifier.statusBarsPadding(), + title = resourceReference(R.string.address_book_title), + startContent = when (state.contentMode) { + is ContentMode.Default -> { + { + TangemButton( + iconStart = TangemIconUM.Icon(imageVector = Icons.ic_chevron_left_20), + onClick = onBackClick, + size = TangemButton.Size.X11, + variant = TangemButton.Variant.Material, + ) + } + } + ContentMode.Select -> null + }, + endContent = { + TangemButton( + iconStart = TangemIconUM.Icon( + imageVector = when (state.contentMode) { + is ContentMode.Default -> Icons.ic_sign_plus_20 + ContentMode.Select -> Icons.ic_cross_20 + }, + ), + onClick = when (state.contentMode) { + is ContentMode.Default -> state.contentMode.onAddClick + ContentMode.Select -> onBackClick + }, + size = TangemButton.Size.X11, + variant = TangemButton.Variant.Material, + ) + }, + ) + LazyColumn(modifier = Modifier.padding(horizontal = 16.dp)) { + items(items = state.contacts, key = ContactUM::id) { contact -> + ContactRow(contact = contact) + } + } + } +} + +@Composable +@Preview(showBackground = true, widthDp = 360) +private fun Preview_AddressBookListScreen() { + TangemThemePreviewRedesign { + Column(verticalArrangement = Arrangement.spacedBy(20.dp)) { + AddressBookListScreen( + state = AddressBookListUM.Content( + contacts = persistentListOf( + ContactUM( + id = "1", + name = "Binance", + icon = AccountIconUM.CryptoPortfolio( + value = CryptoPortfolioIcon.Icon.Letter, + color = CryptoPortfolioIcon.Color.Azure, + ), + networkAddressCount = 1, + onClick = {}, + ), + ContactUM( + id = "2", + name = "Alice", + icon = AccountIconUM.CryptoPortfolio( + value = CryptoPortfolioIcon.Icon.Letter, + color = CryptoPortfolioIcon.Color.UFOGreen, + ), + networkAddressCount = 3, + onClick = {}, + ), + ), + contentMode = ContentMode.Default(onAddClick = {}), + ), + onBackClick = {}, + ) + + AddressBookListScreen( + state = AddressBookListUM.Content( + contacts = persistentListOf( + ContactUM( + id = "1", + name = "Binance", + icon = AccountIconUM.CryptoPortfolio( + value = CryptoPortfolioIcon.Icon.Letter, + color = CryptoPortfolioIcon.Color.Azure, + ), + networkAddressCount = 1, + onClick = {}, + ), + ContactUM( + id = "2", + name = "Alice", + icon = AccountIconUM.CryptoPortfolio( + value = CryptoPortfolioIcon.Icon.Letter, + color = CryptoPortfolioIcon.Color.UFOGreen, + ), + networkAddressCount = 3, + onClick = {}, + ), + ), + contentMode = ContentMode.Select, + ), + onBackClick = {}, + ) + } + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/AddressBookListUM.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/AddressBookListUM.kt index e1b9061014..f8d7481037 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/AddressBookListUM.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/AddressBookListUM.kt @@ -3,10 +3,26 @@ package com.tangem.features.addressbook.list.ui.state import androidx.compose.runtime.Immutable import kotlinx.collections.immutable.ImmutableList +/** + * UI state of the contacts list. The list itself is the same however the address book was opened — it is either + * [Empty] or shows [Content]. How the address book was opened (browse vs. pick a recipient) only changes what a + * contact tap does, which is captured by [ContactUM.onClick], not by a separate state. + */ @Immutable internal sealed interface AddressBookListUM { data class Empty(val onAddClick: () -> Unit) : AddressBookListUM - data class AddressList(val contacts: ImmutableList) : AddressBookListUM + data class Content( + val contacts: ImmutableList, + val contentMode: ContentMode, + ) : AddressBookListUM +} + +@Immutable +internal sealed interface ContentMode { + + data class Default(val onAddClick: () -> Unit) : ContentMode + + data object Select : ContentMode } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/ContactUM.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/ContactUM.kt index c5f526a6bc..d15a700047 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/ContactUM.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/ContactUM.kt @@ -1,11 +1,13 @@ package com.tangem.features.addressbook.list.ui.state import androidx.compose.runtime.Immutable +import com.tangem.common.ui.account.AccountIconUM -/** UI model of a single address-book contact row. Holds only what the list needs to render — no domain types. */ @Immutable internal data class ContactUM( val id: String, val name: String, - val addressCount: Int, + val icon: AccountIconUM.CryptoPortfolio, + val networkAddressCount: Int, + val onClick: () -> Unit, ) \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/route/AddressBookRoute.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/route/AddressBookRoute.kt index c59c929b70..e0a39904b6 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/route/AddressBookRoute.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/route/AddressBookRoute.kt @@ -5,14 +5,19 @@ import kotlinx.serialization.Serializable @Serializable internal sealed class AddressBookRoute { + /** + * The contacts list. [mode] mirrors the entry point: [ListMode.Default] for plain browsing/management, and + * [ListMode.Selector] when the list is opened to pick a contact for a given network — a tap then returns the + * chosen address instead of opening the editor. + */ @Serializable - data object List : AddressBookRoute() + data class List(val mode: ListMode = ListMode.Default) : AddressBookRoute() /** * if [contactId] is not null we should fetch existing contact. * * [predefinedAddress] and [predefinedNetworkId] are set only when the feature is opened in - * [com.tangem.features.addressbook.entity.AddressBookOpenMode.WithContactCreation] mode — the address and its + * [com.tangem.common.routing.entity.AddressBookOpenMode.WithContactCreation] mode — the address and its * network are already known, so the new contact is opened with that address already attached. */ @Serializable @@ -24,4 +29,16 @@ internal sealed class AddressBookRoute { @Serializable data object AddAddress : AddressBookRoute() + + /** How the contacts list is shown — agnostic of which feature opened it. */ + @Serializable + sealed interface ListMode { + + @Serializable + data object Default : ListMode + + /** Pick a contact that has an address in [networkId]. */ + @Serializable + data class Selector(val networkId: String) : ListMode + } } \ No newline at end of file diff --git a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/common/ContactMatcherTest.kt b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/common/ContactMatcherTest.kt new file mode 100644 index 0000000000..2f9974771e --- /dev/null +++ b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/common/ContactMatcherTest.kt @@ -0,0 +1,100 @@ +package com.tangem.features.addressbook.common + +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.addressbook.model.* +import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import org.junit.jupiter.api.Test + +internal class ContactMatcherTest { + + @Test + fun `GIVEN contacts WHEN match THEN keeps only contacts with an address in the network`() { + // Arrange + val ethContact = contact("Binance", entry("0xAAA", ETHEREUM), entry("Trx", TRON)) + val tronOnly = contact("Tron Friend", entry("Trx2", TRON)) + + // Act + val result = ContactMatcher.match(listOf(ethContact, tronOnly), networkId = ETHEREUM) + + // Assert + assertThat(result.map { it.name }).containsExactly("Binance") + assertThat(result.single().entries.map { it.address }).containsExactly("0xAAA") + } + + @Test + fun `GIVEN no contact in the network WHEN match THEN returns empty`() { + // Arrange + val tronOnly = contact("Tron Friend", entry("Trx", TRON)) + + // Act + val result = ContactMatcher.match(listOf(tronOnly), networkId = ETHEREUM) + + // Assert + assertThat(result).isEmpty() + } + + @Test + fun `GIVEN contact with multiple addresses in the network WHEN match THEN all those entries are returned`() { + // Arrange + val exchange = contact("Exchange", entry("0xAAA", ETHEREUM, memo = "1"), entry("0xBBB", ETHEREUM)) + + // Act + val result = ContactMatcher.match(listOf(exchange), networkId = ETHEREUM) + + // Assert + val entries = result.single().entries + assertThat(entries.map { it.address }).containsExactly("0xAAA", "0xBBB") + assertThat(entries.first { it.address == "0xAAA" }.memo).isEqualTo("1") + } + + @Test + fun `GIVEN contact with stored color WHEN match THEN avatar color is taken from the contact`() { + // Arrange + val contact = contact("Binance", entry("0xAAA", ETHEREUM), iconColor = "MexicanPink") + + // Act + val result = ContactMatcher.match(listOf(contact), networkId = ETHEREUM) + + // Assert + assertThat(result.single().icon.color).isEqualTo(CryptoPortfolioIcon.Color.MexicanPink) + } + + @Test + fun `GIVEN contact with unknown color WHEN match THEN avatar color falls back to default`() { + // Arrange + val contact = contact("Binance", entry("0xAAA", ETHEREUM), iconColor = "not-a-color") + + // Act + val result = ContactMatcher.match(listOf(contact), networkId = ETHEREUM) + + // Assert + assertThat(result.single().icon.color).isEqualTo(CryptoPortfolioIcon.Color.Azure) + } + + private fun contact(name: String, vararg entries: AddressEntry, iconColor: String = "Azure"): Contact = Contact( + id = ContactId(name), + walletId = UserWalletId(stringValue = "0001"), + name = requireNotNull(ContactName(name).getOrNull()) { "invalid test name" }, + icon = "", + iconColor = iconColor, + createdAt = "2026-06-10T14:30:00.000Z", + updatedAt = "2026-06-10T14:30:00.000Z", + addressEntries = entries.toList(), + ) + + private fun entry(address: String, networkId: String, memo: String? = null): AddressEntry = AddressEntry( + id = AddressEntryId(address), + address = address, + networkId = Network.RawID(networkId), + memo = memo, + signature = "sig", + networkName = "Ethereum", + ) + + private companion object { + const val ETHEREUM = "ethereum" + const val TRON = "tron" + } +} \ No newline at end of file From 68f451d926cb4f924cb92eb71a7f37f95ccc0ea0 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 23 Jun 2026 13:52:26 +0100 Subject: [PATCH 021/210] Updated on 2026-08-14 --- .../tap/di/domain/AddressBookDomainModule.kt | 21 +- .../GetVerifiedContactsInteractor.kt} | 46 ++-- .../usecase/AddressEntrySigningPayload.kt | 2 +- .../addressbook/usecase/GetContactsUseCase.kt | 19 +- .../usecase/GetVerifiedContactsUseCase.kt | 28 --- .../GetVerifiedContactsInteractorTest.kt | 209 ++++++++++++++++++ .../usecase/GetVerifiedContactsUseCaseTest.kt | 113 ---------- .../VerifyAddressEntriesUseCaseTest.kt | 173 --------------- 8 files changed, 253 insertions(+), 358 deletions(-) rename domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/{usecase/VerifyAddressEntriesUseCase.kt => interactor/GetVerifiedContactsInteractor.kt} (50%) delete mode 100644 domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/GetVerifiedContactsUseCase.kt create mode 100644 domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/interactor/GetVerifiedContactsInteractorTest.kt delete mode 100644 domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetVerifiedContactsUseCaseTest.kt delete mode 100644 domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/VerifyAddressEntriesUseCaseTest.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 5535d25f92..07e8299d97 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 @@ -1,18 +1,17 @@ package com.tangem.tap.di.domain import com.tangem.domain.addressbook.crypto.AddressBookCipher +import com.tangem.domain.addressbook.interactor.GetVerifiedContactsInteractor 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.CreateContactUseCase import com.tangem.domain.addressbook.usecase.DeleteContactUseCase import com.tangem.domain.addressbook.usecase.GetContactsUseCase -import com.tangem.domain.addressbook.usecase.GetVerifiedContactsUseCase import com.tangem.domain.addressbook.usecase.SignAddressEntriesUseCase import com.tangem.domain.addressbook.usecase.UpdateContactUseCase import com.tangem.domain.addressbook.usecase.ValidateContactAddressUseCase import com.tangem.domain.addressbook.usecase.ValidateContactNameUseCase -import com.tangem.domain.addressbook.usecase.VerifyAddressEntriesUseCase import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.tokens.GetNetworkAddressesUseCase import com.tangem.domain.transaction.usecase.SignUseCase @@ -40,14 +39,6 @@ object AddressBookDomainModule { ) } - @Provides - @Singleton - fun provideVerifyAddressEntriesUseCase( - verifyMessagesUseCase: VerifySecp256k1MessagesUseCase, - ): VerifyAddressEntriesUseCase { - return VerifyAddressEntriesUseCase(verifyMessagesUseCase = verifyMessagesUseCase) - } - @Provides @Singleton fun provideSignAddressEntriesUseCase(signUseCase: SignUseCase): SignAddressEntriesUseCase { @@ -68,14 +59,14 @@ object AddressBookDomainModule { @Provides @Singleton - fun provideGetVerifiedContactsUseCase( + fun provideGetVerifiedContactsInteractor( getContactsUseCase: GetContactsUseCase, - verifyAddressEntriesUseCase: VerifyAddressEntriesUseCase, + verifyMessagesUseCase: VerifySecp256k1MessagesUseCase, userWalletsListRepository: UserWalletsListRepository, - ): GetVerifiedContactsUseCase { - return GetVerifiedContactsUseCase( + ): GetVerifiedContactsInteractor { + return GetVerifiedContactsInteractor( getContacts = getContactsUseCase, - verifyAddressEntries = verifyAddressEntriesUseCase, + verifyMessages = verifyMessagesUseCase, userWalletsListRepository = userWalletsListRepository, ) } diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/VerifyAddressEntriesUseCase.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/interactor/GetVerifiedContactsInteractor.kt similarity index 50% rename from domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/VerifyAddressEntriesUseCase.kt rename to domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/interactor/GetVerifiedContactsInteractor.kt index d82ecd9ee4..e008d841e5 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/VerifyAddressEntriesUseCase.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/interactor/GetVerifiedContactsInteractor.kt @@ -1,32 +1,42 @@ -package com.tangem.domain.addressbook.usecase +package com.tangem.domain.addressbook.interactor import arrow.core.Either import arrow.core.right import com.tangem.domain.addressbook.model.AddressEntriesVerification -import com.tangem.domain.addressbook.model.AddressEntry import com.tangem.domain.addressbook.model.Contact +import com.tangem.domain.addressbook.model.VerifiedContact +import com.tangem.domain.addressbook.usecase.GetContactsUseCase +import com.tangem.domain.addressbook.usecase.buildAddressEntryPayload +import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.transaction.error.VerifyMessagesError import com.tangem.domain.transaction.usecase.VerifySecp256k1MessagesUseCase import com.tangem.utils.extensions.hexToBytesOrNull +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map -/** - * Verifies each [AddressEntry] of a [Contact] against [userWallet] and partitions them into the ones - * whose signature was produced by that wallet ([AddressEntriesVerification.valid]) and the ones that - * were not ([AddressEntriesVerification.invalid]). The counterpart of [SignAddressEntriesUseCase]. - * - * An entry is **invalid** when its signature fails verification or is missing/malformed (non-hex); - * such entries should be hidden from the user. Both partitions preserve the contact's original entry - * order. An empty contact yields two empty lists. The wallet's signing key being unavailable surfaces - * as a [VerifyMessagesError.NoSigningKey] failure (the entries cannot be verified at all). - * - * Each entry is verified against the exact bytes that were signed (see [buildAddressEntryPayload]). - */ -class VerifyAddressEntriesUseCase( - private val verifyMessagesUseCase: VerifySecp256k1MessagesUseCase, +class GetVerifiedContactsInteractor( + private val getContacts: GetContactsUseCase, + private val verifyMessages: VerifySecp256k1MessagesUseCase, + private val userWalletsListRepository: UserWalletsListRepository, ) { - operator fun invoke( + operator fun invoke(query: String, userWalletId: UserWalletId? = null): Flow> { + return getContacts(query, userWalletId).map { contacts -> + val walletsById = userWalletsListRepository.userWalletsSync().associateBy { it.walletId } + contacts.mapNotNull { contact -> + val userWallet = walletsById[contact.walletId] ?: return@mapNotNull null + val verification = verify(userWallet, contact).getOrNull() ?: return@mapNotNull null + VerifiedContact( + contact = contact.copy(addressEntries = verification.valid), + invalidEntries = verification.invalid, + ) + } + } + } + + private fun verify( userWallet: UserWallet, contact: Contact, ): Either { @@ -40,7 +50,7 @@ class VerifyAddressEntriesUseCase( val messages = wellFormed.map { (entry, _) -> buildAddressEntryPayload(contact, entry) } val signatures = wellFormed.map { (_, signature) -> signature } - return verifyMessagesUseCase(userWallet = userWallet, messages = messages, signatures = signatures) + return verifyMessages(userWallet = userWallet, messages = messages, signatures = signatures) .map { flags -> val validIds = wellFormed .filterIndexed { index, _ -> flags[index] } diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/AddressEntrySigningPayload.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/AddressEntrySigningPayload.kt index 4a2cde23e9..fedf05098a 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/AddressEntrySigningPayload.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/AddressEntrySigningPayload.kt @@ -7,7 +7,7 @@ import com.tangem.domain.addressbook.model.Contact * Builds the canonical bytes that are signed for a single [AddressEntry]: * `address + networkId + memo + contactId + name`. * - * Shared by [SignAddressEntriesUseCase] (which hashes and signs it) and [VerifyAddressEntriesUseCase] + * Shared by [SignAddressEntriesUseCase] (which hashes and signs it) and `GetVerifiedContactsInteractor` * (which verifies the signature against it), so the signed and verified payloads can never diverge. */ internal fun buildAddressEntryPayload(contact: Contact, entry: AddressEntry): ByteArray { diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCase.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCase.kt index af19f1f2dc..acf8409dc1 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCase.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/GetContactsUseCase.kt @@ -18,15 +18,14 @@ class GetContactsUseCase( } val normalizedQuery = query.trim() if (normalizedQuery.isEmpty()) return source - return source - .map { contacts -> - contacts.filter { contact -> - val isNameContaining = contact.name.value.contains(other = normalizedQuery, ignoreCase = true) - val isAddressContaining = contact.addressEntries.any { addressEntry -> - addressEntry.address.contains(other = normalizedQuery, ignoreCase = true) - } - isNameContaining || isAddressContaining - } - } + return source.map { contacts -> contacts.filter { it.matches(normalizedQuery) } } + } + + private fun Contact.matches(query: String): Boolean { + val isNameContaining = name.value.contains(other = query, ignoreCase = true) + val isAddressContaining = addressEntries.any { addressEntry -> + addressEntry.address.contains(other = query, ignoreCase = true) + } + return isNameContaining || isAddressContaining } } \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/GetVerifiedContactsUseCase.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/GetVerifiedContactsUseCase.kt deleted file mode 100644 index be933b28e7..0000000000 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/GetVerifiedContactsUseCase.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.tangem.domain.addressbook.usecase - -import com.tangem.domain.addressbook.model.VerifiedContact -import com.tangem.domain.common.wallets.UserWalletsListRepository -import com.tangem.domain.models.wallet.UserWalletId -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map - -class GetVerifiedContactsUseCase( - private val getContacts: GetContactsUseCase, - private val verifyAddressEntries: VerifyAddressEntriesUseCase, - private val userWalletsListRepository: UserWalletsListRepository, -) { - - operator fun invoke(query: String, userWalletId: UserWalletId? = null): Flow> { - return getContacts(query, userWalletId).map { contacts -> - val walletsById = userWalletsListRepository.userWalletsSync().associateBy { it.walletId } - contacts.mapNotNull { contact -> - val userWallet = walletsById[contact.walletId] ?: return@mapNotNull null - val verification = verifyAddressEntries(userWallet, contact).getOrNull() ?: return@mapNotNull null - VerifiedContact( - contact = contact.copy(addressEntries = verification.valid), - invalidEntries = verification.invalid, - ) - } - } - } -} \ No newline at end of file diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/interactor/GetVerifiedContactsInteractorTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/interactor/GetVerifiedContactsInteractorTest.kt new file mode 100644 index 0000000000..f0d6b4524f --- /dev/null +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/interactor/GetVerifiedContactsInteractorTest.kt @@ -0,0 +1,209 @@ +package com.tangem.domain.addressbook.interactor + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.addressbook.model.AddressEntry +import com.tangem.domain.addressbook.model.AddressEntryId +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.model.VerifiedContact +import com.tangem.domain.addressbook.usecase.GetContactsUseCase +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.transaction.error.VerifyMessagesError +import com.tangem.domain.transaction.usecase.VerifySecp256k1MessagesUseCase +import com.tangem.utils.extensions.toHexString +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +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 GetVerifiedContactsInteractorTest { + + private val getContacts: GetContactsUseCase = mockk() + private val verifyMessages: VerifySecp256k1MessagesUseCase = mockk() + private val userWalletsListRepository: UserWalletsListRepository = mockk() + + private val interactor = GetVerifiedContactsInteractor( + getContacts = getContacts, + verifyMessages = verifyMessages, + userWalletsListRepository = userWalletsListRepository, + ) + + private val walletId = UserWalletId("011") + private val userWallet: UserWallet = mockk { every { walletId } returns this@GetVerifiedContactsInteractorTest.walletId } + + @BeforeEach + fun resetMocks() { + clearMocks(getContacts, verifyMessages, userWalletsListRepository) + coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet) + } + + @Test + fun `GIVEN mixed entries WHEN invoke THEN displays only valid AND keeps invalid for analytics`() = runTest { + // Arrange + val valid = entry(id = "valid", address = "0xvalid", memo = null, signature = "AABB") + val invalid = entry(id = "invalid", address = "0xinvalid", memo = null, signature = "CCDD") + val contact = contact(valid, invalid) + stubContacts(contact) + every { verifyMessages(any(), any(), any()) } returns listOf(true, false).right() + + // Act + val result = interactor(query = "").first() + + // Assert + assertThat(result).containsExactly( + VerifiedContact( + contact = contact.copy(addressEntries = listOf(valid)), + invalidEntries = listOf(invalid), + ), + ) + } + + @Test + fun `GIVEN contact with entries WHEN invoke THEN verifies each entry payload and its signature`() = runTest { + // Arrange + val contact = contact( + entry(id = "addr-1", address = "0xabc", memo = "memo", signature = "AABB"), + entry(id = "addr-2", address = "0xdef", memo = null, signature = "CCDD"), + ) + stubContacts(contact) + val messagesSlot = slot>() + val signaturesSlot = slot>() + every { + verifyMessages(eq(userWallet), capture(messagesSlot), capture(signaturesSlot)) + } returns listOf(true, true).right() + + // Act + interactor(query = "").first() + + // Assert + assertThat(messagesSlot.captured.map { String(it) }) + .containsExactly( + expectedPayload(contact, contact.addressEntries[0]), + expectedPayload(contact, contact.addressEntries[1]), + ) + .inOrder() + assertThat(signaturesSlot.captured.map { it.toHexString() }).containsExactly("AABB", "CCDD").inOrder() + } + + @Test + fun `GIVEN some entries fail verification WHEN invoke THEN partitions them preserving order`() = runTest { + // Arrange + val valid1 = entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB") + val invalid = entry(id = "addr-2", address = "0xdef", memo = null, signature = "CCDD") + val valid2 = entry(id = "addr-3", address = "0xghi", memo = null, signature = "EEFF") + val contact = contact(valid1, invalid, valid2) + stubContacts(contact) + every { verifyMessages(any(), any(), any()) } returns listOf(true, false, true).right() + + // Act + val result = interactor(query = "").first().single() + + // Assert + assertThat(result.contact.addressEntries).containsExactly(valid1, valid2).inOrder() + assertThat(result.invalidEntries).containsExactly(invalid) + } + + @Test + fun `GIVEN malformed signature WHEN invoke THEN that entry is invalid and excluded from verification`() = runTest { + // Arrange + val malformed = entry(id = "addr-1", address = "0xabc", memo = null, signature = "not-hex") + val signed = entry(id = "addr-2", address = "0xdef", memo = null, signature = "AABB") + val contact = contact(malformed, signed) + stubContacts(contact) + val signaturesSlot = slot>() + every { + verifyMessages(eq(userWallet), any(), capture(signaturesSlot)) + } returns listOf(true).right() + + // Act + val result = interactor(query = "").first().single() + + // Assert + assertThat(signaturesSlot.captured.map { it.toHexString() }).containsExactly("AABB") + assertThat(result.contact.addressEntries).containsExactly(signed) + assertThat(result.invalidEntries).containsExactly(malformed) + } + + @Test + fun `GIVEN contact with no entries WHEN invoke THEN keeps contact without verifying`() = runTest { + // Arrange + val contact = contact() + stubContacts(contact) + + // Act + val result = interactor(query = "").first().single() + + // Assert + assertThat(result.contact.addressEntries).isEmpty() + assertThat(result.invalidEntries).isEmpty() + verify(exactly = 0) { verifyMessages(any(), any(), any()) } + } + + @Test + fun `GIVEN wallet cannot be resolved WHEN invoke THEN contact is dropped`() = runTest { + // Arrange + coEvery { userWalletsListRepository.userWalletsSync() } returns emptyList() + stubContacts(contact(entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB"))) + + // Act + val result = interactor(query = "").first() + + // Assert + assertThat(result).isEmpty() + } + + @Test + fun `GIVEN verification fails WHEN invoke THEN contact is dropped`() = runTest { + // Arrange + stubContacts(contact(entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB"))) + every { verifyMessages(any(), any(), any()) } returns VerifyMessagesError.NoSigningKey.left() + + // Act + val result = interactor(query = "").first() + + // Assert + assertThat(result).isEmpty() + } + + private fun stubContacts(vararg contacts: Contact) { + every { getContacts(query = "", userWalletId = null) } returns flowOf(contacts.toList()) + } + + private fun contact(vararg entries: AddressEntry): Contact = Contact( + id = ContactId("contact-1"), + walletId = walletId, + name = requireNotNull(ContactName("Alice").getOrNull()), + icon = "", + iconColor = "KekColor", + createdAt = "2026-01-01T00:00:00.000Z", + updatedAt = "2026-01-01T00:00:00.000Z", + addressEntries = entries.toList(), + ) + + private fun entry(id: String, address: String, memo: String?, signature: String): AddressEntry = AddressEntry( + id = AddressEntryId(id), + address = address, + networkId = Network.RawID("ethereum"), + networkName = "Ethereum", + memo = memo, + signature = signature, + ) + + private fun expectedPayload(contact: Contact, entry: AddressEntry): String = + entry.address + entry.networkId.value + entry.memo.orEmpty() + contact.id.value + contact.name.value +} \ No newline at end of file diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetVerifiedContactsUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetVerifiedContactsUseCaseTest.kt deleted file mode 100644 index 9ea96b80b0..0000000000 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/GetVerifiedContactsUseCaseTest.kt +++ /dev/null @@ -1,113 +0,0 @@ -package com.tangem.domain.addressbook.usecase - -import arrow.core.left -import arrow.core.right -import com.google.common.truth.Truth.assertThat -import com.tangem.domain.addressbook.model.* -import com.tangem.domain.common.wallets.UserWalletsListRepository -import com.tangem.domain.models.network.Network -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.transaction.error.VerifyMessagesError -import io.mockk.clearMocks -import io.mockk.coEvery -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 GetVerifiedContactsUseCaseTest { - - private val getContacts: GetContactsUseCase = mockk() - private val verifyAddressEntries: VerifyAddressEntriesUseCase = mockk() - private val userWalletsListRepository: UserWalletsListRepository = mockk() - - private val useCase = GetVerifiedContactsUseCase( - getContacts = getContacts, - verifyAddressEntries = verifyAddressEntries, - userWalletsListRepository = userWalletsListRepository, - ) - - private val walletId = UserWalletId("011") - private val userWallet: UserWallet = mockk { every { walletId } returns this@GetVerifiedContactsUseCaseTest.walletId } - - private val validEntry = entry(id = "valid", address = "0xvalid") - private val invalidEntry = entry(id = "invalid", address = "0xinvalid") - private val contact = contact(name = "Alice", entries = listOf(validEntry, invalidEntry)) - - @BeforeEach - fun resetMocks() { - clearMocks(getContacts, verifyAddressEntries, userWalletsListRepository) - coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet) - } - - @Test - fun `GIVEN mixed entries WHEN invoke THEN displays only valid AND keeps invalid for analytics`() = runTest { - // Arrange - every { getContacts(query = "", userWalletId = null) } returns flowOf(listOf(contact)) - every { verifyAddressEntries(userWallet, contact) } returns - AddressEntriesVerification(valid = listOf(validEntry), invalid = listOf(invalidEntry)).right() - - // Act - val result = useCase(query = "").first() - - // Assert - assertThat(result).containsExactly( - VerifiedContact( - contact = contact.copy(addressEntries = listOf(validEntry)), - invalidEntries = listOf(invalidEntry), - ), - ) - } - - @Test - fun `GIVEN wallet cannot be resolved WHEN invoke THEN contact is dropped`() = runTest { - // Arrange - coEvery { userWalletsListRepository.userWalletsSync() } returns emptyList() - every { getContacts(query = "", userWalletId = null) } returns flowOf(listOf(contact)) - - // Act - val result = useCase(query = "").first() - - // Assert - assertThat(result).isEmpty() - } - - @Test - fun `GIVEN verification fails WHEN invoke THEN contact is dropped`() = runTest { - // Arrange - every { getContacts(query = "", userWalletId = null) } returns flowOf(listOf(contact)) - every { verifyAddressEntries(userWallet, contact) } returns VerifyMessagesError.NoSigningKey.left() - - // Act - val result = useCase(query = "").first() - - // Assert - assertThat(result).isEmpty() - } - - private fun entry(id: String, address: String): AddressEntry = AddressEntry( - id = AddressEntryId(id), - address = address, - networkId = Network.RawID("ethereum"), - memo = null, - signature = "sig-$id", - networkName = "Ethereum", - ) - - private fun contact(name: String, entries: List): Contact = Contact( - id = ContactId("id-$name"), - walletId = walletId, - name = requireNotNull(ContactName(name).getOrNull()), - icon = "", - iconColor = "KekColor", - createdAt = "2026-01-01T00:00:00.000Z", - updatedAt = "2026-01-01T00:00:00.000Z", - addressEntries = entries, - ) -} \ No newline at end of file diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/VerifyAddressEntriesUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/VerifyAddressEntriesUseCaseTest.kt deleted file mode 100644 index 04821e35ac..0000000000 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/VerifyAddressEntriesUseCaseTest.kt +++ /dev/null @@ -1,173 +0,0 @@ -package com.tangem.domain.addressbook.usecase - -import arrow.core.left -import arrow.core.right -import com.google.common.truth.Truth.assertThat -import com.tangem.domain.addressbook.model.AddressEntry -import com.tangem.domain.addressbook.model.AddressEntryId -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.models.network.Network -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.transaction.error.VerifyMessagesError -import com.tangem.domain.transaction.usecase.VerifySecp256k1MessagesUseCase -import com.tangem.utils.extensions.toHexString -import io.mockk.clearMocks -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot -import io.mockk.verify -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.TestInstance - -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -class VerifyAddressEntriesUseCaseTest { - - private val verifyMessagesUseCase: VerifySecp256k1MessagesUseCase = mockk() - private val useCase = VerifyAddressEntriesUseCase(verifyMessagesUseCase = verifyMessagesUseCase) - - private val userWallet: UserWallet = mockk() - - @BeforeEach - fun resetMocks() { - clearMocks(verifyMessagesUseCase) - } - - @Test - fun `GIVEN contact with entries WHEN invoke THEN verifies each entry payload and its signature`() { - // Arrange - val contact = contact( - entry(id = "addr-1", address = "0xabc", memo = "memo", signature = "AABB"), - entry(id = "addr-2", address = "0xdef", memo = null, signature = "CCDD"), - ) - val messagesSlot = slot>() - val signaturesSlot = slot>() - every { - verifyMessagesUseCase(eq(userWallet), capture(messagesSlot), capture(signaturesSlot)) - } returns listOf(true, true).right() - - // Act - val result = useCase(userWallet, contact) - - // Assert - // Each entry is verified against address + networkId + memo + contactId + name - assertThat(messagesSlot.captured.map { String(it) }) - .containsExactly( - expectedPayload(contact, contact.addressEntries[0]), - expectedPayload(contact, contact.addressEntries[1]), - ) - .inOrder() - // Hex signatures are decoded to bytes, in entry order - assertThat(signaturesSlot.captured.map { it.toHexString() }).containsExactly("AABB", "CCDD").inOrder() - } - - @Test - fun `GIVEN some entries fail verification WHEN invoke THEN partitions them preserving order`() { - // Arrange - val valid1 = entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB") - val invalid = entry(id = "addr-2", address = "0xdef", memo = null, signature = "CCDD") - val valid2 = entry(id = "addr-3", address = "0xghi", memo = null, signature = "EEFF") - val contact = contact(valid1, invalid, valid2) - every { verifyMessagesUseCase(any(), any(), any()) } returns listOf(true, false, true).right() - - // Act - val result = useCase(userWallet, contact).getOrNull() - - // Assert - assertThat(result!!.valid).containsExactly(valid1, valid2).inOrder() - assertThat(result.invalid).containsExactly(invalid) - assertThat(result.areAllInvalid).isFalse() - } - - @Test - fun `GIVEN malformed signature WHEN invoke THEN that entry is invalid and excluded from verification`() { - // Arrange - val malformed = entry(id = "addr-1", address = "0xabc", memo = null, signature = "not-hex") - val signed = entry(id = "addr-2", address = "0xdef", memo = null, signature = "AABB") - val contact = contact(malformed, signed) - val signaturesSlot = slot>() - every { - verifyMessagesUseCase(eq(userWallet), any(), capture(signaturesSlot)) - } returns listOf(true).right() - - // Act - val result = useCase(userWallet, contact).getOrNull() - - // Assert - // Only the well-formed entry is passed to verification - assertThat(signaturesSlot.captured.map { it.toHexString() }).containsExactly("AABB") - assertThat(result!!.valid).containsExactly(signed) - assertThat(result.invalid).containsExactly(malformed) - } - - @Test - fun `GIVEN every entry is invalid WHEN invoke THEN allInvalid is true`() { - // Arrange - val entry1 = entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB") - val entry2 = entry(id = "addr-2", address = "0xdef", memo = null, signature = "CCDD") - val contact = contact(entry1, entry2) - every { verifyMessagesUseCase(any(), any(), any()) } returns listOf(false, false).right() - - // Act - val result = useCase(userWallet, contact).getOrNull() - - // Assert - assertThat(result!!.valid).isEmpty() - assertThat(result.invalid).containsExactly(entry1, entry2).inOrder() - assertThat(result.areAllInvalid).isTrue() - } - - @Test - fun `GIVEN contact with no entries WHEN invoke THEN returns empty partition without verifying`() { - // Arrange - val contact = contact() - - // Act - val result = useCase(userWallet, contact).getOrNull() - - // Assert - assertThat(result!!.valid).isEmpty() - assertThat(result.invalid).isEmpty() - assertThat(result.areAllInvalid).isFalse() - verify(exactly = 0) { verifyMessagesUseCase(any(), any(), any()) } - } - - @Test - fun `GIVEN verifyMessagesUseCase returns error WHEN invoke THEN propagates the error`() { - // Arrange - val contact = contact(entry(id = "addr-1", address = "0xabc", memo = null, signature = "AABB")) - every { verifyMessagesUseCase(any(), any(), any()) } returns VerifyMessagesError.NoSigningKey.left() - - // Act - val result = useCase(userWallet, contact) - - // Assert - assertThat(result.leftOrNull()).isEqualTo(VerifyMessagesError.NoSigningKey) - } - - private fun contact(vararg entries: AddressEntry): Contact = Contact( - id = ContactId("contact-1"), - walletId = UserWalletId("011"), - name = requireNotNull(ContactName("Alice").getOrNull()), - icon = "", - iconColor = "KekColor", - createdAt = "2026-01-01T00:00:00.000Z", - updatedAt = "2026-01-01T00:00:00.000Z", - addressEntries = entries.toList(), - ) - - private fun entry(id: String, address: String, memo: String?, signature: String): AddressEntry = AddressEntry( - id = AddressEntryId(id), - address = address, - networkId = Network.RawID("ethereum"), - memo = memo, - signature = signature, - networkName = "Ethereum", - ) - - private fun expectedPayload(contact: Contact, entry: AddressEntry): String = - entry.address + entry.networkId.value + entry.memo.orEmpty() + contact.id.value + contact.name.value -} \ No newline at end of file From 8643eb4a907dd73d2196b42c935f06ba3ebbf213 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 23 Jun 2026 17:15:22 +0400 Subject: [PATCH 022/210] Updated on 2026-08-14 --- .../RefactoredTxHistoryRepository.kt | 88 ++++++++++++----- .../converter/ExpressTxHistoryConverter.kt | 19 +++- .../factory/ExpressTransactionAssetFactory.kt | 95 +++++++++++++++++++ .../walletmanager/utils/SdkAmountConverter.kt | 25 +++++ .../SdkTransactionHistoryItemConverter.kt | 1 + ...TransactionDataToTxHistoryItemConverter.kt | 1 + domain/express/models/build.gradle.kts | 1 + .../express/models/ExpressTransactionAsset.kt | 4 + .../tangem/domain/models/network/SdkAmount.kt | 50 ++++++++++ .../tangem/domain/models/network/TxInfo.kt | 2 + 10 files changed, 256 insertions(+), 30 deletions(-) create mode 100644 data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/factory/ExpressTransactionAssetFactory.kt create mode 100644 data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/SdkAmountConverter.kt create mode 100644 domain/models/src/main/kotlin/com/tangem/domain/models/network/SdkAmount.kt diff --git a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/RefactoredTxHistoryRepository.kt b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/RefactoredTxHistoryRepository.kt index 079d385fa7..704a77c58c 100644 --- a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/RefactoredTxHistoryRepository.kt +++ b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/RefactoredTxHistoryRepository.kt @@ -5,9 +5,14 @@ import com.tangem.data.common.converter.ExpressProviderConverter import com.tangem.data.txhistory.repository.converter.ExpressStatusMapper import com.tangem.data.txhistory.repository.converter.ExpressOnrampConverter import com.tangem.data.txhistory.repository.converter.ExpressSwapConverter +import com.tangem.data.txhistory.repository.factory.ExpressTransactionAssetFactory +import com.tangem.data.txhistory.repository.factory.toAssetId import com.tangem.data.txhistory.repository.paging.TxHistoryPageBatchFetcher import com.tangem.datasource.local.txhistory.TxHistoryItemsStore import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao +import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity +import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity +import com.tangem.datasource.local.txhistory.db.entity.express.ExpressProviderEntity import com.tangem.domain.express.models.ExpressAsset import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.TxInfo @@ -36,6 +41,7 @@ internal class RefactoredTxHistoryRepository @Inject constructor( private val walletManagersFacade: WalletManagersFacade, private val txHistoryItemsStore: TxHistoryItemsStore, private val expressHistoryDao: ExpressHistoryDao, + private val expressTransactionAssetFactory: ExpressTransactionAssetFactory, private val cacheRegistry: CacheRegistry, private val dispatchers: CoroutineDispatcherProvider, ) : TxHistoryRepositoryV2 { @@ -85,37 +91,67 @@ internal class RefactoredTxHistoryRepository @Inject constructor( ).distinctUntilChanged(), flow4 = expressHistoryDao.getProvidersById().distinctUntilChanged(), transform = { outgoingSwaps, incomingSwaps, onramps, providers -> - buildList { - fun String.expressProvider() = providers[this]?.let(expressProviderConverter::convert) - outgoingSwaps.forEach { entity -> - val input = ExpressSwapConverter.Input( - entity = entity, - provider = entity.providerId.expressProvider(), - isOutgoing = true, - ) - add(swapConverter.convert(input)) - } - incomingSwaps.forEach { entity -> - val input = ExpressSwapConverter.Input( - entity = entity, - provider = entity.providerId.expressProvider(), - isOutgoing = false, - ) - add(swapConverter.convert(input)) - } - onramps.forEach { entity -> - val input = ExpressOnrampConverter.Input(entity, entity.providerId.expressProvider()) - add(onrampConverter.convert(input)) - } - } - // An exchange row may satisfy both swap queries only in degenerate cases; - // keep the outgoing interpretation (added first). - .distinctBy { it.txId } + buildExpressHistory( + userWalletId = userWalletId, + outgoingSwaps = outgoingSwaps, + incomingSwaps = incomingSwaps, + onramps = onramps, + providers = providers, + ) }, ) emitAll(flow) }.flowOn(dispatchers.io) + private suspend fun buildExpressHistory( + userWalletId: UserWalletId, + outgoingSwaps: List, + incomingSwaps: List, + onramps: List, + providers: Map, + ): List { + val currencies = expressTransactionAssetFactory.create( + userWalletId = userWalletId, + outgoingSwaps = outgoingSwaps, + incomingSwaps = incomingSwaps, + onramps = onramps, + ) + fun String.expressProvider() = providers[this]?.let(expressProviderConverter::convert) + return buildList { + outgoingSwaps.forEach { entity -> + val input = ExpressSwapConverter.Input( + entity = entity, + provider = entity.providerId.expressProvider(), + isOutgoing = true, + fromCurrency = currencies[entity.from.toAssetId()], + toCurrency = currencies[entity.to.toAssetId()], + ) + add(swapConverter.convert(input)) + } + incomingSwaps.forEach { entity -> + val input = ExpressSwapConverter.Input( + entity = entity, + provider = entity.providerId.expressProvider(), + isOutgoing = false, + fromCurrency = currencies[entity.from.toAssetId()], + toCurrency = currencies[entity.to.toAssetId()], + ) + add(swapConverter.convert(input)) + } + onramps.forEach { entity -> + val input = ExpressOnrampConverter.Input( + entity = entity, + provider = entity.providerId.expressProvider(), + toCurrency = currencies[entity.to.toAssetId()], + ) + add(onrampConverter.convert(input)) + } + } + // An exchange row may satisfy both swap queries only in degenerate cases; + // keep the outgoing interpretation (added first). + .distinctBy { it.txId } + } + override fun getTxHistoryBatchFlow(batchSize: Int, context: TxHistoryListBatchingContext): TxHistoryListBatchFlow { return BatchListSource( fetchDispatcher = dispatchers.io, diff --git a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverter.kt b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverter.kt index ce51c41197..5f07b6dc5a 100644 --- a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverter.kt +++ b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverter.kt @@ -9,6 +9,7 @@ import com.tangem.domain.express.models.ExpressOnrampStatus import com.tangem.domain.express.models.ExpressProvider import com.tangem.domain.express.models.ExpressTransactionAsset import com.tangem.domain.express.models.OnrampTransaction +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.tokens.model.Amount import com.tangem.domain.tokens.model.AmountType import com.tangem.domain.txhistory.model.ExpressTx @@ -26,7 +27,7 @@ import java.math.BigDecimal internal class ExpressSwapConverter : Converter { override fun convert(value: Input): ExpressTx.Swap = ExpressTx.Swap( - tx = convertExchangeTransaction(value.entity, value.provider), + tx = convertExchangeTransaction(value), isOutgoing = value.isOutgoing, txInfo = null, ) @@ -35,6 +36,8 @@ internal class ExpressSwapConverter : Converter resolved currency` map covering both legs of every swap and the to-leg of every onramp. + * Entries whose currency could not be resolved at all (no match and no fallback coin) are omitted. + */ + suspend fun create( + userWalletId: UserWalletId, + outgoingSwaps: List, + incomingSwaps: List, + onramps: List, + ): Map { + val assetIds = buildSet { + (outgoingSwaps + incomingSwaps).forEach { entity -> + add(entity.from.toAssetId()) + add(entity.to.toAssetId()) + } + onramps.forEach { entity -> add(entity.to.toAssetId()) } + } + if (assetIds.isEmpty()) return emptyMap() + + val portfolioCurrencies = multiAccountListSupplier.invoke() + .first() + .flatMap { accountList -> accountList.flattenCurrencies() } + + val userWallet = userWalletsListRepository.userWalletsSync() + .firstOrNull { it.walletId == userWalletId } + + return buildMap { + assetIds.forEach { id -> + val currency = portfolioCurrencies.findMatching(id) ?: createFallbackCoin(id, userWallet) + if (currency != null) put(id, currency) + } + } + } + + private fun List.findMatching(id: ExpressAsset.ID): CryptoCurrency? { + val isCoin = id.contractAddress == ExpressAsset.EMPTY_CONTRACT_ADDRESS_VALUE + return firstOrNull { currency -> + currency.network.rawId == id.networkId && + if (isCoin) { + currency is CryptoCurrency.Coin + } else { + currency is CryptoCurrency.Token && + currency.contractAddress.equals(id.contractAddress, ignoreCase = true) + } + } + } + + // TODO txHistory: tokens that are not in any portfolio cannot be resolved yet — fall back to a coin on the asset's + // network. + private fun createFallbackCoin(id: ExpressAsset.ID, userWallet: UserWallet?): CryptoCurrency.Coin? { + userWallet ?: return null + return cryptoCurrencyFactory.createCoin( + networkId = id.networkId, + extraDerivationPath = null, + userWallet = userWallet, + ) + } +} + +internal fun ExpressExchangeEntity.AssetEmbedded.toAssetId(): ExpressAsset.ID = + ExpressAsset.ID(networkId = network, contractAddress = contractAddress) + +internal fun ExpressOnrampEntity.AssetEmbedded.toAssetId(): ExpressAsset.ID = + ExpressAsset.ID(networkId = network, contractAddress = contractAddress) \ No newline at end of file diff --git a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/SdkAmountConverter.kt b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/SdkAmountConverter.kt new file mode 100644 index 0000000000..e89a1acb17 --- /dev/null +++ b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/SdkAmountConverter.kt @@ -0,0 +1,25 @@ +package com.tangem.data.walletmanager.utils + +import com.tangem.domain.models.network.SdkAmount +import com.tangem.domain.models.network.SdkAmountType +import com.tangem.blockchain.common.Amount as BlockchainAmount +import com.tangem.blockchain.common.AmountType as BlockchainAmountType + +/** Maps the blockchain SDK [BlockchainAmount] to the serializable domain [SdkAmount]. */ +internal fun BlockchainAmount.toDomain(): SdkAmount = SdkAmount( + currencySymbol = currencySymbol, + value = value, + decimals = decimals, + type = type.toDomain(), +) + +private fun BlockchainAmountType.toDomain(): SdkAmountType = when (this) { + BlockchainAmountType.Coin -> SdkAmountType.Coin + BlockchainAmountType.Reserve -> SdkAmountType.Reserve + is BlockchainAmountType.FeeResource -> SdkAmountType.FeeResource(name = name) + is BlockchainAmountType.Token -> SdkAmountType.Token(contractAddress = token.contractAddress, id = token.id) + is BlockchainAmountType.TokenYieldSupply -> SdkAmountType.Token( + contractAddress = token.contractAddress, + id = token.id, + ) +} \ No newline at end of file diff --git a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/SdkTransactionHistoryItemConverter.kt b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/SdkTransactionHistoryItemConverter.kt index 9832632e12..c69bd21cc6 100644 --- a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/SdkTransactionHistoryItemConverter.kt +++ b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/SdkTransactionHistoryItemConverter.kt @@ -32,6 +32,7 @@ internal class SdkTransactionHistoryItemConverter( }, type = typeConverter.convert(value), amount = requireNotNull(value.amount.value) { "Transaction amount value must not be null" }, + fee = value.fee.toDomain(), ) private fun SdkTransactionHistoryItem.SourceType.toDomain(): TxInfo.SourceType = when (this) { diff --git a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/TransactionDataToTxHistoryItemConverter.kt b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/TransactionDataToTxHistoryItemConverter.kt index 1247fce030..07e601bc3e 100644 --- a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/TransactionDataToTxHistoryItemConverter.kt +++ b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/utils/TransactionDataToTxHistoryItemConverter.kt @@ -47,6 +47,7 @@ internal class TransactionDataToTxHistoryItemConverter( }, type = getTransactionType(value), amount = amount, + fee = value.fee?.amount?.toDomain(), ) } diff --git a/domain/express/models/build.gradle.kts b/domain/express/models/build.gradle.kts index 22efcd57bd..a63174eff0 100644 --- a/domain/express/models/build.gradle.kts +++ b/domain/express/models/build.gradle.kts @@ -7,5 +7,6 @@ plugins { dependencies { implementation(deps.moshi.adapters) implementation(deps.kotlin.serialization) + implementation(projects.domain.models) implementation(projects.domain.tokens.models) } \ No newline at end of file diff --git a/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressTransactionAsset.kt b/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressTransactionAsset.kt index 954db1d1c6..bce9d73383 100644 --- a/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressTransactionAsset.kt +++ b/domain/express/models/src/main/java/com/tangem/domain/express/models/ExpressTransactionAsset.kt @@ -1,5 +1,6 @@ package com.tangem.domain.express.models +import com.tangem.domain.models.currency.CryptoCurrency import java.math.BigDecimal /** @@ -8,9 +9,12 @@ import java.math.BigDecimal * @property id The asset identifier (network id + contract address). * @property amount Human-readable amount (already scaled by [decimals]). * @property decimals The asset's decimals. + * @property cryptoCurrency The portfolio [CryptoCurrency] this asset was resolved to (matched by network id + + * contract address across all accounts). `null` when no portfolio currency matched and no fallback could be built. */ data class ExpressTransactionAsset( val id: ExpressAsset.ID, val amount: BigDecimal, val decimals: Int, + val cryptoCurrency: CryptoCurrency? = null, ) \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/network/SdkAmount.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/network/SdkAmount.kt new file mode 100644 index 0000000000..4f2bb40fab --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/network/SdkAmount.kt @@ -0,0 +1,50 @@ +package com.tangem.domain.models.network + +import com.tangem.domain.models.serialization.SerializedBigDecimal +import kotlinx.serialization.Serializable + +/** + * Domain mirror of the blockchain SDK `Amount`, kept [Serializable] so it can be carried inside the serializable + * [TxInfo] graph (the SDK `Amount` is not serializable and pulls in blockchain-specific types). + * + * Holds a monetary value together with the metadata needed to display it. Compared to the SDK model it drops + * `maxValue` (irrelevant outside of "send" flows) and keeps only the currency identity on [SdkAmountType]. + * + * @property currencySymbol display symbol of the currency (e.g. `ETH`, `USDT`) + * @property value amount value; `null` when the value is unknown + * @property decimals number of decimals of the currency + * @property type kind of currency the amount is denominated in + */ +@Serializable +data class SdkAmount( + val currencySymbol: String, + val value: SerializedBigDecimal? = null, + val decimals: Int, + val type: SdkAmountType = SdkAmountType.Coin, +) + +/** Kind of currency an [SdkAmount] is denominated in. Mirrors the SDK `AmountType`. */ +@Serializable +sealed interface SdkAmountType { + + /** Native coin of the blockchain. */ + @Serializable + data object Coin : SdkAmountType + + /** Native coin used as a reserve currency for fee calculation (e.g. Algorand). */ + @Serializable + data object Reserve : SdkAmountType + + /** A resource that can be spent to pay the fee (e.g. Mana on Koinos). */ + @Serializable + data class FeeResource(val name: String? = null) : SdkAmountType + + /** + * A token of the blockchain. + * + * @property contractAddress token contract address + * @property id backend currency id, when known + */ + @Serializable + data class Token(val contractAddress: String, val id: String? = null) : SdkAmountType +} \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/network/TxInfo.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/network/TxInfo.kt index 2e0c0b37b7..83e0036176 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/network/TxInfo.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/network/TxInfo.kt @@ -15,6 +15,7 @@ import kotlinx.serialization.Serializable * @property status transaction status * @property type transaction type * @property amount transaction amount + * @property fee transaction fee */ @Serializable data class TxInfo( @@ -27,6 +28,7 @@ data class TxInfo( val status: TransactionStatus, val type: TransactionType, val amount: SerializedBigDecimal, + val fee: SdkAmount? = null, ) { /** Destination type*/ From 116940622331e96ee0d3da24bb6d7228f026bc97 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 23 Jun 2026 14:42:27 +0100 Subject: [PATCH 023/210] Updated on 2026-08-14 --- .../crypto/AddressBookCipherTest.kt | 48 +++++++++++++++++-- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/crypto/AddressBookCipherTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/crypto/AddressBookCipherTest.kt index faff49d72f..078caec724 100644 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/crypto/AddressBookCipherTest.kt +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/crypto/AddressBookCipherTest.kt @@ -221,20 +221,42 @@ internal class AddressBookCipherTest { assertThat(result.leftValue()).isEqualTo(AddressBookCryptoError.NoWalletPublicKey) } + // region cross-platform vectors + // Shared known-answer vector, identical to iOS CommonAddressBookEncryptionServiceTests. Asserting the + // same bytes on both platforms guarantees a blob sealed on one opens on the other. Do not change these + // constants without changing the iOS suite in lockstep. + @Test - fun `GIVEN fixed public key WHEN deriveAesKey THEN matches the locked HMAC-SHA256 vector`() { - // Arrange — independently computed: HMAC-SHA256(SHA-256([01,02,03,04]), "TokensSymmetricKey") - val publicKey = byteArrayOf(0x01, 0x02, 0x03, 0x04) - val expected = "da48094b89902e137ae73ae90acbd809af9ad4f648044c17e7ee6de73e96b0c2" + fun `GIVEN shared cross-platform public key WHEN deriveAesKey THEN matches the iOS vector`() { + // Arrange + val publicKey = VECTOR_PUBLIC_KEY_HEX.hexToBytes() // Act val aesKey = AddressBookKeyDerivation.deriveAesKey(publicKey) // Assert assertThat(aesKey).hasLength(AES_256_KEY_BYTES) - assertThat(aesKey.toHexString().lowercase()).isEqualTo(expected) + assertThat(aesKey.toHexString().lowercase()).isEqualTo(VECTOR_KEY_HEX) } + @Test + fun `GIVEN a blob sealed on the other platform WHEN decrypt with the derived key THEN restores the plaintext`() { + // Arrange — open the iOS-produced AES-256-GCM box with the key derived from the shared seed + val aesKey = AddressBookKeyDerivation.deriveAesKey(VECTOR_PUBLIC_KEY_HEX.hexToBytes()) + + // Act + val plaintext = aesGcmOpen( + key = aesKey, + nonce = VECTOR_NONCE_HEX.hexToBytes(), + ciphertext = VECTOR_CIPHERTEXT_HEX.hexToBytes(), + authTag = VECTOR_TAG_HEX.hexToBytes(), + ) + + // Assert + assertThat(plaintext.toString(Charsets.UTF_8)).isEqualTo(VECTOR_PLAINTEXT) + } + // endregion + // region helpers private fun addressBook(vararg contacts: Contact): AddressBook = AddressBook(walletId = wallet.walletId, contacts = contacts.toList()) @@ -264,6 +286,14 @@ internal class AddressBookCipherTest { private fun String.flipFirstHexNibble(): String = (if (first() == '0') '1' else '0') + substring(1) + private fun String.hexToBytes(): ByteArray = chunked(2).map { it.toInt(16).toByte() }.toByteArray() + + /** Raw AES-256-GCM open, mirroring [AddressBookCipher]'s transformation and tag size. */ + private fun aesGcmOpen(key: ByteArray, nonce: ByteArray, ciphertext: ByteArray, authTag: ByteArray): ByteArray = + Cipher.getInstance("AES/GCM/NoPadding").apply { + init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(TAG_BITS, nonce)) + }.doFinal(ciphertext + authTag) + private fun Either.rightValue(): T = getOrNull() ?: error("Expected Either.Right but was $this") @@ -295,5 +325,13 @@ internal class AddressBookCipherTest { const val NONCE_HEX_LENGTH = NONCE_BYTES * 2 const val TAG_HEX_LENGTH = TAG_BYTES * 2 const val AES_256_KEY_BYTES = 32 + + // Shared cross-platform known-answer vector (see iOS CommonAddressBookEncryptionServiceTests). + const val VECTOR_PUBLIC_KEY_HEX = "0374d0f81f42ddfe34114d533e95e6ae5fe6ea271c96f1fa505199fdc365ae9720" + const val VECTOR_KEY_HEX = "59b85ce53fac0a8493d9d8d9c0d32adb5f586741dd8bbfd9348a3212e493730d" + const val VECTOR_NONCE_HEX = "000102030405060708090a0b" + const val VECTOR_CIPHERTEXT_HEX = "f4ee0f404e747b5b5cca730c44baf86ca3d8f6fbdf66ff2fe98d3b8f88cb23df7ff55b52205f32c8ab" + const val VECTOR_TAG_HEX = "6c4b71b27958f43afc6633850369a17a" + const val VECTOR_PLAINTEXT = "Tangem Address Book cross-platform vector" } } \ No newline at end of file From 3fd67fb474e5b40eb0829d4edac630f07a8a4e30 Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 23 Jun 2026 16:51:05 +0100 Subject: [PATCH 024/210] Updated on 2026-08-14 --- .../tap/di/domain/AddressBookDomainModule.kt | 34 +- .../interactor/SaveContactInteractor.kt | 104 ++++++ .../usecase/AddressEntrySigningPayload.kt | 2 +- .../usecase/CreateContactUseCase.kt | 57 ---- .../usecase/SignAddressEntriesUseCase.kt | 41 --- .../usecase/UpdateContactUseCase.kt | 43 --- .../interactor/SaveContactInteractorTest.kt | 318 ++++++++++++++++++ .../usecase/CreateContactUseCaseTest.kt | 164 --------- .../usecase/SignAddressEntriesUseCaseTest.kt | 148 -------- .../usecase/UpdateContactUseCaseTest.kt | 145 -------- 10 files changed, 429 insertions(+), 627 deletions(-) create mode 100644 domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/interactor/SaveContactInteractor.kt delete mode 100644 domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCase.kt delete mode 100644 domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/SignAddressEntriesUseCase.kt delete mode 100644 domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCase.kt create mode 100644 domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/interactor/SaveContactInteractorTest.kt delete mode 100644 domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCaseTest.kt delete mode 100644 domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/SignAddressEntriesUseCaseTest.kt delete mode 100644 domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCaseTest.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 07e8299d97..d16d61ed8a 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 @@ -2,14 +2,12 @@ package com.tangem.tap.di.domain import com.tangem.domain.addressbook.crypto.AddressBookCipher import com.tangem.domain.addressbook.interactor.GetVerifiedContactsInteractor +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.CreateContactUseCase import com.tangem.domain.addressbook.usecase.DeleteContactUseCase import com.tangem.domain.addressbook.usecase.GetContactsUseCase -import com.tangem.domain.addressbook.usecase.SignAddressEntriesUseCase -import com.tangem.domain.addressbook.usecase.UpdateContactUseCase import com.tangem.domain.addressbook.usecase.ValidateContactAddressUseCase import com.tangem.domain.addressbook.usecase.ValidateContactNameUseCase import com.tangem.domain.common.wallets.UserWalletsListRepository @@ -39,12 +37,6 @@ object AddressBookDomainModule { ) } - @Provides - @Singleton - fun provideSignAddressEntriesUseCase(signUseCase: SignUseCase): SignAddressEntriesUseCase { - return SignAddressEntriesUseCase(signUseCase = signUseCase) - } - @Provides @Singleton fun provideValidateContactNameUseCase(repository: AddressBookRepository): ValidateContactNameUseCase { @@ -73,30 +65,16 @@ object AddressBookDomainModule { @Provides @Singleton - fun provideCreateContactUseCase( + fun provideSaveContactInteractor( repository: AddressBookRepository, validateContactNameUseCase: ValidateContactNameUseCase, - signAddressEntriesUseCase: SignAddressEntriesUseCase, + signUseCase: SignUseCase, timestampProvider: IsoTimestampProvider, - ): CreateContactUseCase { - return CreateContactUseCase( + ): SaveContactInteractor { + return SaveContactInteractor( repository = repository, validateContactName = validateContactNameUseCase, - signAddressEntries = signAddressEntriesUseCase, - timestampProvider = timestampProvider, - ) - } - - @Provides - @Singleton - fun provideUpdateContactUseCase( - repository: AddressBookRepository, - signAddressEntriesUseCase: SignAddressEntriesUseCase, - timestampProvider: IsoTimestampProvider, - ): UpdateContactUseCase { - return UpdateContactUseCase( - repository = repository, - signAddressEntries = signAddressEntriesUseCase, + signUseCase = signUseCase, timestampProvider = timestampProvider, ) } diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/interactor/SaveContactInteractor.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/interactor/SaveContactInteractor.kt new file mode 100644 index 0000000000..4bcd36a802 --- /dev/null +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/interactor/SaveContactInteractor.kt @@ -0,0 +1,104 @@ +package com.tangem.domain.addressbook.interactor + +import arrow.core.Either +import arrow.core.raise.either +import com.tangem.domain.addressbook.error.ContactNameValidationError +import com.tangem.domain.addressbook.error.SaveContactError +import com.tangem.domain.addressbook.model.AddressEntry +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.addressbook.time.IsoTimestampProvider +import com.tangem.domain.addressbook.usecase.ValidateContactNameUseCase +import com.tangem.domain.addressbook.usecase.buildAddressEntryPayload +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.transaction.error.SignHashesError +import com.tangem.domain.transaction.usecase.SignUseCase +import com.tangem.domain.transaction.usecase.primarySecp256k1PublicKey +import com.tangem.utils.extensions.toHexString +import java.security.MessageDigest +import java.util.UUID + +class SaveContactInteractor( + private val repository: AddressBookRepository, + private val validateContactName: ValidateContactNameUseCase, + private val signUseCase: SignUseCase, + private val timestampProvider: IsoTimestampProvider, +) { + + suspend fun createContact( + userWallet: UserWallet, + name: String, + iconColor: String, + addressEntries: List, + ): Either = either { + val userWalletId = userWallet.walletId + val validName = validateContactName(userWalletId, name) + .mapLeft(SaveContactError::Name) + .bind() + + val now = timestampProvider.now() + val contact = Contact( + id = ContactId(UUID.randomUUID().toString()), + walletId = userWalletId, + name = validName, + icon = "", + iconColor = iconColor, + createdAt = now, + updatedAt = now, + addressEntries = addressEntries, + ) + val signed = signAddressEntries(userWallet, contact) + .mapLeft(SaveContactError::Signing) + .bind() + repository.saveContact(signed) + signed + } + + suspend fun updateContact( + userWallet: UserWallet, + contact: Contact, + name: String, + iconColor: String, + addressEntries: List, + ): Either = either { + val validName = ContactName(name) + .mapLeft { SaveContactError.Name(ContactNameValidationError.Format(it)) } + .bind() + + val updated = contact.copy( + name = validName, + iconColor = iconColor, + addressEntries = addressEntries, + updatedAt = timestampProvider.now(), + ) + val signed = signAddressEntries(userWallet, updated) + .mapLeft(SaveContactError::Signing) + .bind() + repository.saveContact(signed) + signed + } + + private suspend fun signAddressEntries( + userWallet: UserWallet, + contact: Contact, + ): Either = either { + val entries = contact.addressEntries + if (entries.isEmpty()) return@either contact + + val publicKey = userWallet.primarySecp256k1PublicKey() ?: raise(SignHashesError.NoSigningKey) + val hashes = entries.map { entry -> hashEntry(contact, entry) } + val signatures = signUseCase(hashes = hashes, publicKey = publicKey, userWallet = userWallet).bind() + + val signedEntries = entries.mapIndexed { index, entry -> + entry.copy(signature = signatures[index].toHexString()) + } + contact.copy(addressEntries = signedEntries) + } + + private fun hashEntry(contact: Contact, entry: AddressEntry): ByteArray { + val payload = buildAddressEntryPayload(contact, entry) + return MessageDigest.getInstance("SHA-256").digest(payload) + } +} \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/AddressEntrySigningPayload.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/AddressEntrySigningPayload.kt index fedf05098a..cd0101bf0e 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/AddressEntrySigningPayload.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/AddressEntrySigningPayload.kt @@ -7,7 +7,7 @@ import com.tangem.domain.addressbook.model.Contact * Builds the canonical bytes that are signed for a single [AddressEntry]: * `address + networkId + memo + contactId + name`. * - * Shared by [SignAddressEntriesUseCase] (which hashes and signs it) and `GetVerifiedContactsInteractor` + * Shared by `SaveContactInteractor` (which hashes and signs it) and `GetVerifiedContactsInteractor` * (which verifies the signature against it), so the signed and verified payloads can never diverge. */ internal fun buildAddressEntryPayload(contact: Contact, entry: AddressEntry): ByteArray { diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCase.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCase.kt deleted file mode 100644 index 3cab6232ed..0000000000 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCase.kt +++ /dev/null @@ -1,57 +0,0 @@ -package com.tangem.domain.addressbook.usecase - -import arrow.core.Either -import arrow.core.raise.either -import com.tangem.domain.addressbook.error.SaveContactError -import com.tangem.domain.addressbook.model.AddressEntry -import com.tangem.domain.addressbook.model.Contact -import com.tangem.domain.addressbook.model.ContactId -import com.tangem.domain.addressbook.repository.AddressBookRepository -import com.tangem.domain.addressbook.time.IsoTimestampProvider -import com.tangem.domain.models.network.Network -import com.tangem.domain.models.wallet.UserWallet -import java.util.UUID - -/** - * Creates a new [Contact] with client-generated UUID v4 ids. The name must be valid and unique - - * the current time. Every address entry is signed with [userWallet]'s key before the contact is - * persisted, so only signed contacts are ever stored. - */ -class CreateContactUseCase( - private val repository: AddressBookRepository, - private val validateContactName: ValidateContactNameUseCase, - private val signAddressEntries: SignAddressEntriesUseCase, - private val timestampProvider: IsoTimestampProvider, -) { - - suspend operator fun invoke( - userWallet: UserWallet, - name: String, - iconColor: String, - network: Network, - addressEntries: List, - ): Either = either { - val userWalletId = userWallet.walletId - val validName = validateContactName(userWalletId, name) - .mapLeft(SaveContactError::Name) - .bind() - - val now = timestampProvider.now() - val contact = Contact( - id = ContactId(UUID.randomUUID().toString()), - walletId = userWalletId, - name = validName, - icon = "", - iconColor = iconColor, - createdAt = now, - updatedAt = now, - addressEntries = addressEntries, - ) - val signed = signAddressEntries(userWallet, contact) - .mapLeft(SaveContactError::Signing) - .bind() - repository.saveContact(signed) - signed - } -} \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/SignAddressEntriesUseCase.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/SignAddressEntriesUseCase.kt deleted file mode 100644 index 525745b768..0000000000 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/SignAddressEntriesUseCase.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.tangem.domain.addressbook.usecase - -import arrow.core.Either -import arrow.core.raise.either -import com.tangem.domain.addressbook.model.AddressEntry -import com.tangem.domain.addressbook.model.Contact -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.transaction.error.SignHashesError -import com.tangem.domain.transaction.usecase.SignUseCase -import com.tangem.domain.transaction.usecase.primarySecp256k1PublicKey -import com.tangem.utils.extensions.toHexString -import java.security.MessageDigest - -/** - * Signs every [AddressEntry] of a [Contact] with the wallet's primary secp256k1 key in a single - * signing session (one card tap). Each entry is hashed as `SHA-256(address + networkId + memo + - * contactId + name)` and the produced signature is stored back into [AddressEntry.signature]. - */ -class SignAddressEntriesUseCase( - private val signUseCase: SignUseCase, -) { - - suspend operator fun invoke(userWallet: UserWallet, contact: Contact): Either = either { - val entries = contact.addressEntries - if (entries.isEmpty()) return@either contact - - val publicKey = userWallet.primarySecp256k1PublicKey() ?: raise(SignHashesError.NoSigningKey) - val hashes = entries.map { entry -> hashEntry(contact, entry) } - val signatures = signUseCase(hashes = hashes, publicKey = publicKey, userWallet = userWallet).bind() - - val signedEntries = entries.mapIndexed { index, entry -> - entry.copy(signature = signatures[index].toHexString()) - } - contact.copy(addressEntries = signedEntries) - } - - private fun hashEntry(contact: Contact, entry: AddressEntry): ByteArray { - val payload = buildAddressEntryPayload(contact, entry) - return MessageDigest.getInstance("SHA-256").digest(payload) - } -} \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCase.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCase.kt deleted file mode 100644 index 113a870f0f..0000000000 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCase.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.tangem.domain.addressbook.usecase - -import arrow.core.Either -import arrow.core.raise.either -import com.tangem.domain.addressbook.error.ContactNameValidationError -import com.tangem.domain.addressbook.error.SaveContactError -import com.tangem.domain.addressbook.model.AddressEntry -import com.tangem.domain.addressbook.model.Contact -import com.tangem.domain.addressbook.model.ContactName -import com.tangem.domain.addressbook.repository.AddressBookRepository -import com.tangem.domain.addressbook.time.IsoTimestampProvider -import com.tangem.domain.models.wallet.UserWallet - -class UpdateContactUseCase( - private val repository: AddressBookRepository, - private val signAddressEntries: SignAddressEntriesUseCase, - private val timestampProvider: IsoTimestampProvider, -) { - - suspend operator fun invoke( - userWallet: UserWallet, - contact: Contact, - name: String, - iconColor: String, - addressEntries: List, - ): Either = either { - val validName = ContactName(name) - .mapLeft { SaveContactError.Name(ContactNameValidationError.Format(it)) } - .bind() - - val updated = contact.copy( - name = validName, - iconColor = iconColor, - addressEntries = addressEntries, - updatedAt = timestampProvider.now(), - ) - val signed = signAddressEntries(userWallet, updated) - .mapLeft(SaveContactError::Signing) - .bind() - repository.saveContact(signed) - signed - } -} \ No newline at end of file diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/interactor/SaveContactInteractorTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/interactor/SaveContactInteractorTest.kt new file mode 100644 index 0000000000..961e8fb25f --- /dev/null +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/interactor/SaveContactInteractorTest.kt @@ -0,0 +1,318 @@ +package com.tangem.domain.addressbook.interactor + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.common.test.domain.wallet.MockUserWalletFactory +import com.tangem.domain.addressbook.error.ContactNameValidationError +import com.tangem.domain.addressbook.error.SaveContactError +import com.tangem.domain.addressbook.model.AddressEntry +import com.tangem.domain.addressbook.model.AddressEntryId +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.addressbook.time.IsoTimestampProvider +import com.tangem.domain.addressbook.usecase.ValidateContactNameUseCase +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.transaction.error.SignHashesError +import com.tangem.domain.transaction.usecase.SignUseCase +import com.tangem.utils.extensions.toHexString +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.security.MessageDigest + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class SaveContactInteractorTest { + + private val repository: AddressBookRepository = mockk(relaxUnitFun = true) + private val signUseCase: SignUseCase = mockk() + private val timestampProvider: IsoTimestampProvider = mockk { + every { now() } returns NEW_TIMESTAMP + } + private val interactor = SaveContactInteractor( + repository = repository, + validateContactName = ValidateContactNameUseCase(repository), + signUseCase = signUseCase, + timestampProvider = timestampProvider, + ) + + // MockUserWalletFactory builds each wallet key with publicKey = curve.name bytes → secp256k1 key is "Secp256k1" + private val userWallet: UserWallet = MockUserWalletFactory.create() + private val secp256k1Key = "Secp256k1".toByteArray() + private val networkRawId = Network.RawID("ethereum") + + @BeforeEach + fun resetMocks() { + clearMocks(repository, signUseCase, answers = false) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class CreateContact { + + private val entries = listOf(entry(id = "addr-1", address = "0xabc", memo = "memo")) + + @Test + fun `GIVEN unique name WHEN createContact THEN generates ids AND persists the signed contact`() = runTest { + // Arrange + stubNoExistingContacts() + val signatures = listOf(byteArrayOf(0x01, 0xAB.toByte())) + coEvery { signUseCase(hashes = any(), publicKey = any(), userWallet = eq(userWallet)) } returns + signatures.right() + val saved = slot() + coEvery { repository.saveContact(capture(saved)) } returns Unit + + // Act + val result = interactor.createContact(userWallet, name = "Alice", iconColor = "TestColor", entries) + + // Assert + val contact = result.getOrNull() + assertThat(contact).isEqualTo(saved.captured) + assertThat(contact!!.walletId).isEqualTo(userWallet.walletId) + assertThat(contact.name.value).isEqualTo("Alice") + assertThat(contact.id.value).isNotEmpty() + assertThat(contact.createdAt).isEqualTo(NEW_TIMESTAMP) + assertThat(contact.updatedAt).isEqualTo(NEW_TIMESTAMP) + assertThat(contact.addressEntries.map { it.signature }) + .containsExactly(signatures[0].toHexString()) + } + + @Test + fun `GIVEN entries WHEN createContact THEN signs each with the wallet key over the canonical payload`() = + runTest { + // Arrange + stubNoExistingContacts() + val twoEntries = listOf( + entry(id = "addr-1", address = "0xabc", memo = "memo"), + entry(id = "addr-2", address = "0xdef", memo = null), + ) + val signatures = listOf(byteArrayOf(0x01, 0xAB.toByte()), byteArrayOf(0xCD.toByte())) + val hashesSlot = slot>() + val publicKeySlot = slot() + coEvery { + signUseCase(hashes = capture(hashesSlot), publicKey = capture(publicKeySlot), userWallet = eq(userWallet)) + } returns signatures.right() + val saved = slot() + coEvery { repository.saveContact(capture(saved)) } returns Unit + + // Act + interactor.createContact(userWallet, name = "Alice", iconColor = "TestColor", twoEntries) + + // Assert + assertThat(publicKeySlot.captured).isEqualTo(secp256k1Key) + val persisted = saved.captured + assertThat(hashesSlot.captured.map { it.toHexString() }) + .containsExactly( + expectedHash(persisted, twoEntries[0]).toHexString(), + expectedHash(persisted, twoEntries[1]).toHexString(), + ) + .inOrder() + assertThat(persisted.addressEntries.map { it.signature }) + .containsExactly(signatures[0].toHexString(), signatures[1].toHexString()) + .inOrder() + } + + @Test + fun `GIVEN no entries WHEN createContact THEN persists without signing`() = runTest { + // Arrange + stubNoExistingContacts() + val saved = slot() + coEvery { repository.saveContact(capture(saved)) } returns Unit + + // Act + val result = interactor.createContact(userWallet, name = "Alice", iconColor = "TestColor", emptyList()) + + // Assert + assertThat(result.getOrNull()).isEqualTo(saved.captured) + assertThat(saved.captured.addressEntries).isEmpty() + coVerify(exactly = 0) { signUseCase(any>(), any(), any()) } + } + + @Test + fun `GIVEN wallet without a secp256k1 key WHEN createContact THEN Signing NoSigningKey without persisting`() = + runTest { + // Arrange — a locked hot wallet exposes no key; validation must still pass first + val lockedWallet = mockk { + every { walletId } returns userWallet.walletId + every { wallets } returns null + } + stubNoExistingContacts() + + // Act + val result = interactor.createContact(lockedWallet, name = "Alice", iconColor = "TestColor", entries) + + // Assert + assertThat(result.leftOrNull()) + .isEqualTo(SaveContactError.Signing(SignHashesError.NoSigningKey)) + coVerify(exactly = 0) { repository.saveContact(any()) } + } + + @Test + fun `GIVEN signUseCase fails WHEN createContact THEN propagates Signing error without persisting`() = runTest { + // Arrange + stubNoExistingContacts() + coEvery { signUseCase(hashes = any(), publicKey = any(), userWallet = any()) } returns + SignHashesError.SigningFailed(message = "canceled").left() + + // Act + val result = interactor.createContact(userWallet, name = "Alice", iconColor = "TestColor", entries) + + // Assert + assertThat(result.leftOrNull()) + .isEqualTo(SaveContactError.Signing(SignHashesError.SigningFailed(message = "canceled"))) + coVerify(exactly = 0) { repository.saveContact(any()) } + } + + @Test + fun `GIVEN duplicate name WHEN createContact THEN Name Duplicate without persisting`() = runTest { + // Arrange + every { repository.getContacts(userWallet.walletId) } returns flowOf(listOf(contact(name = "Alice"))) + + // Act + val result = interactor.createContact(userWallet, name = "alice", iconColor = "TestColor", entries) + + // Assert + assertThat(result.leftOrNull()) + .isEqualTo(SaveContactError.Name(ContactNameValidationError.Duplicate)) + coVerify(exactly = 0) { repository.saveContact(any()) } + } + + @Test + fun `GIVEN blank name WHEN createContact THEN Name Format without persisting`() = runTest { + // Arrange + stubNoExistingContacts() + + // Act + val result = interactor.createContact(userWallet, name = "", iconColor = "TestColor", entries) + + // Assert + assertThat(result.leftOrNull()) + .isEqualTo(SaveContactError.Name(ContactNameValidationError.Format(ContactName.Error.Empty))) + coVerify(exactly = 0) { repository.saveContact(any()) } + } + + private fun stubNoExistingContacts() { + every { repository.getContacts(userWallet.walletId) } returns flowOf(emptyList()) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class UpdateContact { + + private val updatedEntries = listOf(entry(id = "addr-new", address = "0xnew", memo = "memo")) + + @Test + fun `GIVEN existing contact WHEN updateContact THEN preserves id AND restamps AND persists without uniqueness check`() = + runTest { + // Arrange + val existing = contact(name = "Alice") + val signatures = listOf(byteArrayOf(0x01, 0xAB.toByte())) + coEvery { signUseCase(hashes = any(), publicKey = any(), userWallet = eq(userWallet)) } returns + signatures.right() + val saved = slot() + coEvery { repository.saveContact(capture(saved)) } returns Unit + + // Act + val result = interactor.updateContact( + userWallet = userWallet, + contact = existing, + name = "Bob", + iconColor = "TestColor", + addressEntries = updatedEntries, + ) + + // Assert + val contact = result.getOrNull() + assertThat(contact).isEqualTo(saved.captured) + assertThat(contact!!.id).isEqualTo(existing.id) + assertThat(contact.name.value).isEqualTo("Bob") + assertThat(contact.createdAt).isEqualTo(ORIGINAL_TIMESTAMP) + assertThat(contact.updatedAt).isEqualTo(NEW_TIMESTAMP) + assertThat(contact.addressEntries.map { it.signature }) + .containsExactly(signatures[0].toHexString()) + coVerify(exactly = 0) { repository.getContacts(any()) } + } + + @Test + fun `GIVEN signUseCase fails WHEN updateContact THEN propagates Signing error without persisting`() = runTest { + // Arrange + coEvery { signUseCase(hashes = any(), publicKey = any(), userWallet = any()) } returns + SignHashesError.NoSigningKey.left() + + // Act + val result = interactor.updateContact( + userWallet = userWallet, + contact = contact(name = "Alice"), + name = "Bob", + iconColor = "TestColor", + addressEntries = updatedEntries, + ) + + // Assert + assertThat(result.leftOrNull()).isEqualTo(SaveContactError.Signing(SignHashesError.NoSigningKey)) + coVerify(exactly = 0) { repository.saveContact(any()) } + } + + @Test + fun `GIVEN blank name WHEN updateContact THEN Name Format without persisting`() = runTest { + // Act + val result = interactor.updateContact( + userWallet = userWallet, + contact = contact(name = "Alice"), + name = "", + iconColor = "TestColor", + addressEntries = updatedEntries, + ) + + // Assert + assertThat(result.leftOrNull()) + .isEqualTo(SaveContactError.Name(ContactNameValidationError.Format(ContactName.Error.Empty))) + coVerify(exactly = 0) { repository.saveContact(any()) } + } + } + + private fun contact(name: String): Contact = Contact( + id = ContactId("id-$name"), + walletId = userWallet.walletId, + name = requireNotNull(ContactName(name).getOrNull()), + icon = "", + iconColor = "TestColor", + createdAt = ORIGINAL_TIMESTAMP, + updatedAt = ORIGINAL_TIMESTAMP, + addressEntries = listOf(entry(id = "addr-$name", address = "0xabc", memo = null)), + ) + + private fun entry(id: String, address: String, memo: String?): AddressEntry = AddressEntry( + id = AddressEntryId(id), + address = address, + networkId = networkRawId, + memo = memo, + signature = "sig", + networkName = "Ethereum", + ) + + private fun expectedHash(contact: Contact, entry: AddressEntry): ByteArray { + val payload = entry.address + entry.networkId.value + entry.memo.orEmpty() + + contact.id.value + contact.name.value + return MessageDigest.getInstance("SHA-256").digest(payload.toByteArray(Charsets.UTF_8)) + } + + private companion object { + const val NEW_TIMESTAMP = "2026-06-10T14:30:00.000Z" + const val ORIGINAL_TIMESTAMP = "2026-01-01T00:00:00.000Z" + } +} \ No newline at end of file diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCaseTest.kt deleted file mode 100644 index cf1d9fba15..0000000000 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/CreateContactUseCaseTest.kt +++ /dev/null @@ -1,164 +0,0 @@ -package com.tangem.domain.addressbook.usecase - -import arrow.core.left -import arrow.core.right -import com.google.common.truth.Truth.assertThat -import com.tangem.domain.addressbook.error.ContactNameValidationError -import com.tangem.domain.addressbook.error.SaveContactError -import com.tangem.domain.addressbook.model.* -import com.tangem.domain.addressbook.repository.AddressBookRepository -import com.tangem.domain.addressbook.time.IsoTimestampProvider -import com.tangem.domain.models.network.Network -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.transaction.error.SignHashesError -import io.mockk.* -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 CreateContactUseCaseTest { - - private val repository: AddressBookRepository = mockk(relaxUnitFun = true) - private val expectedTimestamp = "2026-06-10T14:30:00.000Z" - private val timestampProvider: IsoTimestampProvider = mockk { - every { now() } returns expectedTimestamp - } - private val signAddressEntries: SignAddressEntriesUseCase = mockk() - private val useCase = CreateContactUseCase( - repository = repository, - validateContactName = ValidateContactNameUseCase(repository), - signAddressEntries = signAddressEntries, - timestampProvider = timestampProvider, - ) - - private val walletId = UserWalletId("011") - private val userWallet: UserWallet = mockk { every { walletId } returns this@CreateContactUseCaseTest.walletId } - private val networkRawId = Network.RawID("ethereum") - private val networkId = Network.ID(value = "ethereum", derivationPath = Network.DerivationPath.None) - private val network: Network = mockk { every { id } returns networkId } - - private val addressEntries = listOf( - AddressEntry( - id = AddressEntryId("addr-1"), - address = "0xabc", - networkId = networkRawId, - memo = "memo", - signature = "sig", - networkName = "Ethereum", - ), - ) - - private val signedEntries = listOf(addressEntries.first().copy(signature = "signed")) - - @BeforeEach - fun resetMocks() { - clearMocks(repository, signAddressEntries) - // Sign returns the contact with signed entries; the persisted contact must be the signed one. - coEvery { signAddressEntries(eq(userWallet), any()) } answers { - secondArg().copy(addressEntries = signedEntries).right() - } - } - - @Test - fun `create generates ids and persists the signed contact`() = runTest { - every { repository.getContacts(walletId) } returns flowOf(emptyList()) - val saved = slot() - coEvery { repository.saveContact(capture(saved)) } returns Unit - - val result = useCase( - userWallet = userWallet, - name = "Alice", - iconColor = "TestColor", - network = network, - addressEntries = addressEntries, - ) - - val contact = result.getOrNull() - assertThat(contact).isEqualTo(saved.captured) - assertThat(contact!!.walletId).isEqualTo(walletId) - assertThat(contact.name.value).isEqualTo("Alice") - assertThat(contact.id.value).isNotEmpty() - assertThat(contact.addressEntries).isEqualTo(signedEntries) - assertThat(contact.createdAt).isEqualTo(expectedTimestamp) - assertThat(contact.updatedAt).isEqualTo(expectedTimestamp) - } - - @Test - fun `signing failure fails without persisting`() = runTest { - every { repository.getContacts(walletId) } returns flowOf(emptyList()) - coEvery { signAddressEntries(eq(userWallet), any()) } returns SignHashesError.NoSigningKey.left() - - val result = useCase( - userWallet = userWallet, - name = "Alice", - iconColor = "TestColor", - network = network, - addressEntries = addressEntries, - ) - - assertThat(result.leftOrNull()).isEqualTo(SaveContactError.Signing(SignHashesError.NoSigningKey)) - coVerify(exactly = 0) { repository.saveContact(any()) } - } - - @Test - fun `duplicate name fails without persisting`() = runTest { - every { repository.getContacts(walletId) } returns flowOf( - listOf( - contact(name = "Alice", iconColor = "TestColor") - ) - ) - - val result = useCase( - userWallet = userWallet, - name = "alice", - iconColor = "TestColor", - network = network, - addressEntries = addressEntries, - ) - - assertThat(result.leftOrNull()) - .isEqualTo(SaveContactError.Name(ContactNameValidationError.Duplicate)) - coVerify(exactly = 0) { repository.saveContact(any()) } - } - - @Test - fun `invalid name fails without persisting`() = runTest { - every { repository.getContacts(walletId) } returns flowOf(emptyList()) - - val result = useCase( - userWallet = userWallet, - name = "", - iconColor = "TestColor", - network = network, - addressEntries = addressEntries, - ) - - assertThat(result.leftOrNull()) - .isEqualTo(SaveContactError.Name(ContactNameValidationError.Format(ContactName.Error.Empty))) - coVerify(exactly = 0) { repository.saveContact(any()) } - } - - private fun contact(name: String, iconColor: String): Contact = Contact( - id = ContactId("id-$name"), - walletId = walletId, - name = requireNotNull(ContactName(name).getOrNull()), - icon = "", - iconColor = iconColor, - createdAt = expectedTimestamp, - updatedAt = expectedTimestamp, - addressEntries = listOf( - AddressEntry( - id = AddressEntryId("addr-$name"), - address = "0xabc", - networkId = networkRawId, - memo = null, - signature = "sig", - networkName = "Ethereum", - ), - ), - ) -} \ No newline at end of file diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/SignAddressEntriesUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/SignAddressEntriesUseCaseTest.kt deleted file mode 100644 index c4db011355..0000000000 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/SignAddressEntriesUseCaseTest.kt +++ /dev/null @@ -1,148 +0,0 @@ -package com.tangem.domain.addressbook.usecase - -import arrow.core.left -import arrow.core.right -import com.google.common.truth.Truth.assertThat -import com.tangem.common.test.domain.wallet.MockUserWalletFactory -import com.tangem.domain.addressbook.model.AddressEntry -import com.tangem.domain.addressbook.model.AddressEntryId -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.models.network.Network -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.transaction.error.SignHashesError -import com.tangem.domain.transaction.usecase.SignUseCase -import com.tangem.utils.extensions.toHexString -import io.mockk.clearMocks -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.TestInstance -import java.security.MessageDigest - -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -class SignAddressEntriesUseCaseTest { - - private val signUseCase: SignUseCase = mockk() - private val useCase = SignAddressEntriesUseCase(signUseCase = signUseCase) - - // The mock factory builds each wallet key with publicKey = curve.name bytes, so the secp256k1 key is "Secp256k1" - private val userWallet: UserWallet = MockUserWalletFactory.create() - private val secp256k1Key = "Secp256k1".toByteArray() - - @BeforeEach - fun resetMocks() { - clearMocks(signUseCase) - } - - @Test - fun `GIVEN contact with entries WHEN invoke THEN every entry receives its signature`() = runTest { - // Arrange - val contact = contact( - entry(id = "addr-1", address = "0xabc", memo = "memo"), - entry(id = "addr-2", address = "0xdef", memo = null), - ) - val signatures = listOf(byteArrayOf(0x01, 0xAB.toByte()), byteArrayOf(0xCD.toByte())) - val hashesSlot = slot>() - val publicKeySlot = slot() - coEvery { - signUseCase(hashes = capture(hashesSlot), publicKey = capture(publicKeySlot), userWallet = eq(userWallet)) - } returns signatures.right() - - // Act - val result = useCase(userWallet, contact) - - // Assert - // Signatures are applied in entry order, hex-encoded; all other fields are preserved - val expected = contact.copy( - addressEntries = listOf( - contact.addressEntries[0].copy(signature = signatures[0].toHexString()), - contact.addressEntries[1].copy(signature = signatures[1].toHexString()), - ), - ) - assertThat(result.getOrNull()).isEqualTo(expected) - // The wallet's primary secp256k1 key is the one signing - assertThat(publicKeySlot.captured).isEqualTo(secp256k1Key) - // Each entry is hashed as SHA-256(address + networkId + memo + contactId + name), in order - assertThat(hashesSlot.captured.map { it.toHexString() }) - .containsExactly( - expectedHash(contact, contact.addressEntries[0]).toHexString(), - expectedHash(contact, contact.addressEntries[1]).toHexString(), - ) - .inOrder() - } - - @Test - fun `GIVEN contact with no entries WHEN invoke THEN returns contact unchanged without signing`() = runTest { - // Arrange - val contact = contact() - - // Act - val result = useCase(userWallet, contact) - - // Assert - assertThat(result.getOrNull()).isEqualTo(contact) - coVerify(exactly = 0) { signUseCase(any>(), any(), any()) } - } - - @Test - fun `GIVEN wallet without a secp256k1 key WHEN invoke THEN returns NoSigningKey without signing`() = runTest { - // Arrange — a locked hot wallet exposes no key - val lockedWallet = mockk { every { wallets } returns null } - val contact = contact(entry(id = "addr-1", address = "0xabc", memo = null)) - - // Act - val result = useCase(lockedWallet, contact) - - // Assert - assertThat(result.leftOrNull()).isEqualTo(SignHashesError.NoSigningKey) - coVerify(exactly = 0) { signUseCase(any>(), any(), any()) } - } - - @Test - fun `GIVEN signUseCase returns error WHEN invoke THEN propagates the error`() = runTest { - // Arrange - val contact = contact(entry(id = "addr-1", address = "0xabc", memo = null)) - coEvery { signUseCase(any>(), any(), any()) } returns - SignHashesError.SigningFailed(message = "canceled").left() - - // Act - val result = useCase(userWallet, contact) - - // Assert - assertThat(result.leftOrNull()).isEqualTo(SignHashesError.SigningFailed(message = "canceled")) - } - - private fun contact(vararg entries: AddressEntry): Contact = Contact( - id = ContactId("contact-1"), - walletId = UserWalletId("011"), - name = requireNotNull(ContactName("Alice").getOrNull()), - icon = "", - iconColor = "KekColor", - createdAt = "2026-01-01T00:00:00.000Z", - updatedAt = "2026-01-01T00:00:00.000Z", - addressEntries = entries.toList(), - ) - - private fun entry(id: String, address: String, memo: String?): AddressEntry = AddressEntry( - id = AddressEntryId(id), - address = address, - networkId = Network.RawID("ethereum"), - memo = memo, - signature = "", - networkName = "Ethereum", - ) - - private fun expectedHash(contact: Contact, entry: AddressEntry): ByteArray { - val payload = entry.address + entry.networkId.value + entry.memo.orEmpty() + - contact.id.value + contact.name.value - return MessageDigest.getInstance("SHA-256").digest(payload.toByteArray(Charsets.UTF_8)) - } -} \ No newline at end of file diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCaseTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCaseTest.kt deleted file mode 100644 index 6fb2cfe6aa..0000000000 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/usecase/UpdateContactUseCaseTest.kt +++ /dev/null @@ -1,145 +0,0 @@ -package com.tangem.domain.addressbook.usecase - -import arrow.core.left -import arrow.core.right -import com.google.common.truth.Truth.assertThat -import com.tangem.domain.addressbook.error.ContactNameValidationError -import com.tangem.domain.addressbook.error.SaveContactError -import com.tangem.domain.addressbook.model.AddressEntry -import com.tangem.domain.addressbook.model.AddressEntryId -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.addressbook.time.IsoTimestampProvider -import com.tangem.domain.models.network.Network -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.transaction.error.SignHashesError -import io.mockk.clearMocks -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot -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 UpdateContactUseCaseTest { - - private val repository: AddressBookRepository = mockk(relaxUnitFun = true) - private val newTimestamp = "2026-06-10T14:30:00.000Z" - private val originalTimestamp = "2026-01-01T00:00:00.000Z" - private val timestampProvider: IsoTimestampProvider = mockk { - every { now() } returns newTimestamp - } - private val signAddressEntries: SignAddressEntriesUseCase = mockk() - private val useCase = UpdateContactUseCase( - repository = repository, - signAddressEntries = signAddressEntries, - timestampProvider = timestampProvider, - ) - - private val walletId = UserWalletId("011") - private val userWallet: UserWallet = mockk { every { walletId } returns this@UpdateContactUseCaseTest.walletId } - private val networkRawId = Network.RawID("ethereum") - - private val updatedEntries = listOf( - AddressEntry( - id = AddressEntryId("addr-new"), - address = "0xnew", - networkId = networkRawId, - memo = "memo", - signature = "sig2", - networkName = "Ethereum", - ), - ) - - private val signedEntries = listOf(updatedEntries.first().copy(signature = "signed")) - - @BeforeEach - fun resetMocks() { - clearMocks(repository, signAddressEntries) - coEvery { signAddressEntries(eq(userWallet), any()) } answers { - secondArg().copy(addressEntries = signedEntries).right() - } - } - - @Test - fun `update preserves id and persists signed changes without checking uniqueness`() = runTest { - val existing = contact(name = "Alice", iconColor = "TestColor") - val saved = slot() - coEvery { repository.saveContact(capture(saved)) } returns Unit - - val result = useCase( - userWallet = userWallet, - contact = existing, - name = "Bob", - iconColor = "TestColor", - addressEntries = updatedEntries, - ) - - val contact = result.getOrNull() - assertThat(contact).isEqualTo(saved.captured) - assertThat(contact!!.id).isEqualTo(existing.id) - assertThat(contact.name.value).isEqualTo("Bob") - assertThat(contact.addressEntries).isEqualTo(signedEntries) - assertThat(contact.createdAt).isEqualTo(originalTimestamp) // preserved - assertThat(contact.updatedAt).isEqualTo(newTimestamp) // restamped - coVerify(exactly = 0) { repository.getContacts(any()) } - } - - @Test - fun `signing failure fails without persisting`() = runTest { - coEvery { signAddressEntries(eq(userWallet), any()) } returns SignHashesError.NoSigningKey.left() - - val result = useCase( - userWallet = userWallet, - contact = contact(name = "Alice", iconColor = "TestColor"), - name = "Bob", - iconColor = "TestColor", - addressEntries = updatedEntries, - ) - - assertThat(result.leftOrNull()).isEqualTo(SaveContactError.Signing(SignHashesError.NoSigningKey)) - coVerify(exactly = 0) { repository.saveContact(any()) } - } - - @Test - fun `invalid name fails without persisting`() = runTest { - val result = useCase( - userWallet = userWallet, - contact = contact(name = "Alice", iconColor = "TestColor"), - name = "", - iconColor = "TestColor", - addressEntries = updatedEntries, - ) - - assertThat(result.leftOrNull()) - .isEqualTo(SaveContactError.Name(ContactNameValidationError.Format(ContactName.Error.Empty))) - coVerify(exactly = 0) { repository.saveContact(any()) } - } - - private fun contact(name: String, iconColor: String): Contact = Contact( - id = ContactId("id-$name"), - walletId = walletId, - name = requireNotNull(ContactName(name).getOrNull()), - icon = "", - iconColor = iconColor, - createdAt = originalTimestamp, - updatedAt = originalTimestamp, - addressEntries = listOf( - AddressEntry( - id = AddressEntryId("addr-$name"), - address = "0xabc", - networkId = networkRawId, - memo = null, - signature = "sig", - networkName = "Ethereum", - ), - ), - ) -} \ No newline at end of file From b23b3daf63e132db351f35327c07f0f39e7c0c23 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 24 Jun 2026 10:46:39 +0300 Subject: [PATCH 025/210] Updated on 2026-08-14 --- .../com/tangem/tests/addFunds/BuyTest.kt | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/app/src/androidTest/kotlin/com/tangem/tests/addFunds/BuyTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/addFunds/BuyTest.kt index 91f7c4781d..fe1772587c 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/addFunds/BuyTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/addFunds/BuyTest.kt @@ -3,6 +3,7 @@ package com.tangem.tests.addFunds import com.tangem.common.BaseTestCase import com.tangem.common.extensions.assertTextContainsSafe import com.tangem.common.extensions.clickWithAssertion +import com.tangem.domain.models.scan.ProductType import com.tangem.scenarios.openMainScreen import com.tangem.scenarios.synchronizeAddresses import com.tangem.screens.onAddTokenBottomSheet @@ -83,4 +84,24 @@ class BuyTest : BaseTestCase() { } } } + + @AllureId("3613") + @DisplayName("On-ramp Buy: S2C card hasn't Buy and Sell options") + @Test + fun buyAndSellIsNotAvailableForS2CCardTest() { + setupHooks().run { + step("Open 'Main' screen") { + openMainScreen(productType = ProductType.Start2Coin) + } + step("Verify 'Add funds' button is display") { + onMainScreen { addFundsButton.assertIsDisplayed() } + } + step("Verify Buy/Sell action buttons are hidden") { + onMainScreen { + buyButton.assertDoesNotExist() + sellButton.assertDoesNotExist() + } + } + } + } } \ No newline at end of file From 553139ee3b41181dd16975470ae5ee233c907869 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 24 Jun 2026 11:07:19 +0300 Subject: [PATCH 026/210] Updated on 2026-08-14 --- .../androidTest/kotlin/com/tangem/tests/addFunds/BuyTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/androidTest/kotlin/com/tangem/tests/addFunds/BuyTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/addFunds/BuyTest.kt index fe1772587c..17e994c3dc 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/addFunds/BuyTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/addFunds/BuyTest.kt @@ -86,14 +86,14 @@ class BuyTest : BaseTestCase() { } @AllureId("3613") - @DisplayName("On-ramp Buy: S2C card hasn't Buy and Sell options") + @DisplayName("On-ramp Buy: S2C card doesn't have Buy and Sell options") @Test fun buyAndSellIsNotAvailableForS2CCardTest() { setupHooks().run { step("Open 'Main' screen") { openMainScreen(productType = ProductType.Start2Coin) } - step("Verify 'Add funds' button is display") { + step("Verify 'Add funds' button is displayed") { onMainScreen { addFundsButton.assertIsDisplayed() } } step("Verify Buy/Sell action buttons are hidden") { From bbf2b144792ca09435efe26cd817e6557dcfb02d Mon Sep 17 00:00:00 2001 From: Tangem Date: Sat, 20 Jun 2026 20:56:35 +0200 Subject: [PATCH 027/210] Updated on 2026-08-14 --- .../com/tangem/data/addressbook/di/AddressBookDataModule.kt | 6 ++++++ .../features/addressbook/common/ContactMatcherTest.kt | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/data/address-book/src/main/kotlin/com/tangem/data/addressbook/di/AddressBookDataModule.kt b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/di/AddressBookDataModule.kt index ce181b3417..abebf8690e 100644 --- a/data/address-book/src/main/kotlin/com/tangem/data/addressbook/di/AddressBookDataModule.kt +++ b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/di/AddressBookDataModule.kt @@ -11,6 +11,7 @@ import com.tangem.datasource.utils.KotlinxDataStoreSerializer import com.tangem.domain.addressbook.crypto.AddressBookCipher import com.tangem.domain.addressbook.repository.AddressBookRepository import com.tangem.domain.addressbook.time.IsoTimestampProvider +import com.tangem.domain.addressbook.usecase.GetContactsUseCase import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -65,4 +66,9 @@ internal object AddressBookDataModule { dispatchers = dispatchers, ) } + + @Provides + @Singleton + fun provideGetContactsUseCase(repository: AddressBookRepository): GetContactsUseCase = + GetContactsUseCase(repository) } \ No newline at end of file diff --git a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/common/ContactMatcherTest.kt b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/common/ContactMatcherTest.kt index 2f9974771e..43b52ec060 100644 --- a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/common/ContactMatcherTest.kt +++ b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/common/ContactMatcherTest.kt @@ -73,7 +73,7 @@ internal class ContactMatcherTest { assertThat(result.single().icon.color).isEqualTo(CryptoPortfolioIcon.Color.Azure) } - private fun contact(name: String, vararg entries: AddressEntry, iconColor: String = "Azure"): Contact = Contact( + private fun contact(name: String, vararg entries: AddressEntry): Contact = Contact( id = ContactId(name), walletId = UserWalletId(stringValue = "0001"), name = requireNotNull(ContactName(name).getOrNull()) { "invalid test name" }, From 8eb46c9672fa64058472c31c54e2b857495e7232 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 22 Jun 2026 10:22:31 +0200 Subject: [PATCH 028/210] Updated on 2026-08-14 --- .../com/tangem/data/addressbook/di/AddressBookDataModule.kt | 6 ------ .../features/addressbook/common/ContactMatcherTest.kt | 2 +- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/data/address-book/src/main/kotlin/com/tangem/data/addressbook/di/AddressBookDataModule.kt b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/di/AddressBookDataModule.kt index abebf8690e..ce181b3417 100644 --- a/data/address-book/src/main/kotlin/com/tangem/data/addressbook/di/AddressBookDataModule.kt +++ b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/di/AddressBookDataModule.kt @@ -11,7 +11,6 @@ import com.tangem.datasource.utils.KotlinxDataStoreSerializer import com.tangem.domain.addressbook.crypto.AddressBookCipher import com.tangem.domain.addressbook.repository.AddressBookRepository import com.tangem.domain.addressbook.time.IsoTimestampProvider -import com.tangem.domain.addressbook.usecase.GetContactsUseCase import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -66,9 +65,4 @@ internal object AddressBookDataModule { dispatchers = dispatchers, ) } - - @Provides - @Singleton - fun provideGetContactsUseCase(repository: AddressBookRepository): GetContactsUseCase = - GetContactsUseCase(repository) } \ No newline at end of file diff --git a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/common/ContactMatcherTest.kt b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/common/ContactMatcherTest.kt index 43b52ec060..2f9974771e 100644 --- a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/common/ContactMatcherTest.kt +++ b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/common/ContactMatcherTest.kt @@ -73,7 +73,7 @@ internal class ContactMatcherTest { assertThat(result.single().icon.color).isEqualTo(CryptoPortfolioIcon.Color.Azure) } - private fun contact(name: String, vararg entries: AddressEntry): Contact = Contact( + private fun contact(name: String, vararg entries: AddressEntry, iconColor: String = "Azure"): Contact = Contact( id = ContactId(name), walletId = UserWalletId(stringValue = "0001"), name = requireNotNull(ContactName(name).getOrNull()) { "invalid test name" }, From bb0aa53982f6eb7a9fb8f66824fe9a403e848eef Mon Sep 17 00:00:00 2001 From: Tangem Date: Sat, 20 Jun 2026 20:57:03 +0200 Subject: [PATCH 029/210] Updated on 2026-08-14 --- .../SendDestinationComponentParams.kt | 1 + .../entity/DestinationTextFieldUM.kt | 3 + features/send/impl/build.gradle.kts | 2 + .../send/send/DefaultSendComponent.kt | 12 +- .../send/send/confirm/model/ConfirmData.kt | 2 + .../success/ui/SendConfirmSuccessContent.kt | 2 +- .../send/sendnft/DefaultNFTSendComponent.kt | 10 ++ .../DefaultSendDestinationBlockComponent.kt | 3 + .../DefaultSendDestinationComponent.kt | 54 ++++++- .../analytics/EnterAddressSource.kt | 3 +- .../destination/model/SendDestinationModel.kt | 143 ++++++++++++++++-- .../SendDestinationContactTransformer.kt | 22 +++ .../destination/ui/DestinationBlock.kt | 53 +++++-- .../destination/ui/SendDestinationContent.kt | 11 ++ 14 files changed, 294 insertions(+), 27 deletions(-) create mode 100644 features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/transformers/SendDestinationContactTransformer.kt diff --git a/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/destination/SendDestinationComponentParams.kt b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/destination/SendDestinationComponentParams.kt index e4879499a7..ead4f35164 100644 --- a/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/destination/SendDestinationComponentParams.kt +++ b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/destination/SendDestinationComponentParams.kt @@ -40,5 +40,6 @@ sealed class SendDestinationComponentParams { val blockClickEnableFlow: StateFlow, val predefinedValues: PredefinedValues, override val isAllowSelfSend: Boolean = false, + val isAddContactAvailable: Boolean = false, ) : SendDestinationComponentParams() } \ No newline at end of file diff --git a/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/destination/entity/DestinationTextFieldUM.kt b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/destination/entity/DestinationTextFieldUM.kt index 00dc80f599..943d1e5a93 100644 --- a/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/destination/entity/DestinationTextFieldUM.kt +++ b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/destination/entity/DestinationTextFieldUM.kt @@ -2,6 +2,7 @@ package com.tangem.features.send.api.subcomponents.destination.entity import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Immutable +import com.tangem.common.ui.account.AccountIconUM import com.tangem.core.ui.extensions.TextReference import com.tangem.utils.toBriefAddressFormat @@ -24,6 +25,8 @@ sealed class DestinationTextFieldUM { val isValuePasted: Boolean, // if value is human-readable address, this field contains the actual blockchain address val blockchainAddress: String? = null, + val contactName: String? = null, + val contactIcon: AccountIconUM.CryptoPortfolio? = null, ) : DestinationTextFieldUM() { val actualAddress: String diff --git a/features/send/impl/build.gradle.kts b/features/send/impl/build.gradle.kts index d5b2231051..d57e08c9b0 100644 --- a/features/send/impl/build.gradle.kts +++ b/features/send/impl/build.gradle.kts @@ -17,6 +17,7 @@ dependencies { implementation(projects.features.nft.api) implementation(projects.features.swapV2.api) implementation(projects.features.manageTokens.api) + implementation(projects.features.addressBook.api) /** Libs */ implementation(projects.libs.crypto) @@ -68,6 +69,7 @@ dependencies { implementation(projects.domain.swap.models) implementation(projects.domain.account) implementation(projects.domain.account.status) + implementation(projects.domain.addressBook) implementation(projects.domain.transaction) diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/DefaultSendComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/DefaultSendComponent.kt index dc13b7b071..bae3465d9b 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/DefaultSendComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/DefaultSendComponent.kt @@ -28,6 +28,9 @@ import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.models.account.derivationIndex +import com.tangem.features.addressbook.AddressBookContactsBlockComponent +import com.tangem.features.addressbook.AddressBookFeatureToggles +import com.tangem.features.addressbook.AddressSelectorComponent import com.tangem.features.send.api.FeeSelectorBlockComponent import com.tangem.features.send.api.SendComponent import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents @@ -52,12 +55,15 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.launch -@Suppress("LargeClass") +@Suppress("LargeClass", "LongParameterList") internal class DefaultSendComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, @Assisted private val params: SendComponent.Params, private val analyticsEventHandler: AnalyticsEventHandler, private val feeSelectorComponentFactory: FeeSelectorBlockComponent.Factory, + private val contactsBlockFactory: AddressBookContactsBlockComponent.Factory, + private val addressSelectorFactory: AddressSelectorComponent.Factory, + private val addressBookFeatureToggles: AddressBookFeatureToggles, ) : SendComponent, AppComponentContext by appComponentContext { private val stackNavigation = StackNavigation() @@ -176,6 +182,9 @@ internal class DefaultSendComponent @AssistedInject constructor( cryptoCurrency = params.currency, callback = model, ), + addressBookFeatureToggles = addressBookFeatureToggles, + contactsBlockFactory = contactsBlockFactory, + addressSelectorFactory = addressSelectorFactory, ) private fun getAmountComponent(factoryContext: AppComponentContext): ComposableContentComponent { @@ -261,6 +270,7 @@ internal class DefaultSendComponent @AssistedInject constructor( cryptoCurrency = cryptoCurrencyStatus.currency, blockClickEnableFlow = MutableStateFlow(true), predefinedValues = model.predefinedValues, + isAddContactAvailable = true, ), onResult = { }, onClick = {}, diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/ConfirmData.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/ConfirmData.kt index 96f1544055..1d28efbbdd 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/ConfirmData.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/ConfirmData.kt @@ -1,9 +1,11 @@ package com.tangem.features.send.send.confirm.model +import androidx.compose.runtime.Immutable import com.tangem.blockchain.common.transaction.Fee import com.tangem.domain.transaction.error.GetFeeError import java.math.BigDecimal +@Immutable data class ConfirmData( val enteredAmount: BigDecimal?, val reduceAmountBy: BigDecimal, diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/success/ui/SendConfirmSuccessContent.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/success/ui/SendConfirmSuccessContent.kt index e0788a8bf3..598a87cf9d 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/success/ui/SendConfirmSuccessContent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/success/ui/SendConfirmSuccessContent.kt @@ -25,8 +25,8 @@ import com.tangem.core.ui.utils.toTimeFormat import com.tangem.features.send.api.subcomponents.destination.SendDestinationBlockComponent import com.tangem.features.send.common.ui.FeeBlockSuccess import com.tangem.features.send.common.ui.state.ConfirmUM -import com.tangem.features.send.send.ui.state.SendUM import com.tangem.features.send.impl.R +import com.tangem.features.send.send.ui.state.SendUM @Composable internal fun SendConfirmSuccessContent(sendUM: SendUM, destinationBlockComponent: SendDestinationBlockComponent) { diff --git a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/DefaultNFTSendComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/DefaultNFTSendComponent.kt index eb6ed71a6a..abce364a1b 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/DefaultNFTSendComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/DefaultNFTSendComponent.kt @@ -18,6 +18,9 @@ import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.decompose.navigation.inner.InnerRouter import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.addressbook.AddressBookContactsBlockComponent +import com.tangem.features.addressbook.AddressBookFeatureToggles +import com.tangem.features.addressbook.AddressSelectorComponent import com.tangem.features.send.api.NFTSendComponent import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams @@ -35,12 +38,16 @@ import dagger.assisted.AssistedInject import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.launch +@Suppress("LongParameterList") internal class DefaultNFTSendComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, @Assisted private val params: NFTSendComponent.Params, private val nftSendConfirmComponentFactory: NFTSendConfirmComponent.Factory, private val nftSendSuccessComponentFactory: NFTSendSuccessComponent.Factory, private val analyticsEventHandler: AnalyticsEventHandler, + private val contactsBlockFactory: AddressBookContactsBlockComponent.Factory, + private val addressSelectorFactory: AddressSelectorComponent.Factory, + private val addressBookFeatureToggles: AddressBookFeatureToggles, ) : NFTSendComponent, AppComponentContext by appComponentContext { private val stackNavigation = StackNavigation() @@ -145,6 +152,9 @@ internal class DefaultNFTSendComponent @AssistedInject constructor( cryptoCurrency = model.cryptoCurrency, callback = model, ), + addressBookFeatureToggles = addressBookFeatureToggles, + contactsBlockFactory = contactsBlockFactory, + addressSelectorFactory = addressSelectorFactory, ) private fun getConfirmComponent(factoryContext: AppComponentContext) = nftSendConfirmComponentFactory.create( diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/DefaultSendDestinationBlockComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/DefaultSendDestinationBlockComponent.kt index 90df4644fb..324a0ef93e 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/DefaultSendDestinationBlockComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/DefaultSendDestinationBlockComponent.kt @@ -39,12 +39,15 @@ internal class DefaultSendDestinationBlockComponent @AssistedInject constructor( override fun Content(modifier: Modifier) { val state by model.uiState.collectAsStateWithLifecycle() val isClickEnabled by params.blockClickEnableFlow.collectAsStateWithLifecycle() + val isAddContactVisible by model.showAddContact.collectAsStateWithLifecycle() DestinationBlock( destinationUM = state, isClickDisabled = !isClickEnabled, isEditingDisabled = params.predefinedValues is PredefinedValues.Content.Deeplink, onClick = onClick, + showAddContact = isAddContactVisible, + onAddContactClick = model::onAddContactClick, ) } diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/DefaultSendDestinationComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/DefaultSendDestinationComponent.kt index d798a85168..c6afc17f98 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/DefaultSendDestinationComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/DefaultSendDestinationComponent.kt @@ -4,8 +4,16 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.slot.childSlot +import com.arkivanov.decompose.router.slot.dismiss import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.child +import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.features.addressbook.AddressBookContactsBlockComponent +import com.tangem.features.addressbook.AddressBookFeatureToggles +import com.tangem.features.addressbook.AddressSelectorComponent import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponent import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM @@ -18,18 +26,62 @@ import dagger.assisted.AssistedInject internal class DefaultSendDestinationComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, @Assisted private val params: SendDestinationComponentParams.DestinationParams, + addressBookFeatureToggles: AddressBookFeatureToggles, + contactsBlockFactory: AddressBookContactsBlockComponent.Factory, + addressSelectorFactory: AddressSelectorComponent.Factory, ) : SendDestinationComponent, AppComponentContext by appComponentContext { private val model: SendDestinationModel = getOrCreateModel(params = params) + private val contactsBlock: AddressBookContactsBlockComponent? by lazy { + if (addressBookFeatureToggles.isAddressBookEnabled) { + contactsBlockFactory.create( + context = child("send_contacts_block"), + params = AddressBookContactsBlockComponent.Params( + userWalletId = params.userWalletId, + network = params.cryptoCurrency.network, + queryFlow = model.addressQuery, + onContactClick = model::onContactClick, + onSeeAllClick = model::onSeeAllContactsClick, + ), + ) + } else { + null + } + } + + private val addressSelectorSlot = childSlot( + source = model.addressSelectorNavigation, + serializer = null, + key = "send_address_selector_slot", + handleBackButton = true, + childFactory = { contact, componentContext -> + addressSelectorFactory.create( + context = childByContext(componentContext), + params = AddressSelectorComponent.Params( + contact = contact, + onAddressSelected = model::applySelectedContact, + onDismiss = { model.addressSelectorNavigation.dismiss() }, + ), + ) + }, + ) + override fun updateState(destinationUM: DestinationUM) = model.updateState(destinationUM) @Composable override fun Content(modifier: Modifier) { val state by model.uiState.collectAsStateWithLifecycle() val isBalanceHidden by params.isBalanceHidingFlow.collectAsStateWithLifecycle() + val selector by addressSelectorSlot.subscribeAsState() - SendDestinationContent(state = state, clickIntents = model, isBalanceHidden = isBalanceHidden) + SendDestinationContent( + state = state, + clickIntents = model, + isBalanceHidden = isBalanceHidden, + contactsBlock = contactsBlock, + ) + selector.child?.instance?.BottomSheet() } @AssistedFactory diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/analytics/EnterAddressSource.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/analytics/EnterAddressSource.kt index 911f500009..54c9e5e288 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/analytics/EnterAddressSource.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/analytics/EnterAddressSource.kt @@ -6,11 +6,12 @@ internal enum class EnterAddressSource { RecentAddress, InputField, MyWallets, + Contact, ; val isPasted: Boolean get() = this != InputField val isAutoNext: Boolean - get() = this == RecentAddress || this == MyWallets + get() = this == RecentAddress || this == MyWallets || this == Contact } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt index 2a14399fd0..0056c9c2ff 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt @@ -3,7 +3,12 @@ package com.tangem.features.send.subcomponents.destination.model import androidx.compose.runtime.Stable import arrow.core.getOrElse import arrow.core.left +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.arkivanov.decompose.router.slot.dismiss import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.entity.AddressBookOpenMode +import com.tangem.common.ui.account.AccountIconUM import com.tangem.common.ui.navigationButtons.NavigationButton import com.tangem.common.ui.navigationButtons.NavigationUM import com.tangem.core.analytics.api.AnalyticsEventHandler @@ -15,8 +20,11 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier import com.tangem.domain.account.status.usecase.GetBackupProblematicWalletForAddressUseCase import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase +import com.tangem.domain.addressbook.model.Contact +import com.tangem.domain.addressbook.usecase.GetContactsUseCase import com.tangem.domain.feedback.SendBackupProblemEmailUseCase import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.account.CryptoPortfolioIcon import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.CryptoCurrencyAddress @@ -34,6 +42,9 @@ import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase import com.tangem.domain.transaction.usecase.ValidateWalletMemoUseCase import com.tangem.domain.txhistory.usecase.GetFixedTxHistoryItemsUseCase import com.tangem.domain.wallets.usecase.GetWalletsUseCase +import com.tangem.features.addressbook.ContactSelectionListener +import com.tangem.features.addressbook.MatchedContact +import com.tangem.features.addressbook.SelectedContact import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents.SendScreenSource import com.tangem.features.send.api.entity.PredefinedValues @@ -41,18 +52,12 @@ import com.tangem.features.send.api.subcomponents.destination.SendDestinationCom import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams.DestinationBlockParams import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM import com.tangem.features.send.common.CommonSendRoute -import com.tangem.features.send.subcomponents.destination.analytics.EnterAddressSource -import com.tangem.features.send.subcomponents.destination.analytics.SendDestinationAnalyticEvents -import com.tangem.features.send.subcomponents.destination.model.transformers.SendDestinationAddressTransformer -import com.tangem.features.send.subcomponents.destination.model.transformers.SendDestinationInitialStateTransformer -import com.tangem.features.send.subcomponents.destination.model.transformers.SendDestinationMemoTransformer -import com.tangem.features.send.subcomponents.destination.model.transformers.SendDestinationPredefinedStateTransformer -import com.tangem.features.send.subcomponents.destination.model.transformers.SendDestinationRecentListTransformer -import com.tangem.features.send.subcomponents.destination.model.transformers.SendDestinationValidationResultTransformer -import com.tangem.features.send.subcomponents.destination.model.transformers.SendDestinationValidationStartedTransformer -import com.tangem.features.send.subcomponents.destination.ui.state.DestinationWalletUM import com.tangem.features.send.impl.R import com.tangem.features.send.subcomponents.destination.SendDestinationAlertFactory +import com.tangem.features.send.subcomponents.destination.analytics.EnterAddressSource +import com.tangem.features.send.subcomponents.destination.analytics.SendDestinationAnalyticEvents +import com.tangem.features.send.subcomponents.destination.model.transformers.* +import com.tangem.features.send.subcomponents.destination.ui.state.DestinationWalletUM import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn @@ -88,6 +93,8 @@ internal class SendDestinationModel @Inject constructor( private val getBackupProblematicWalletForAddressUseCase: GetBackupProblematicWalletForAddressUseCase, private val sendDestinationAlertFactory: SendDestinationAlertFactory, private val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase, + getContactsUseCase: GetContactsUseCase, + contactSelectionListener: ContactSelectionListener, ) : Model(), SendDestinationClickIntents { private val params: SendDestinationComponentParams = paramsContainer.require() @@ -98,6 +105,36 @@ internal class SendDestinationModel @Inject constructor( private val cryptoCurrency = params.cryptoCurrency private val userWalletId = params.userWalletId + private val contacts: StateFlow> = getContactsUseCase(query = "", userWalletId = userWalletId) + .stateIn(modelScope, SharingStarted.Eagerly, emptyList()) + + val addressSelectorNavigation = SlotNavigation() + val addressQuery: StateFlow = uiState + .map { (it as? DestinationUM.Content)?.addressTextField?.value.orEmpty() } + .distinctUntilChanged() + .stateIn(modelScope, SharingStarted.Eagerly, "") + + /** + * Whether to offer saving the recipient to the address book under the recipient block. Only for the success-screen + * block ([DestinationBlockParams.isAddContactAvailable]) and only when the recipient was NOT a contact and its + * `(address, network)` pair is not already saved in the current wallet's book. + */ + val showAddContact: StateFlow = + if ((params as? DestinationBlockParams)?.isAddContactAvailable == true) { + combine(uiState, contacts) { state, contactList -> + val address = (state as? DestinationUM.Content)?.addressTextField ?: return@combine false + if (address.contactName != null) return@combine false // sent via a contact + val networkId = cryptoCurrency.network.rawId + contactList.none { contact -> + contact.addressEntries.any { + it.networkId.value == networkId && it.address.equals(address.actualAddress, ignoreCase = true) + } + } + }.stateIn(modelScope, SharingStarted.Eagerly, false) + } else { + MutableStateFlow(false) + } + // In "Send with swap" flow, these are addresses in the destination network (not the actual sender addresses). // Self-send validation must be skipped for them, so use only with params.isAllowSelfSend. private val senderAddresses = MutableStateFlow>(emptyList()) @@ -110,6 +147,63 @@ internal class SendDestinationModel @Inject constructor( configDestinationNavigation() subscribeOnQRScannerResult() initialState() + resetContactOnEdit() + contactSelectionListener.resultFlow + .onEach(::applySelectedContact) + .launchIn(modelScope) + } + + private fun resetContactOnEdit() { + val params = params as? SendDestinationComponentParams.DestinationParams ?: return + params.currentRoute + .filter { it.isEditMode } + .onEach { + val content = uiState.value as? DestinationUM.Content ?: return@onEach + if (content.addressTextField.contactName != null) { + _uiState.update(SendDestinationContactTransformer(contactName = null, contactIcon = null)) + _uiState.update(SendDestinationAddressTransformer(address = "", isPasted = false)) + validate(address = "", memo = content.memoTextField?.value) + } + } + .launchIn(modelScope) + } + + fun onContactClick(contact: MatchedContact) { + val singleEntry = contact.entries.singleOrNull() + if (singleEntry != null) { + applySelectedContact(contact.toSelectedContact(singleEntry)) + } else { + addressSelectorNavigation.activate(contact) + } + } + + fun onSeeAllContactsClick() { + router.push( + AppRoute.AddressBook(AddressBookOpenMode.ContactSelection(networkId = cryptoCurrency.network.rawId)), + ) + } + + /** Opens the contact editor pre-filled with the sent address/network to save the recipient (success screen). */ + fun onAddContactClick() { + val address = (uiState.value as? DestinationUM.Content)?.addressTextField?.actualAddress ?: return + router.push( + AppRoute.AddressBook( + AddressBookOpenMode.WithContactCreation(address = address, networkId = cryptoCurrency.network.rawId), + ), + ) + } + + fun applySelectedContact(contact: SelectedContact) { + addressSelectorNavigation.dismiss() + _uiState.update(SendDestinationAddressTransformer(address = contact.address, isPasted = true)) + _uiState.update(SendDestinationContactTransformer(contactName = contact.name, contactIcon = contact.icon)) + + val isMemoSupported = (uiState.value as? DestinationUM.Content)?.memoTextField != null + val memo = contact.memo?.takeIf { isMemoSupported && it.isNotBlank() } + if (memo != null) { + _uiState.update(SendDestinationMemoTransformer(memo = memo, isPasted = true)) + } + validate(address = contact.address, memo = memo, type = EnterAddressSource.Contact) } private fun initialState() { @@ -371,6 +465,7 @@ internal class SendDestinationModel @Inject constructor( isMemoRequired = isMemoRequired, ), ) + recognizeContact(type = type, isValidAddress = addressValidationResult.isRight(), address = resolvedAddress) if (type != null) { autoNextFromRecipient( type = type, @@ -381,6 +476,34 @@ internal class SendDestinationModel @Inject constructor( }.saveIn(validationJobHolder) } + private fun recognizeContact(type: EnterAddressSource?, isValidAddress: Boolean, address: String) { + if (type == null || type == EnterAddressSource.Contact) return + val recognized = if (isValidAddress) findContactByAddress(address) else null + _uiState.update(SendDestinationContactTransformer(recognized?.name, recognized?.icon)) + } + + private fun findContactByAddress(address: String): RecognizedContact? { + val networkId = cryptoCurrency.network.rawId + contacts.value.forEach { contact -> + val isMatch = contact.addressEntries.any { entry -> + entry.networkId.value == networkId && entry.address.equals(address, ignoreCase = true) + } + if (isMatch) { + return RecognizedContact( + name = contact.name.value, + // TODO([REDACTED_TASK_KEY]): take the color from the domain Contact once the data layer stores it. + icon = AccountIconUM.CryptoPortfolio( + value = CryptoPortfolioIcon.Icon.Letter, + color = CryptoPortfolioIcon.Color.Azure, + ), + ) + } + } + return null + } + + private data class RecognizedContact(val name: String, val icon: AccountIconUM.CryptoPortfolio) + private fun autoNextFromRecipient(type: EnterAddressSource, isValidAddress: Boolean, isValidMemo: Boolean) { if (type.isAutoNext && isValidAddress && isValidMemo) { saveResult() diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/transformers/SendDestinationContactTransformer.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/transformers/SendDestinationContactTransformer.kt new file mode 100644 index 0000000000..1dd67091a4 --- /dev/null +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/transformers/SendDestinationContactTransformer.kt @@ -0,0 +1,22 @@ +package com.tangem.features.send.subcomponents.destination.model.transformers + +import com.tangem.common.ui.account.AccountIconUM +import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM +import com.tangem.utils.transformer.Transformer + +internal class SendDestinationContactTransformer( + private val contactName: String?, + private val contactIcon: AccountIconUM.CryptoPortfolio?, +) : Transformer { + + override fun transform(prevState: DestinationUM): DestinationUM { + val state = prevState as? DestinationUM.Content ?: return prevState + + return state.copy( + addressTextField = state.addressTextField.copy( + contactName = contactName, + contactIcon = contactIcon, + ), + ) + } +} \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/ui/DestinationBlock.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/ui/DestinationBlock.kt index 7897334853..12175f963f 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/ui/DestinationBlock.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/ui/DestinationBlock.kt @@ -15,17 +15,21 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.tangem.common.ui.account.AccountIcon +import com.tangem.core.ui.components.SecondaryButtonIconStart +import com.tangem.core.ui.components.account.AccountIconSize import com.tangem.core.ui.components.icons.identicon.IdentIcon import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.test.SendConfirmScreenTestTags -import com.tangem.features.send.impl.R import com.tangem.features.send.api.subcomponents.destination.entity.DestinationRecipientListUM import com.tangem.features.send.api.subcomponents.destination.entity.DestinationTextFieldUM import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM +import com.tangem.features.send.impl.R import kotlinx.collections.immutable.toImmutableList @Composable @@ -34,6 +38,8 @@ internal fun DestinationBlock( isClickDisabled: Boolean, isEditingDisabled: Boolean, onClick: () -> Unit, + showAddContact: Boolean = false, + onAddContactClick: () -> Unit = {}, ) { if (destinationUM !is DestinationUM.Content) return @@ -50,6 +56,16 @@ internal fun DestinationBlock( address = destinationUM.addressTextField, memo = destinationUM.memoTextField, ) + if (showAddContact) { + SecondaryButtonIconStart( + text = stringResourceSafe(com.tangem.core.ui.R.string.address_book_add_contact), + iconResId = com.tangem.core.ui.R.drawable.ic_plus_24, + onClick = onAddContactClick, + modifier = Modifier + .fillMaxWidth() + .padding(top = TangemTheme.dimens.spacing12), + ) + } } } @@ -68,31 +84,42 @@ private fun AddressWithMemoBlock( horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing24), modifier = Modifier.padding(top = TangemTheme.dimens.spacing8), ) { + val contactName = address.contactName + val contactIcon = address.contactIcon Column(modifier = Modifier.weight(1f)) { Text( - text = address.value, + text = contactName ?: address.value, style = TangemTheme.typography.body1, color = TangemTheme.colors.text.primary1, modifier = Modifier.testTag(SendConfirmScreenTestTags.RECIPIENT_ADDRESS), ) - val blockchainAddress = address.briefBlockchainAddress - if (!blockchainAddress.isNullOrBlank()) { + val recipient = if (contactName != null) address.value else address.briefBlockchainAddress + if (!recipient.isNullOrBlank()) { Text( - text = blockchainAddress, + text = recipient, style = TangemTheme.typography.caption2, color = TangemTheme.colors.text.tertiary, modifier = Modifier.testTag(SendConfirmScreenTestTags.BLOCKCHAIN_ADDRESS), ) } } - IdentIcon( - address = address.value, - modifier = Modifier - .size(TangemTheme.dimens.size36) - .clip(RoundedCornerShape(TangemTheme.dimens.radius18)) - .background(TangemTheme.colors.background.tertiary) - .testTag(SendConfirmScreenTestTags.RECIPIENT_ADDRESS_ICON), - ) + if (contactName != null && contactIcon != null) { + AccountIcon( + name = stringReference(contactName), + icon = contactIcon, + size = AccountIconSize.Medium, + modifier = Modifier.testTag(SendConfirmScreenTestTags.RECIPIENT_ADDRESS_ICON), + ) + } else { + IdentIcon( + address = address.value, + modifier = Modifier + .size(TangemTheme.dimens.size36) + .clip(RoundedCornerShape(TangemTheme.dimens.radius18)) + .background(TangemTheme.colors.background.tertiary) + .testTag(SendConfirmScreenTestTags.RECIPIENT_ADDRESS_ICON), + ) + } } if (memo != null && memo.value.isNotBlank()) { diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/ui/SendDestinationContent.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/ui/SendDestinationContent.kt index 1c539cd97b..84f2269749 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/ui/SendDestinationContent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/ui/SendDestinationContent.kt @@ -30,6 +30,7 @@ import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.test.SendAddressScreenTestTags import com.tangem.core.ui.utils.GlobalMultipleClickPreventer +import com.tangem.features.addressbook.AddressBookContactsBlockComponent import com.tangem.features.send.api.subcomponents.destination.entity.DestinationRecipientListUM import com.tangem.features.send.api.subcomponents.destination.entity.DestinationTextFieldUM import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM @@ -47,6 +48,7 @@ internal fun SendDestinationContent( state: DestinationUM, clickIntents: SendDestinationClickIntents, isBalanceHidden: Boolean, + contactsBlock: AddressBookContactsBlockComponent? = null, ) { if (state !is DestinationUM.Content) return val recipients = state.recent @@ -115,6 +117,15 @@ internal fun SendDestinationContent( ) }, ) + if (contactsBlock != null && !state.isRecentHidden) { + item(key = "CONTACTS_BLOCK_KEY") { + contactsBlock.Content( + modifier = Modifier + .fillMaxWidth() + .padding(top = 20.dp), + ) + } + } item("SPACER_KEY") { SpacerH(16.dp) } From e65bf8538bd23adfa4b32e71bbcb55c9072f23a6 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 22 Jun 2026 14:11:36 +0200 Subject: [PATCH 030/210] Updated on 2026-08-14 --- .../send/send/DefaultSendComponent.kt | 24 +++++---------- .../send/sendnft/DefaultNFTSendComponent.kt | 23 +++++---------- .../destination/model/SendDestinationModel.kt | 29 ++++--------------- .../model/converter/ContactIconConverter.kt | 15 ++++++++++ .../SendDestinationContactTransformer.kt | 7 +++++ 5 files changed, 43 insertions(+), 55 deletions(-) create mode 100644 features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/converter/ContactIconConverter.kt diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/DefaultSendComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/DefaultSendComponent.kt index bae3465d9b..cda575b53c 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/DefaultSendComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/DefaultSendComponent.kt @@ -28,25 +28,22 @@ import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.models.account.derivationIndex -import com.tangem.features.addressbook.AddressBookContactsBlockComponent -import com.tangem.features.addressbook.AddressBookFeatureToggles -import com.tangem.features.addressbook.AddressSelectorComponent import com.tangem.features.send.api.FeeSelectorBlockComponent import com.tangem.features.send.api.SendComponent import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents +import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponent import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM import com.tangem.features.send.common.CommonSendRoute import com.tangem.features.send.common.ui.SendContent import com.tangem.features.send.common.ui.state.ConfirmUM +import com.tangem.features.send.impl.R import com.tangem.features.send.send.confirm.SendConfirmComponent import com.tangem.features.send.send.model.SendModel import com.tangem.features.send.send.success.SendConfirmSuccessComponent import com.tangem.features.send.subcomponents.amount.SendAmountComponent import com.tangem.features.send.subcomponents.amount.SendAmountComponentParams import com.tangem.features.send.subcomponents.destination.DefaultSendDestinationBlockComponent -import com.tangem.features.send.subcomponents.destination.DefaultSendDestinationComponent -import com.tangem.features.send.impl.R import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -55,15 +52,13 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.launch -@Suppress("LargeClass", "LongParameterList") +@Suppress("LargeClass") internal class DefaultSendComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, @Assisted private val params: SendComponent.Params, private val analyticsEventHandler: AnalyticsEventHandler, private val feeSelectorComponentFactory: FeeSelectorBlockComponent.Factory, - private val contactsBlockFactory: AddressBookContactsBlockComponent.Factory, - private val addressSelectorFactory: AddressSelectorComponent.Factory, - private val addressBookFeatureToggles: AddressBookFeatureToggles, + private val sendDestinationComponentFactory: SendDestinationComponent.Factory, ) : SendComponent, AppComponentContext by appComponentContext { private val stackNavigation = StackNavigation() @@ -128,7 +123,7 @@ internal class DefaultSendComponent @AssistedInject constructor( ) activeComponent.updateState(model.uiState.value.amountUM) } - is DefaultSendDestinationComponent -> { + is SendDestinationComponent -> { analyticsEventHandler.send( CommonSendAnalyticEvents.AddressScreenOpened( categoryName = model.analyticCategoryName, @@ -168,9 +163,9 @@ internal class DefaultSendComponent @AssistedInject constructor( is CommonSendRoute.ConfirmSuccess -> getConfirmSuccessComponent(factoryContext) } - private fun getDestinationComponent(factoryContext: AppComponentContext): DefaultSendDestinationComponent = - DefaultSendDestinationComponent( - appComponentContext = factoryContext, + private fun getDestinationComponent(factoryContext: AppComponentContext): SendDestinationComponent = + sendDestinationComponentFactory.create( + context = factoryContext, params = SendDestinationComponentParams.DestinationParams( state = model.uiState.value.destinationUM, currentRoute = model.currentRoute.filterIsInstance(), @@ -182,9 +177,6 @@ internal class DefaultSendComponent @AssistedInject constructor( cryptoCurrency = params.currency, callback = model, ), - addressBookFeatureToggles = addressBookFeatureToggles, - contactsBlockFactory = contactsBlockFactory, - addressSelectorFactory = addressSelectorFactory, ) private fun getAmountComponent(factoryContext: AppComponentContext): ComposableContentComponent { diff --git a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/DefaultNFTSendComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/DefaultNFTSendComponent.kt index abce364a1b..e06f11e545 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/DefaultNFTSendComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/DefaultNFTSendComponent.kt @@ -18,36 +18,30 @@ import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.decompose.navigation.inner.InnerRouter import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.extensions.resourceReference -import com.tangem.features.addressbook.AddressBookContactsBlockComponent -import com.tangem.features.addressbook.AddressBookFeatureToggles -import com.tangem.features.addressbook.AddressSelectorComponent import com.tangem.features.send.api.NFTSendComponent import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents +import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponent import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams import com.tangem.features.send.common.CommonSendRoute import com.tangem.features.send.common.ui.SendContent import com.tangem.features.send.common.ui.state.ConfirmUM +import com.tangem.features.send.impl.R import com.tangem.features.send.sendnft.confirm.NFTSendConfirmComponent import com.tangem.features.send.sendnft.model.NFTSendModel import com.tangem.features.send.sendnft.success.NFTSendSuccessComponent -import com.tangem.features.send.subcomponents.destination.DefaultSendDestinationComponent -import com.tangem.features.send.impl.R import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.launch -@Suppress("LongParameterList") internal class DefaultNFTSendComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, @Assisted private val params: NFTSendComponent.Params, private val nftSendConfirmComponentFactory: NFTSendConfirmComponent.Factory, private val nftSendSuccessComponentFactory: NFTSendSuccessComponent.Factory, private val analyticsEventHandler: AnalyticsEventHandler, - private val contactsBlockFactory: AddressBookContactsBlockComponent.Factory, - private val addressSelectorFactory: AddressSelectorComponent.Factory, - private val addressBookFeatureToggles: AddressBookFeatureToggles, + private val sendDestinationComponentFactory: SendDestinationComponent.Factory, ) : NFTSendComponent, AppComponentContext by appComponentContext { private val stackNavigation = StackNavigation() @@ -103,7 +97,7 @@ internal class DefaultNFTSendComponent @AssistedInject constructor( activeComponent.updateState(model.uiState.value) } } - is DefaultSendDestinationComponent -> { + is SendDestinationComponent -> { analyticsEventHandler.send( CommonSendAnalyticEvents.AddressScreenOpened( categoryName = analyticsCategoryName, @@ -138,9 +132,9 @@ internal class DefaultNFTSendComponent @AssistedInject constructor( else -> getStubComponent() } - private fun getDestinationComponent(factoryContext: AppComponentContext): DefaultSendDestinationComponent = - DefaultSendDestinationComponent( - appComponentContext = factoryContext, + private fun getDestinationComponent(factoryContext: AppComponentContext): SendDestinationComponent = + sendDestinationComponentFactory.create( + context = factoryContext, params = SendDestinationComponentParams.DestinationParams( state = model.uiState.value.destinationUM, currentRoute = model.currentRouteFlow.filterIsInstance(), @@ -152,9 +146,6 @@ internal class DefaultNFTSendComponent @AssistedInject constructor( cryptoCurrency = model.cryptoCurrency, callback = model, ), - addressBookFeatureToggles = addressBookFeatureToggles, - contactsBlockFactory = contactsBlockFactory, - addressSelectorFactory = addressSelectorFactory, ) private fun getConfirmComponent(factoryContext: AppComponentContext) = nftSendConfirmComponentFactory.create( diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt index 0056c9c2ff..d493255dca 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt @@ -8,7 +8,6 @@ import com.arkivanov.decompose.router.slot.activate import com.arkivanov.decompose.router.slot.dismiss import com.tangem.common.routing.AppRoute import com.tangem.common.routing.entity.AddressBookOpenMode -import com.tangem.common.ui.account.AccountIconUM import com.tangem.common.ui.navigationButtons.NavigationButton import com.tangem.common.ui.navigationButtons.NavigationUM import com.tangem.core.analytics.api.AnalyticsEventHandler @@ -24,7 +23,6 @@ import com.tangem.domain.addressbook.model.Contact import com.tangem.domain.addressbook.usecase.GetContactsUseCase import com.tangem.domain.feedback.SendBackupProblemEmailUseCase import com.tangem.domain.models.account.AccountStatus -import com.tangem.domain.models.account.CryptoPortfolioIcon import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.CryptoCurrencyAddress @@ -160,9 +158,7 @@ internal class SendDestinationModel @Inject constructor( .onEach { val content = uiState.value as? DestinationUM.Content ?: return@onEach if (content.addressTextField.contactName != null) { - _uiState.update(SendDestinationContactTransformer(contactName = null, contactIcon = null)) - _uiState.update(SendDestinationAddressTransformer(address = "", isPasted = false)) - validate(address = "", memo = content.memoTextField?.value) + _uiState.update(SendDestinationContactTransformer(contact = null)) } } .launchIn(modelScope) @@ -478,32 +474,19 @@ internal class SendDestinationModel @Inject constructor( private fun recognizeContact(type: EnterAddressSource?, isValidAddress: Boolean, address: String) { if (type == null || type == EnterAddressSource.Contact) return - val recognized = if (isValidAddress) findContactByAddress(address) else null - _uiState.update(SendDestinationContactTransformer(recognized?.name, recognized?.icon)) + val contact = if (isValidAddress) findContactByAddress(address) else null + _uiState.update(SendDestinationContactTransformer(contact)) } - private fun findContactByAddress(address: String): RecognizedContact? { + private fun findContactByAddress(address: String): Contact? { val networkId = cryptoCurrency.network.rawId - contacts.value.forEach { contact -> - val isMatch = contact.addressEntries.any { entry -> + return contacts.value.firstOrNull { contact -> + contact.addressEntries.any { entry -> entry.networkId.value == networkId && entry.address.equals(address, ignoreCase = true) } - if (isMatch) { - return RecognizedContact( - name = contact.name.value, - // TODO([REDACTED_TASK_KEY]): take the color from the domain Contact once the data layer stores it. - icon = AccountIconUM.CryptoPortfolio( - value = CryptoPortfolioIcon.Icon.Letter, - color = CryptoPortfolioIcon.Color.Azure, - ), - ) - } } - return null } - private data class RecognizedContact(val name: String, val icon: AccountIconUM.CryptoPortfolio) - private fun autoNextFromRecipient(type: EnterAddressSource, isValidAddress: Boolean, isValidMemo: Boolean) { if (type.isAutoNext && isValidAddress && isValidMemo) { saveResult() diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/converter/ContactIconConverter.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/converter/ContactIconConverter.kt new file mode 100644 index 0000000000..7aab52ca2e --- /dev/null +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/converter/ContactIconConverter.kt @@ -0,0 +1,15 @@ +package com.tangem.features.send.subcomponents.destination.model.converter + +import com.tangem.common.ui.account.AccountIconUM +import com.tangem.domain.addressbook.model.Contact +import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.utils.converter.Converter + +internal object ContactIconConverter : Converter { + + override fun convert(value: Contact): AccountIconUM.CryptoPortfolio = AccountIconUM.CryptoPortfolio( + value = CryptoPortfolioIcon.Icon.Letter, + color = CryptoPortfolioIcon.Color.entries.firstOrNull { it.name == value.iconColor } + ?: CryptoPortfolioIcon.Color.Azure, + ) +} \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/transformers/SendDestinationContactTransformer.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/transformers/SendDestinationContactTransformer.kt index 1dd67091a4..802a0a8a04 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/transformers/SendDestinationContactTransformer.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/transformers/SendDestinationContactTransformer.kt @@ -1,7 +1,9 @@ package com.tangem.features.send.subcomponents.destination.model.transformers import com.tangem.common.ui.account.AccountIconUM +import com.tangem.domain.addressbook.model.Contact import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM +import com.tangem.features.send.subcomponents.destination.model.converter.ContactIconConverter import com.tangem.utils.transformer.Transformer internal class SendDestinationContactTransformer( @@ -9,6 +11,11 @@ internal class SendDestinationContactTransformer( private val contactIcon: AccountIconUM.CryptoPortfolio?, ) : Transformer { + constructor(contact: Contact?) : this( + contactName = contact?.name?.value, + contactIcon = contact?.let(ContactIconConverter::convert), + ) + override fun transform(prevState: DestinationUM): DestinationUM { val state = prevState as? DestinationUM.Content ?: return prevState From 2c57ba3611edfe153b331ad5ff6ad54b7aa36def Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 24 Jun 2026 15:38:13 +0300 Subject: [PATCH 031/210] Updated on 2026-08-14 --- .claude/rules/codestyle/design-system.md | 15 +- core/ui/ds-tokens | 2 +- .../core/ui/ds2/glowring/TangemGlowRing.kt | 297 +++++++++++++++ .../ui/ds2/glowring/TangemGlowRingInternal.kt | 231 ++++++++++++ .../com/tangem/core/ui/res/TangemTheme.kt | 2 + .../tangem/core/ui/res/generated/.tokens-hash | 2 +- .../core/ui/res/generated/TangemColors3.kt | 342 +++++++++++++++++- .../ui/res/generated/TangemColors3Dark.kt | 82 ++++- .../ui/res/generated/TangemColors3Light.kt | 82 ++++- .../ui/res/generated/TangemTypography3.kt | 2 +- .../core/ui/res/generated/icons/.icons-hash | 2 +- .../res/generated/icons/IcAddressPolygon16.kt | 2 +- .../res/generated/icons/IcAddressPolygon20.kt | 2 +- .../res/generated/icons/IcArrowRefresh12.kt | 2 +- .../res/generated/icons/IcArrowRefresh16.kt | 2 +- .../res/generated/icons/IcArrowRefresh28.kt | 52 +++ .../ui/res/generated/icons/IcBinoculars28.kt | 47 +++ .../ui/res/generated/icons/IcCheckmark24.kt | 2 +- .../core/ui/res/generated/icons/IcClock12.kt | 2 +- .../core/ui/res/generated/icons/IcClock16.kt | 4 +- .../core/ui/res/generated/icons/IcClock20.kt | 2 +- .../core/ui/res/generated/icons/IcClock24.kt | 2 +- .../core/ui/res/generated/icons/IcClock28.kt | 52 +++ .../core/ui/res/generated/icons/IcClock32.kt | 2 +- .../core/ui/res/generated/icons/IcCloud16.kt | 2 +- .../core/ui/res/generated/icons/IcCopy16.kt | 2 +- .../core/ui/res/generated/icons/IcCopy20.kt | 2 +- .../res/generated/icons/IcDotsHorizontal24.kt | 2 +- .../core/ui/res/generated/icons/IcEdit20.kt | 2 +- .../core/ui/res/generated/icons/IcError16.kt | 2 +- .../core/ui/res/generated/icons/IcError20.kt | 2 +- .../core/ui/res/generated/icons/IcError24.kt | 2 +- .../core/ui/res/generated/icons/IcGauge20.kt | 2 +- .../core/ui/res/generated/icons/IcGrid16.kt | 62 ++++ .../core/ui/res/generated/icons/IcGrid20.kt | 62 ++++ .../core/ui/res/generated/icons/IcGrid24.kt | 62 ++++ .../core/ui/res/generated/icons/IcGrid28.kt | 62 ++++ .../ui/res/generated/icons/IcGridPlus16.kt | 62 ++++ .../ui/res/generated/icons/IcGridPlus20.kt | 62 ++++ .../ui/res/generated/icons/IcGridPlus24.kt | 62 ++++ .../ui/res/generated/icons/IcGridPlus28.kt | 62 ++++ .../core/ui/res/generated/icons/IcHeart16.kt | 2 +- .../core/ui/res/generated/icons/IcHeart28.kt | 47 +++ .../ui/res/generated/icons/IcHeart28Filled.kt | 47 +++ .../core/ui/res/generated/icons/IcHeart32.kt | 2 +- .../ui/res/generated/icons/IcHeartBroken16.kt | 2 +- .../ui/res/generated/icons/IcHeartBroken28.kt | 47 +++ .../core/ui/res/generated/icons/IcInfo28.kt | 57 +++ .../core/ui/res/generated/icons/IcMail16.kt | 52 +++ .../core/ui/res/generated/icons/IcMail20.kt | 52 +++ .../core/ui/res/generated/icons/IcMail24.kt | 52 +++ .../ui/res/generated/icons/IcPercent16.kt | 57 +++ .../ui/res/generated/icons/IcPercent20.kt | 57 +++ .../ui/res/generated/icons/IcPercent24.kt | 57 +++ .../ui/res/generated/icons/IcPercent28.kt | 57 +++ .../generated/icons/IcPercentBackward20.kt | 2 +- .../generated/icons/IcPercentBackward24.kt | 4 +- .../ui/res/generated/icons/IcPincode20.kt | 2 +- .../ui/res/generated/icons/IcPincode24.kt | 2 +- .../ui/res/generated/icons/IcScanFace20.kt | 82 +++++ .../ui/res/generated/icons/IcScanFace24.kt | 82 +++++ .../ui/res/generated/icons/IcScanFace28.kt | 82 +++++ .../ui/res/generated/icons/IcScanFinger20.kt | 67 ++++ .../ui/res/generated/icons/IcScanFinger24.kt | 67 ++++ .../ui/res/generated/icons/IcScanFinger28.kt | 67 ++++ .../core/ui/res/generated/icons/IcScanQr20.kt | 102 ++++++ .../core/ui/res/generated/icons/IcScanQr24.kt | 102 ++++++ .../res/generated/icons/IcShareAndroid20.kt | 2 +- .../generated/icons/IcShieldCheckmark24.kt | 2 +- .../ui/res/generated/icons/IcSnowflake12.kt | 47 +++ .../ui/res/generated/icons/IcSnowflake20.kt | 2 +- .../ui/res/generated/icons/IcSnowflake24.kt | 2 +- .../ui/res/generated/icons/IcSnowflake28.kt | 47 +++ .../core/ui/res/generated/icons/IcSun16.kt | 2 +- .../core/ui/res/generated/icons/IcSun28.kt | 87 +++++ .../ui/res/generated/icons/IcTrashBin12.kt | 47 +++ .../ui/res/generated/icons/IcTrashBin16.kt | 47 +++ .../ui/res/generated/icons/IcTrashBin20.kt | 47 +++ .../ui/res/generated/icons/IcTrashBin24.kt | 47 +++ .../ui/res/generated/icons/IcTrashBin28.kt | 47 +++ .../core/ui/res/generated/icons/IcWallet16.kt | 47 +++ .../core/ui/res/generated/icons/IcWallet20.kt | 47 +++ .../core/ui/res/generated/icons/IcWallet24.kt | 47 +++ .../core/ui/res/generated/icons/IcWallet28.kt | 47 +++ .../storybook/entity/StoryBookPage.kt | 20 + .../page/ds/DsComponentsListScreen.kt | 2 + .../storybook/page/ds/glowring/Build.kt | 30 ++ .../page/ds/glowring/TangemGlowRingStory.kt | 227 ++++++++++++ .../storybook/ui/StoryBookScreen.kt | 2 + 89 files changed, 3836 insertions(+), 54 deletions(-) create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds2/glowring/TangemGlowRing.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/ds2/glowring/TangemGlowRingInternal.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowRefresh28.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcBinoculars28.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcClock28.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcGrid16.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcGrid20.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcGrid24.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcGrid28.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcGridPlus16.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcGridPlus20.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcGridPlus24.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcGridPlus28.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcHeart28.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcHeart28Filled.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcHeartBroken28.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcInfo28.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcMail16.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcMail20.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcMail24.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcPercent16.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcPercent20.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcPercent24.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcPercent28.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcScanFace20.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcScanFace24.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcScanFace28.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcScanFinger20.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcScanFinger24.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcScanFinger28.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcScanQr20.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcScanQr24.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcSnowflake12.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcSnowflake28.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcSun28.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcTrashBin12.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcTrashBin16.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcTrashBin20.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcTrashBin24.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcTrashBin28.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcWallet16.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcWallet20.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcWallet24.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcWallet28.kt create mode 100644 features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/glowring/Build.kt create mode 100644 features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/glowring/TangemGlowRingStory.kt diff --git a/.claude/rules/codestyle/design-system.md b/.claude/rules/codestyle/design-system.md index 99b4534e6b..5872714e4f 100644 --- a/.claude/rules/codestyle/design-system.md +++ b/.claude/rules/codestyle/design-system.md @@ -19,10 +19,12 @@ generation a component belongs to is essential so you don't mix tokens or pull t |---|---|---|---|---|---| | **DS1** (legacy) | `core/ui/src/main/java/com/tangem/core/ui/components/` | `TangemTheme.colors` | `TangemTheme.typography` | `TangemTheme.dimens` | `TangemThemePreview` | | **DS2** (redesign) | `core/ui/src/main/java/com/tangem/core/ui/ds/` | `TangemTheme.colors2` | `TangemTheme.typography2` | `TangemTheme.dimens2` | `TangemThemePreviewRedesign` | -| **DS3** (target) | `core/ui/src/main/java/com/tangem/core/ui/ds2/` | `TangemTheme.colors3` | `TangemTheme.typography3` | `TangemTheme.dimens2` | `TangemThemePreviewRedesign` | +| **DS3** (target) | `core/ui/src/main/java/com/tangem/core/ui/ds2/` | `TangemTheme.colors3` | `TangemTheme.typography3` | literal `.dp` (no token) | `TangemThemePreviewRedesign` | > Mind the numbering mismatch: **folder `ds` is DS2**, **folder `ds2` is DS3**. > The `colors2` / `typography2` tokens are `@Deprecated` (ReplaceWith `colors3` / `typography3`). +> **DS3 has no dimension token** — `dimens2` is a DS2 token and must **not** be used in `ds2/` +> components. Express dimensions as literal `.dp` values (see rule 2 below). - **DS1** — the entire current app is built on it. Do **not** add new components here. - **DS2** — redesign components. A transitional generation; don't write new components in it, only @@ -49,9 +51,11 @@ Pattern rules: 1. **Package & location.** `com.tangem.core.ui.ds2.`, folder `core/ui/.../ds2//`. The component name is `Tangem`. -2. **DS3 tokens only.** Colors — `TangemTheme.colors3.*`, text — `TangemTheme.typography3.*`, - dimensions — `TangemTheme.dimens2.*`. No `colors` / `colors2` / hardcoded values (literal dp/colors - are acceptable only inside `@Preview`, where you add `@Suppress("MagicNumber")`). +2. **DS3 tokens only.** Colors — `TangemTheme.colors3.*`, text — `TangemTheme.typography3.*`. No + `colors` / `colors2` and no hardcoded colors outside `@Preview`. **Dimensions have no DS3 token** — + do **not** use `TangemTheme.dimens2.*` (it is a DS2 token); express dimensions as literal `.dp` + values and add `@Suppress("MagicNumber")` to the composable (or a `…Ext.kt` / `…Internal.kt` token + holder, as `TangemButtonInternal.kt` and `TangemCheckmark.kt` do). 3. **Signature.** `modifier: Modifier = Modifier` is mandatory (defaulting to `Modifier`, placed first among the optional params or right after the required ones). Express variants/sizes via a nested `enum` in `object Tangem` (like `TangemButton.Variant` / `TangemButton.Size`), not boolean flags. @@ -156,7 +160,8 @@ Page layout guidelines live in - [ ] Component created under `core/ui/.../ds2//`, package `com.tangem.core.ui.ds2.`. - [ ] Named `Tangem`; first optional parameter is `modifier: Modifier = Modifier`. -- [ ] Uses **only** DS3 tokens: `colors3`, `typography3`, `dimens2`. No hardcoded values outside previews. +- [ ] Uses **only** DS3 tokens: `colors3`, `typography3`. No hardcoded colors outside previews. Dimensions + are literal `.dp` (DS3 has no dimension token — never use `dimens2`), with `@Suppress("MagicNumber")`. - [ ] Variants/sizes expressed as an `enum` inside `object Tangem` (not a set of boolean flags). - [ ] All public types (enums, statuses, constants) declared inside the `object Tangem`. - [ ] Convenient overloads provided (simpler `@Composable` overloads and/or `object` extension presets). diff --git a/core/ui/ds-tokens b/core/ui/ds-tokens index 76d6a50dc3..42aac70c1d 160000 --- a/core/ui/ds-tokens +++ b/core/ui/ds-tokens @@ -1 +1 @@ -Subproject commit 76d6a50dc3161cfc6cc7055afc8ce4ba619ac5c6 +Subproject commit 42aac70c1d2d0d636470fa476703cab353010bba diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/glowring/TangemGlowRing.kt b/core/ui/src/main/java/com/tangem/core/ui/ds2/glowring/TangemGlowRing.kt new file mode 100644 index 0000000000..b8b5f4b822 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds2/glowring/TangemGlowRing.kt @@ -0,0 +1,297 @@ +@file:Suppress("MagicNumber") + +package com.tangem.core.ui.ds2.glowring + +import android.os.Build +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.core.ui.res.generated.TangemColors3 + +/** + * Design-system v2 (DS3) **Glow Ring** — an animated angular-gradient halo that runs around a + * rounded-rect outline, like lights chasing along the border. + * + * [Figma](https://www.figma.com/design/AsnJ5CPHib4Qxw12gszjMS/%F0%9F%92%A0-DS-Components?node-id=4933-126&m=dev) + * + * @param modifier Modifier for the whole component; also defines its size when there is no [content]. + * @param variant Color theme of the gradient — see [TangemGlowRing.Variant]. + * @param cornerRadius Corner radius of the ring; should match the radius of the wrapped surface. + * @param animated When `false`, the ring is rendered static (no rotation). + * @param quality Rendering strategy; defaults to [TangemGlowRing.Quality.Auto] (device-appropriate). + * Force [TangemGlowRing.Quality.LayeredStrokes] to preview the pre-Android-12 fallback on any device. + * @param contentDescription Accessibility label; pass a value when the ring conveys state (e.g. error), + * leave `null` when it is purely decorative. + * @param content Optional content drawn inside/over the ring. + */ +@Composable +fun TangemGlowRing( + modifier: Modifier = Modifier, + variant: TangemGlowRing.Variant = TangemGlowRing.Variant.Magic, + cornerRadius: Dp = 24.dp, + animated: Boolean = true, + quality: TangemGlowRing.Quality = TangemGlowRing.Quality.Auto, + contentDescription: String? = null, + content: @Composable BoxScope.() -> Unit = {}, +) { + val resolved = remember(quality) { resolveQuality(quality) } + val stops = rememberGlowRingStops(variant, animated) + val metrics = remember { + GlowRingMetrics(coreWidth = 2.dp, ringWidth = 4.dp, blurMid = 8.dp, blurBottom = 16.dp) + } + + val angle = if (animated) { + val transition = rememberInfiniteTransition(label = "glowRing") + val rotation by transition.animateFloat( + initialValue = GLOW_RING_START_ANGLE, + targetValue = 270f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 24_000, + easing = CubicBezierEasing(a = 0.1f, b = 0f, c = 0.9f, d = 1f), + ), + repeatMode = RepeatMode.Restart, + ), + label = "angle", + ) + rotation + } else { + GLOW_RING_START_ANGLE + } + + Box( + modifier = if (contentDescription != null) { + modifier.semantics { this.contentDescription = contentDescription } + } else { + modifier + }, + ) { + val ringModifier = Modifier.matchParentSize() + when (resolved) { + ResolvedGlowRingQuality.Blur -> BlurGlowRing( + angle = angle, + stops = stops, + cornerRadius = cornerRadius, + metrics = metrics, + modifier = ringModifier, + ) + ResolvedGlowRingQuality.LayeredStrokes -> LayeredStrokesGlowRing( + angle = angle, + stops = stops, + cornerRadius = cornerRadius, + metrics = metrics, + modifier = ringModifier, + ) + } + content() + } +} + +/** Sweep start angle, also reused as the static angle when [TangemGlowRing] is not animated (Figma: -90°). */ +private const val GLOW_RING_START_ANGLE = -90f + +/** + * Resolves the gradient stops for [variant] from the DS3 `colors3.glow` tokens. The + * [TangemGlowRing.Variant.Magic] variant continuously ping-pongs between gradient A (`glow.magic`) and + * gradient B (`glow.magicBlend`) while [animated] is `true`; every other variant has a single static + * gradient. + */ +@Composable +private fun rememberGlowRingStops(variant: TangemGlowRing.Variant, animated: Boolean): List> { + val glow = TangemTheme.colors3.glow + if (variant != TangemGlowRing.Variant.Magic || !animated) { + return variant.stops(glow) + } + val morphTransition = rememberInfiniteTransition(label = "glowRingMorph") + val mix by morphTransition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + // 6s A→B half-period; Reverse makes a 12s ping-pong (Figma morphDur = 12s). + animation = tween(durationMillis = 6_000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "morphMix", + ) + return morphedMagicStops(glow.magic.steps(), glow.magicBlend.steps(), mix) +} + +/** Public API surface of [TangemGlowRing]. */ +object TangemGlowRing { + + /** Color theme of the glow ring gradient. */ + enum class Variant { + /** + * Multi-color "magic" gradient that continuously auto-morphs (ping-pongs) between separated + * orange / blue / purple arcs and a continuous fully-saturated blend. + */ + Magic, + + /** Green success glow. */ + Success, + + /** Red error glow. */ + Error, + + /** Orange/amber warning glow. */ + Warning, + + /** Blue informational glow. */ + Info, + } + + /** + * Rendering strategy for the glow. + * + * [Auto] picks the best renderer for the current device — a real Gaussian blur on Android 12+ + * (API 31) and a layered-stroke approximation on older versions. The explicit values force one + * renderer regardless of API level; they exist mainly for previews / Storybook so the + * pre-Android-12 fallback can be inspected on a modern device. Product code should use [Auto]. + */ + enum class Quality { + /** Auto-detect the renderer from the device API level (recommended). */ + Auto, + + /** Force the Android 12+ real-blur renderer. */ + Blur, + + /** Force the pre-Android-12 layered-stroke fallback. */ + LayeredStrokes, + } +} + +/** + * Resolves [quality] to a concrete renderer. [TangemGlowRing.Quality.Auto] picks a real blur on + * Android 12+ (API 31) and falls back to stacked translucent strokes on older versions; the explicit + * values force their renderer regardless of API level. + */ +private fun resolveQuality(quality: TangemGlowRing.Quality): ResolvedGlowRingQuality = when (quality) { + TangemGlowRing.Quality.Blur -> ResolvedGlowRingQuality.Blur + TangemGlowRing.Quality.LayeredStrokes -> ResolvedGlowRingQuality.LayeredStrokes + TangemGlowRing.Quality.Auto -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + ResolvedGlowRingQuality.Blur + } else { + ResolvedGlowRingQuality.LayeredStrokes + } +} + +/** Concrete rendering strategy chosen by [resolveQuality]. */ +private enum class ResolvedGlowRingQuality { Blur, LayeredStrokes } + +/** + * Builds the angular-gradient stops for [this] variant from its DS3 `colors3.glow` token group. Every + * variant token exposes the same 10 [steps] — solid arcs at steps 1/4/7, a faint arc at 9 and transparent + * gaps elsewhere — which [glowStops] lays out as evenly-spaced, seamlessly-looping stops. + */ +private fun TangemGlowRing.Variant.stops(glow: TangemColors3.Glow): List> = glowStops( + when (this) { + TangemGlowRing.Variant.Magic -> glow.magic.steps() + TangemGlowRing.Variant.Success -> glow.success.steps() + TangemGlowRing.Variant.Error -> glow.error.steps() + TangemGlowRing.Variant.Warning -> glow.warning.steps() + TangemGlowRing.Variant.Info -> glow.info.steps() + }, +) + +/** + * Blends the Magic gradients A ([magic] = `glow.magic`) and B ([magicBlend] = `glow.magicBlend`) at + * [mix] (`0` = A, `1` = B). Both token groups share the same stop positions, so the morph is a direct + * per-step color lerp. Mirrors the reference rig's auto-morph (ping-pong) between gradient A and B. + */ +private fun morphedMagicStops(magic: List, magicBlend: List, mix: Float): List> { + val m = mix.coerceIn(0f, 1f) + return glowStops(List(magic.size) { lerp(magic[it], magicBlend[it], m) }) +} + +/** + * Lays the glow [steps] out as an angular gradient: evenly spaced from `0`, with step 1 repeated at `1.0` + * so the rotation loops seamlessly. Transparent steps create the gaps between the glowing arcs. + */ +private fun glowStops(steps: List): List> { + val count = steps.size + return steps.mapIndexed { index, color -> index.toFloat() / count to color } + (1f to steps.first()) +} + +private fun TangemColors3.Glow.Magic.steps(): List = + listOf(step1, step2, step3, step4, step5, step6, step7, step8, step9, step10) + +private fun TangemColors3.Glow.MagicBlend.steps(): List = + listOf(step1, step2, step3, step4, step5, step6, step7, step8, step9, step10) + +private fun TangemColors3.Glow.Success.steps(): List = + listOf(step1, step2, step3, step4, step5, step6, step7, step8, step9, step10) + +private fun TangemColors3.Glow.Error.steps(): List = + listOf(step1, step2, step3, step4, step5, step6, step7, step8, step9, step10) + +private fun TangemColors3.Glow.Warning.steps(): List = + listOf(step1, step2, step3, step4, step5, step6, step7, step8, step9, step10) + +private fun TangemColors3.Glow.Info.steps(): List = + listOf(step1, step2, step3, step4, step5, step6, step7, step8, step9, step10) + +@Preview(name = "Light", showBackground = true) +@Preview(name = "Dark", uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES, showBackground = true) +@Composable +private fun TangemGlowRingPreview() { + TangemThemePreviewRedesign { + Column( + modifier = Modifier + .background(TangemTheme.colors3.bg.primary) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) { + TangemGlowRing( + modifier = Modifier.size(120.dp, 72.dp), + variant = TangemGlowRing.Variant.Magic, + ) + TangemGlowRing( + modifier = Modifier.size(120.dp, 72.dp), + variant = TangemGlowRing.Variant.Success, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) { + TangemGlowRing( + modifier = Modifier.size(120.dp, 72.dp), + variant = TangemGlowRing.Variant.Error, + ) + TangemGlowRing( + modifier = Modifier.size(120.dp, 72.dp), + variant = TangemGlowRing.Variant.Warning, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) { + TangemGlowRing( + modifier = Modifier.size(120.dp, 72.dp), + variant = TangemGlowRing.Variant.Info, + ) + } + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds2/glowring/TangemGlowRingInternal.kt b/core/ui/src/main/java/com/tangem/core/ui/ds2/glowring/TangemGlowRingInternal.kt new file mode 100644 index 0000000000..cb4f4f1ad5 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/ds2/glowring/TangemGlowRingInternal.kt @@ -0,0 +1,231 @@ +@file:Suppress("MagicNumber") + +package com.tangem.core.ui.ds2.glowring + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.BlurredEdgeTreatment +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.clipPath +import androidx.compose.ui.graphics.drawscope.withTransform +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.unit.Dp +import kotlin.math.cos +import kotlin.math.max +import kotlin.math.min + +/** + * Token-driven measurements shared by both renderers, mirroring the Figma component anatomy: + * a crisp [coreWidth] core line plus two wider, blurred glow bands ([ringWidth] stroked, blurred by + * [blurMid] and [blurBottom]). + */ +internal data class GlowRingMetrics( + val coreWidth: Dp, // crisp core stroke (top layer) + val ringWidth: Dp, // glow band stroke (mid + bottom layers) + val blurMid: Dp, // mid glow blur radius + val blurBottom: Dp, // widest glow blur radius +) + +/** + * Tier 1 — works on every API level, no blur or shader. Approximates the blurred glow by stacking the + * same breathing angular-gradient ring several times: progressively wider + fainter bands under a crisp + * core. Everything is clipped to the rounded box, so only the inner half of each band shows → inner glow. + */ +@Composable +internal fun LayeredStrokesGlowRing( + angle: Float, + stops: List>, + cornerRadius: Dp, + metrics: GlowRingMetrics, + modifier: Modifier = Modifier, +) { + Box(modifier.clip(RoundedCornerShape(cornerRadius))) { + Canvas(Modifier.fillMaxSize()) { + val r = cornerRadius.toPx() + // widest & faintest first, crisp core last + drawBreathingRing( + stops = stops, + angleDeg = angle, + cornerRadiusPx = r, + strokePx = (metrics.ringWidth + metrics.blurBottom).toPx(), + alpha = 0.06f, + ) + drawBreathingRing( + stops = stops, + angleDeg = angle, + cornerRadiusPx = r, + strokePx = (metrics.ringWidth + metrics.blurMid).toPx(), + alpha = 0.12f, + ) + drawBreathingRing( + stops = stops, + angleDeg = angle, + cornerRadiusPx = r, + strokePx = metrics.ringWidth.toPx(), + alpha = 0.30f, + ) + drawBreathingRing( + stops = stops, + angleDeg = angle, + cornerRadiusPx = r, + strokePx = metrics.coreWidth.toPx(), + alpha = 1.0f, + ) + } + } +} + +/** + * Tier 2 — Android 12+ (API 31). Reproduces the Figma anatomy directly: three stacked breathing + * angular-gradient rings with real blur (bottom widest, mid, top crisp). Each layer bleeds with + * [BlurredEdgeTreatment.Unbounded]; the surrounding [clip] to the rounded box keeps only the inner + * bloom, producing the inner glow. + */ +@Composable +internal fun BlurGlowRing( + angle: Float, + stops: List>, + cornerRadius: Dp, + metrics: GlowRingMetrics, + modifier: Modifier = Modifier, +) { + Box(modifier.clip(RoundedCornerShape(cornerRadius))) { + // bottom — widest halo + BreathingRing( + angle = angle, + stops = stops, + cornerRadius = cornerRadius, + strokeWidth = metrics.ringWidth, + modifier = Modifier + .fillMaxSize() + .blur(radius = metrics.blurBottom, edgeTreatment = BlurredEdgeTreatment.Unbounded), + ) + // mid + BreathingRing( + angle = angle, + stops = stops, + cornerRadius = cornerRadius, + strokeWidth = metrics.ringWidth, + modifier = Modifier + .fillMaxSize() + .blur(radius = metrics.blurMid, edgeTreatment = BlurredEdgeTreatment.Unbounded), + ) + // top — crisp core + BreathingRing( + angle = angle, + stops = stops, + cornerRadius = cornerRadius, + strokeWidth = metrics.coreWidth, + modifier = Modifier.fillMaxSize(), + ) + } +} + +@Composable +private fun BreathingRing( + angle: Float, + stops: List>, + cornerRadius: Dp, + strokeWidth: Dp, + modifier: Modifier = Modifier, +) { + Canvas(modifier) { + drawBreathingRing( + stops = stops, + angleDeg = angle, + cornerRadiusPx = cornerRadius.toPx(), + strokePx = strokeWidth.toPx(), + alpha = 1f, + ) + } +} + +/** + * Draws one angular-gradient ring band clipped to the rounded-rect stroke outline. The gradient is a + * sweep whose colour seam is rotated by [angleDeg] (via [rotatedStops]) and whose vertical squish + * breathes between W/2 and W/8 over the rotation (`rxM = mid + amp·cos(2φ)`), reproducing the morphing + * arcs of the reference rig. + */ +private fun DrawScope.drawBreathingRing( + stops: List>, + angleDeg: Float, + cornerRadiusPx: Float, + strokePx: Float, + alpha: Float, +) { + val w = size.width + val h = size.height + if (w <= 0f || h <= 0f) return + val center = Offset(w / 2f, h / 2f) + + // Breathing horizontal radius of the gradient ellipse → vertical squish of the angle sampling. + val maxRx = w / 2f + val minRx = w / 8f + val mid = (maxRx + minRx) / 2f + val amp = max((maxRx - minRx) / 2f, 0f) + val phaseRad = Math.toRadians(angleDeg.toDouble()).toFloat() + val rxM = mid + amp * cos(2f * phaseRad) + val scaleY = h / 2f / max(rxM, 1f) + + val r = min(cornerRadiusPx, min(w, h) / 2f) + val o = strokePx / 2f + val ring = Path().apply { + fillType = PathFillType.EvenOdd + addRoundRect( + RoundRect(rect = Rect(Offset(-o, -o), Size(w + 2f * o, h + 2f * o)), cornerRadius = CornerRadius(r + o)), + ) + addRoundRect( + RoundRect( + rect = Rect(Offset(o, o), Size(w - 2f * o, h - 2f * o)), + cornerRadius = CornerRadius(max(r - o, 0f)), + ), + ) + } + + val brush = Brush.sweepGradient(colorStops = rotatedStops(stops, angleDeg), center = center) + val big = max(w, h) * 4f + clipPath(ring) { + withTransform({ scale(scaleX = 1f, scaleY = scaleY, pivot = center) }) { + drawRect( + brush = brush, + topLeft = Offset(center.x - big / 2f, center.y - big / 2f), + size = Size(big, big), + alpha = alpha, + ) + } + } +} + +/** + * Compose's [Brush.sweepGradient] has no start-angle parameter, so the colour seam is rotated by + * shifting every stop position by `deg/360` (wrapping around the loop) and re-anchoring boundary stops + * at 0 and 1 with the interpolated wrap colour. Mirrors `rotatedStops` from the reference rig. + */ +private fun rotatedStops(base: List>, deg: Float): Array> { + val d = (deg / 360f % 1f + 1f) % 1f + val uniq = base.dropLast(1) // drop the duplicate wrap stop at 1.0 + val shifted = uniq + .map { (p, c) -> ((p + d) % 1f + 1f) % 1f to c } + .sortedBy { it.first } + val first = shifted.first() + val last = shifted.last() + val span = first.first + 1f - last.first + val wrapFraction = if (span > 1e-6f) (1f - last.first) / span else 0f + val wrapColor = lerp(last.second, first.second, wrapFraction) + return (listOf(0f to wrapColor) + shifted + listOf(1f to wrapColor)).toTypedArray() +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt index d95e93902a..7ea6cfb4c7 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/TangemTheme.kt @@ -190,11 +190,13 @@ object TangemTheme { @ReadOnlyComposable get() = LocalTangemTypography3.current + @Deprecated("Use plain dp") val dimens: TangemDimens @Composable @ReadOnlyComposable get() = LocalTangemDimens.current + @Deprecated("Use plain dp") val dimens2: TangemDimens2 @Composable @ReadOnlyComposable diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/.tokens-hash b/core/ui/src/main/java/com/tangem/core/ui/res/generated/.tokens-hash index 0800a2ba15..25a1abae85 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/.tokens-hash +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/.tokens-hash @@ -1 +1 @@ -7a974320353cf7ea1e0a25ca074f8e200ce44506044cc5b2bdcb00aee2c6dc85 +d90598b8786899b4dbdd8f8744c24c13ea8a9545971b0f20c1c2136be83e63be diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemColors3.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemColors3.kt index 12e2412528..6d46d9a9b0 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemColors3.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemColors3.kt @@ -19,6 +19,7 @@ class TangemColors3 internal constructor( val border: Border, val overlay: Overlay, val interaction: Interaction, + val glow: Glow, val material: Material, ) { @@ -135,6 +136,7 @@ class TangemColors3 internal constructor( orange: Color, yellow: Color, green: Color, + neutral: Color, ) { var blue by mutableStateOf(blue) private set @@ -148,6 +150,8 @@ class TangemColors3 internal constructor( private set var green by mutableStateOf(green) private set + var neutral by mutableStateOf(neutral) + private set fun update(other: Accent) { blue = other.blue @@ -156,6 +160,7 @@ class TangemColors3 internal constructor( orange = other.orange yellow = other.yellow green = other.green + neutral = other.neutral } } @@ -264,6 +269,7 @@ class TangemColors3 internal constructor( orange: Color, yellow: Color, green: Color, + neutral: Color, ) { var blue by mutableStateOf(blue) private set @@ -277,6 +283,8 @@ class TangemColors3 internal constructor( private set var green by mutableStateOf(green) private set + var neutral by mutableStateOf(neutral) + private set fun update(other: Accent) { blue = other.blue @@ -285,6 +293,7 @@ class TangemColors3 internal constructor( orange = other.orange yellow = other.yellow green = other.green + neutral = other.neutral } } @@ -361,6 +370,7 @@ class TangemColors3 internal constructor( orange: Color, yellow: Color, green: Color, + neutral: Color, ) { var blue by mutableStateOf(blue) private set @@ -374,6 +384,8 @@ class TangemColors3 internal constructor( private set var green by mutableStateOf(green) private set + var neutral by mutableStateOf(neutral) + private set fun update(other: Accent) { blue = other.blue @@ -382,6 +394,7 @@ class TangemColors3 internal constructor( orange = other.orange yellow = other.yellow green = other.green + neutral = other.neutral } } @@ -485,6 +498,7 @@ class TangemColors3 internal constructor( orange: Color, yellow: Color, green: Color, + neutral: Color, ) { var blue by mutableStateOf(blue) private set @@ -498,6 +512,8 @@ class TangemColors3 internal constructor( private set var green by mutableStateOf(green) private set + var neutral by mutableStateOf(neutral) + private set fun update(other: Accent) { blue = other.blue @@ -506,6 +522,7 @@ class TangemColors3 internal constructor( orange = other.orange yellow = other.yellow green = other.green + neutral = other.neutral } } @@ -534,28 +551,30 @@ class TangemColors3 internal constructor( @Stable class Interaction internal constructor( - pressStaticLight: Color, - pressStaticDark: Color, val press: Press, val focusRing: FocusRing, ) { - var pressStaticLight by mutableStateOf(pressStaticLight) - private set - var pressStaticDark by mutableStateOf(pressStaticDark) - private set @Stable class Press internal constructor( default: Color, + staticLight: Color, + staticDark: Color, inverse: Color, ) { var default by mutableStateOf(default) private set + var staticLight by mutableStateOf(staticLight) + private set + var staticDark by mutableStateOf(staticDark) + private set var inverse by mutableStateOf(inverse) private set fun update(other: Press) { default = other.default + staticLight = other.staticLight + staticDark = other.staticDark inverse = other.inverse } } @@ -577,13 +596,319 @@ class TangemColors3 internal constructor( } fun update(other: Interaction) { - pressStaticLight = other.pressStaticLight - pressStaticDark = other.pressStaticDark press.update(other.press) focusRing.update(other.focusRing) } } + @Stable + class Glow internal constructor( + val magic: Magic, + val magicBlend: MagicBlend, + val success: Success, + val error: Error, + val warning: Warning, + val info: Info, + ) { + + @Stable + class Magic internal constructor( + step1: Color, + step2: Color, + step3: Color, + step4: Color, + step5: Color, + step6: Color, + step7: Color, + step8: Color, + step9: Color, + step10: Color, + ) { + var step1 by mutableStateOf(step1) + private set + var step2 by mutableStateOf(step2) + private set + var step3 by mutableStateOf(step3) + private set + var step4 by mutableStateOf(step4) + private set + var step5 by mutableStateOf(step5) + private set + var step6 by mutableStateOf(step6) + private set + var step7 by mutableStateOf(step7) + private set + var step8 by mutableStateOf(step8) + private set + var step9 by mutableStateOf(step9) + private set + var step10 by mutableStateOf(step10) + private set + + fun update(other: Magic) { + step1 = other.step1 + step2 = other.step2 + step3 = other.step3 + step4 = other.step4 + step5 = other.step5 + step6 = other.step6 + step7 = other.step7 + step8 = other.step8 + step9 = other.step9 + step10 = other.step10 + } + } + + @Stable + class MagicBlend internal constructor( + step1: Color, + step2: Color, + step3: Color, + step4: Color, + step5: Color, + step6: Color, + step7: Color, + step8: Color, + step9: Color, + step10: Color, + ) { + var step1 by mutableStateOf(step1) + private set + var step2 by mutableStateOf(step2) + private set + var step3 by mutableStateOf(step3) + private set + var step4 by mutableStateOf(step4) + private set + var step5 by mutableStateOf(step5) + private set + var step6 by mutableStateOf(step6) + private set + var step7 by mutableStateOf(step7) + private set + var step8 by mutableStateOf(step8) + private set + var step9 by mutableStateOf(step9) + private set + var step10 by mutableStateOf(step10) + private set + + fun update(other: MagicBlend) { + step1 = other.step1 + step2 = other.step2 + step3 = other.step3 + step4 = other.step4 + step5 = other.step5 + step6 = other.step6 + step7 = other.step7 + step8 = other.step8 + step9 = other.step9 + step10 = other.step10 + } + } + + @Stable + class Success internal constructor( + step1: Color, + step2: Color, + step3: Color, + step4: Color, + step5: Color, + step6: Color, + step7: Color, + step8: Color, + step9: Color, + step10: Color, + ) { + var step1 by mutableStateOf(step1) + private set + var step2 by mutableStateOf(step2) + private set + var step3 by mutableStateOf(step3) + private set + var step4 by mutableStateOf(step4) + private set + var step5 by mutableStateOf(step5) + private set + var step6 by mutableStateOf(step6) + private set + var step7 by mutableStateOf(step7) + private set + var step8 by mutableStateOf(step8) + private set + var step9 by mutableStateOf(step9) + private set + var step10 by mutableStateOf(step10) + private set + + fun update(other: Success) { + step1 = other.step1 + step2 = other.step2 + step3 = other.step3 + step4 = other.step4 + step5 = other.step5 + step6 = other.step6 + step7 = other.step7 + step8 = other.step8 + step9 = other.step9 + step10 = other.step10 + } + } + + @Stable + class Error internal constructor( + step1: Color, + step2: Color, + step3: Color, + step4: Color, + step5: Color, + step6: Color, + step7: Color, + step8: Color, + step9: Color, + step10: Color, + ) { + var step1 by mutableStateOf(step1) + private set + var step2 by mutableStateOf(step2) + private set + var step3 by mutableStateOf(step3) + private set + var step4 by mutableStateOf(step4) + private set + var step5 by mutableStateOf(step5) + private set + var step6 by mutableStateOf(step6) + private set + var step7 by mutableStateOf(step7) + private set + var step8 by mutableStateOf(step8) + private set + var step9 by mutableStateOf(step9) + private set + var step10 by mutableStateOf(step10) + private set + + fun update(other: Error) { + step1 = other.step1 + step2 = other.step2 + step3 = other.step3 + step4 = other.step4 + step5 = other.step5 + step6 = other.step6 + step7 = other.step7 + step8 = other.step8 + step9 = other.step9 + step10 = other.step10 + } + } + + @Stable + class Warning internal constructor( + step1: Color, + step2: Color, + step3: Color, + step4: Color, + step5: Color, + step6: Color, + step7: Color, + step8: Color, + step9: Color, + step10: Color, + ) { + var step1 by mutableStateOf(step1) + private set + var step2 by mutableStateOf(step2) + private set + var step3 by mutableStateOf(step3) + private set + var step4 by mutableStateOf(step4) + private set + var step5 by mutableStateOf(step5) + private set + var step6 by mutableStateOf(step6) + private set + var step7 by mutableStateOf(step7) + private set + var step8 by mutableStateOf(step8) + private set + var step9 by mutableStateOf(step9) + private set + var step10 by mutableStateOf(step10) + private set + + fun update(other: Warning) { + step1 = other.step1 + step2 = other.step2 + step3 = other.step3 + step4 = other.step4 + step5 = other.step5 + step6 = other.step6 + step7 = other.step7 + step8 = other.step8 + step9 = other.step9 + step10 = other.step10 + } + } + + @Stable + class Info internal constructor( + step1: Color, + step2: Color, + step3: Color, + step4: Color, + step5: Color, + step6: Color, + step7: Color, + step8: Color, + step9: Color, + step10: Color, + ) { + var step1 by mutableStateOf(step1) + private set + var step2 by mutableStateOf(step2) + private set + var step3 by mutableStateOf(step3) + private set + var step4 by mutableStateOf(step4) + private set + var step5 by mutableStateOf(step5) + private set + var step6 by mutableStateOf(step6) + private set + var step7 by mutableStateOf(step7) + private set + var step8 by mutableStateOf(step8) + private set + var step9 by mutableStateOf(step9) + private set + var step10 by mutableStateOf(step10) + private set + + fun update(other: Info) { + step1 = other.step1 + step2 = other.step2 + step3 = other.step3 + step4 = other.step4 + step5 = other.step5 + step6 = other.step6 + step7 = other.step7 + step8 = other.step8 + step9 = other.step9 + step10 = other.step10 + } + } + + fun update(other: Glow) { + magic.update(other.magic) + magicBlend.update(other.magicBlend) + success.update(other.success) + error.update(other.error) + warning.update(other.warning) + info.update(other.info) + } + } + @Stable class Material internal constructor( val tint: Tint, @@ -709,6 +1034,7 @@ class TangemColors3 internal constructor( border.update(other.border) overlay.update(other.overlay) interaction.update(other.interaction) + glow.update(other.glow) material.update(other.material) } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemColors3Dark.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemColors3Dark.kt index a2fa0d535e..461fd9f863 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemColors3Dark.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemColors3Dark.kt @@ -43,6 +43,7 @@ internal fun darkColors3() = orange = TangemColorPalette.Orange.`40`, yellow = TangemColorPalette.Yellow.`40`, green = TangemColorPalette.Green.`40`, + neutral = TangemColorPalette.Neutral.`40`, ), ), bg = TangemColors3.Bg( @@ -74,6 +75,7 @@ internal fun darkColors3() = orange = TangemColorPalette.Orange.`50`, yellow = TangemColorPalette.Yellow.`50`, green = TangemColorPalette.Green.`50`, + neutral = TangemColorPalette.Neutral.`50`, ), ), icon = TangemColors3.Icon( @@ -97,6 +99,7 @@ internal fun darkColors3() = orange = TangemColorPalette.Orange.`40`, yellow = TangemColorPalette.Yellow.`40`, green = TangemColorPalette.Green.`40`, + neutral = TangemColorPalette.Neutral.`40`, ), ), border = TangemColors3.Border( @@ -126,16 +129,17 @@ internal fun darkColors3() = orange = TangemColorPalette.Orange.`40`, yellow = TangemColorPalette.Yellow.`40`, green = TangemColorPalette.Green.`40`, + neutral = TangemColorPalette.Neutral.`40`, ), ), overlay = TangemColors3.Overlay( modal = TangemColorPalette.Opaque.BaseBlack.`80`, ), interaction = TangemColors3.Interaction( - pressStaticLight = TangemColorPalette.Opaque.BaseBlack.`10`, - pressStaticDark = TangemColorPalette.Opaque.BaseWhite.`10`, press = TangemColors3.Interaction.Press( default = TangemColorPalette.Opaque.BaseWhite.`10`, + staticLight = TangemColorPalette.Opaque.BaseBlack.`10`, + staticDark = TangemColorPalette.Opaque.BaseWhite.`10`, inverse = TangemColorPalette.Opaque.BaseBlack.`10`, ), focusRing = TangemColors3.Interaction.FocusRing( @@ -143,6 +147,80 @@ internal fun darkColors3() = brand = TangemColorPalette.Blue.`50`, ), ), + glow = TangemColors3.Glow( + magic = TangemColors3.Glow.Magic( + step1 = TangemColorPalette.Yellow.`30`, + step2 = Color(0x000077E1), + step3 = Color(0x000C58AF), + step4 = TangemColorPalette.Blue.`50`, + step5 = Color(0x005EBDF9), + step6 = Color(0x00473068), + step7 = TangemColorPalette.Violet.`50`, + step8 = Color(0x00E12C2E), + step9 = Color(0x4D473068), + step10 = Color(0x0067419B), + ), + magicBlend = TangemColors3.Glow.MagicBlend( + step1 = TangemColorPalette.Violet.`50`, + step2 = Color(0x00143C70), + step3 = Color(0x00FA6931), + step4 = TangemColorPalette.Yellow.`30`, + step5 = Color(0x002DAE3B), + step6 = Color(0x0098D7FF), + step7 = TangemColorPalette.Green.`20`, + step8 = Color(0x00A967FD), + step9 = Color(0x4D67419B), + step10 = Color(0x00FF5E66), + ), + success = TangemColors3.Glow.Success( + step1 = TangemColorPalette.Green.`50`, + step2 = Color(0x001C4415), + step3 = Color(0x001C4415), + step4 = TangemColorPalette.Green.`60`, + step5 = Color(0x001C4415), + step6 = Color(0x001C4415), + step7 = TangemColorPalette.Green.`40`, + step8 = Color(0x001C4415), + step9 = Color(0x4D1C4415), + step10 = Color(0x001C4415), + ), + error = TangemColors3.Glow.Error( + step1 = TangemColorPalette.Red.`50`, + step2 = Color(0x006D2323), + step3 = Color(0x006D2323), + step4 = TangemColorPalette.Red.`60`, + step5 = Color(0x006D2323), + step6 = Color(0x006D2323), + step7 = TangemColorPalette.Red.`40`, + step8 = Color(0x006D2323), + step9 = Color(0x4D6D2323), + step10 = Color(0x006D2323), + ), + warning = TangemColors3.Glow.Warning( + step1 = TangemColorPalette.Yellow.`40`, + step2 = Color(0x00573414), + step3 = Color(0x00573414), + step4 = TangemColorPalette.Yellow.`50`, + step5 = Color(0x00573414), + step6 = Color(0x00573414), + step7 = TangemColorPalette.Yellow.`30`, + step8 = Color(0x00573414), + step9 = Color(0x4D573414), + step10 = Color(0x00573414), + ), + info = TangemColors3.Glow.Info( + step1 = TangemColorPalette.Blue.`50`, + step2 = Color(0x00143C70), + step3 = Color(0x00143C70), + step4 = TangemColorPalette.Blue.`60`, + step5 = Color(0x00143C70), + step6 = Color(0x00143C70), + step7 = TangemColorPalette.Blue.`40`, + step8 = Color(0x00143C70), + step9 = Color(0x4D143C70), + step10 = Color(0x00143C70), + ), + ), material = TangemColors3.Material( tint = TangemColors3.Material.Tint( glass = Color(0x662C2C2C), diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemColors3Light.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemColors3Light.kt index b52a4e4221..2e19348749 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemColors3Light.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemColors3Light.kt @@ -43,6 +43,7 @@ internal fun lightColors3() = orange = TangemColorPalette.Orange.`50`, yellow = TangemColorPalette.Yellow.`50`, green = TangemColorPalette.Green.`50`, + neutral = TangemColorPalette.Neutral.`50`, ), ), bg = TangemColors3.Bg( @@ -74,6 +75,7 @@ internal fun lightColors3() = orange = TangemColorPalette.Orange.`50`, yellow = TangemColorPalette.Yellow.`50`, green = TangemColorPalette.Green.`50`, + neutral = TangemColorPalette.Neutral.`50`, ), ), icon = TangemColors3.Icon( @@ -97,6 +99,7 @@ internal fun lightColors3() = orange = TangemColorPalette.Orange.`50`, yellow = TangemColorPalette.Yellow.`50`, green = TangemColorPalette.Green.`50`, + neutral = TangemColorPalette.Neutral.`50`, ), ), border = TangemColors3.Border( @@ -126,16 +129,17 @@ internal fun lightColors3() = orange = TangemColorPalette.Orange.`50`, yellow = TangemColorPalette.Yellow.`50`, green = TangemColorPalette.Green.`50`, + neutral = TangemColorPalette.Neutral.`50`, ), ), overlay = TangemColors3.Overlay( modal = TangemColorPalette.Opaque.BaseBlack.`60`, ), interaction = TangemColors3.Interaction( - pressStaticLight = TangemColorPalette.Opaque.BaseBlack.`10`, - pressStaticDark = TangemColorPalette.Opaque.BaseWhite.`10`, press = TangemColors3.Interaction.Press( default = TangemColorPalette.Opaque.BaseBlack.`10`, + staticLight = TangemColorPalette.Opaque.BaseBlack.`10`, + staticDark = TangemColorPalette.Opaque.BaseWhite.`10`, inverse = TangemColorPalette.Opaque.BaseWhite.`10`, ), focusRing = TangemColors3.Interaction.FocusRing( @@ -143,6 +147,80 @@ internal fun lightColors3() = brand = TangemColorPalette.Blue.`50`, ), ), + glow = TangemColors3.Glow( + magic = TangemColors3.Glow.Magic( + step1 = TangemColorPalette.Yellow.`30`, + step2 = Color(0x00DBF1FF), + step3 = Color(0x00109FF0), + step4 = TangemColorPalette.Blue.`40`, + step5 = Color(0x00DBF1FF), + step6 = Color(0x00C5A5FC), + step7 = TangemColorPalette.Violet.`40`, + step8 = Color(0x00FF979D), + step9 = Color(0x4DC5A5FC), + step10 = Color(0x00EEE7FD), + ), + magicBlend = TangemColors3.Glow.MagicBlend( + step1 = TangemColorPalette.Violet.`40`, + step2 = Color(0x0098D7FF), + step3 = Color(0x00FFC3AD), + step4 = TangemColorPalette.Yellow.`30`, + step5 = Color(0x009EE1AB), + step6 = Color(0x00109FF0), + step7 = TangemColorPalette.Green.`30`, + step8 = Color(0x00B07BFD), + step9 = Color(0x4DC5A5FC), + step10 = Color(0x00FF979D), + ), + success = TangemColors3.Glow.Success( + step1 = TangemColorPalette.Green.`40`, + step2 = Color(0x009EE1AB), + step3 = Color(0x009EE1AB), + step4 = TangemColorPalette.Green.`50`, + step5 = Color(0x009EE1AB), + step6 = Color(0x009EE1AB), + step7 = TangemColorPalette.Green.`30`, + step8 = Color(0x009EE1AB), + step9 = Color(0x4D9EE1AB), + step10 = Color(0x009EE1AB), + ), + error = TangemColors3.Glow.Error( + step1 = TangemColorPalette.Red.`40`, + step2 = Color(0x00FFC0C3), + step3 = Color(0x00FFC0C3), + step4 = TangemColorPalette.Red.`50`, + step5 = Color(0x00FFC0C3), + step6 = Color(0x00FFC0C3), + step7 = TangemColorPalette.Red.`30`, + step8 = Color(0x00FFC0C3), + step9 = Color(0x4DFFC0C3), + step10 = Color(0x00FFC0C3), + ), + warning = TangemColors3.Glow.Warning( + step1 = TangemColorPalette.Yellow.`30`, + step2 = Color(0x00F7CA75), + step3 = Color(0x00F7CA75), + step4 = TangemColorPalette.Yellow.`40`, + step5 = Color(0x00F7CA75), + step6 = Color(0x00F7CA75), + step7 = TangemColorPalette.Yellow.`20`, + step8 = Color(0x00F7CA75), + step9 = Color(0x4DF7CA75), + step10 = Color(0x00F7CA75), + ), + info = TangemColors3.Glow.Info( + step1 = TangemColorPalette.Blue.`40`, + step2 = Color(0x0098D7FF), + step3 = Color(0x0098D7FF), + step4 = TangemColorPalette.Blue.`50`, + step5 = Color(0x0098D7FF), + step6 = Color(0x0098D7FF), + step7 = TangemColorPalette.Blue.`30`, + step8 = Color(0x0098D7FF), + step9 = Color(0x4D98D7FF), + step10 = Color(0x0098D7FF), + ), + ), material = TangemColors3.Material( tint = TangemColors3.Material.Tint( glass = Color(0x00000000), diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemTypography3.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemTypography3.kt index 82f5ccf04b..d26159f053 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemTypography3.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/TangemTypography3.kt @@ -43,7 +43,7 @@ class TangemTypography3 internal constructor(fontFamily: FontFamily) { fontFamily = fontFamily, fontWeight = FontWeight.SemiBold, fontSize = 28.sp, - lineHeight = 33.sp, + lineHeight = 34.sp, letterSpacing = (-0.37).sp, lineHeightStyle = LineHeightStyle( alignment = LineHeightStyle.Alignment.Center, diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/.icons-hash b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/.icons-hash index f8f420442f..d528c86c9e 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/.icons-hash +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/.icons-hash @@ -1 +1 @@ -f264a99d653eca57bedd4b49ff9ce5171ba9615770a57d574824e387f6cb1d5c +023a0f2a00de6fcd99f046ded7f648786a000cf1b10b70e17c78b1efed9e63f6 diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcAddressPolygon16.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcAddressPolygon16.kt index 0f45533d70..153362ca79 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcAddressPolygon16.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcAddressPolygon16.kt @@ -31,7 +31,7 @@ val Icons.ic_address_polygon_16: ImageVector addPath( fill = SolidColor(Color.Black), pathFillType = PathFillType.NonZero, - pathData = addPathNodes("M4.53711 2.69414C5.02195 2.43596 5.60404 2.43592 6.08887 2.69414L7.75195 3.57988C8.28964 3.86657 8.62591 4.42656 8.62598 5.03594V5.49492C8.62547 5.83958 8.34573 6.11979 8.00098 6.11992C7.65613 6.1199 7.37649 5.83965 7.37598 5.49492V5.03594C7.37591 4.88841 7.29414 4.75294 7.16406 4.6834L5.50098 3.79766C5.38347 3.73507 5.24252 3.73512 5.125 3.79766L3.46191 4.6834C3.3317 4.7529 3.25007 4.88833 3.25 5.03594V7.38457C3.2502 7.53207 3.33176 7.66767 3.46191 7.73711L5.125 8.62285C5.24241 8.68521 5.3836 8.6853 5.50098 8.62285L9.91309 6.27324C10.3979 6.01506 10.98 6.01502 11.4648 6.27324L13.1279 7.15898C13.6656 7.44565 14.0019 8.00568 14.002 8.61504V10.9637C14.0018 11.573 13.6656 12.1331 13.1279 12.4197L11.4648 13.3055C10.9801 13.5636 10.3978 13.5635 9.91309 13.3055L8.25 12.4197C7.71226 12.1331 7.37617 11.573 7.37598 10.9637V10.5057C7.37598 10.1605 7.65581 9.88068 8.00098 9.88066C8.34604 9.88079 8.62598 10.1606 8.62598 10.5057V10.9637C8.62617 11.1113 8.70759 11.2478 8.83789 11.3172L10.501 12.2029C10.6183 12.2652 10.7597 12.2652 10.877 12.2029L12.54 11.3172C12.6703 11.2478 12.7518 11.1112 12.752 10.9637V8.61504C12.7519 8.46752 12.6701 8.33202 12.54 8.2625L10.877 7.37676C10.7595 7.31417 10.6185 7.31422 10.501 7.37676L6.08887 9.72637C5.60417 9.98446 5.02184 9.98437 4.53711 9.72637L2.87402 8.84062C2.33626 8.55404 2.0002 7.99392 2 7.38457V5.03594C2.00007 4.42648 2.3362 3.86654 2.87402 3.57988L4.53711 2.69414Z"), + pathData = addPathNodes("M4.53711 2.69414C5.02195 2.43596 5.60404 2.43592 6.08887 2.69414L7.75195 3.57988C8.28964 3.86657 8.62591 4.42656 8.62598 5.03594V5.49492C8.62547 5.83958 8.34573 6.11979 8.00098 6.11992C7.65613 6.1199 7.37649 5.83965 7.37598 5.49492V5.03594C7.37591 4.88841 7.29414 4.75294 7.16406 4.6834L5.50098 3.79766C5.38347 3.73507 5.24252 3.73512 5.125 3.79766L3.46191 4.6834C3.3317 4.7529 3.25007 4.88833 3.25 5.03594V7.38457C3.2502 7.53207 3.33176 7.66767 3.46191 7.73711L5.125 8.62285C5.24241 8.68521 5.3836 8.6853 5.50098 8.62285L9.91309 6.27324C10.3979 6.01506 10.98 6.01502 11.4648 6.27324L13.1279 7.15898C13.6656 7.44565 14.0019 8.00567 14.002 8.61504V10.9637C14.0018 11.573 13.6656 12.1331 13.1279 12.4197L11.4648 13.3055C10.9801 13.5636 10.3978 13.5635 9.91309 13.3055L8.25 12.4197C7.71226 12.1331 7.37617 11.573 7.37598 10.9637V10.5057C7.37598 10.1605 7.65581 9.88068 8.00098 9.88066C8.34604 9.8808 8.62598 10.1606 8.62598 10.5057V10.9637C8.62617 11.1113 8.70759 11.2478 8.83789 11.3172L10.501 12.2029C10.6183 12.2652 10.7597 12.2652 10.877 12.2029L12.54 11.3172C12.6703 11.2478 12.7518 11.1112 12.752 10.9637V8.61504C12.7519 8.46752 12.6701 8.33202 12.54 8.2625L10.877 7.37676C10.7595 7.31417 10.6185 7.31422 10.501 7.37676L6.08887 9.72637C5.60417 9.98446 5.02184 9.98437 4.53711 9.72637L2.87402 8.84062C2.33626 8.55404 2.0002 7.99392 2 7.38457V5.03594C2.00007 4.42648 2.3362 3.86654 2.87402 3.57988L4.53711 2.69414Z"), ) }.build() return _ic_address_polygon_16!! diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcAddressPolygon20.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcAddressPolygon20.kt index 9f2ef9e32b..c6f1f61ade 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcAddressPolygon20.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcAddressPolygon20.kt @@ -31,7 +31,7 @@ val Icons.ic_address_polygon_20: ImageVector addPath( fill = SolidColor(Color.Black), pathFillType = PathFillType.NonZero, - pathData = addPathNodes("M5.78125 3.21402C6.30813 2.92909 6.94286 2.92905 7.46973 3.21402L9.82031 4.48453C10.3935 4.79465 10.751 5.39437 10.751 6.04605V6.80484C10.7504 7.21849 10.4147 7.55467 10.001 7.55484C9.5871 7.55484 9.25152 7.21859 9.25098 6.80484V6.04605C9.25095 5.94518 9.19511 5.85194 9.10645 5.80386L6.75586 4.53335C6.67424 4.48924 6.57575 4.48921 6.49414 4.53335L4.14453 5.80386C4.05587 5.85194 4.00002 5.94518 4 6.04605V9.38882C4.00037 9.48941 4.056 9.58215 4.14453 9.63003L6.49414 10.9015C6.57557 10.9455 6.6744 10.9454 6.75586 10.9015L12.5312 7.7775C13.0582 7.4925 13.6938 7.4925 14.2207 7.7775L16.5703 9.04898C17.1435 9.35911 17.501 9.9588 17.501 10.6105V13.9523C17.5008 14.6039 17.1434 15.2038 16.5703 15.5138L14.2207 16.7853C13.6939 17.0701 13.058 17.0701 12.5312 16.7853L10.1816 15.5138C9.6085 15.2038 9.25119 14.6039 9.25098 13.9523V13.1945C9.25098 12.7803 9.58676 12.4445 10.001 12.4445C10.415 12.4447 10.751 12.7804 10.751 13.1945V13.9523C10.7512 14.053 10.806 14.1465 10.8945 14.1945L13.2451 15.466C13.3266 15.5099 13.4254 15.5099 13.5068 15.466L15.8574 14.1945C15.946 14.1465 16.0008 14.053 16.001 13.9523V10.6105C16.001 10.5097 15.946 10.4164 15.8574 10.3683L13.5068 9.09683C13.4253 9.05275 13.3267 9.05274 13.2451 9.09683L7.46973 12.2209C6.94305 12.5056 6.30796 12.5055 5.78125 12.2209L3.43066 10.9494C2.85767 10.6394 2.50037 10.0402 2.5 9.38882V6.04605C2.50002 5.39437 2.85752 4.79465 3.43066 4.48453L5.78125 3.21402Z"), + pathData = addPathNodes("M5.78125 3.21402C6.30813 2.92909 6.94286 2.92905 7.46973 3.21402L9.82031 4.48453C10.3935 4.79465 10.751 5.39437 10.751 6.04605V6.80484C10.7504 7.21849 10.4147 7.55467 10.001 7.55484C9.5871 7.55484 9.25152 7.21859 9.25098 6.80484V6.04605C9.25095 5.94518 9.19511 5.85194 9.10645 5.80386L6.75586 4.53335C6.67424 4.48924 6.57575 4.48921 6.49414 4.53335L4.14453 5.80386C4.05587 5.85194 4.00002 5.94518 4 6.04605V9.38882C4.00037 9.48941 4.05601 9.58215 4.14453 9.63003L6.49414 10.9015C6.57557 10.9455 6.6744 10.9454 6.75586 10.9015L12.5312 7.7775C13.0582 7.4925 13.6938 7.4925 14.2207 7.7775L16.5703 9.04898C17.1435 9.35911 17.501 9.9588 17.501 10.6105V13.9523C17.5008 14.6039 17.1434 15.2038 16.5703 15.5138L14.2207 16.7853C13.6939 17.0701 13.058 17.0701 12.5312 16.7853L10.1816 15.5138C9.6085 15.2038 9.25119 14.6039 9.25098 13.9523V13.1945C9.25098 12.7803 9.58676 12.4445 10.001 12.4445C10.415 12.4447 10.751 12.7804 10.751 13.1945V13.9523C10.7512 14.053 10.806 14.1465 10.8945 14.1945L13.2451 15.466C13.3266 15.5099 13.4254 15.5099 13.5068 15.466L15.8574 14.1945C15.946 14.1465 16.0008 14.053 16.001 13.9523V10.6105C16.001 10.5097 15.946 10.4164 15.8574 10.3683L13.5068 9.09683C13.4253 9.05275 13.3267 9.05274 13.2451 9.09683L7.46973 12.2209C6.94305 12.5056 6.30796 12.5055 5.78125 12.2209L3.43066 10.9494C2.85767 10.6394 2.50037 10.0402 2.5 9.38882V6.04605C2.50002 5.39437 2.85752 4.79465 3.43066 4.48453L5.78125 3.21402Z"), ) }.build() return _ic_address_polygon_20!! diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowRefresh12.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowRefresh12.kt index 4b96effc22..727cee5a88 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowRefresh12.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowRefresh12.kt @@ -31,7 +31,7 @@ val Icons.ic_arrow_refresh_12: ImageVector addPath( fill = SolidColor(Color.Black), pathFillType = PathFillType.NonZero, - pathData = addPathNodes("M9.96777 5.4668C10.2623 5.4668 10.501 5.70545 10.501 6C10.501 8.48579 8.48581 10.501 6 10.501C4.62026 10.5007 3.39017 9.87706 2.56641 8.90039V9.30664C2.56606 9.6009 2.32754 9.83984 2.0332 9.83984C1.73913 9.83953 1.50035 9.6007 1.5 9.30664V7.65332C1.5 7.35896 1.73892 7.12043 2.0332 7.12012H3.68652C3.98107 7.12012 4.21973 7.35877 4.21973 7.65332C4.21954 7.94772 3.98096 8.18652 3.68652 8.18652H3.35938C3.98887 8.94784 4.93716 9.43331 6 9.43359C7.89672 9.43359 9.43457 7.89668 9.43457 6C9.43457 5.7056 9.67343 5.46705 9.96777 5.4668Z"), + pathData = addPathNodes("M9.96777 5.4668C10.2623 5.4668 10.501 5.70545 10.501 6C10.501 8.48579 8.48581 10.501 6 10.501C4.62026 10.5007 3.39017 9.87706 2.56641 8.90039V9.30664C2.56606 9.6009 2.32754 9.83984 2.0332 9.83984C1.73913 9.83953 1.50035 9.6007 1.5 9.30664V7.65332C1.5 7.35896 1.73892 7.12043 2.0332 7.12012H3.68652C3.98107 7.12012 4.21973 7.35877 4.21973 7.65332C4.21954 7.94772 3.98096 8.18652 3.68652 8.18652H3.35938C3.98887 8.94784 4.93716 9.43331 6 9.43359C7.89672 9.43359 9.43457 7.89668 9.43457 6C9.43457 5.7056 9.67344 5.46705 9.96777 5.4668Z"), ) addPath( fill = SolidColor(Color.Black), diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowRefresh16.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowRefresh16.kt index d50ac44c1e..9632567b09 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowRefresh16.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowRefresh16.kt @@ -31,7 +31,7 @@ val Icons.ic_arrow_refresh_16: ImageVector addPath( fill = SolidColor(Color.Black), pathFillType = PathFillType.NonZero, - pathData = addPathNodes("M12.8789 7.37695C13.224 7.37704 13.5039 7.65683 13.5039 8.00195C13.5039 11.0407 11.0407 13.5038 8.00195 13.5039C6.28439 13.5039 4.7571 12.713 3.75 11.4814V12.0664C3.74971 12.4113 3.46988 12.6913 3.125 12.6914C2.7801 12.6913 2.50029 12.4113 2.5 12.0664V10.0342C2.5 9.68908 2.77992 9.4093 3.125 9.40918H5.15723C5.50229 9.40932 5.78223 9.68909 5.78223 10.0342C5.78207 10.3791 5.50219 10.659 5.15723 10.6592H4.69141C5.47041 11.6307 6.66264 12.2539 8.00195 12.2539C10.3503 12.2538 12.2539 10.3503 12.2539 8.00195C12.2539 7.65678 12.5337 7.37695 12.8789 7.37695Z"), + pathData = addPathNodes("M12.8789 7.37695C13.224 7.37704 13.5039 7.65683 13.5039 8.00195C13.5039 11.0407 11.0407 13.5038 8.00195 13.5039C6.28439 13.5039 4.7571 12.713 3.75 11.4814V12.0664C3.74971 12.4113 3.46988 12.6913 3.125 12.6914C2.7801 12.6913 2.50029 12.4113 2.5 12.0664V10.0342C2.5 9.68907 2.77992 9.4093 3.125 9.40918H5.15723C5.50229 9.40932 5.78223 9.68909 5.78223 10.0342C5.78207 10.3791 5.50219 10.659 5.15723 10.6592H4.69141C5.47041 11.6307 6.66264 12.2539 8.00195 12.2539C10.3503 12.2538 12.2539 10.3503 12.2539 8.00195C12.2539 7.65677 12.5337 7.37695 12.8789 7.37695Z"), ) addPath( fill = SolidColor(Color.Black), diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowRefresh28.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowRefresh28.kt new file mode 100644 index 0000000000..bc6d7de29b --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcArrowRefresh28.kt @@ -0,0 +1,52 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_arrow_refresh_28: ImageVector? = null + +val Icons.ic_arrow_refresh_28: ImageVector + get() { + if (_ic_arrow_refresh_28 != null) return _ic_arrow_refresh_28!! + _ic_arrow_refresh_28 = ImageVector.Builder( + name = "ic_arrow_refresh_28", + defaultWidth = 28.dp, + defaultHeight = 28.dp, + viewportWidth = 28f, + viewportHeight = 28f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M24.751 12.749C24.0606 12.749 23.501 13.3087 23.501 13.999C23.501 19.2457 19.248 23.4978 14.001 23.498C10.9116 23.498 8.16877 22.0188 6.43457 19.7275H7.72949C8.41975 19.7274 8.97949 19.1678 8.97949 18.4775C8.9793 17.7874 8.41963 17.2277 7.72949 17.2275H3.25C2.55993 17.2277 2.00019 17.7875 2 18.4775V22.9561C2 23.6463 2.55981 24.2059 3.25 24.2061C3.94026 24.2059 4.5 23.6463 4.5 22.9561V21.3115C6.69035 24.1575 10.1264 25.998 14.001 25.998C20.6286 25.9978 26.001 20.6265 26.001 13.999C26.001 13.3088 25.4412 12.7492 24.751 12.749Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M14 2C7.37245 2.0002 2.00018 7.37166 2 13.999C2.00013 14.6891 2.55994 15.2488 3.25 15.249C3.94022 15.249 4.49987 14.6892 4.5 13.999C4.50018 8.75249 8.75304 4.5002 14 4.5C17.0888 4.50004 19.8312 5.97872 21.5654 8.26953H20.2715C19.5815 8.26973 19.0218 8.82959 19.0215 9.51953C19.0215 10.2098 19.5813 10.7693 20.2715 10.7695H24.751C25.4412 10.7693 26.001 10.2098 26.001 9.51953V5.04102C26.0008 4.35091 25.4411 3.79122 24.751 3.79102C24.0607 3.79102 23.5011 4.35078 23.501 5.04102V6.6875C21.3106 3.84097 17.8749 2.00004 14 2Z"), + ) + }.build() + return _ic_arrow_refresh_28!! + } + +@Composable +@Preview(showBackground = true) +private fun IcArrowRefresh28Preview() { + Icon( + imageVector = Icons.ic_arrow_refresh_28, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcBinoculars28.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcBinoculars28.kt new file mode 100644 index 0000000000..6bf84da21b --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcBinoculars28.kt @@ -0,0 +1,47 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_binoculars_28: ImageVector? = null + +val Icons.ic_binoculars_28: ImageVector + get() { + if (_ic_binoculars_28 != null) return _ic_binoculars_28!! + _ic_binoculars_28 = ImageVector.Builder( + name = "ic_binoculars_28", + defaultWidth = 28.dp, + defaultHeight = 28.dp, + viewportWidth = 28f, + viewportHeight = 28f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M19.7578 5.00391C21.6512 5.08382 23.2755 6.39977 23.751 8.24805L25.7334 15.9453C25.9024 16.4714 25.9951 17.028 25.9951 17.5996C25.995 19.7768 24.6902 21.7452 22.6816 22.5811C20.6717 23.4172 18.3597 22.9512 16.8262 21.4053C15.9614 20.5334 15.4557 19.438 15.3076 18.3047H12.6846C12.6248 18.7649 12.509 19.2228 12.3272 19.665C11.499 21.679 9.54216 22.9979 7.36622 22.998C5.19023 22.998 3.23354 21.679 2.40528 19.665C1.95365 18.5664 1.8872 17.377 2.16797 16.2725L2.18067 16.2168C2.18644 16.1951 2.19123 16.173 2.19727 16.1514L4.23438 8.24902C4.72469 6.34088 6.43972 5.00092 8.41114 5.00098C10.4237 5.00098 12.1068 6.38144 12.5879 8.24219H15.3984C15.8795 6.38135 17.5618 5.00015 19.5742 5L19.7578 5.00391ZM9.38868 15.5566C8.27017 14.4341 6.46227 14.4341 5.34376 15.5566C4.52086 16.3827 4.27211 17.6296 4.71778 18.7139C5.1632 19.7968 6.21056 20.498 7.36622 20.498C8.52184 20.4979 9.5693 19.7969 10.0147 18.7139C10.1716 18.3321 10.2405 17.9298 10.2305 17.5332H10.2256V17.4111C10.1795 16.7245 9.89192 16.0619 9.38868 15.5566ZM21.7207 14.9268C20.6517 14.4821 19.4204 14.7281 18.6006 15.5547C17.4807 16.684 17.4808 18.5152 18.6006 19.6445C19.4204 20.4709 20.6518 20.7179 21.7207 20.2734C22.7909 19.8282 23.495 18.774 23.4951 17.5996C23.4951 17.3576 23.4631 17.121 23.4063 16.8936L23.3975 16.8965L23.334 16.6504C23.0688 15.8823 22.4904 15.2471 21.7207 14.9268ZM12.7256 10.7422V15.8047H15.2598V10.7422H12.7256ZM8.41114 7.5C7.59 7.49995 6.86494 8.05939 6.65626 8.87109L5.72852 12.4717C7.21662 11.9941 8.87032 12.1841 10.2256 13.043V9.33301C10.2254 8.31386 9.40615 7.5 8.41114 7.5ZM19.5742 7.5C18.5794 7.50017 17.7599 8.31397 17.7598 9.33301V13.041C19.0875 12.1979 20.7338 11.9753 22.2549 12.4619L21.3301 8.87207C21.1214 8.06026 20.3955 7.49981 19.5742 7.5Z"), + ) + }.build() + return _ic_binoculars_28!! + } + +@Composable +@Preview(showBackground = true) +private fun IcBinoculars28Preview() { + Icon( + imageVector = Icons.ic_binoculars_28, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcCheckmark24.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcCheckmark24.kt index 035dd8cb2a..dd2db530bc 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcCheckmark24.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcCheckmark24.kt @@ -31,7 +31,7 @@ val Icons.ic_checkmark_24: ImageVector addPath( fill = SolidColor(Color.Black), pathFillType = PathFillType.NonZero, - pathData = addPathNodes("M18.2784 6.30768C18.6608 5.90939 19.294 5.89615 19.6924 6.27839C20.0907 6.66081 20.104 7.29407 19.7217 7.69245L10.1202 17.6924C9.93164 17.8888 9.67073 18 9.3985 18.0001C9.12637 18 8.86632 17.8887 8.6778 17.6924L4.27838 13.1124C3.89612 12.714 3.90943 12.0808 4.30768 11.6983C4.70604 11.3161 5.33929 11.3294 5.72174 11.7276L9.39752 15.5557L18.2784 6.30768Z"), + pathData = addPathNodes("M18.2784 6.30768C18.6608 5.90939 19.294 5.89615 19.6924 6.27839C20.0907 6.66081 20.104 7.29407 19.7217 7.69245L10.1202 17.6924C9.93164 17.8888 9.67073 18 9.3985 18.0001C9.12638 18 8.86632 17.8887 8.6778 17.6924L4.27838 13.1124C3.89612 12.714 3.90943 12.0808 4.30768 11.6983C4.70604 11.3161 5.33929 11.3294 5.72174 11.7276L9.39752 15.5557L18.2784 6.30768Z"), ) }.build() return _ic_checkmark_24!! diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcClock12.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcClock12.kt index 15d9512929..3b3447d5c4 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcClock12.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcClock12.kt @@ -35,7 +35,7 @@ val Icons.ic_clock_12: ImageVector ) addPath( fill = SolidColor(Color.Black), - pathFillType = PathFillType.EvenOdd, + pathFillType = PathFillType.NonZero, pathData = addPathNodes("M6 1C8.76142 1 11 3.23858 11 6C11 8.76142 8.76142 11 6 11C3.23858 11 1 8.76142 1 6C1 3.23858 3.23858 1 6 1ZM6 2C3.79086 2 2 3.79086 2 6C2 8.20914 3.79086 10 6 10C8.20914 10 10 8.20914 10 6C10 3.79086 8.20914 2 6 2Z"), ) }.build() diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcClock16.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcClock16.kt index e74be3345c..ac4448c990 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcClock16.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcClock16.kt @@ -31,11 +31,11 @@ val Icons.ic_clock_16: ImageVector addPath( fill = SolidColor(Color.Black), pathFillType = PathFillType.NonZero, - pathData = addPathNodes("M8.36523 4.5C8.71001 4.50033 8.99003 4.7802 8.99023 5.125V8.26074C8.99023 8.60572 8.71014 8.88542 8.36523 8.88574H6.125C5.77982 8.88574 5.5 8.60592 5.5 8.26074C5.50026 7.91578 5.77998 7.63574 6.125 7.63574H7.74023V5.125C7.74044 4.78 8.02018 4.5 8.36523 4.5Z"), + pathData = addPathNodes("M8.36523 4.5C8.71001 4.50033 8.99003 4.7802 8.99023 5.125V8.26074C8.99023 8.60572 8.71014 8.88542 8.36523 8.88574H6.125C5.77982 8.88574 5.5 8.60592 5.5 8.26074C5.50026 7.91578 5.77998 7.63574 6.125 7.63574H7.74023V5.125C7.74044 4.77999 8.02018 4.5 8.36523 4.5Z"), ) addPath( fill = SolidColor(Color.Black), - pathFillType = PathFillType.EvenOdd, + pathFillType = PathFillType.NonZero, pathData = addPathNodes("M8.30957 2.00781C11.48 2.16874 14.001 4.79058 14.001 8.00098C14.0007 11.3147 11.3146 14.0006 8.00098 14.001C4.68703 14.001 2.00027 11.3149 2 8.00098C2 4.68687 4.68687 2 8.00098 2L8.30957 2.00781ZM8.00098 3.25C5.37722 3.25 3.25 5.37722 3.25 8.00098C3.25027 10.6245 5.37739 12.751 8.00098 12.751C10.6243 12.7506 12.7507 10.6243 12.751 8.00098C12.751 5.37743 10.6244 3.25033 8.00098 3.25Z"), ) }.build() diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcClock20.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcClock20.kt index 44e589f073..e32940208c 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcClock20.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcClock20.kt @@ -35,7 +35,7 @@ val Icons.ic_clock_20: ImageVector ) addPath( fill = SolidColor(Color.Black), - pathFillType = PathFillType.EvenOdd, + pathFillType = PathFillType.NonZero, pathData = addPathNodes("M10.4092 2.01074C14.6352 2.2248 17.996 5.71889 17.9961 9.99805C17.996 14.4151 14.4151 17.996 9.99805 17.9961C5.581 17.996 2.00007 14.4151 2 9.99805C2.00005 5.58099 5.58099 2.00005 9.99805 2L10.4092 2.01074ZM9.99805 3.5C6.40942 3.50005 3.50005 6.40942 3.5 9.99805C3.50007 13.5867 6.40943 16.496 9.99805 16.4961C13.5867 16.496 16.496 13.5867 16.4961 9.99805C16.496 6.40943 13.5867 3.50007 9.99805 3.5Z"), ) }.build() diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcClock24.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcClock24.kt index 5addfb5842..64e7264cbd 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcClock24.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcClock24.kt @@ -35,7 +35,7 @@ val Icons.ic_clock_24: ImageVector ) addPath( fill = SolidColor(Color.Black), - pathFillType = PathFillType.EvenOdd, + pathFillType = PathFillType.NonZero, pathData = addPathNodes("M12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2ZM12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4Z"), ) }.build() diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcClock28.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcClock28.kt new file mode 100644 index 0000000000..efd839d0ff --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcClock28.kt @@ -0,0 +1,52 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_clock_28: ImageVector? = null + +val Icons.ic_clock_28: ImageVector + get() { + if (_ic_clock_28 != null) return _ic_clock_28!! + _ic_clock_28 = ImageVector.Builder( + name = "ic_clock_28", + defaultWidth = 28.dp, + defaultHeight = 28.dp, + viewportWidth = 28f, + viewportHeight = 28f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M14.522 6.77881C15.2123 6.77881 15.7719 7.33851 15.772 8.02881V14.5981C15.7717 15.2883 15.2122 15.8481 14.522 15.8481H9.74463C9.05481 15.8477 8.49492 15.288 8.49463 14.5981C8.4947 13.9081 9.05468 13.3486 9.74463 13.3481H13.272V8.02881C13.272 7.33887 13.8321 6.77938 14.522 6.77881Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M14.3101 2.00439C20.7939 2.16878 25.9993 7.47709 25.9995 14.0005C25.9993 20.6274 20.6274 25.9993 14.0005 25.9995C7.37353 25.9993 2.00068 20.6274 2.00049 14.0005C2.00072 7.37357 7.37355 2.00065 14.0005 2.00049L14.3101 2.00439ZM14.0005 4.50049C8.75426 4.50065 4.50072 8.75428 4.50049 14.0005C4.50068 19.2467 8.75424 23.4993 14.0005 23.4995C19.2467 23.4993 23.4993 19.2467 23.4995 14.0005C23.4993 8.7543 19.2467 4.50068 14.0005 4.50049Z"), + ) + }.build() + return _ic_clock_28!! + } + +@Composable +@Preview(showBackground = true) +private fun IcClock28Preview() { + Icon( + imageVector = Icons.ic_clock_28, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcClock32.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcClock32.kt index 8be8dcea32..843232e4f3 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcClock32.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcClock32.kt @@ -35,7 +35,7 @@ val Icons.ic_clock_32: ImageVector ) addPath( fill = SolidColor(Color.Black), - pathFillType = PathFillType.EvenOdd, + pathFillType = PathFillType.NonZero, pathData = addPathNodes("M16.001 4C22.6288 4.00026 28.0027 9.37314 28.0029 16.001C28.0027 22.6288 22.6288 28.0027 16.001 28.0029C9.37314 28.0027 4.00026 22.6288 4 16.001C4.00026 9.37313 9.37313 4.00026 16.001 4ZM16.001 6.5C10.7538 6.50026 6.50026 10.7538 6.5 16.001C6.50026 21.2481 10.7538 25.5027 16.001 25.5029C21.2481 25.5027 25.5027 21.2481 25.5029 16.001C25.5027 10.7538 21.2481 6.50026 16.001 6.5Z"), ) }.build() diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcCloud16.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcCloud16.kt index 46fd08bb02..7fbeed631c 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcCloud16.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcCloud16.kt @@ -31,7 +31,7 @@ val Icons.ic_cloud_16: ImageVector addPath( fill = SolidColor(Color.Black), pathFillType = PathFillType.NonZero, - pathData = addPathNodes("M8 3.375C10.1072 3.375 11.8925 4.86911 12.1826 6.8584C13.556 7.11784 14.6248 8.27613 14.625 9.71387C14.625 11.3498 13.2412 12.625 11.5996 12.625H5C3.02705 12.625 1.375 11.0941 1.375 9.14258L1.37891 8.97754C1.45745 7.41255 2.61167 6.12397 4.14746 5.77051C4.82684 4.31304 6.33845 3.37577 8 3.375ZM8 4.625C6.70774 4.62572 5.58437 5.40923 5.18262 6.53516C5.10338 6.75663 4.90622 6.9146 4.67285 6.94434C3.47814 7.09672 2.62729 8.05592 2.625 9.14355C2.6254 10.3476 3.6595 11.375 5 11.375H11.5996C12.609 11.375 13.375 10.6027 13.375 9.71387C13.3748 8.82524 12.6088 8.05371 11.5996 8.05371C11.2547 8.0535 10.9747 7.77369 10.9746 7.42871C10.9746 5.90873 9.67213 4.625 8 4.625Z"), + pathData = addPathNodes("M8 3.375C10.1072 3.375 11.8925 4.86911 12.1826 6.8584C13.556 7.11784 14.6248 8.27613 14.625 9.71387C14.625 11.3498 13.2412 12.625 11.5996 12.625H5C3.02705 12.625 1.375 11.0941 1.375 9.14258L1.37891 8.97754C1.45745 7.41255 2.61167 6.12397 4.14746 5.77051C4.82684 4.31304 6.33845 3.37577 8 3.375ZM8 4.625C6.70774 4.62572 5.58437 5.40923 5.18262 6.53516C5.10338 6.75663 4.90621 6.9146 4.67285 6.94434C3.47814 7.09672 2.62729 8.05592 2.625 9.14355C2.6254 10.3476 3.6595 11.375 5 11.375H11.5996C12.609 11.375 13.375 10.6027 13.375 9.71387C13.3748 8.82524 12.6088 8.05371 11.5996 8.05371C11.2547 8.0535 10.9747 7.77369 10.9746 7.42871C10.9746 5.90873 9.67213 4.625 8 4.625Z"), ) }.build() return _ic_cloud_16!! diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcCopy16.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcCopy16.kt index 1c2f4cd054..e3baa9973a 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcCopy16.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcCopy16.kt @@ -31,7 +31,7 @@ val Icons.ic_copy_16: ImageVector addPath( fill = SolidColor(Color.Black), pathFillType = PathFillType.NonZero, - pathData = addPathNodes("M7.23633 4.93848C7.78585 4.93848 8.23583 4.93797 8.60059 4.96777C8.97264 4.99819 9.3113 5.06328 9.62793 5.22461C10.1217 5.47629 10.5238 5.87829 10.7754 6.37207C10.9365 6.68855 11.0009 7.02757 11.0313 7.39941C11.061 7.76414 11.0615 8.21431 11.0615 8.76367V9.67285C11.0615 10.2223 11.061 10.6724 11.0313 11.0371C11.0008 11.4091 10.9367 11.7479 10.7754 12.0645C10.5237 12.5583 10.1218 12.9603 9.62793 13.2119C9.31131 13.3732 8.97262 13.4374 8.60059 13.4678C8.23583 13.4976 7.78585 13.498 7.23633 13.498H6.32715C5.77772 13.498 5.32764 13.4975 4.96289 13.4678C4.59102 13.4374 4.25204 13.373 3.93555 13.2119C3.44178 12.9603 3.03977 12.5582 2.78809 12.0645C2.62678 11.7479 2.56167 11.4091 2.53125 11.0371C2.50145 10.6724 2.50195 10.2223 2.50195 9.67285V8.76367C2.50195 8.21431 2.5015 7.76414 2.53125 7.39941C2.56164 7.02743 2.62688 6.68865 2.78809 6.37207C3.03975 5.87815 3.44162 5.47628 3.93555 5.22461C4.25218 5.06334 4.59084 4.99817 4.96289 4.96777C5.32764 4.93801 5.77771 4.93848 6.32715 4.93848H7.23633ZM6.32715 6.18848C5.757 6.18848 5.36656 6.18921 5.06445 6.21387C4.76993 6.23793 4.61402 6.28136 4.50293 6.33789C4.24421 6.46972 4.03319 6.68073 3.90137 6.93945C3.84488 7.05054 3.8014 7.20661 3.77734 7.50098C3.7527 7.80306 3.75195 8.19363 3.75195 8.76367V9.67285C3.75195 10.2429 3.75267 10.6335 3.77734 10.9355C3.80145 11.23 3.84479 11.386 3.90137 11.4971C4.03321 11.7556 4.24433 11.9659 4.50293 12.0977C4.61402 12.1542 4.76999 12.1986 5.06445 12.2227C5.36654 12.2473 5.75706 12.248 6.32715 12.248H7.23633C7.80643 12.248 8.19696 12.2473 8.49902 12.2227C8.79361 12.1986 8.94948 12.1542 9.06055 12.0977C9.31902 11.9659 9.52934 11.7555 9.66113 11.4971C9.71771 11.386 9.76203 11.23 9.78613 10.9355C9.81081 10.6335 9.81152 10.2429 9.81152 9.67285V8.76367C9.81152 8.19367 9.81077 7.80305 9.78613 7.50098C9.76208 7.20657 9.71763 7.05054 9.66113 6.93945C9.52936 6.68084 9.31912 6.46973 9.06055 6.33789C8.94949 6.28131 8.79354 6.23797 8.49902 6.21387C8.19696 6.18919 7.80643 6.18848 7.23633 6.18848H6.32715Z"), + pathData = addPathNodes("M7.23633 4.93848C7.78585 4.93848 8.23583 4.93797 8.60059 4.96777C8.97264 4.99819 9.3113 5.06328 9.62793 5.22461C10.1217 5.47629 10.5238 5.87829 10.7754 6.37207C10.9365 6.68855 11.0009 7.02757 11.0313 7.39941C11.061 7.76414 11.0615 8.21431 11.0615 8.76367V9.67285C11.0615 10.2223 11.061 10.6724 11.0313 11.0371C11.0008 11.4091 10.9367 11.7479 10.7754 12.0645C10.5237 12.5583 10.1218 12.9603 9.62793 13.2119C9.31131 13.3732 8.97262 13.4374 8.60059 13.4678C8.23583 13.4976 7.78585 13.498 7.23633 13.498H6.32715C5.77772 13.498 5.32764 13.4975 4.96289 13.4678C4.59102 13.4374 4.25204 13.373 3.93555 13.2119C3.44178 12.9603 3.03977 12.5582 2.78809 12.0645C2.62678 11.7479 2.56167 11.4091 2.53125 11.0371C2.50145 10.6724 2.50195 10.2223 2.50195 9.67285V8.76367C2.50195 8.21431 2.5015 7.76414 2.53125 7.39941C2.56164 7.02743 2.62688 6.68865 2.78809 6.37207C3.03975 5.87815 3.44162 5.47628 3.93555 5.22461C4.25218 5.06334 4.59084 4.99817 4.96289 4.96777C5.32764 4.93801 5.77771 4.93848 6.32715 4.93848H7.23633ZM6.32715 6.18848C5.757 6.18848 5.36655 6.18921 5.06445 6.21387C4.76993 6.23793 4.61402 6.28136 4.50293 6.33789C4.24421 6.46972 4.03319 6.68073 3.90137 6.93945C3.84488 7.05054 3.8014 7.20661 3.77734 7.50098C3.7527 7.80306 3.75195 8.19363 3.75195 8.76367V9.67285C3.75195 10.2429 3.75267 10.6335 3.77734 10.9355C3.80145 11.23 3.84479 11.386 3.90137 11.4971C4.03321 11.7556 4.24433 11.9659 4.50293 12.0977C4.61402 12.1542 4.76999 12.1986 5.06445 12.2227C5.36654 12.2473 5.75706 12.248 6.32715 12.248H7.23633C7.80643 12.248 8.19696 12.2473 8.49902 12.2227C8.79361 12.1986 8.94948 12.1542 9.06055 12.0977C9.31902 11.9659 9.52934 11.7555 9.66113 11.4971C9.71771 11.386 9.76203 11.23 9.78613 10.9355C9.81081 10.6335 9.81152 10.2429 9.81152 9.67285V8.76367C9.81152 8.19367 9.81077 7.80305 9.78613 7.50098C9.76208 7.20657 9.71763 7.05054 9.66113 6.93945C9.52936 6.68084 9.31912 6.46973 9.06055 6.33789C8.94949 6.28131 8.79354 6.23797 8.49902 6.21387C8.19696 6.18919 7.80643 6.18848 7.23633 6.18848H6.32715Z"), ) addPath( fill = SolidColor(Color.Black), diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcCopy20.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcCopy20.kt index 086ffbdcbf..05630af380 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcCopy20.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcCopy20.kt @@ -31,7 +31,7 @@ val Icons.ic_copy_20: ImageVector addPath( fill = SolidColor(Color.Black), pathFillType = PathFillType.NonZero, - pathData = addPathNodes("M9.125 6.125C9.81266 6.125 10.3736 6.124 10.8281 6.16113C11.2914 6.19898 11.7099 6.2804 12.1006 6.47949C12.7119 6.79105 13.209 7.28807 13.5205 7.89941C13.7195 8.2901 13.801 8.70869 13.8389 9.17188C13.876 9.62634 13.875 10.1874 13.875 10.875V12.25C13.875 12.9375 13.876 13.4987 13.8389 13.9531C13.801 14.4163 13.7196 14.8349 13.5205 15.2256C13.2089 15.837 12.7121 16.3349 12.1006 16.6465C11.7099 16.8455 11.2913 16.926 10.8281 16.9639C10.3736 17.001 9.81266 17 9.125 17H7.75C7.06232 17 6.50138 17.001 6.04688 16.9639C5.5837 16.926 5.1651 16.8455 4.77442 16.6465C4.16295 16.3349 3.66609 15.837 3.35449 15.2256C3.15544 14.8349 3.07399 14.4163 3.03614 13.9531C2.99903 13.4987 3 12.9375 3 12.25V10.875C3 10.1874 2.99902 9.62634 3.03614 9.17188C3.07398 8.70869 3.15547 8.2901 3.35449 7.89941C3.66604 7.28808 4.16309 6.79106 4.77442 6.47949C5.16514 6.28041 5.58363 6.19898 6.04688 6.16113C6.50138 6.124 7.06232 6.125 7.75 6.125H9.125ZM7.75 7.625C7.03757 7.625 6.5482 7.62526 6.16895 7.65625C5.79853 7.68652 5.5991 7.74205 5.45508 7.81543C5.12602 7.98318 4.85816 8.251 4.69043 8.58008C4.61709 8.72409 4.56151 8.92366 4.53125 9.29395C4.50028 9.67317 4.5 10.1627 4.5 10.875V12.25C4.5 12.9622 4.50028 13.4519 4.53125 13.8311C4.56153 14.2014 4.61705 14.4009 4.69043 14.5449C4.85816 14.874 5.12601 15.1418 5.45508 15.3096C5.5991 15.3829 5.79861 15.4385 6.16895 15.4688C6.5482 15.4997 7.03757 15.5 7.75 15.5H9.125C9.83743 15.5 10.3268 15.4997 10.7061 15.4688C11.0765 15.4385 11.2759 15.3829 11.4199 15.3096C11.749 15.1418 12.0168 14.874 12.1846 14.5449C12.258 14.4009 12.3135 14.2014 12.3438 13.8311C12.3747 13.4519 12.375 12.9622 12.375 12.25V10.875C12.375 10.1627 12.3747 9.67317 12.3438 9.29395C12.3135 8.92366 12.2579 8.72409 12.1846 8.58008C12.0168 8.25099 11.749 7.98317 11.4199 7.81543C11.2759 7.74204 11.0765 7.68652 10.7061 7.65625C10.3268 7.62526 9.83744 7.625 9.125 7.625H7.75Z"), + pathData = addPathNodes("M9.125 6.125C9.81266 6.125 10.3736 6.124 10.8281 6.16113C11.2914 6.19898 11.7099 6.2804 12.1006 6.47949C12.7119 6.79105 13.209 7.28807 13.5205 7.89941C13.7195 8.2901 13.801 8.70869 13.8389 9.17188C13.876 9.62634 13.875 10.1874 13.875 10.875V12.25C13.875 12.9375 13.876 13.4987 13.8389 13.9531C13.801 14.4163 13.7196 14.8349 13.5205 15.2256C13.2089 15.837 12.7121 16.3349 12.1006 16.6465C11.7099 16.8455 11.2913 16.926 10.8281 16.9639C10.3736 17.001 9.81266 17 9.125 17H7.75C7.06232 17 6.50138 17.001 6.04688 16.9639C5.5837 16.926 5.1651 16.8455 4.77442 16.6465C4.16295 16.3349 3.66609 15.837 3.35449 15.2256C3.15544 14.8349 3.07399 14.4163 3.03614 13.9531C2.99903 13.4987 3 12.9375 3 12.25V10.875C3 10.1874 2.99902 9.62634 3.03614 9.17188C3.07398 8.70869 3.15547 8.2901 3.35449 7.89941C3.66604 7.28808 4.16309 6.79106 4.77442 6.47949C5.16514 6.28041 5.58363 6.19898 6.04688 6.16113C6.50138 6.124 7.06232 6.125 7.75 6.125H9.125ZM7.75 7.625C7.03757 7.625 6.5482 7.62526 6.16895 7.65625C5.79853 7.68652 5.5991 7.74205 5.45508 7.81543C5.12601 7.98318 4.85816 8.251 4.69043 8.58008C4.61709 8.72409 4.56151 8.92366 4.53125 9.29395C4.50028 9.67317 4.5 10.1627 4.5 10.875V12.25C4.5 12.9622 4.50028 13.4519 4.53125 13.8311C4.56153 14.2014 4.61705 14.4009 4.69043 14.5449C4.85816 14.874 5.12601 15.1418 5.45508 15.3096C5.5991 15.3829 5.79861 15.4385 6.16895 15.4688C6.5482 15.4997 7.03757 15.5 7.75 15.5H9.125C9.83743 15.5 10.3268 15.4997 10.7061 15.4688C11.0765 15.4385 11.2759 15.3829 11.4199 15.3096C11.749 15.1418 12.0168 14.874 12.1846 14.5449C12.258 14.4009 12.3135 14.2014 12.3438 13.8311C12.3747 13.4519 12.375 12.9622 12.375 12.25V10.875C12.375 10.1627 12.3747 9.67317 12.3438 9.29395C12.3135 8.92366 12.2579 8.72409 12.1846 8.58008C12.0168 8.25099 11.749 7.98317 11.4199 7.81543C11.2759 7.74204 11.0765 7.68652 10.7061 7.65625C10.3268 7.62526 9.83744 7.625 9.125 7.625H7.75Z"), ) addPath( fill = SolidColor(Color.Black), diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcDotsHorizontal24.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcDotsHorizontal24.kt index acca0be2b8..6199b70fc6 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcDotsHorizontal24.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcDotsHorizontal24.kt @@ -31,7 +31,7 @@ val Icons.ic_dots_horizontal_24: ImageVector addPath( fill = SolidColor(Color.Black), pathFillType = PathFillType.NonZero, - pathData = addPathNodes("M6.09953 10.5059C6.87727 10.5599 7.49688 11.2045 7.49699 12C7.49686 12.8273 6.82427 13.5 5.99699 13.5C5.17312 13.4997 4.50325 12.8325 4.49797 12.0098L4.49699 12.0107C4.48844 11.2094 5.11151 10.5611 5.88761 10.5059C5.92247 10.5022 5.95823 10.5 5.99406 10.5C6.02951 10.5 6.06503 10.5023 6.09953 10.5059Z"), + pathData = addPathNodes("M6.09953 10.5059C6.87727 10.5599 7.49688 11.2045 7.49699 12C7.49686 12.8273 6.82428 13.5 5.99699 13.5C5.17312 13.4997 4.50325 12.8325 4.49797 12.0098L4.49699 12.0107C4.48844 11.2094 5.11151 10.5611 5.88761 10.5059C5.92247 10.5022 5.95823 10.5 5.99406 10.5C6.02951 10.5 6.06503 10.5023 6.09953 10.5059Z"), ) addPath( fill = SolidColor(Color.Black), diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcEdit20.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcEdit20.kt index 4579e6d927..9baedb074d 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcEdit20.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcEdit20.kt @@ -36,7 +36,7 @@ val Icons.ic_edit_20: ImageVector addPath( fill = SolidColor(Color.Black), pathFillType = PathFillType.NonZero, - pathData = addPathNodes("M14.7041 3C15.3127 3.00063 15.8962 3.24314 16.3262 3.67383C16.757 4.10457 16.999 4.68886 16.999 5.29785C16.999 5.90709 16.7569 6.49135 16.3262 6.92188C16.3189 6.92918 16.3103 6.93545 16.3027 6.94238H16.3047C16.3081 6.93926 16.3135 6.93451 16.3154 6.93262L15.834 7.41406C15.5321 7.7158 15.1182 8.12892 14.6719 8.5752C13.7792 9.46776 12.7549 10.4922 12.2285 11.0186C11.8808 11.3662 11.4265 11.5868 10.9385 11.6465L9.86133 11.7803C9.40623 11.8364 8.95107 11.6769 8.62988 11.3496C8.30873 11.0223 8.15798 10.5643 8.22266 10.1104L8.38184 8.99317C8.44827 8.52187 8.66648 8.08476 9.00293 7.74805C9.73986 7.0108 11.5406 5.20998 13.0791 3.67188C13.51 3.24129 14.0949 2.99946 14.7041 3ZM15.2656 4.7334C15.1166 4.58395 14.9132 4.50024 14.7021 4.5C14.5177 4.49995 14.3398 4.56396 14.1982 4.67969L14.1396 4.73242C12.6013 6.27043 10.8013 8.07145 10.0645 8.80859C9.95776 8.91535 9.8881 9.05365 9.86719 9.20313V9.20606L9.71289 10.2861L10.7549 10.1582H10.7568C10.9123 10.1391 11.0572 10.0687 11.168 9.95801L15.2686 5.85742C15.416 5.70838 15.499 5.50744 15.499 5.29785C15.499 5.08655 15.4149 4.88268 15.2656 4.7334Z"), + pathData = addPathNodes("M14.7041 3C15.3127 3.00063 15.8962 3.24314 16.3262 3.67383C16.757 4.10457 16.999 4.68886 16.999 5.29785C16.999 5.90709 16.7569 6.49135 16.3262 6.92188C16.3189 6.92918 16.3103 6.93545 16.3027 6.94238H16.3047C16.3081 6.93926 16.3135 6.93451 16.3154 6.93262L15.834 7.41406C15.5321 7.7158 15.1182 8.12892 14.6719 8.5752C13.7792 9.46775 12.7549 10.4922 12.2285 11.0186C11.8808 11.3662 11.4265 11.5868 10.9385 11.6465L9.86133 11.7803C9.40623 11.8364 8.95107 11.6769 8.62988 11.3496C8.30873 11.0223 8.15798 10.5643 8.22266 10.1104L8.38184 8.99317C8.44827 8.52187 8.66648 8.08476 9.00293 7.74805C9.73986 7.0108 11.5406 5.20998 13.0791 3.67188C13.51 3.24129 14.0949 2.99946 14.7041 3ZM15.2656 4.7334C15.1166 4.58395 14.9132 4.50024 14.7021 4.5C14.5177 4.49995 14.3398 4.56396 14.1982 4.67969L14.1396 4.73242C12.6013 6.27043 10.8013 8.07145 10.0645 8.80859C9.95776 8.91535 9.8881 9.05365 9.86719 9.20313V9.20606L9.71289 10.2861L10.7549 10.1582H10.7568C10.9123 10.1391 11.0572 10.0687 11.168 9.95801L15.2686 5.85742C15.416 5.70838 15.499 5.50744 15.499 5.29785C15.499 5.08655 15.4149 4.88268 15.2656 4.7334Z"), ) }.build() return _ic_edit_20!! diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcError16.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcError16.kt index c42c4c1bfe..a6f8941a91 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcError16.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcError16.kt @@ -31,7 +31,7 @@ val Icons.ic_error_16: ImageVector addPath( fill = SolidColor(Color.Black), pathFillType = PathFillType.NonZero, - pathData = addPathNodes("M8.0791 10.0029C8.47125 10.0427 8.77901 10.3735 8.7793 10.7783C8.77927 11.2087 8.43035 11.5576 8 11.5576C7.59677 11.5574 7.26459 11.2512 7.22461 10.8584L7.2207 10.7783C7.22033 10.3459 7.57225 9.99916 8 9.99902L8.0791 10.0029Z"), + pathData = addPathNodes("M8.0791 10.0029C8.47125 10.0427 8.77901 10.3735 8.7793 10.7783C8.77927 11.2087 8.43035 11.5576 8 11.5576C7.59677 11.5574 7.26459 11.2512 7.22461 10.8584L7.2207 10.7783C7.22032 10.3459 7.57225 9.99916 8 9.99902L8.0791 10.0029Z"), ) addPath( fill = SolidColor(Color.Black), diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcError20.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcError20.kt index 313bc452b5..12171f0943 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcError20.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcError20.kt @@ -41,7 +41,7 @@ val Icons.ic_error_20: ImageVector addPath( fill = SolidColor(Color.Black), pathFillType = PathFillType.NonZero, - pathData = addPathNodes("M12.5537 2.00195C13.1795 2.00199 13.7802 2.25102 14.2227 2.69336L17.3047 5.77539C17.7485 6.21806 17.998 6.81932 17.998 7.44531V12.5537C17.998 13.1795 17.749 13.7802 17.3066 14.2227L13.752 17.7783C13.6113 17.919 13.4196 17.998 13.2207 17.998H7.44531C6.81953 17.998 6.21879 17.7489 5.77637 17.3066L2.69336 14.2236C2.25101 13.7812 2.00204 13.1805 2.00195 12.5547V7.44531C2.00201 6.81948 2.25098 6.2188 2.69336 5.77637L5.77637 2.69336C6.21881 2.25099 6.81947 2.002 7.44531 2.00195H12.5537ZM7.44531 3.50195C7.21775 3.502 6.99836 3.59252 6.83691 3.75391L3.75391 6.83691C3.59252 6.99836 3.50201 7.21775 3.50195 7.44531V12.5547C3.50204 12.7822 3.59255 13.0017 3.75391 13.1631L6.83691 16.2451C6.99838 16.4066 7.21769 16.498 7.44531 16.498H12.9102L16.2451 13.1621C16.4066 13.0006 16.498 12.7813 16.498 12.5537V7.44531C16.498 7.2178 16.4068 6.99912 16.2451 6.83789L13.1621 3.75391C13.0007 3.59255 12.7813 3.50199 12.5537 3.50195H7.44531Z"), + pathData = addPathNodes("M12.5537 2.00195C13.1795 2.00199 13.7802 2.25102 14.2227 2.69336L17.3047 5.77539C17.7485 6.21806 17.998 6.81932 17.998 7.44531V12.5537C17.998 13.1795 17.749 13.7802 17.3066 14.2227L13.752 17.7783C13.6113 17.919 13.4196 17.998 13.2207 17.998H7.44531C6.81953 17.998 6.21879 17.7489 5.77637 17.3066L2.69336 14.2236C2.25101 13.7812 2.00204 13.1805 2.00195 12.5547V7.44531C2.00201 6.81948 2.25098 6.2188 2.69336 5.77637L5.77637 2.69336C6.21881 2.25099 6.81947 2.002 7.44531 2.00195H12.5537ZM7.44531 3.50195C7.21775 3.502 6.99836 3.59252 6.83691 3.75391L3.75391 6.83691C3.59252 6.99836 3.502 7.21775 3.50195 7.44531V12.5547C3.50204 12.7822 3.59255 13.0017 3.75391 13.1631L6.83691 16.2451C6.99838 16.4066 7.21769 16.498 7.44531 16.498H12.9102L16.2451 13.1621C16.4066 13.0006 16.498 12.7813 16.498 12.5537V7.44531C16.498 7.2178 16.4068 6.99912 16.2451 6.83789L13.1621 3.75391C13.0007 3.59255 12.7813 3.50199 12.5537 3.50195H7.44531Z"), ) }.build() return _ic_error_20!! diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcError24.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcError24.kt index 81976a905c..fe6b8d1bca 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcError24.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcError24.kt @@ -41,7 +41,7 @@ val Icons.ic_error_24: ImageVector addPath( fill = SolidColor(Color.Black), pathFillType = PathFillType.NonZero, - pathData = addPathNodes("M15.1709 2C15.9664 2 16.7297 2.31661 17.292 2.87891L21.1191 6.70605L21.3193 6.92578C21.7579 7.45976 22 8.13194 22 8.82812V15.1709C22 15.9664 21.6834 16.7297 21.1211 17.292L16.707 21.707C16.5195 21.8946 16.2652 22 16 22H8.82812C8.03264 22 7.26935 21.6834 6.70703 21.1211L2.87891 17.293C2.31662 16.7307 2 15.9674 2 15.1719V8.82812C2 8.03264 2.31662 7.26935 2.87891 6.70703L6.70703 2.87891C7.26935 2.31662 8.03264 2 8.82812 2H15.1709ZM8.82812 4C8.56367 4 8.30876 4.10533 8.12109 4.29297L4.29297 8.12109C4.10533 8.30876 4 8.56367 4 8.82812V15.1719C4 15.4363 4.10533 15.6912 4.29297 15.8789L8.12109 19.707C8.30876 19.8947 8.56367 20 8.82812 20H15.5859L19.707 15.8779L19.7734 15.8047C19.9194 15.6266 20 15.4024 20 15.1709V8.82812C20 8.56383 19.8948 8.30943 19.707 8.12207L15.8779 4.29297C15.6903 4.10533 15.4354 4 15.1709 4H8.82812Z"), + pathData = addPathNodes("M15.1709 2C15.9664 2 16.7297 2.31661 17.292 2.87891L21.1191 6.70605L21.3193 6.92578C21.7579 7.45976 22 8.13194 22 8.82812V15.1709C22 15.9664 21.6834 16.7297 21.1211 17.292L16.707 21.707C16.5195 21.8946 16.2652 22 16 22H8.82812C8.03264 22 7.26935 21.6834 6.70703 21.1211L2.87891 17.293C2.31662 16.7306 2 15.9674 2 15.1719V8.82812C2 8.03264 2.31662 7.26935 2.87891 6.70703L6.70703 2.87891C7.26935 2.31662 8.03264 2 8.82812 2H15.1709ZM8.82812 4C8.56367 4 8.30876 4.10533 8.12109 4.29297L4.29297 8.12109C4.10533 8.30876 4 8.56367 4 8.82812V15.1719C4 15.4363 4.10533 15.6912 4.29297 15.8789L8.12109 19.707C8.30876 19.8947 8.56367 20 8.82812 20H15.5859L19.707 15.8779L19.7734 15.8047C19.9194 15.6266 20 15.4024 20 15.1709V8.82812C20 8.56383 19.8948 8.30943 19.707 8.12207L15.8779 4.29297C15.6903 4.10533 15.4354 4 15.1709 4H8.82812Z"), ) }.build() return _ic_error_24!! diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcGauge20.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcGauge20.kt index 38ea6a4dc4..215ed7a2c0 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcGauge20.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcGauge20.kt @@ -36,7 +36,7 @@ val Icons.ic_gauge_20: ImageVector addPath( fill = SolidColor(Color.Black), pathFillType = PathFillType.NonZero, - pathData = addPathNodes("M12.1219 6.72445C12.4133 6.43066 12.8883 6.42759 13.1825 6.71859C13.4765 7.00977 13.479 7.48477 13.1883 7.77914L10.4637 10.5311C10.1723 10.8253 9.69749 10.8273 9.40318 10.536C9.1089 10.2446 9.10599 9.76976 9.39732 9.47543L12.1219 6.72445Z"), + pathData = addPathNodes("M12.1219 6.72445C12.4133 6.43067 12.8883 6.42759 13.1825 6.71859C13.4765 7.00977 13.479 7.48477 13.1883 7.77914L10.4637 10.5311C10.1723 10.8253 9.69749 10.8273 9.40318 10.536C9.1089 10.2446 9.10599 9.76976 9.39732 9.47543L12.1219 6.72445Z"), ) }.build() return _ic_gauge_20!! diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcGrid16.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcGrid16.kt new file mode 100644 index 0000000000..27248eb1b2 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcGrid16.kt @@ -0,0 +1,62 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_grid_16: ImageVector? = null + +val Icons.ic_grid_16: ImageVector + get() { + if (_ic_grid_16 != null) return _ic_grid_16!! + _ic_grid_16 = ImageVector.Builder( + name = "ic_grid_16", + defaultWidth = 16.dp, + defaultHeight = 16.dp, + viewportWidth = 16f, + viewportHeight = 16f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M5.46191 8.71777C6.46661 8.71817 7.28125 9.53232 7.28125 10.5371V12.1797C7.28095 13.1842 6.46643 13.9986 5.46191 13.999H3.81934C2.81448 13.999 2.0003 13.1845 2 12.1797V10.5371C2 9.53207 2.8143 8.71777 3.81934 8.71777H5.46191ZM3.81934 9.96777C3.50465 9.96777 3.25 10.2224 3.25 10.5371V12.1797C3.25029 12.4941 3.50484 12.749 3.81934 12.749H5.46191C5.77607 12.7486 6.03096 12.4939 6.03125 12.1797V10.5371C6.03125 10.2227 5.77626 9.96817 5.46191 9.96777H3.81934Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M12.1797 8.71777C13.1844 8.71818 13.999 9.53233 13.999 10.5371V12.1797C13.9987 13.1842 13.1842 13.9986 12.1797 13.999H10.5371C9.53225 13.999 8.71807 13.1845 8.71777 12.1797V10.5371C8.71777 9.53207 9.53207 8.71777 10.5371 8.71777H12.1797ZM10.5371 9.96777C10.2224 9.96777 9.96777 10.2224 9.96777 10.5371V12.1797C9.96807 12.4941 10.2226 12.749 10.5371 12.749H12.1797C12.4938 12.7486 12.7487 12.4939 12.749 12.1797V10.5371C12.749 10.2227 12.494 9.96818 12.1797 9.96777H10.5371Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M5.46191 2C6.46661 2.0004 7.28125 2.81454 7.28125 3.81934V5.46191C7.2808 6.46632 6.46634 7.28085 5.46191 7.28125H3.81934C2.81458 7.28125 2.00045 6.46657 2 5.46191V3.81934C2 2.8143 2.8143 2 3.81934 2H5.46191ZM3.81934 3.25C3.50465 3.25 3.25 3.50465 3.25 3.81934V5.46191C3.25045 5.77621 3.50493 6.03125 3.81934 6.03125H5.46191C5.77598 6.03085 6.0308 5.77597 6.03125 5.46191V3.81934C6.03125 3.5049 5.77626 3.2504 5.46191 3.25H3.81934Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M12.1797 2C13.1844 2.00041 13.999 2.81455 13.999 3.81934V5.46191C13.9986 6.46632 13.1841 7.28084 12.1797 7.28125H10.5371C9.53235 7.28125 8.71822 6.46657 8.71777 5.46191V3.81934C8.71777 2.8143 9.53207 2 10.5371 2H12.1797ZM10.5371 3.25C10.2224 3.25 9.96777 3.50465 9.96777 3.81934V5.46191C9.96822 5.77621 10.2227 6.03125 10.5371 6.03125H12.1797C12.4937 6.03084 12.7486 5.77596 12.749 5.46191V3.81934C12.749 3.50491 12.494 3.25041 12.1797 3.25H10.5371Z"), + ) + }.build() + return _ic_grid_16!! + } + +@Composable +@Preview(showBackground = true) +private fun IcGrid16Preview() { + Icon( + imageVector = Icons.ic_grid_16, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcGrid20.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcGrid20.kt new file mode 100644 index 0000000000..ec3c16e81f --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcGrid20.kt @@ -0,0 +1,62 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_grid_20: ImageVector? = null + +val Icons.ic_grid_20: ImageVector + get() { + if (_ic_grid_20 != null) return _ic_grid_20!! + _ic_grid_20 = ImageVector.Builder( + name = "ic_grid_20", + defaultWidth = 20.dp, + defaultHeight = 20.dp, + viewportWidth = 20f, + viewportHeight = 20f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M6.57617 11.0615C7.88022 11.0617 8.93652 12.1188 8.93652 13.4229V15.6377C8.93616 16.9415 7.88 17.9978 6.57617 17.998H4.36133C3.05744 17.9979 2.00036 16.9415 2 15.6377V13.4229C2 12.1187 3.05722 11.0616 4.36133 11.0615H6.57617ZM4.36133 12.5615C3.88564 12.5616 3.5 12.9471 3.5 13.4229V15.6377C3.50036 16.1131 3.88587 16.4979 4.36133 16.498H6.57617C7.05157 16.4978 7.43616 16.1131 7.43652 15.6377V13.4229C7.43652 12.9472 7.05179 12.5617 6.57617 12.5615H4.36133Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M15.6377 11.0615C16.9417 11.0618 17.998 12.1188 17.998 13.4229V15.6377C17.9977 16.9414 16.9415 17.9978 15.6377 17.998H13.4229C12.1189 17.998 11.0619 16.9416 11.0615 15.6377V13.4229C11.0615 12.1186 12.1186 11.0615 13.4229 11.0615H15.6377ZM13.4229 12.5615C12.9471 12.5615 12.5615 12.9471 12.5615 13.4229V15.6377C12.5619 16.1132 12.9473 16.498 13.4229 16.498H15.6377C16.113 16.4978 16.4977 16.113 16.498 15.6377V13.4229C16.498 12.9472 16.1133 12.5618 15.6377 12.5615H13.4229Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M6.57617 2C7.88017 2.0002 8.93645 3.0573 8.93652 4.36133V6.57617C8.93633 7.8801 7.8801 8.93633 6.57617 8.93652H4.36133C3.05734 8.9364 2.0002 7.88014 2 6.57617V4.36133C2.00007 3.05725 3.05726 2.00012 4.36133 2H6.57617ZM4.36133 3.5C3.88569 3.50012 3.50007 3.88568 3.5 4.36133V6.57617C3.5002 7.05172 3.88577 7.4364 4.36133 7.43652H6.57617C7.05167 7.43633 7.43633 7.05167 7.43652 6.57617V4.36133C7.43645 3.88572 7.05175 3.5002 6.57617 3.5H4.36133Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M15.6377 2C16.9416 2.00026 17.998 3.05734 17.998 4.36133V6.57617C17.9978 7.88006 16.9416 8.93626 15.6377 8.93652H13.4229C12.1188 8.93652 11.0617 7.88022 11.0615 6.57617V4.36133C11.0616 3.05718 12.1187 2 13.4229 2H15.6377ZM13.4229 3.5C12.9471 3.5 12.5616 3.8856 12.5615 4.36133V6.57617C12.5617 7.05179 12.9472 7.43652 13.4229 7.43652H15.6377C16.1131 7.43626 16.4978 7.05163 16.498 6.57617V4.36133C16.498 3.88576 16.1132 3.50026 15.6377 3.5H13.4229Z"), + ) + }.build() + return _ic_grid_20!! + } + +@Composable +@Preview(showBackground = true) +private fun IcGrid20Preview() { + Icon( + imageVector = Icons.ic_grid_20, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcGrid24.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcGrid24.kt new file mode 100644 index 0000000000..bb4eaed8a7 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcGrid24.kt @@ -0,0 +1,62 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_grid_24: ImageVector? = null + +val Icons.ic_grid_24: ImageVector + get() { + if (_ic_grid_24 != null) return _ic_grid_24!! + _ic_grid_24 = ImageVector.Builder( + name = "ic_grid_24", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M7.74902 13.249C9.40615 13.249 10.749 14.5919 10.749 16.249V18.998C10.7488 20.6551 9.40603 21.998 7.74902 21.998H5C3.34319 21.9978 2.00018 20.6549 2 18.998V16.249C2 14.592 3.34308 13.2493 5 13.249H7.74902ZM5 15.249C4.44756 15.2493 4 15.6967 4 16.249V18.998C4.00018 19.5502 4.44767 19.9978 5 19.998H7.74902C8.30156 19.998 8.74884 19.5504 8.74902 18.998V16.249C8.74902 15.6965 8.30167 15.249 7.74902 15.249H5Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M18.998 13.249C20.6552 13.249 21.998 14.5919 21.998 16.249V18.998C21.9979 20.6551 20.6551 21.998 18.998 21.998H16.249C14.5921 21.9979 13.2492 20.655 13.249 18.998V16.249C13.249 14.5919 14.592 13.2491 16.249 13.249H18.998ZM16.249 15.249C15.6965 15.2491 15.249 15.6966 15.249 16.249V18.998C15.2492 19.5503 15.6966 19.9979 16.249 19.998H18.998C19.5506 19.998 19.9979 19.5504 19.998 18.998V16.249C19.998 15.6965 19.5507 15.249 18.998 15.249H16.249Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M7.74902 2C9.40602 2 10.7488 3.34302 10.749 5V7.74902C10.749 9.40619 9.40615 10.749 7.74902 10.749H5C3.34308 10.7488 2 9.40604 2 7.74902V5C2.00021 3.34317 3.34321 2.00024 5 2H7.74902ZM5 4C4.44769 4.00024 4.00021 4.44783 4 5V7.74902C4 8.30138 4.44756 8.74879 5 8.74902H7.74902C8.30167 8.74902 8.74902 8.30152 8.74902 7.74902V5C8.74881 4.44768 8.30154 4 7.74902 4H5Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M18.998 2C20.655 2 21.9978 3.34302 21.998 5V7.74902C21.998 9.40619 20.6552 10.749 18.998 10.749H16.249C14.592 10.7489 13.249 9.40612 13.249 7.74902V5C13.2492 3.34308 14.5921 2.00011 16.249 2H18.998ZM16.249 4C15.6966 4.00011 15.2492 4.44775 15.249 5V7.74902C15.249 8.30146 15.6965 8.74892 16.249 8.74902H18.998C19.5507 8.74902 19.998 8.30152 19.998 7.74902V5C19.9978 4.44768 19.5506 4 18.998 4H16.249Z"), + ) + }.build() + return _ic_grid_24!! + } + +@Composable +@Preview(showBackground = true) +private fun IcGrid24Preview() { + Icon( + imageVector = Icons.ic_grid_24, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcGrid28.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcGrid28.kt new file mode 100644 index 0000000000..6d46ea8a42 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcGrid28.kt @@ -0,0 +1,62 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_grid_28: ImageVector? = null + +val Icons.ic_grid_28: ImageVector + get() { + if (_ic_grid_28 != null) return _ic_grid_28!! + _ic_grid_28 = ImageVector.Builder( + name = "ic_grid_28", + defaultWidth = 28.dp, + defaultHeight = 28.dp, + viewportWidth = 28f, + viewportHeight = 28f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M9.39648 15.1885C11.2838 15.1886 12.8124 16.7181 12.8125 18.6055V21.585C12.8123 23.4722 11.2837 25.0008 9.39648 25.001H6.41699C4.52965 25.0009 3.00017 23.4723 3 21.585V18.6055C3.00008 16.7181 4.5296 15.1886 6.41699 15.1885H9.39648ZM6.41699 17.6885C5.91031 17.6886 5.50008 18.0988 5.5 18.6055V21.585C5.50017 22.0916 5.91037 22.5009 6.41699 22.501H9.39648C9.90303 22.5008 10.3123 22.0915 10.3125 21.585V18.6055C10.3124 18.0988 9.90309 17.6886 9.39648 17.6885H6.41699Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M21.585 15.1885C23.4722 15.1887 25.0009 16.7182 25.001 18.6055V21.585C25.0008 23.4722 23.4721 25.0007 21.585 25.001H18.6055C16.7181 25.001 15.1887 23.4723 15.1885 21.585V18.6055C15.1886 16.718 16.718 15.1885 18.6055 15.1885H21.585ZM18.6055 17.6885C18.0987 17.6885 17.6886 18.0987 17.6885 18.6055V21.585C17.6887 22.0916 18.0988 22.501 18.6055 22.501H21.585C22.0914 22.5007 22.5008 22.0915 22.501 21.585V18.6055C22.5009 18.0989 22.0915 17.6887 21.585 17.6885H18.6055Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M9.39648 3C11.2837 3.00017 12.8123 4.52974 12.8125 6.41699V9.39648C12.8123 11.2837 11.2837 12.8123 9.39648 12.8125H6.41699C4.52965 12.8124 3.00017 11.2838 3 9.39648V6.41699C3.00018 4.52968 4.52966 3.00008 6.41699 3H9.39648ZM6.41699 5.5C5.91037 5.50008 5.50018 5.9104 5.5 6.41699V9.39648C5.50017 9.90309 5.91037 10.3124 6.41699 10.3125H9.39648C9.90303 10.3123 10.3123 9.90303 10.3125 9.39648V6.41699C10.3123 5.91045 9.90303 5.50017 9.39648 5.5H6.41699Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M21.585 3C23.4721 3.00026 25.0008 4.52979 25.001 6.41699V9.39648C25.0008 11.2837 23.4721 12.8122 21.585 12.8125H18.6055C16.7181 12.8125 15.1887 11.2839 15.1885 9.39648V6.41699C15.1887 4.52963 16.7181 3 18.6055 3H21.585ZM18.6055 5.5C18.0988 5.5 17.6887 5.91034 17.6885 6.41699V9.39648C17.6887 9.90314 18.0988 10.3125 18.6055 10.3125H21.585C22.0914 10.3122 22.5008 9.90298 22.501 9.39648V6.41699C22.5008 5.9105 22.0914 5.50026 21.585 5.5H18.6055Z"), + ) + }.build() + return _ic_grid_28!! + } + +@Composable +@Preview(showBackground = true) +private fun IcGrid28Preview() { + Icon( + imageVector = Icons.ic_grid_28, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcGridPlus16.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcGridPlus16.kt new file mode 100644 index 0000000000..32d787d6cb --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcGridPlus16.kt @@ -0,0 +1,62 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_grid_plus_16: ImageVector? = null + +val Icons.ic_grid_plus_16: ImageVector + get() { + if (_ic_grid_plus_16 != null) return _ic_grid_plus_16!! + _ic_grid_plus_16 = ImageVector.Builder( + name = "ic_grid_plus_16", + defaultWidth = 16.dp, + defaultHeight = 16.dp, + viewportWidth = 16f, + viewportHeight = 16f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M5.61133 8.56934C6.61611 8.56962 7.43047 9.38387 7.43066 10.3887V12.1807C7.43063 13.1856 6.61621 13.9997 5.61133 14H3.81934C2.81429 13.9999 2.00003 13.1857 2 12.1807V10.3887C2.00019 9.38375 2.81439 8.56943 3.81934 8.56934H5.61133ZM3.81934 9.81934C3.50475 9.81943 3.25019 10.0741 3.25 10.3887V12.1807C3.25003 12.4954 3.50465 12.7499 3.81934 12.75H5.61133C5.92585 12.7497 6.18063 12.4953 6.18066 12.1807V10.3887C6.18047 10.0742 5.92575 9.81962 5.61133 9.81934H3.81934Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M11.2852 9.16699C11.6301 9.16722 11.9102 9.44696 11.9102 9.79199V10.6602H12.7783C13.1233 10.6604 13.4033 10.9401 13.4033 11.2852C13.4032 11.6301 13.1232 11.9099 12.7783 11.9102H11.9102V12.7783C11.91 13.1232 11.63 13.4031 11.2852 13.4033C10.9401 13.4033 10.6603 13.1234 10.6602 12.7783V11.9102H9.79199C9.4469 11.9102 9.16713 11.6302 9.16699 11.2852C9.16699 10.94 9.44681 10.6602 9.79199 10.6602H10.6602V9.79199C10.6602 9.44681 10.94 9.16699 11.2852 9.16699Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M5.61133 2C6.61617 2.00028 7.43057 2.81445 7.43066 3.81934V5.61133C7.43048 6.61614 6.61612 7.43038 5.61133 7.43066H3.81934C2.81438 7.43057 2.00018 6.61626 2 5.61133V3.81934C2.00009 2.81433 2.81433 2.00009 3.81934 2H5.61133ZM3.81934 3.25C3.50469 3.25009 3.25009 3.50468 3.25 3.81934V5.61133C3.25018 5.9259 3.50474 6.18057 3.81934 6.18066H5.61133C5.92576 6.18038 6.18048 5.92579 6.18066 5.61133V3.81934C6.18057 3.5048 5.92582 3.25028 5.61133 3.25H3.81934Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M12.1807 2C13.1857 2.00003 13.9999 2.81429 14 3.81934V5.61133C13.9998 6.6163 13.1857 7.43063 12.1807 7.43066H10.3887C9.38377 7.43052 8.56952 6.61623 8.56934 5.61133V3.81934C8.56943 2.81436 9.38371 2.00015 10.3887 2H12.1807ZM10.3887 3.25C10.0741 3.25015 9.81943 3.50472 9.81934 3.81934V5.61133C9.81952 5.92587 10.0741 6.18052 10.3887 6.18066H12.1807C12.4953 6.18063 12.7498 5.92594 12.75 5.61133V3.81934C12.7499 3.50465 12.4954 3.25003 12.1807 3.25H10.3887Z"), + ) + }.build() + return _ic_grid_plus_16!! + } + +@Composable +@Preview(showBackground = true) +private fun IcGridPlus16Preview() { + Icon( + imageVector = Icons.ic_grid_plus_16, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcGridPlus20.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcGridPlus20.kt new file mode 100644 index 0000000000..0517db1ea0 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcGridPlus20.kt @@ -0,0 +1,62 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_grid_plus_20: ImageVector? = null + +val Icons.ic_grid_plus_20: ImageVector + get() { + if (_ic_grid_plus_20 != null) return _ic_grid_plus_20!! + _ic_grid_plus_20 = ImageVector.Builder( + name = "ic_grid_plus_20", + defaultWidth = 20.dp, + defaultHeight = 20.dp, + viewportWidth = 20f, + viewportHeight = 20f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M6.77734 10.8604C8.08152 10.8604 9.1377 11.9175 9.1377 13.2217V15.6377C9.1374 16.9416 8.08134 17.998 6.77734 17.998H4.36133C3.05738 17.9979 2.00029 16.9416 2 15.6377V13.2217C2 11.9175 3.0572 10.8605 4.36133 10.8604H6.77734ZM4.36133 12.3604C3.88563 12.3605 3.5 12.746 3.5 13.2217V15.6377C3.50029 16.1132 3.88581 16.4979 4.36133 16.498H6.77734C7.25292 16.498 7.63741 16.1132 7.6377 15.6377V13.2217C7.6377 12.7459 7.2531 12.3604 6.77734 12.3604H4.36133Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M14.4287 11.665C14.8427 11.6652 15.1785 12.001 15.1787 12.415V13.6787H16.4424C16.8564 13.6788 17.1922 14.0147 17.1924 14.4287C17.1923 14.8428 16.8565 15.1786 16.4424 15.1787H15.1787V16.4424C15.1787 16.8565 14.8428 17.1923 14.4287 17.1924C14.0147 17.1922 13.6788 16.8564 13.6787 16.4424V15.1787H12.415C12.0009 15.1786 11.6651 14.8428 11.665 14.4287C11.6652 14.0147 12.001 13.6788 12.415 13.6787H13.6787V12.415C13.6789 12.0011 14.0148 11.6652 14.4287 11.665Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M6.77734 2C8.08152 2.00006 9.13769 3.05714 9.1377 4.36133V6.77734C9.13764 8.08149 8.08149 9.13763 6.77734 9.1377H4.36133C3.05724 9.13757 2.00006 8.08145 2 6.77734V4.36133C2.00001 3.05718 3.05721 2.00012 4.36133 2H6.77734ZM4.36133 3.5C3.88564 3.50012 3.50001 3.88561 3.5 4.36133V6.77734C3.50006 7.25302 3.88567 7.63757 4.36133 7.6377H6.77734C7.25306 7.63763 7.63764 7.25306 7.6377 6.77734V4.36133C7.63769 3.88557 7.25309 3.50006 6.77734 3.5H4.36133Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M15.6377 2C16.9418 2.00013 17.998 3.05719 17.998 4.36133V6.77734C17.998 8.08144 16.9418 9.13756 15.6377 9.1377H13.2217C11.9175 9.1377 10.8604 8.08153 10.8604 6.77734V4.36133C10.8604 3.0571 11.9175 2 13.2217 2H15.6377ZM13.2217 3.5C12.7459 3.5 12.3604 3.88553 12.3604 4.36133V6.77734C12.3604 7.2531 12.7459 7.6377 13.2217 7.6377H15.6377C16.1133 7.63756 16.498 7.25302 16.498 6.77734V4.36133C16.498 3.88561 16.1134 3.50013 15.6377 3.5H13.2217Z"), + ) + }.build() + return _ic_grid_plus_20!! + } + +@Composable +@Preview(showBackground = true) +private fun IcGridPlus20Preview() { + Icon( + imageVector = Icons.ic_grid_plus_20, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcGridPlus24.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcGridPlus24.kt new file mode 100644 index 0000000000..3f52fdb61e --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcGridPlus24.kt @@ -0,0 +1,62 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_grid_plus_24: ImageVector? = null + +val Icons.ic_grid_plus_24: ImageVector + get() { + if (_ic_grid_plus_24 != null) return _ic_grid_plus_24!! + _ic_grid_plus_24 = ImageVector.Builder( + name = "ic_grid_plus_24", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M8 13C9.65728 13 11 14.3427 11 16V19C11 20.6573 9.65728 22 8 22H5C3.34272 22 2 20.6573 2 19V16C2 14.3427 3.34272 13 5 13H8ZM5 15C4.44728 15 4 15.4473 4 16V19C4 19.5527 4.44728 20 5 20H8C8.55272 20 9 19.5527 9 19V16C9 15.4473 8.55272 15 8 15H5Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M17.5 14C18.0523 14 18.5 14.4477 18.5 15V16.5H20C20.5523 16.5 21 16.9477 21 17.5C21 18.0523 20.5523 18.5 20 18.5H18.5V20C18.5 20.5523 18.0523 21 17.5 21C16.9477 21 16.5 20.5523 16.5 20V18.5H15C14.4477 18.5 14 18.0523 14 17.5C14 16.9477 14.4477 16.5 15 16.5H16.5V15C16.5 14.4477 16.9477 14 17.5 14Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M8 2C9.65728 2 11 3.34272 11 5V8C11 9.65728 9.65728 11 8 11H5C3.34272 11 2 9.65728 2 8V5C2 3.34272 3.34272 2 5 2H8ZM5 4C4.44728 4 4 4.44728 4 5V8C4 8.55272 4.44728 9 5 9H8C8.55272 9 9 8.55272 9 8V5C9 4.44728 8.55272 4 8 4H5Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M19 2C20.6573 2 22 3.34272 22 5V8C22 9.65728 20.6573 11 19 11H16C14.3427 11 13 9.65728 13 8V5C13 3.34272 14.3427 2 16 2H19ZM16 4C15.4473 4 15 4.44728 15 5V8C15 8.55272 15.4473 9 16 9H19C19.5527 9 20 8.55272 20 8V5C20 4.44728 19.5527 4 19 4H16Z"), + ) + }.build() + return _ic_grid_plus_24!! + } + +@Composable +@Preview(showBackground = true) +private fun IcGridPlus24Preview() { + Icon( + imageVector = Icons.ic_grid_plus_24, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcGridPlus28.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcGridPlus28.kt new file mode 100644 index 0000000000..b81d3c22f4 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcGridPlus28.kt @@ -0,0 +1,62 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_grid_plus_28: ImageVector? = null + +val Icons.ic_grid_plus_28: ImageVector + get() { + if (_ic_grid_plus_28 != null) return _ic_grid_plus_28!! + _ic_grid_plus_28 = ImageVector.Builder( + name = "ic_grid_plus_28", + defaultWidth = 28.dp, + defaultHeight = 28.dp, + viewportWidth = 28f, + viewportHeight = 28f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M9.66699 14.918C11.5545 14.918 13.084 16.4474 13.084 18.335V21.585C13.0839 23.4725 11.5545 25.0019 9.66699 25.002H6.41699C4.52948 25.002 3.00007 23.4725 3 21.585V18.335C3 16.4474 4.52944 14.918 6.41699 14.918H9.66699ZM6.41699 17.418C5.91015 17.418 5.5 17.8281 5.5 18.335V21.585C5.50007 22.0917 5.91019 22.502 6.41699 22.502H9.66699C10.1738 22.5019 10.5839 22.0917 10.584 21.585V18.335C10.584 17.8281 10.1738 17.418 9.66699 17.418H6.41699Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M19.96 16.001C20.6501 16.0011 21.2098 16.5609 21.21 17.251V18.71H22.668C23.3583 18.71 23.9179 19.2697 23.918 19.96C23.9178 20.6502 23.3582 21.21 22.668 21.21H21.21V22.668C21.21 23.3582 20.6502 23.9178 19.96 23.918C19.2696 23.9179 18.71 23.3583 18.71 22.668V21.21H17.251C16.5609 21.2098 16.0011 20.6501 16.001 19.96C16.0011 19.2698 16.5608 18.7101 17.251 18.71H18.71V17.251C18.7101 16.5608 19.2697 16.001 19.96 16.001Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M9.66699 3C11.5545 3.00001 13.084 4.52945 13.084 6.41699V9.66699C13.084 11.5545 11.5545 13.084 9.66699 13.084H6.41699C4.52944 13.084 3 11.5545 3 9.66699V6.41699C3.00001 4.52945 4.52944 3 6.41699 3H9.66699ZM6.41699 5.5C5.91016 5.5 5.50001 5.91016 5.5 6.41699V9.66699C5.5 10.1738 5.91015 10.584 6.41699 10.584H9.66699C10.1738 10.584 10.584 10.1738 10.584 9.66699V6.41699C10.584 5.91017 10.1738 5.50001 9.66699 5.5H6.41699Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M21.585 3C23.4724 3.00014 25.0019 4.52954 25.002 6.41699V9.66699C25.002 11.5545 23.4724 13.0838 21.585 13.084H18.335C16.4474 13.084 14.918 11.5545 14.918 9.66699V6.41699C14.918 4.52945 16.4474 3 18.335 3H21.585ZM18.335 5.5C17.8281 5.5 17.418 5.91016 17.418 6.41699V9.66699C17.418 10.1738 17.8281 10.584 18.335 10.584H21.585C22.0917 10.5838 22.502 10.1737 22.502 9.66699V6.41699C22.5019 5.91025 22.0917 5.50014 21.585 5.5H18.335Z"), + ) + }.build() + return _ic_grid_plus_28!! + } + +@Composable +@Preview(showBackground = true) +private fun IcGridPlus28Preview() { + Icon( + imageVector = Icons.ic_grid_plus_28, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcHeart16.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcHeart16.kt index dcb0a7ee63..62f84bdc35 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcHeart16.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcHeart16.kt @@ -31,7 +31,7 @@ val Icons.ic_heart_16: ImageVector addPath( fill = SolidColor(Color.Black), pathFillType = PathFillType.NonZero, - pathData = addPathNodes("M10.2119 2.5C12.5521 2.50031 14.0029 4.70655 14.0029 6.63379C14.0028 7.65361 13.6105 8.59271 13.0713 9.40234C12.5316 10.2126 11.8223 10.9278 11.1299 11.5156C10.435 12.1054 9.73935 12.5815 9.21191 12.9102C8.94794 13.0746 8.72349 13.2043 8.55859 13.2939C8.47673 13.3385 8.40636 13.3747 8.35156 13.4014C8.32501 13.4143 8.29574 13.4279 8.26855 13.4395C8.25576 13.4449 8.23542 13.4529 8.21191 13.4609C8.20024 13.4649 8.18004 13.4714 8.15527 13.4775C8.14029 13.4813 8.07935 13.497 8.00098 13.4971C7.92349 13.497 7.8634 13.4815 7.84766 13.4775C7.82308 13.4714 7.80276 13.4649 7.79102 13.4609C7.76739 13.4529 7.74624 13.4449 7.7334 13.4395C7.70641 13.428 7.67774 13.4142 7.65137 13.4014C7.59665 13.3747 7.52608 13.3384 7.44434 13.2939C7.27944 13.2043 7.05403 13.0746 6.79004 12.9102C6.26273 12.5816 5.56768 12.1052 4.87305 11.5156C4.18068 10.9279 3.47133 10.2124 2.93164 9.40234C2.39241 8.59271 2.0001 7.65361 2 6.63379C2.00005 4.70644 3.45057 2.50005 5.79102 2.5C6.76206 2.50006 7.48919 2.86673 8.00098 3.29004C8.51283 2.86653 9.24045 2.5 10.2119 2.5ZM10.2119 3.75C9.3646 3.75 8.81364 4.17408 8.47852 4.57031C8.35982 4.71056 8.18471 4.79192 8.00098 4.79199C7.81744 4.79187 7.64307 4.71032 7.52441 4.57031C7.18932 4.17412 6.63819 3.75008 5.79102 3.75C4.34424 3.75005 3.25005 5.1742 3.25 6.63379C3.2501 7.32849 3.51797 8.02773 3.97168 8.70898C4.42501 9.38953 5.04224 10.0197 5.68164 10.5625C6.31859 11.1031 6.96175 11.5436 7.45117 11.8486C7.67273 11.9867 7.86195 12.0952 8.00098 12.1719C8.14011 12.0951 8.3288 11.9869 8.55078 11.8486C9.04033 11.5436 9.68405 11.1034 10.3213 10.5625C10.9607 10.0197 11.5779 9.38958 12.0312 8.70898C12.4849 8.02775 12.7528 7.32847 12.7529 6.63379C12.7529 5.17434 11.6585 3.75032 10.2119 3.75Z"), + pathData = addPathNodes("M10.2119 2.5C12.5521 2.50031 14.0029 4.70655 14.0029 6.63379C14.0028 7.65361 13.6105 8.59271 13.0713 9.40234C12.5316 10.2126 11.8223 10.9278 11.1299 11.5156C10.435 12.1054 9.73935 12.5815 9.21191 12.9102C8.94794 13.0746 8.72349 13.2043 8.55859 13.2939C8.47673 13.3385 8.40636 13.3747 8.35156 13.4014C8.32501 13.4143 8.29574 13.4279 8.26855 13.4395C8.25576 13.4449 8.23542 13.4529 8.21191 13.4609C8.20024 13.4649 8.18004 13.4714 8.15527 13.4775C8.14029 13.4813 8.07935 13.497 8.00098 13.4971C7.92349 13.497 7.8634 13.4815 7.84766 13.4775C7.82308 13.4714 7.80276 13.4649 7.79102 13.4609C7.76739 13.4529 7.74624 13.4449 7.7334 13.4395C7.70641 13.428 7.67774 13.4142 7.65137 13.4014C7.59665 13.3747 7.52608 13.3384 7.44434 13.2939C7.27944 13.2043 7.05403 13.0746 6.79004 12.9102C6.26273 12.5816 5.56768 12.1052 4.87305 11.5156C4.18068 10.9279 3.47133 10.2124 2.93164 9.40234C2.39241 8.59271 2.0001 7.65361 2 6.63379C2.00005 4.70644 3.45057 2.50005 5.79102 2.5C6.76206 2.50006 7.48919 2.86673 8.00098 3.29004C8.51283 2.86653 9.24045 2.5 10.2119 2.5ZM10.2119 3.75C9.3646 3.75 8.81364 4.17408 8.47852 4.57031C8.35982 4.71056 8.18471 4.79192 8.00098 4.79199C7.81744 4.79187 7.64307 4.71032 7.52441 4.57031C7.18932 4.17412 6.63819 3.75008 5.79102 3.75C4.34424 3.75005 3.25005 5.1742 3.25 6.63379C3.2501 7.32849 3.51797 8.02773 3.97168 8.70898C4.42501 9.38953 5.04224 10.0197 5.68164 10.5625C6.31859 11.1031 6.96175 11.5436 7.45117 11.8486C7.67273 11.9867 7.86195 12.0952 8.00098 12.1719C8.14011 12.0951 8.3288 11.9869 8.55078 11.8486C9.04033 11.5436 9.68405 11.1034 10.3213 10.5625C10.9607 10.0197 11.5779 9.38958 12.0312 8.70898C12.4849 8.02775 12.7528 7.32846 12.7529 6.63379C12.7529 5.17434 11.6585 3.75032 10.2119 3.75Z"), ) }.build() return _ic_heart_16!! diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcHeart28.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcHeart28.kt new file mode 100644 index 0000000000..245b7073a5 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcHeart28.kt @@ -0,0 +1,47 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_heart_28: ImageVector? = null + +val Icons.ic_heart_28: ImageVector + get() { + if (_ic_heart_28 != null) return _ic_heart_28!! + _ic_heart_28 = ImageVector.Builder( + name = "ic_heart_28", + defaultWidth = 28.dp, + defaultHeight = 28.dp, + viewportWidth = 28f, + viewportHeight = 28f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M18.4189 3.5C23.043 3.5 25.999 7.66221 25.999 11.4092C25.9989 13.379 25.2017 15.181 24.1201 16.7227C23.0366 18.2667 21.615 19.6276 20.2305 20.7432C18.8407 21.8628 17.4505 22.7663 16.3965 23.3896C15.8687 23.7018 15.4192 23.9472 15.0898 24.1172C14.9263 24.2016 14.7858 24.2708 14.6768 24.3213C14.6239 24.3457 14.5671 24.3711 14.5137 24.3926C14.4884 24.4027 14.4483 24.4177 14.4023 24.4326C14.3796 24.44 14.3404 24.4527 14.293 24.4639C14.263 24.4709 14.1481 24.498 14 24.498C13.851 24.498 13.7352 24.4707 13.7061 24.4639C13.6586 24.4527 13.6204 24.44 13.5977 24.4326C13.5514 24.4176 13.5106 24.4027 13.4854 24.3926C13.4319 24.3711 13.3751 24.3457 13.3223 24.3213C13.2132 24.2708 13.0728 24.2016 12.9092 24.1172C12.5799 23.9472 12.1311 23.7017 11.6035 23.3896C10.5494 22.7662 9.15845 21.863 7.76855 20.7432C6.38398 19.6276 4.9634 18.2668 3.87988 16.7227C2.79814 15.181 2.00013 13.3791 2 11.4092C2.00004 7.66234 4.95627 3.50031 9.58008 3.5C11.5194 3.5 12.9731 4.2018 13.999 5.01465C15.0249 4.20158 16.4793 3.50006 18.4189 3.5ZM18.4189 6C16.6992 6.00008 15.5931 6.81735 14.9326 7.55859C14.6955 7.82435 14.3561 7.97646 14 7.97656C13.6436 7.97649 13.3035 7.8246 13.0664 7.55859C12.4059 6.81731 11.3 6 9.58008 6C6.63197 6.00032 4.50004 8.72832 4.5 11.4092C4.50013 12.6933 5.02207 13.9991 5.92578 15.2871C6.82793 16.5728 8.05909 17.7655 9.33789 18.7959C10.6114 19.822 11.8967 20.6581 12.876 21.2373C13.333 21.5076 13.7192 21.7179 14 21.8643C14.2807 21.7179 14.6675 21.5073 15.124 21.2373C16.1032 20.6581 17.3888 19.8218 18.6621 18.7959C19.9407 17.7657 21.1712 16.5726 22.0732 15.2871C22.977 13.9991 23.4989 12.6933 23.499 11.4092C23.499 8.72817 21.3673 6 18.4189 6Z"), + ) + }.build() + return _ic_heart_28!! + } + +@Composable +@Preview(showBackground = true) +private fun IcHeart28Preview() { + Icon( + imageVector = Icons.ic_heart_28, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcHeart28Filled.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcHeart28Filled.kt new file mode 100644 index 0000000000..ddda047d1d --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcHeart28Filled.kt @@ -0,0 +1,47 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_heart_28_filled: ImageVector? = null + +val Icons.ic_heart_28_filled: ImageVector + get() { + if (_ic_heart_28_filled != null) return _ic_heart_28_filled!! + _ic_heart_28_filled = ImageVector.Builder( + name = "ic_heart_28_filled", + defaultWidth = 28.dp, + defaultHeight = 28.dp, + viewportWidth = 28f, + viewportHeight = 28f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M18.9328 3.49902C23.1595 3.49902 25.9995 7.41027 25.9995 11.059C25.9995 18.4484 14.2128 24.499 13.9995 24.499C13.7862 24.499 1.99951 18.4484 1.99951 11.059C1.99951 7.41027 4.83951 3.49902 9.06618 3.49902C11.4928 3.49902 13.0795 4.6934 13.9995 5.7434C14.9195 4.6934 16.5062 3.49902 18.9328 3.49902Z"), + ) + }.build() + return _ic_heart_28_filled!! + } + +@Composable +@Preview(showBackground = true) +private fun IcHeart28FilledPreview() { + Icon( + imageVector = Icons.ic_heart_28_filled, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcHeart32.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcHeart32.kt index 97b92fa41e..690b65e389 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcHeart32.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcHeart32.kt @@ -31,7 +31,7 @@ val Icons.ic_heart_32: ImageVector addPath( fill = SolidColor(Color.Black), pathFillType = PathFillType.NonZero, - pathData = addPathNodes("M20.834 4.5C25.8256 4.50032 29.0046 9.05209 29.0049 13.1299C29.0048 15.2713 28.1491 17.2402 26.9756 18.9355C25.8006 20.633 24.2568 22.1319 22.748 23.3643C21.2341 24.6008 19.7184 25.5981 18.5693 26.2871C17.9941 26.632 17.5046 26.9034 17.1465 27.0908C16.9686 27.1839 16.8166 27.2594 16.6992 27.3145C16.6423 27.3411 16.5824 27.3688 16.5264 27.3916C16.4999 27.4024 16.4584 27.418 16.4111 27.4336C16.3879 27.4412 16.3481 27.4542 16.2998 27.4658C16.2696 27.4731 16.1523 27.5009 16.002 27.501C15.8526 27.5009 15.7363 27.4733 15.7051 27.4658C15.657 27.4543 15.6171 27.4413 15.5938 27.4336C15.5467 27.4181 15.5051 27.4024 15.4785 27.3916C15.4226 27.3688 15.3625 27.3411 15.3057 27.3145C15.1884 27.2595 15.0361 27.1838 14.8584 27.0908C14.5003 26.9035 14.0107 26.6319 13.4355 26.2871C12.2866 25.5982 10.7707 24.6007 9.25684 23.3643C7.74815 22.132 6.20426 20.6328 5.0293 18.9355C3.8558 17.2402 3.00012 15.2713 3 13.1299C3.00024 9.05194 6.17897 4.5 11.1709 4.5C13.306 4.50013 14.8942 5.29749 16.002 6.20703C17.1098 5.29726 18.6983 4.5 20.834 4.5ZM20.834 7C18.9179 7 17.6812 7.92499 16.9414 8.7666C16.7042 9.03641 16.3612 9.19127 16.002 9.19141C15.6429 9.19128 15.3007 9.03609 15.0635 8.7666C14.3238 7.92506 13.0866 7.00017 11.1709 7C7.88427 7 5.50024 10.084 5.5 13.1299C5.50012 14.595 6.08771 16.0719 7.08496 17.5127C8.08079 18.9511 9.43611 20.282 10.8389 21.4277C12.2366 22.5693 13.6468 23.4997 14.7207 24.1436C15.248 24.4597 15.6898 24.7035 16.002 24.8672C16.3142 24.7034 16.7564 24.46 17.2842 24.1436C18.3582 23.4996 19.7682 22.5694 21.166 21.4277C22.5689 20.2819 23.9241 18.9513 24.9199 17.5127C25.9172 16.0719 26.5048 14.595 26.5049 13.1299C26.5046 10.0842 24.1203 7.00033 20.834 7Z"), + pathData = addPathNodes("M20.834 4.5C25.8256 4.50032 29.0046 9.05209 29.0049 13.1299C29.0048 15.2713 28.1491 17.2402 26.9756 18.9355C25.8006 20.633 24.2568 22.1319 22.748 23.3643C21.2341 24.6008 19.7184 25.5981 18.5693 26.2871C17.9941 26.632 17.5046 26.9034 17.1465 27.0908C16.9686 27.1839 16.8166 27.2594 16.6992 27.3145C16.6423 27.3411 16.5824 27.3688 16.5264 27.3916C16.4999 27.4024 16.4584 27.418 16.4111 27.4336C16.3879 27.4412 16.3481 27.4543 16.2998 27.4658C16.2696 27.4731 16.1523 27.5009 16.002 27.501C15.8526 27.5009 15.7363 27.4733 15.7051 27.4658C15.657 27.4543 15.6171 27.4413 15.5938 27.4336C15.5467 27.4181 15.5051 27.4024 15.4785 27.3916C15.4226 27.3688 15.3625 27.3411 15.3057 27.3145C15.1884 27.2595 15.0361 27.1838 14.8584 27.0908C14.5003 26.9035 14.0107 26.6319 13.4355 26.2871C12.2866 25.5982 10.7707 24.6007 9.25684 23.3643C7.74815 22.132 6.20426 20.6328 5.0293 18.9355C3.8558 17.2402 3.00012 15.2713 3 13.1299C3.00024 9.05194 6.17897 4.5 11.1709 4.5C13.306 4.50013 14.8942 5.29749 16.002 6.20703C17.1098 5.29726 18.6983 4.5 20.834 4.5ZM20.834 7C18.9179 7 17.6812 7.92499 16.9414 8.7666C16.7042 9.03641 16.3612 9.19127 16.002 9.19141C15.6429 9.19128 15.3007 9.03609 15.0635 8.7666C14.3238 7.92506 13.0866 7.00017 11.1709 7C7.88427 7 5.50024 10.084 5.5 13.1299C5.50012 14.595 6.08771 16.0719 7.08496 17.5127C8.08079 18.9511 9.43611 20.282 10.8389 21.4277C12.2366 22.5693 13.6468 23.4997 14.7207 24.1436C15.248 24.4597 15.6898 24.7035 16.002 24.8672C16.3142 24.7034 16.7564 24.46 17.2842 24.1436C18.3582 23.4996 19.7682 22.5694 21.166 21.4277C22.5689 20.2819 23.9241 18.9513 24.9199 17.5127C25.9172 16.0719 26.5048 14.595 26.5049 13.1299C26.5046 10.0842 24.1203 7.00033 20.834 7Z"), ) }.build() return _ic_heart_32!! diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcHeartBroken16.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcHeartBroken16.kt index 5d2319b1cc..94784125f9 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcHeartBroken16.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcHeartBroken16.kt @@ -31,7 +31,7 @@ val Icons.ic_heart_broken_16: ImageVector addPath( fill = SolidColor(Color.Black), pathFillType = PathFillType.NonZero, - pathData = addPathNodes("M10.2119 2.5C12.5521 2.50031 14.0029 4.70655 14.0029 6.63379C14.0028 7.65361 13.6105 8.59271 13.0713 9.40234C12.5316 10.2126 11.8223 10.9278 11.1299 11.5156C10.435 12.1054 9.73935 12.5815 9.21191 12.9102C8.94794 13.0746 8.72349 13.2043 8.55859 13.2939C8.47673 13.3385 8.40636 13.3747 8.35156 13.4014C8.32501 13.4143 8.29574 13.4279 8.26855 13.4395C8.25576 13.4449 8.23542 13.4529 8.21191 13.4609C8.20024 13.4649 8.18004 13.4714 8.15527 13.4775C8.14029 13.4813 8.07935 13.497 8.00098 13.4971C7.92349 13.497 7.8634 13.4815 7.84766 13.4775C7.82308 13.4714 7.80276 13.4649 7.79102 13.4609C7.76739 13.4529 7.74624 13.4449 7.7334 13.4395C7.70641 13.428 7.67774 13.4142 7.65137 13.4014C7.59665 13.3747 7.52608 13.3384 7.44434 13.2939C7.27944 13.2043 7.05403 13.0746 6.79004 12.9102C6.26273 12.5816 5.56768 12.1052 4.87305 11.5156C4.18068 10.9279 3.47133 10.2124 2.93164 9.40234C2.39241 8.59271 2.0001 7.65361 2 6.63379C2.00005 4.70644 3.45057 2.50005 5.79102 2.5C6.76206 2.50006 7.48919 2.86673 8.00098 3.29004C8.51283 2.86653 9.24045 2.5 10.2119 2.5ZM5.79102 3.75C4.34424 3.75005 3.25005 5.1742 3.25 6.63379C3.2501 7.32849 3.51797 8.02773 3.97168 8.70898C4.42501 9.38953 5.04224 10.0197 5.68164 10.5625C6.31859 11.1031 6.96175 11.5436 7.45117 11.8486C7.48253 11.8682 7.51395 11.886 7.54395 11.9043L8.2041 9.66016L6.91992 8.08887C6.73201 7.85885 6.73214 7.52794 6.91992 7.29785L8.16016 5.78027L7.60254 4.64453C7.57508 4.6216 7.54789 4.59802 7.52441 4.57031C7.18932 4.17412 6.63819 3.75008 5.79102 3.75ZM10.2119 3.75C9.5951 3.75 9.13554 3.97487 8.80176 4.25L9.45898 5.59082C9.56625 5.80992 9.53593 6.07166 9.38184 6.26074L8.21094 7.69238L9.38184 9.125C9.5123 9.28474 9.55517 9.49938 9.49707 9.69727L8.9375 11.5986C9.3564 11.3201 9.84091 10.9703 10.3213 10.5625C10.9607 10.0197 11.5779 9.38958 12.0312 8.70898C12.4849 8.02775 12.7528 7.32847 12.7529 6.63379C12.7529 5.17434 11.6585 3.75032 10.2119 3.75Z"), + pathData = addPathNodes("M10.2119 2.5C12.5521 2.50031 14.0029 4.70655 14.0029 6.63379C14.0028 7.65361 13.6105 8.59271 13.0713 9.40234C12.5316 10.2126 11.8223 10.9278 11.1299 11.5156C10.435 12.1054 9.73935 12.5815 9.21191 12.9102C8.94794 13.0746 8.72349 13.2043 8.55859 13.2939C8.47673 13.3385 8.40636 13.3747 8.35156 13.4014C8.32501 13.4143 8.29574 13.4279 8.26855 13.4395C8.25576 13.4449 8.23542 13.4529 8.21191 13.4609C8.20024 13.4649 8.18004 13.4714 8.15527 13.4775C8.14029 13.4813 8.07935 13.497 8.00098 13.4971C7.92349 13.497 7.8634 13.4815 7.84766 13.4775C7.82308 13.4714 7.80276 13.4649 7.79102 13.4609C7.76739 13.4529 7.74624 13.4449 7.7334 13.4395C7.70641 13.428 7.67774 13.4142 7.65137 13.4014C7.59665 13.3747 7.52608 13.3384 7.44434 13.2939C7.27944 13.2043 7.05403 13.0746 6.79004 12.9102C6.26273 12.5816 5.56768 12.1052 4.87305 11.5156C4.18068 10.9279 3.47133 10.2124 2.93164 9.40234C2.39241 8.59271 2.0001 7.65361 2 6.63379C2.00005 4.70644 3.45057 2.50005 5.79102 2.5C6.76206 2.50006 7.48919 2.86673 8.00098 3.29004C8.51283 2.86653 9.24045 2.5 10.2119 2.5ZM5.79102 3.75C4.34424 3.75005 3.25005 5.1742 3.25 6.63379C3.2501 7.32849 3.51797 8.02773 3.97168 8.70898C4.42501 9.38953 5.04224 10.0197 5.68164 10.5625C6.31859 11.1031 6.96175 11.5436 7.45117 11.8486C7.48253 11.8682 7.51395 11.886 7.54395 11.9043L8.2041 9.66016L6.91992 8.08887C6.73201 7.85885 6.73214 7.52794 6.91992 7.29785L8.16016 5.78027L7.60254 4.64453C7.57508 4.6216 7.54789 4.59802 7.52441 4.57031C7.18932 4.17412 6.63819 3.75008 5.79102 3.75ZM10.2119 3.75C9.5951 3.75 9.13554 3.97487 8.80176 4.25L9.45898 5.59082C9.56625 5.80992 9.53593 6.07166 9.38184 6.26074L8.21094 7.69238L9.38184 9.125C9.5123 9.28474 9.55517 9.49938 9.49707 9.69727L8.9375 11.5986C9.3564 11.3201 9.84091 10.9703 10.3213 10.5625C10.9607 10.0197 11.5779 9.38958 12.0312 8.70898C12.4849 8.02775 12.7528 7.32846 12.7529 6.63379C12.7529 5.17434 11.6585 3.75032 10.2119 3.75Z"), ) }.build() return _ic_heart_broken_16!! diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcHeartBroken28.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcHeartBroken28.kt new file mode 100644 index 0000000000..1ced68ea96 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcHeartBroken28.kt @@ -0,0 +1,47 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_heart_broken_28: ImageVector? = null + +val Icons.ic_heart_broken_28: ImageVector + get() { + if (_ic_heart_broken_28 != null) return _ic_heart_broken_28!! + _ic_heart_broken_28 = ImageVector.Builder( + name = "ic_heart_broken_28", + defaultWidth = 28.dp, + defaultHeight = 28.dp, + viewportWidth = 28f, + viewportHeight = 28f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M18.4189 3.5C23.043 3.5 25.999 7.66221 25.999 11.4092C25.9989 13.379 25.2017 15.181 24.1201 16.7227C23.0366 18.2667 21.615 19.6276 20.2305 20.7432C18.8407 21.8628 17.4505 22.7663 16.3965 23.3896C15.8687 23.7018 15.4192 23.9472 15.0898 24.1172C14.9263 24.2016 14.7858 24.2708 14.6768 24.3213C14.6239 24.3457 14.5671 24.3711 14.5137 24.3926C14.4884 24.4027 14.4483 24.4177 14.4023 24.4326C14.3796 24.44 14.3404 24.4527 14.293 24.4639C14.263 24.4709 14.1481 24.498 14 24.498C13.851 24.498 13.7352 24.4707 13.7061 24.4639C13.6586 24.4527 13.6204 24.44 13.5977 24.4326C13.5514 24.4176 13.5106 24.4027 13.4854 24.3926C13.4319 24.3711 13.3751 24.3457 13.3223 24.3213C13.2132 24.2708 13.0728 24.2016 12.9092 24.1172C12.5799 23.9472 12.1311 23.7017 11.6035 23.3896C10.5494 22.7662 9.15845 21.863 7.76855 20.7432C6.38398 19.6276 4.9634 18.2668 3.87988 16.7227C2.79814 15.181 2.00013 13.3791 2 11.4092C2.00004 7.66234 4.95627 3.50031 9.58008 3.5C11.5194 3.5 12.9731 4.2018 13.999 5.01465C15.0249 4.20158 16.4793 3.50006 18.4189 3.5ZM9.58008 6C6.63197 6.00032 4.50004 8.72832 4.5 11.4092C4.50013 12.6933 5.02207 13.9991 5.92578 15.2871C6.82793 16.5728 8.05909 17.7655 9.33789 18.7959C10.6114 19.822 11.8967 20.6581 12.876 21.2373C12.9504 21.2813 13.0242 21.3215 13.0947 21.3623L14.3916 17.1787L11.8584 14.2354C11.4552 13.7666 11.455 13.0732 11.8584 12.6045L14.293 9.77539L13.2217 7.70215C13.1668 7.65843 13.1138 7.61172 13.0664 7.55859C12.4059 6.81731 11.3 6 9.58008 6ZM18.4189 6C17.2003 6.00005 16.29 6.41016 15.6279 6.91309L16.9014 9.37793C17.1353 9.83082 17.0707 10.3812 16.7383 10.7676L14.4541 13.4199L16.7383 16.0732C17.019 16.3994 17.1126 16.8477 16.9854 17.2588L15.8984 20.7617C16.7354 20.2329 17.7026 19.569 18.6621 18.7959C19.9407 17.7657 21.1712 16.5726 22.0732 15.2871C22.977 13.9991 23.4989 12.6933 23.499 11.4092C23.499 8.72817 21.3673 6 18.4189 6Z"), + ) + }.build() + return _ic_heart_broken_28!! + } + +@Composable +@Preview(showBackground = true) +private fun IcHeartBroken28Preview() { + Icon( + imageVector = Icons.ic_heart_broken_28, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcInfo28.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcInfo28.kt new file mode 100644 index 0000000000..e2a28e9383 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcInfo28.kt @@ -0,0 +1,57 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_info_28: ImageVector? = null + +val Icons.ic_info_28: ImageVector + get() { + if (_ic_info_28 != null) return _ic_info_28!! + _ic_info_28 = ImageVector.Builder( + name = "ic_info_28", + defaultWidth = 28.dp, + defaultHeight = 28.dp, + viewportWidth = 28f, + viewportHeight = 28f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M14 12.75C14.6902 12.7502 15.25 13.3097 15.25 14V19.9717C15.25 20.6619 14.6902 21.2215 14 21.2217C13.3097 21.2216 12.75 20.662 12.75 19.9717V15.2461C12.0855 15.2169 11.5557 14.6717 11.5557 14C11.5557 13.3096 12.1153 12.75 12.8057 12.75H14Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M13.832 7.97949C14.6237 8.04632 15.2498 8.70702 15.25 9.52051C15.25 10.3756 14.5562 11.0692 13.7012 11.0693C12.8462 11.0691 12.1533 10.3755 12.1533 9.52051C12.1523 8.70625 12.7786 8.04689 13.5674 7.97949C13.611 7.97489 13.6554 7.97266 13.7002 7.97266C13.7446 7.97266 13.7888 7.97497 13.832 7.97949ZM13.6035 10.4678L13.7002 10.4727C13.6667 10.4727 13.6334 10.4694 13.6006 10.4668L13.6035 10.4678Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M14 2C20.6274 2.00019 25.9988 7.37257 25.999 14C25.9988 20.6275 20.6275 25.9988 14 25.999C7.37252 25.9989 2.00019 20.6275 2 14C2.00023 7.37256 7.37254 2.00016 14 2ZM14 4.5C8.75325 4.50016 4.50023 8.75327 4.5 14C4.50019 19.2468 8.75323 23.4989 14 23.499C19.2467 23.4988 23.4988 19.2467 23.499 14C23.4988 8.75329 19.2467 4.50019 14 4.5Z"), + ) + }.build() + return _ic_info_28!! + } + +@Composable +@Preview(showBackground = true) +private fun IcInfo28Preview() { + Icon( + imageVector = Icons.ic_info_28, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcMail16.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcMail16.kt new file mode 100644 index 0000000000..93dd181843 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcMail16.kt @@ -0,0 +1,52 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_mail_16: ImageVector? = null + +val Icons.ic_mail_16: ImageVector + get() { + if (_ic_mail_16 != null) return _ic_mail_16!! + _ic_mail_16 = ImageVector.Builder( + name = "ic_mail_16", + defaultWidth = 16.dp, + defaultHeight = 16.dp, + viewportWidth = 16f, + viewportHeight = 16f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M10.9355 5.4375C11.2286 5.25521 11.6135 5.34473 11.7959 5.6377C11.9782 5.9307 11.8886 6.31565 11.5957 6.49805L8.33105 8.5293C8.129 8.6549 7.87295 8.65492 7.6709 8.5293L4.40625 6.49805C4.11345 6.31566 4.02389 5.93067 4.20605 5.6377C4.38838 5.34478 4.77338 5.25535 5.06641 5.4375L8.00098 7.26172L10.9355 5.4375Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M12.5713 2.5C13.6766 2.50014 14.502 3.44503 14.502 4.52148V11.4775C14.5016 12.486 13.7765 13.3795 12.7754 13.4873L12.5713 13.498H3.43066C2.32546 13.498 1.50023 12.5529 1.5 11.4766V4.52148C1.5 3.445 2.32531 2.50009 3.43066 2.5H12.5713ZM3.43066 3.75C3.09322 3.7501 2.75 4.05515 2.75 4.52148V11.4766C2.75022 11.9426 3.09333 12.248 3.43066 12.248H12.5713C12.9087 12.2479 13.2516 11.9425 13.252 11.4775V4.52148C13.252 4.05519 12.9087 3.75015 12.5713 3.75H3.43066Z"), + ) + }.build() + return _ic_mail_16!! + } + +@Composable +@Preview(showBackground = true) +private fun IcMail16Preview() { + Icon( + imageVector = Icons.ic_mail_16, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcMail20.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcMail20.kt new file mode 100644 index 0000000000..ece0daf722 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcMail20.kt @@ -0,0 +1,52 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_mail_20: ImageVector? = null + +val Icons.ic_mail_20: ImageVector + get() { + if (_ic_mail_20 != null) return _ic_mail_20!! + _ic_mail_20 = ImageVector.Builder( + name = "ic_mail_20", + defaultWidth = 20.dp, + defaultHeight = 20.dp, + viewportWidth = 20f, + viewportHeight = 20f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M13.6484 6.95996C14.0043 6.74868 14.464 6.86514 14.6758 7.2207C14.8873 7.57655 14.7707 8.03721 14.415 8.24902L10.3857 10.6455C10.1496 10.7858 9.85527 10.7857 9.61914 10.6455L5.58984 8.24902C5.2341 8.0372 5.11744 7.5766 5.3291 7.2207C5.54094 6.865 6.00154 6.74831 6.35742 6.95996L10.002 9.12695L13.6484 6.95996Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M15.6436 3.5C16.9633 3.50042 18.0049 4.58896 18.0049 5.89746V14.1045C18.0048 15.3306 17.0892 16.3635 15.8877 16.4883L15.6436 16.501H4.3623C3.04231 16.5009 2.00002 15.4122 2 14.1035V5.89746C2.00001 4.58878 3.04231 3.50012 4.3623 3.5H15.6436ZM4.3623 5C3.90175 5.00012 3.50099 5.38584 3.50098 5.89746V14.1035C3.50099 14.6151 3.90175 15.0009 4.3623 15.001H15.6436C16.1041 15.0006 16.5048 14.6148 16.5049 14.1045V5.89746C16.5049 5.38605 16.1039 5.00042 15.6436 5H4.3623Z"), + ) + }.build() + return _ic_mail_20!! + } + +@Composable +@Preview(showBackground = true) +private fun IcMail20Preview() { + Icon( + imageVector = Icons.ic_mail_20, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcMail24.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcMail24.kt new file mode 100644 index 0000000000..9f0fbf048a --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcMail24.kt @@ -0,0 +1,52 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_mail_24: ImageVector? = null + +val Icons.ic_mail_24: ImageVector + get() { + if (_ic_mail_24 != null) return _ic_mail_24!! + _ic_mail_24 = ImageVector.Builder( + name = "ic_mail_24", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M6.14258 8.48535C6.42676 8.01188 7.04112 7.85846 7.51465 8.14258L12 10.833L16.4854 8.14258C16.9589 7.85846 17.5732 8.01188 17.8574 8.48535C18.1415 8.95888 17.9881 9.57324 17.5146 9.85742L12.5146 12.8574C12.198 13.0474 11.802 13.0474 11.4854 12.8574L6.48535 9.85742C6.01188 9.57324 5.85846 8.95888 6.14258 8.48535Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M19 4C20.6598 4 22 5.34813 22 7.00586V16.9951C22 18.6522 20.6595 20 19 20H5C3.34015 20 2 18.6519 2 16.9941V7.00586C2 5.34813 3.34015 4 5 4H19ZM5 6C4.44985 6 4 6.44757 4 7.00586V16.9941C4 17.5524 4.44985 18 5 18H19C19.5505 18 20 17.5521 20 16.9951V7.00586C20 6.44757 19.5502 6 19 6H5Z"), + ) + }.build() + return _ic_mail_24!! + } + +@Composable +@Preview(showBackground = true) +private fun IcMail24Preview() { + Icon( + imageVector = Icons.ic_mail_24, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcPercent16.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcPercent16.kt new file mode 100644 index 0000000000..33fa8d3ec9 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcPercent16.kt @@ -0,0 +1,57 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_percent_16: ImageVector? = null + +val Icons.ic_percent_16: ImageVector + get() { + if (_ic_percent_16 != null) return _ic_percent_16!! + _ic_percent_16 = ImageVector.Builder( + name = "ic_percent_16", + defaultWidth = 16.dp, + defaultHeight = 16.dp, + viewportWidth = 16f, + viewportHeight = 16f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.EvenOdd, + pathData = addPathNodes("M9.87392 9.87313C10.8176 8.92951 12.3472 8.92957 13.2909 9.87313L13.4569 10.0567C14.231 11.0058 14.1756 12.4055 13.2909 13.2901C12.3472 14.2335 10.8176 14.2337 9.87392 13.2901C8.93031 12.3465 8.93038 10.8168 9.87392 9.87313ZM12.3192 10.6768C11.861 10.3029 11.1849 10.3298 10.7577 10.7569C10.3023 11.2124 10.3023 11.9509 10.7577 12.4063C11.2132 12.8617 11.9516 12.8616 12.4071 12.4063C12.8342 11.9793 12.8608 11.3039 12.4872 10.8458L12.4071 10.7569L12.3192 10.6768Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M12.036 3.07723C12.2799 2.83356 12.6757 2.83397 12.9198 3.07723C13.1639 3.32131 13.1639 3.71793 12.9198 3.962L3.96279 12.919C3.7187 13.1631 3.32209 13.1631 3.07802 12.919C2.83453 12.6751 2.83454 12.2792 3.07802 12.0352L12.036 3.07723Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.EvenOdd, + pathData = addPathNodes("M2.70791 2.70712C3.65162 1.76369 5.18124 1.76359 6.1249 2.70712L6.29091 2.89071C7.06491 3.83975 7.00942 5.23944 6.1249 6.12411C5.18119 7.06765 3.6516 7.06768 2.70791 6.12411C1.76422 5.18044 1.76421 3.65078 2.70791 2.70712ZM5.15322 3.51083C4.69509 3.13693 4.0189 3.16394 3.59169 3.59091C3.13617 4.0464 3.1362 4.7848 3.59169 5.24032C4.04723 5.69574 4.78555 5.69571 5.24111 5.24032C5.66794 4.81331 5.69469 4.13783 5.32119 3.67977L5.24111 3.59091L5.15322 3.51083Z"), + ) + }.build() + return _ic_percent_16!! + } + +@Composable +@Preview(showBackground = true) +private fun IcPercent16Preview() { + Icon( + imageVector = Icons.ic_percent_16, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcPercent20.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcPercent20.kt new file mode 100644 index 0000000000..0343a35a5d --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcPercent20.kt @@ -0,0 +1,57 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_percent_20: ImageVector? = null + +val Icons.ic_percent_20: ImageVector + get() { + if (_ic_percent_20 != null) return _ic_percent_20!! + _ic_percent_20 = ImageVector.Builder( + name = "ic_percent_20", + defaultWidth = 20.dp, + defaultHeight = 20.dp, + viewportWidth = 20f, + viewportHeight = 20f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M12.164 12.1622C13.2512 11.0751 14.9998 11.0586 16.1103 12.1075L16.1142 12.1105L16.1708 12.1622L16.3661 12.3771C17.2738 13.49 17.2082 15.1318 16.1708 16.1691C15.0642 17.2753 13.2705 17.2754 12.164 16.1691C11.0577 15.0626 11.0576 13.2687 12.164 12.1622ZM15.0097 13.131C14.4859 12.7039 13.7128 12.7346 13.2245 13.2228C12.704 13.7434 12.7041 14.5878 13.2245 15.1085C13.7453 15.6291 14.5895 15.629 15.1103 15.1085C15.5984 14.6204 15.6291 13.8481 15.2021 13.3243L15.1103 13.2228L15.0097 13.131Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M14.6796 4.25989C14.9725 3.96734 15.4474 3.96712 15.7402 4.25989C16.0328 4.5527 16.0327 5.02758 15.7402 5.32044L5.3222 15.7374C5.02935 16.0303 4.55455 16.0302 4.26166 15.7374C3.96879 15.4445 3.96875 14.9698 4.26166 14.6769L14.6796 4.25989Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M3.83002 3.82825C4.91726 2.74143 6.66596 2.72458 7.7763 3.77356L7.78021 3.77649L7.83685 3.82825L8.03216 4.04309C8.93972 5.15588 8.87397 6.79775 7.83685 7.83509C6.73027 8.94143 4.93656 8.94149 3.83002 7.83509C2.72358 6.72857 2.7235 4.93472 3.83002 3.82825ZM6.67572 4.797C6.15202 4.36988 5.37884 4.40087 4.89056 4.8888C4.36985 5.40947 4.36996 6.2538 4.89056 6.77454C5.41132 7.29516 6.25551 7.29509 6.7763 6.77454C7.26417 6.28641 7.29503 5.51397 6.8681 4.99036L6.7763 4.8888L6.67572 4.797Z"), + ) + }.build() + return _ic_percent_20!! + } + +@Composable +@Preview(showBackground = true) +private fun IcPercent20Preview() { + Icon( + imageVector = Icons.ic_percent_20, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcPercent24.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcPercent24.kt new file mode 100644 index 0000000000..97de7dfcb8 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcPercent24.kt @@ -0,0 +1,57 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_percent_24: ImageVector? = null + +val Icons.ic_percent_24: ImageVector + get() { + if (_ic_percent_24 != null) return _ic_percent_24!! + _ic_percent_24 = ImageVector.Builder( + name = "ic_percent_24", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M15.1704 15.1704C16.7324 13.6084 19.2647 13.6084 20.8267 15.1704L20.9683 15.3188C22.3869 16.8889 22.3398 19.3134 20.8267 20.8266C19.2648 22.3885 16.7324 22.3882 15.1704 20.8266C13.6084 19.2647 13.6085 16.7324 15.1704 15.1704ZM19.2603 16.4467C18.4748 15.8062 17.3165 15.8524 16.5845 16.5844C15.8036 17.3654 15.8036 18.6316 16.5845 19.4126C17.3654 20.193 18.6318 20.1933 19.4126 19.4126C20.1447 18.6804 20.19 17.5212 19.5493 16.7358L19.4126 16.5844L19.2603 16.4467Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M18.7905 3.79245C19.1809 3.40203 19.814 3.40224 20.2046 3.79245C20.5951 4.18297 20.5951 4.81598 20.2046 5.20651L5.20654 20.2046C4.81601 20.5949 4.18295 20.595 3.79248 20.2046C3.40239 19.8141 3.40225 19.1809 3.79248 18.7905L18.7905 3.79245Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M3.17139 3.17135C4.73327 1.60947 7.26565 1.60963 8.82764 3.17135L8.96924 3.31979C10.388 4.88987 10.3408 7.31437 8.82764 8.8276C7.26577 10.3893 4.73333 10.3892 3.17139 8.8276C1.60972 7.26565 1.60964 4.73326 3.17139 3.17135ZM7.26123 4.44772C6.47582 3.80747 5.31743 3.85343 4.58545 4.58542C3.80474 5.36627 3.80483 6.63264 4.58545 7.41354C5.36635 8.19411 6.63275 8.19423 7.41357 7.41354C8.14573 6.68135 8.19107 5.5222 7.55029 4.73678L7.41357 4.58542L7.26123 4.44772Z"), + ) + }.build() + return _ic_percent_24!! + } + +@Composable +@Preview(showBackground = true) +private fun IcPercent24Preview() { + Icon( + imageVector = Icons.ic_percent_24, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcPercent28.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcPercent28.kt new file mode 100644 index 0000000000..791b59b8c5 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcPercent28.kt @@ -0,0 +1,57 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_percent_28: ImageVector? = null + +val Icons.ic_percent_28: ImageVector + get() { + if (_ic_percent_28 != null) return _ic_percent_28!! + _ic_percent_28 = ImageVector.Builder( + name = "ic_percent_28", + defaultWidth = 28.dp, + defaultHeight = 28.dp, + viewportWidth = 28f, + viewportHeight = 28f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M17.3147 17.3155C19.0717 15.5586 21.9209 15.5587 23.678 17.3155C25.4347 19.0726 25.4348 21.9218 23.678 23.6788C21.921 25.4358 19.0718 25.4356 17.3147 23.6788C15.5579 21.9217 15.5577 19.0725 17.3147 17.3155ZM21.7581 18.9464C20.9728 18.3062 19.8151 18.3523 19.0833 19.0841C18.3025 19.8649 18.3025 21.1304 19.0833 21.9112C19.8641 22.6915 21.1298 22.6918 21.9104 21.9112C22.6422 21.1792 22.6875 20.0207 22.0471 19.2354L21.9104 19.0841L21.7581 18.9464Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M21.2366 4.9913C21.7246 4.50323 22.516 4.50343 23.0042 4.9913C23.4923 5.47945 23.4923 6.27072 23.0042 6.75888L6.75806 23.0059C6.26995 23.4938 5.47859 23.4939 4.99048 23.0059C4.50252 22.5178 4.50256 21.7265 4.99048 21.2384L21.2366 4.9913Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M4.31763 4.31747C6.07464 2.56089 8.92395 2.5608 10.6809 4.31747C12.4378 6.07442 12.4375 8.92361 10.6809 10.6808C8.92383 12.4379 6.07471 12.4379 4.31763 10.6808C2.56089 8.92362 2.56067 6.07446 4.31763 4.31747ZM8.76099 5.94833C7.97588 5.30821 6.81808 5.35453 6.08618 6.08603C5.30541 6.86681 5.30543 8.13238 6.08618 8.91317C6.86696 9.69379 8.13262 9.6939 8.91333 8.91317C9.64494 8.18106 9.69063 7.02252 9.05005 6.23739L8.91333 6.08603L8.76099 5.94833Z"), + ) + }.build() + return _ic_percent_28!! + } + +@Composable +@Preview(showBackground = true) +private fun IcPercent28Preview() { + Icon( + imageVector = Icons.ic_percent_28, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcPercentBackward20.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcPercentBackward20.kt index 968735888a..ec28c7ce8a 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcPercentBackward20.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcPercentBackward20.kt @@ -46,7 +46,7 @@ val Icons.ic_percent_backward_20: ImageVector addPath( fill = SolidColor(Color.Black), pathFillType = PathFillType.NonZero, - pathData = addPathNodes("M8.5 7.62531C8.98325 7.62531 9.375 8.01706 9.375 8.50031C9.3748 8.98274 8.98418 9.37328 8.50195 9.37434L8.5 9.37531C8.26802 9.37544 8.04499 9.28348 7.88086 9.11945C7.71615 8.95474 7.62424 8.73031 7.625 8.49738C7.62658 8.01548 8.01773 7.62531 8.5 7.62531Z"), + pathData = addPathNodes("M8.5 7.62531C8.98325 7.62531 9.375 8.01706 9.375 8.50031C9.3748 8.98274 8.98418 9.37328 8.50195 9.37434L8.5 9.37531C8.26802 9.37543 8.04499 9.28348 7.88086 9.11945C7.71615 8.95474 7.62424 8.73031 7.625 8.49738C7.62658 8.01548 8.01772 7.62531 8.5 7.62531Z"), ) }.build() return _ic_percent_backward_20!! diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcPercentBackward24.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcPercentBackward24.kt index 2a3039e933..f48fb0b51c 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcPercentBackward24.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcPercentBackward24.kt @@ -36,7 +36,7 @@ val Icons.ic_percent_backward_24: ImageVector addPath( fill = SolidColor(Color.Black), pathFillType = PathFillType.NonZero, - pathData = addPathNodes("M14.126 13.0018C14.3867 13.0017 14.6387 13.091 14.8389 13.2547L14.9219 13.3299L14.9971 13.4129C15.1608 13.6131 15.251 13.865 15.251 14.1258C15.251 14.7471 14.7472 15.2507 14.126 15.2508C13.5047 15.2508 13.001 14.7471 13.001 14.1258C13.0011 13.5059 13.5026 13.0039 14.1221 13.0018H14.126Z"), + pathData = addPathNodes("M14.126 13.0018C14.3867 13.0017 14.6388 13.091 14.8389 13.2547L14.9219 13.3299L14.9971 13.4129C15.1608 13.6131 15.251 13.865 15.251 14.1258C15.251 14.7471 14.7472 15.2507 14.126 15.2508C13.5047 15.2508 13.001 14.7471 13.001 14.1258C13.0011 13.5059 13.5026 13.0039 14.1221 13.0018H14.126Z"), ) addPath( fill = SolidColor(Color.Black), @@ -46,7 +46,7 @@ val Icons.ic_percent_backward_24: ImageVector addPath( fill = SolidColor(Color.Black), pathFillType = PathFillType.NonZero, - pathData = addPathNodes("M9.98926 8.75472C10.5565 8.81232 10.9988 9.29151 10.999 9.87386C10.999 10.4943 10.497 10.9964 9.87695 10.9979L9.87793 10.9989L9.875 10.9979L9.87402 10.9989C9.57573 10.9989 9.28912 10.8808 9.07812 10.6698C8.86639 10.4579 8.74791 10.1695 8.74902 9.86995C8.75133 9.25065 9.25419 8.74897 9.87402 8.74886L9.98926 8.75472Z"), + pathData = addPathNodes("M9.98926 8.75472C10.5565 8.81231 10.9988 9.29151 10.999 9.87386C10.999 10.4943 10.497 10.9964 9.87695 10.9979L9.87793 10.9989L9.875 10.9979L9.87402 10.9989C9.57573 10.9989 9.28912 10.8808 9.07812 10.6698C8.86639 10.4579 8.74791 10.1695 8.74902 9.86995C8.75133 9.25065 9.25419 8.74897 9.87402 8.74886L9.98926 8.75472Z"), ) }.build() return _ic_percent_backward_24!! diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcPincode20.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcPincode20.kt index 21e2906729..224b1a38f9 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcPincode20.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcPincode20.kt @@ -36,7 +36,7 @@ val Icons.ic_pincode_20: ImageVector addPath( fill = SolidColor(Color.Black), pathFillType = PathFillType.NonZero, - pathData = addPathNodes("M5.95898 9.27246C6.34232 8.88986 6.95046 8.87284 7.35547 9.21875L7.36035 9.22168L7.41699 9.27246L7.46777 9.3291C7.47273 9.33522 7.47668 9.34242 7.48145 9.34863C7.72182 9.6405 7.78718 10.0421 7.64062 10.3965C7.48111 10.7819 7.10463 11.034 6.6875 11.0342C6.27036 11.0341 5.89395 10.7819 5.73438 10.3965C5.57495 10.011 5.66379 9.56729 5.95898 9.27246Z"), + pathData = addPathNodes("M5.95898 9.27246C6.34232 8.88986 6.95046 8.87284 7.35547 9.21875L7.36035 9.22168L7.41699 9.27246L7.46777 9.3291C7.47273 9.33522 7.47668 9.34242 7.48145 9.34863C7.72183 9.6405 7.78718 10.0421 7.64062 10.3965C7.48111 10.7819 7.10463 11.034 6.6875 11.0342C6.27036 11.0341 5.89395 10.7819 5.73438 10.3965C5.57495 10.011 5.66379 9.56729 5.95898 9.27246Z"), ) addPath( fill = SolidColor(Color.Black), diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcPincode24.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcPincode24.kt index 40f4bb2968..51ee030a85 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcPincode24.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcPincode24.kt @@ -36,7 +36,7 @@ val Icons.ic_pincode_24: ImageVector addPath( fill = SolidColor(Color.Black), pathFillType = PathFillType.NonZero, - pathData = addPathNodes("M6.71484 10.9326C7.25469 10.493 8.05043 10.5248 8.55371 11.0273C8.94733 11.4205 9.06523 12.0123 8.85254 12.5264C8.6397 13.0402 8.13825 13.375 7.58203 13.375C7.02584 13.3749 6.5243 13.0402 6.31152 12.5264C6.09886 12.0124 6.21682 11.4205 6.61035 11.0273L6.71484 10.9326Z"), + pathData = addPathNodes("M6.71484 10.9326C7.25469 10.493 8.05043 10.5248 8.55371 11.0273C8.94733 11.4205 9.06523 12.0123 8.85254 12.5264C8.6397 13.0402 8.13825 13.375 7.58203 13.375C7.02584 13.3749 6.5243 13.0402 6.31152 12.5264C6.09886 12.0124 6.21683 11.4205 6.61035 11.0273L6.71484 10.9326Z"), ) addPath( fill = SolidColor(Color.Black), diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcScanFace20.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcScanFace20.kt new file mode 100644 index 0000000000..00a623083d --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcScanFace20.kt @@ -0,0 +1,82 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_scan_face_20: ImageVector? = null + +val Icons.ic_scan_face_20: ImageVector + get() { + if (_ic_scan_face_20 != null) return _ic_scan_face_20!! + _ic_scan_face_20 = ImageVector.Builder( + name = "ic_scan_face_20", + defaultWidth = 20.dp, + defaultHeight = 20.dp, + viewportWidth = 20f, + viewportHeight = 20f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M2.75 13.2812C3.16403 13.2815 3.5 13.6172 3.5 14.0312V14.8369C3.50011 15.7576 4.24636 16.5046 5.16699 16.5049H5.97363C6.3875 16.5053 6.72363 16.8409 6.72363 17.2549C6.72333 17.6686 6.38731 18.0045 5.97363 18.0049H5.16699C3.41794 18.0046 2.00011 16.586 2 14.8369V14.0312C2 13.617 2.33579 13.2812 2.75 13.2812Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M17.2549 13.2812C17.6689 13.2815 18.0049 13.6172 18.0049 14.0312V14.8369C18.0048 16.5861 16.5861 18.0048 14.8369 18.0049H14.0312C13.6172 18.0049 13.2816 17.6688 13.2812 17.2549C13.2812 16.8407 13.617 16.5049 14.0312 16.5049H14.8369C15.7577 16.5048 16.5048 15.7577 16.5049 14.8369V14.0312C16.5049 13.617 16.8407 13.2812 17.2549 13.2812Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M12.084 12.8213C12.3769 12.5287 12.8527 12.5285 13.1455 12.8213C13.4378 13.114 13.4377 13.589 13.1455 13.8818C11.4102 15.6171 8.59567 15.617 6.86035 13.8818C6.56746 13.5889 6.56746 13.1142 6.86035 12.8213C7.15327 12.5287 7.62811 12.5285 7.9209 12.8213C9.07035 13.9706 10.9345 13.9705 12.084 12.8213Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M10.4053 6.91309C10.8193 6.91332 11.1553 7.24902 11.1553 7.66309V10.4834C11.155 11.342 10.4582 12.0387 9.59961 12.0391H9.19629C8.78223 12.0391 8.44653 11.7031 8.44629 11.2891C8.4464 10.8749 8.78214 10.5391 9.19629 10.5391H9.59961C9.62979 10.5387 9.65503 10.5136 9.65527 10.4834V7.66309C9.65527 7.24887 9.99106 6.91309 10.4053 6.91309Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M6.37598 6.89941C6.79 6.89964 7.12598 7.23534 7.12598 7.64941V8.8584C7.12558 9.27214 6.78976 9.60818 6.37598 9.6084C5.96204 9.60835 5.62637 9.27225 5.62598 8.8584V7.64941C5.62598 7.23523 5.9618 6.89946 6.37598 6.89941Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M13.6279 6.89941C14.042 6.89964 14.3779 7.23534 14.3779 7.64941V8.8584C14.3775 9.27214 14.0417 9.60818 13.6279 9.6084C13.2142 9.60814 12.8783 9.27212 12.8779 8.8584V7.64941C12.8779 7.23536 13.2139 6.89967 13.6279 6.89941Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M5.97363 2C6.3875 2.0004 6.72363 2.33604 6.72363 2.75C6.72342 3.16378 6.38737 3.4996 5.97363 3.5H5.16699C4.24645 3.50025 3.50025 4.24645 3.5 5.16699V5.97363C3.4996 6.38737 3.16378 6.72342 2.75 6.72363C2.33604 6.72363 2.0004 6.3875 2 5.97363V5.16699C2.00025 3.41802 3.41802 2.00025 5.16699 2H5.97363Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M14.8369 2C16.5861 2.00004 18.0046 3.4179 18.0049 5.16699V5.97363C18.0045 6.38736 17.6686 6.72339 17.2549 6.72363C16.8409 6.72363 16.5053 6.3875 16.5049 5.97363V5.16699C16.5046 4.24632 15.7576 3.50004 14.8369 3.5H14.0312C13.6172 3.5 13.2815 3.16403 13.2812 2.75C13.2812 2.33579 13.617 2 14.0312 2H14.8369Z"), + ) + }.build() + return _ic_scan_face_20!! + } + +@Composable +@Preview(showBackground = true) +private fun IcScanFace20Preview() { + Icon( + imageVector = Icons.ic_scan_face_20, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcScanFace24.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcScanFace24.kt new file mode 100644 index 0000000000..72f1fab236 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcScanFace24.kt @@ -0,0 +1,82 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_scan_face_24: ImageVector? = null + +val Icons.ic_scan_face_24: ImageVector + get() { + if (_ic_scan_face_24 != null) return _ic_scan_face_24!! + _ic_scan_face_24 = ImageVector.Builder( + name = "ic_scan_face_24", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M3 16C3.55228 16 4 16.4477 4 17V18C4 19.1046 4.89543 20 6 20H7C7.55228 20 8 20.4477 8 21C8 21.5523 7.55228 22 7 22H6C3.79086 22 2 20.2091 2 18V17C2 16.4477 2.44772 16 3 16Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M21 16C21.5523 16 22 16.4477 22 17V18C22 20.2091 20.2091 22 18 22H17C16.4477 22 16 21.5523 16 21C16 20.4477 16.4477 20 17 20H18C19.1046 20 20 19.1046 20 18V17C20 16.4477 20.4477 16 21 16Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M14.5352 15.4502C14.9257 15.0599 15.5588 15.0597 15.9492 15.4502C16.3395 15.8407 16.3395 16.4738 15.9492 16.8643C13.7687 19.0445 10.2322 19.0447 8.05176 16.8643C7.66131 16.4738 7.66148 15.8407 8.05176 15.4502C8.44228 15.0597 9.0753 15.0597 9.46582 15.4502C10.8652 16.8496 13.1357 16.8494 14.5352 15.4502Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M12.5 8.09668C13.0522 8.09668 13.4998 8.54454 13.5 9.09668V12.5967C13.5 13.701 12.6043 14.5967 11.5 14.5967H11C10.4477 14.5967 10 14.149 10 13.5967C10.0002 13.0445 10.4478 12.5967 11 12.5967H11.5V9.09668C11.5002 8.54454 11.9478 8.09668 12.5 8.09668Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M7.5 8.08008C8.05228 8.08008 8.5 8.52779 8.5 9.08008V10.5801C8.49997 11.1323 8.05226 11.5801 7.5 11.5801C6.94774 11.5801 6.50003 11.1323 6.5 10.5801V9.08008C6.5 8.52779 6.94772 8.08008 7.5 8.08008Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M16.5 8.08008C17.0523 8.08008 17.5 8.52779 17.5 9.08008V10.5801C17.5 11.1323 17.0523 11.5801 16.5 11.5801C15.9477 11.5801 15.5 11.1323 15.5 10.5801V9.08008C15.5 8.52779 15.9477 8.08008 16.5 8.08008Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M7 2C7.55228 2 8 2.44772 8 3C8 3.55228 7.55228 4 7 4H6C4.89543 4 4 4.89543 4 6V7C4 7.55228 3.55228 8 3 8C2.44772 8 2 7.55228 2 7V6C2 3.79086 3.79086 2 6 2H7Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M18 2C20.2091 2 22 3.79086 22 6V7C22 7.55228 21.5523 8 21 8C20.4477 8 20 7.55228 20 7V6C20 4.89543 19.1046 4 18 4H17C16.4477 4 16 3.55228 16 3C16 2.44772 16.4477 2 17 2H18Z"), + ) + }.build() + return _ic_scan_face_24!! + } + +@Composable +@Preview(showBackground = true) +private fun IcScanFace24Preview() { + Icon( + imageVector = Icons.ic_scan_face_24, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcScanFace28.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcScanFace28.kt new file mode 100644 index 0000000000..e2429d8fa5 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcScanFace28.kt @@ -0,0 +1,82 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_scan_face_28: ImageVector? = null + +val Icons.ic_scan_face_28: ImageVector + get() { + if (_ic_scan_face_28 != null) return _ic_scan_face_28!! + _ic_scan_face_28 = ImageVector.Builder( + name = "ic_scan_face_28", + defaultWidth = 28.dp, + defaultHeight = 28.dp, + viewportWidth = 28f, + viewportHeight = 28f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M3.24902 18.7217C3.93928 18.7217 4.49886 19.2815 4.49902 19.9717V21.166C4.49929 22.4541 5.54377 23.4987 6.83203 23.499H8.02637C8.71653 23.499 9.27606 24.0589 9.27637 24.749C9.2761 25.4392 8.71656 25.999 8.02637 25.999H6.83203C4.16315 25.9987 1.99929 23.8349 1.99902 21.166V19.9717C1.99918 19.2816 2.55891 18.7218 3.24902 18.7217Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M24.749 18.7217C25.4393 18.7217 25.9989 19.2815 25.999 19.9717V21.166C25.9988 23.8351 23.8352 25.999 21.166 25.999H19.9717C19.2815 25.999 18.7219 25.4392 18.7217 24.749C18.722 24.0589 19.2815 23.499 19.9717 23.499H21.166C22.4546 23.499 23.4988 22.4543 23.499 21.166V19.9717C23.4992 19.2816 24.059 18.7219 24.749 18.7217Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M16.9873 18.0811C17.4754 17.593 18.2677 17.5932 18.7559 18.0811C19.2434 18.5692 19.2436 19.3606 18.7559 19.8486C16.1297 22.4744 11.8703 22.4744 9.24414 19.8486C8.75638 19.3605 8.75635 18.5691 9.24414 18.0811C9.73219 17.593 10.5235 17.5932 11.0117 18.0811C12.6615 19.7305 15.3374 19.7303 16.9873 18.0811Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M14.5967 9.28223C15.2868 9.2824 15.8465 9.84216 15.8467 10.5322V14.7129C15.8463 16.0621 14.7515 17.157 13.4023 17.1572H12.8047C12.1146 17.1571 11.555 16.5972 11.5547 15.9072C11.5549 15.2171 12.1146 14.6573 12.8047 14.6572H13.3467V10.5322C13.3469 9.84212 13.9065 9.28233 14.5967 9.28223Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M8.62402 9.2627C9.31438 9.2627 9.87402 9.82234 9.87402 10.5127V12.3037C9.87354 12.9937 9.31408 13.5537 8.62402 13.5537C7.93415 13.5535 7.37451 12.9935 7.37402 12.3037V10.5127C7.37402 9.82248 7.93386 9.26292 8.62402 9.2627Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M19.374 9.2627C20.0644 9.2627 20.624 9.82234 20.624 10.5127V12.3037C20.6235 12.9937 20.0641 13.5537 19.374 13.5537C18.6842 13.5534 18.1245 12.9935 18.124 12.3037V10.5127C18.124 9.82254 18.6839 9.26301 19.374 9.2627Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M8.02637 1.99902C8.7166 1.99902 9.27616 2.55982 9.27637 3.25C9.2761 3.94013 8.71656 4.5 8.02637 4.5H6.83203C5.5438 4.50036 4.49934 5.54494 4.49902 6.83301V8.02734C4.49876 8.71747 3.93922 9.27734 3.24902 9.27734C2.55897 9.27718 1.99929 8.71737 1.99902 8.02734V6.83301C1.99934 4.16414 4.16318 1.99938 6.83203 1.99902H8.02637Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M21.166 1.99902C23.8352 1.99903 25.9987 4.16392 25.999 6.83301V8.02734C25.9988 8.71747 25.4392 9.27734 24.749 9.27734C24.059 9.27709 23.4993 8.71732 23.499 8.02734V6.83301C23.4987 5.54473 22.4545 4.5 21.166 4.5H19.9717C19.2815 4.5 18.7219 3.94013 18.7217 3.25C18.7219 2.55982 19.2814 1.99902 19.9717 1.99902H21.166Z"), + ) + }.build() + return _ic_scan_face_28!! + } + +@Composable +@Preview(showBackground = true) +private fun IcScanFace28Preview() { + Icon( + imageVector = Icons.ic_scan_face_28, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcScanFinger20.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcScanFinger20.kt new file mode 100644 index 0000000000..2baee88672 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcScanFinger20.kt @@ -0,0 +1,67 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_scan_finger_20: ImageVector? = null + +val Icons.ic_scan_finger_20: ImageVector + get() { + if (_ic_scan_finger_20 != null) return _ic_scan_finger_20!! + _ic_scan_finger_20 = ImageVector.Builder( + name = "ic_scan_finger_20", + defaultWidth = 20.dp, + defaultHeight = 20.dp, + viewportWidth = 20f, + viewportHeight = 20f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M12.0124 14.8955C12.1618 14.5097 12.5961 14.318 12.9821 14.4668C13.3681 14.6161 13.5598 15.0503 13.4108 15.4365C13.1205 16.1883 12.7739 16.9187 12.3747 17.6201C12.1697 17.9796 11.712 18.1051 11.3523 17.9004C10.9927 17.6953 10.8672 17.2377 11.072 16.8779C11.4345 16.241 11.7489 15.5779 12.0124 14.8955Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M10.2644 8.88865C10.6784 8.88868 11.0142 9.22459 11.0144 9.63865C11.015 12.4596 10.0677 15.2001 8.32198 17.4306C8.06683 17.7565 7.59537 17.8142 7.26924 17.5596C6.94342 17.3042 6.88526 16.8319 7.14034 16.5058C8.68058 14.5378 9.51494 12.1225 9.51436 9.63865C9.51444 9.22477 9.85051 8.88904 10.2644 8.88865Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M10.2653 5.44432C12.5975 5.44453 14.5055 7.31162 14.5065 9.6367V9.63865C14.5063 10.3695 14.4562 11.1001 14.3562 11.8242C14.2993 12.2343 13.9206 12.5204 13.5105 12.4638C13.1007 12.4069 12.8135 12.029 12.8698 11.6191C12.9605 10.963 13.0063 10.3009 13.0065 9.63865V9.6367C13.0055 8.15931 11.7885 6.94453 10.2653 6.94432C8.74176 6.94469 7.52324 8.16057 7.52315 9.63865C7.52315 9.64975 7.52169 9.66087 7.5212 9.67185C7.5158 11.8542 6.73697 13.965 5.31905 15.6367C5.05109 15.9523 4.57722 15.9914 4.26143 15.7236C3.94624 15.4558 3.9073 14.9827 4.17452 14.667C5.37107 13.2564 6.02442 11.476 6.02217 9.63963C6.02217 9.62597 6.02342 9.61209 6.02413 9.59861C6.04586 7.29137 7.94609 5.44469 10.2653 5.44432Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M6.01339 3.26072C8.3863 1.71559 11.4267 1.58123 13.9294 2.91014C16.4329 4.23982 17.9996 6.82266 17.9987 9.63768C17.998 11.1835 17.8129 12.7254 17.448 14.2285C17.3502 14.6307 16.945 14.8777 16.5427 14.7803C16.1406 14.6824 15.8935 14.2772 15.9909 13.875C16.3278 12.4875 16.498 11.0652 16.4987 9.63865C16.4995 7.38607 15.2453 5.30815 13.2253 4.23533C11.2039 3.16206 8.74676 3.27059 6.83174 4.51756C6.48474 4.7431 6.0196 4.64562 5.79366 4.29881C5.56802 3.9519 5.6668 3.4868 6.01339 3.26072Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M3.30831 6.31541C3.49148 5.94415 3.94185 5.79178 4.31319 5.97459C4.68465 6.15786 4.83728 6.60801 4.65401 6.97947C4.24545 7.8077 4.03204 8.71657 4.03096 9.6367V9.63963C4.03015 10.5378 3.82117 11.4239 3.42061 12.2295C3.23614 12.5999 2.78639 12.7513 2.41573 12.5674C2.04534 12.3828 1.89373 11.9322 2.07784 11.5615C2.37574 10.9623 2.53042 10.3031 2.53096 9.6367C2.53201 8.48565 2.79812 7.34958 3.30831 6.31541Z"), + ) + }.build() + return _ic_scan_finger_20!! + } + +@Composable +@Preview(showBackground = true) +private fun IcScanFinger20Preview() { + Icon( + imageVector = Icons.ic_scan_finger_20, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcScanFinger24.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcScanFinger24.kt new file mode 100644 index 0000000000..256de02305 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcScanFinger24.kt @@ -0,0 +1,67 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_scan_finger_24: ImageVector? = null + +val Icons.ic_scan_finger_24: ImageVector + get() { + if (_ic_scan_finger_24 != null) return _ic_scan_finger_24!! + _ic_scan_finger_24 = ImageVector.Builder( + name = "ic_scan_finger_24", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M14.248 17.6974C14.447 17.1823 15.0259 16.9263 15.541 17.1251C16.056 17.3242 16.3131 17.9031 16.1143 18.4181C15.7714 19.3057 15.3621 20.1673 14.8906 20.9953C14.6173 21.475 14.0062 21.6423 13.5264 21.3693C13.0468 21.096 12.8796 20.4858 13.1523 20.006C13.5747 19.2642 13.941 18.4922 14.248 17.6974Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M12.3125 10.5773C12.8648 10.5772 13.3133 11.025 13.3135 11.5773C13.3141 14.9117 12.1924 18.1511 10.1279 20.7873C9.78744 21.2215 9.15925 21.2982 8.72461 20.9582C8.28999 20.6178 8.21372 19.9896 8.55371 19.5548C10.3441 17.2686 11.314 14.4621 11.3135 11.5773C11.3135 11.0252 11.7605 10.5776 12.3125 10.5773Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M12.3125 6.53823C15.1124 6.53844 17.406 8.78132 17.4062 11.5763C17.4062 11.584 17.4045 11.5921 17.4043 11.5998C17.4029 12.4548 17.3446 13.3102 17.2275 14.1574C17.1513 14.7037 16.6471 15.0862 16.1006 15.0109C15.5539 14.9351 15.1719 14.4296 15.2471 13.883C15.3527 13.119 15.4049 12.3482 15.4053 11.5773C15.4052 9.91234 14.0338 8.53844 12.3125 8.53823C10.5915 8.53829 9.21956 9.9109 9.21875 11.5753C9.22189 14.1772 8.29513 16.6958 6.60449 18.6876C6.24706 19.1084 5.6153 19.1602 5.19433 18.8029C4.77367 18.4455 4.72197 17.8137 5.0791 17.3927C6.46441 15.7606 7.22136 13.7013 7.21875 11.5773V11.5753C7.21956 8.78071 9.51277 6.53829 12.3125 6.53823Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M7.26171 3.99721C10.0807 2.16169 13.6922 2.00324 16.665 3.58218C19.6387 5.16176 21.5008 8.23004 21.5 11.5753V11.5773C21.4991 13.3991 21.2809 15.2159 20.8506 16.9874C20.7198 17.5233 20.1797 17.8528 19.6436 17.7228C19.1071 17.5925 18.7773 17.0512 18.9072 16.5148C19.3001 14.8976 19.4991 13.24 19.5 11.5773V11.5753C19.5008 8.97961 18.0558 6.58493 15.7266 5.3478C13.3954 4.10975 10.562 4.236 8.35351 5.67397C7.89075 5.97518 7.27108 5.84368 6.96972 5.381C6.66861 4.91828 6.79913 4.29857 7.26171 3.99721Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M4.04785 7.6271C4.29225 7.13212 4.89258 6.92896 5.38769 7.173C5.88258 7.41745 6.08586 8.01679 5.84179 8.51186C5.37056 9.46676 5.12528 10.5147 5.12402 11.5753V11.5783C5.12301 12.6501 4.8735 13.7079 4.3955 14.6691C4.14934 15.163 3.5489 15.365 3.05468 15.1193C2.56055 14.8734 2.35915 14.2728 2.60449 13.7785C2.94563 13.0925 3.12336 12.3381 3.12402 11.5753C3.12528 10.2069 3.44122 8.85644 4.04785 7.6271Z"), + ) + }.build() + return _ic_scan_finger_24!! + } + +@Composable +@Preview(showBackground = true) +private fun IcScanFinger24Preview() { + Icon( + imageVector = Icons.ic_scan_finger_24, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcScanFinger28.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcScanFinger28.kt new file mode 100644 index 0000000000..f424ebc5ae --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcScanFinger28.kt @@ -0,0 +1,67 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_scan_finger_28: ImageVector? = null + +val Icons.ic_scan_finger_28: ImageVector + get() { + if (_ic_scan_finger_28 != null) return _ic_scan_finger_28!! + _ic_scan_finger_28 = ImageVector.Builder( + name = "ic_scan_finger_28", + defaultWidth = 28.dp, + defaultHeight = 28.dp, + viewportWidth = 28f, + viewportHeight = 28f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M16.4788 20.4991C16.7273 19.8554 17.4512 19.5353 18.095 19.7833C18.739 20.0318 19.0602 20.7555 18.8118 21.3995C18.4171 22.4222 17.9462 23.4151 17.4036 24.3692C17.0622 24.969 16.2985 25.1792 15.6985 24.838C15.0992 24.4967 14.8893 23.7336 15.2298 23.1339C15.7112 22.2874 16.1288 21.406 16.4788 20.4991Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M14.3557 12.2647C15.0458 12.2647 15.6064 12.8246 15.6067 13.5147C15.6075 17.3629 14.3141 21.1014 11.9329 24.1436C11.5073 24.6868 10.7215 24.7828 10.178 24.3575C9.63466 23.9321 9.53905 23.1462 9.96413 22.6026C12.0029 19.9979 13.1075 16.8011 13.1067 13.5147C13.1069 12.8249 13.666 12.2653 14.3557 12.2647Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M14.3557 7.63288C17.6231 7.63288 20.3017 10.2504 20.302 13.5147C20.3016 14.5087 20.2329 15.5026 20.0969 16.4874C20.0024 17.1709 19.3713 17.6489 18.6878 17.5548C18.0042 17.4602 17.5262 16.8292 17.6204 16.1456C17.7401 15.2782 17.8001 14.4037 17.801 13.5284C17.801 13.5242 17.801 13.5198 17.801 13.5157C17.801 11.6635 16.2746 10.1329 14.3557 10.1329C12.4381 10.1333 10.9119 11.662 10.9104 13.5128L10.8987 14.0762C10.7769 16.8841 9.72096 19.5788 7.88893 21.7383C7.44228 22.2644 6.6535 22.3293 6.12721 21.8829C5.6011 21.4364 5.53654 20.6475 5.98268 20.1212C7.55523 18.2675 8.41329 15.9286 8.41042 13.5167V13.5128C8.41185 10.2496 11.0893 7.63327 14.3557 7.63288Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M8.5071 4.73444C11.7718 2.60837 15.9538 2.42444 19.3967 4.253C22.8417 6.08298 24.9992 9.63856 24.9973 13.5157C24.9963 15.6135 24.7443 17.7043 24.2493 19.7442C24.0864 20.4148 23.4111 20.8267 22.7405 20.6641C22.0703 20.5011 21.6574 19.8257 21.8196 19.1553C22.2679 17.3077 22.4955 15.4134 22.4964 13.5137C22.4964 13.5093 22.4963 13.5045 22.4964 13.5001C22.4923 10.5662 20.859 7.86048 18.2249 6.46101C15.5843 5.05847 12.3739 5.20038 9.87233 6.82917C9.2939 7.20555 8.51853 7.04224 8.14186 6.46394C7.76562 5.8857 7.92927 5.11125 8.5071 4.73444Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M4.78444 8.93952C5.08961 8.3205 5.83917 8.06632 6.45827 8.37116C7.07747 8.67642 7.33188 9.42579 7.02663 10.045C6.49388 11.1259 6.21669 12.3121 6.2151 13.5128V13.5157C6.21404 14.7613 5.92428 15.9905 5.3694 17.1075C5.06208 17.7253 4.31165 17.9769 3.69362 17.67C3.07597 17.3628 2.82364 16.6131 3.13014 15.9952C3.51404 15.2223 3.71435 14.373 3.7151 13.5137C3.71664 11.9286 4.08254 10.3636 4.78444 8.93952Z"), + ) + }.build() + return _ic_scan_finger_28!! + } + +@Composable +@Preview(showBackground = true) +private fun IcScanFinger28Preview() { + Icon( + imageVector = Icons.ic_scan_finger_28, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcScanQr20.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcScanQr20.kt new file mode 100644 index 0000000000..bf2e9eeb29 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcScanQr20.kt @@ -0,0 +1,102 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_scan_qr_20: ImageVector? = null + +val Icons.ic_scan_qr_20: ImageVector + get() { + if (_ic_scan_qr_20 != null) return _ic_scan_qr_20!! + _ic_scan_qr_20 = ImageVector.Builder( + name = "ic_scan_qr_20", + defaultWidth = 20.dp, + defaultHeight = 20.dp, + viewportWidth = 20f, + viewportHeight = 20f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M2.75 13.2812C3.16403 13.2815 3.5 13.6172 3.5 14.0312V14.8369C3.50011 15.7576 4.24636 16.5046 5.16699 16.5049H5.97363C6.3875 16.5053 6.72363 16.8409 6.72363 17.2549C6.72333 17.6686 6.38731 18.0045 5.97363 18.0049H5.16699C3.41794 18.0046 2.00011 16.586 2 14.8369V14.0312C2 13.617 2.33579 13.2812 2.75 13.2812Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M17.2549 13.2812C17.6689 13.2815 18.0049 13.6172 18.0049 14.0312V14.8369C18.0048 16.5861 16.5861 18.0048 14.8369 18.0049H14.0312C13.6172 18.0049 13.2816 17.6688 13.2812 17.2549C13.2812 16.8407 13.617 16.5049 14.0312 16.5049H14.8369C15.7577 16.5048 16.5048 15.7577 16.5049 14.8369V14.0312C16.5049 13.617 16.8407 13.2812 17.2549 13.2812Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M10.7773 13.4453C11.0718 13.2051 11.5066 13.2217 11.7812 13.4961L11.7852 13.501C12.0777 13.7939 12.0779 14.2687 11.7852 14.5615L11.7812 14.5654C11.4885 14.8581 11.0136 14.858 10.7207 14.5654L10.7158 14.5615C10.4234 14.2688 10.4235 13.7938 10.7158 13.501L10.7207 13.4961L10.7773 13.4453Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M13.5176 13.4453C13.8121 13.2051 14.2469 13.2216 14.5215 13.4961L14.5254 13.501C14.8179 13.7939 14.818 14.2687 14.5254 14.5615L14.5215 14.5654C14.2287 14.8582 13.7539 14.858 13.4609 14.5654L13.4561 14.5615C13.1634 14.2688 13.1636 13.7939 13.4561 13.501L13.4609 13.4961L13.5176 13.4453Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M8.29297 10.4609C8.98333 10.4609 9.54297 11.0206 9.54297 11.7109V14.0312C9.54282 14.4453 9.20709 14.7812 8.79297 14.7812H6.47266C5.78243 14.7812 5.22281 14.2214 5.22266 13.5312V11.7109C5.22266 11.0206 5.78234 10.461 6.47266 10.4609H8.29297ZM6.72266 13.2812H8.04297V11.9609H6.72266V13.2812Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M12.1475 12.0352C12.4419 11.7949 12.8767 11.8116 13.1514 12.0859L13.1553 12.0908C13.4478 12.3837 13.448 12.8586 13.1553 13.1514L13.1514 13.1553C12.8586 13.448 12.3837 13.4478 12.0908 13.1553L12.0859 13.1514C11.7935 12.8586 11.7937 12.3837 12.0859 12.0908L12.0908 12.0859L12.1475 12.0352Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M10.7773 10.625C11.0718 10.3847 11.5066 10.4014 11.7812 10.6758L11.7852 10.6807C12.0777 10.9736 12.0779 11.4484 11.7852 11.7412L11.7812 11.7451C11.4885 12.0378 11.0136 12.0376 10.7207 11.7451L10.7158 11.7412C10.4233 11.4485 10.4235 10.9735 10.7158 10.6807L10.7207 10.6758L10.7773 10.625Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M13.5176 10.625C13.8121 10.3848 14.2469 10.4012 14.5215 10.6758L14.5254 10.6807C14.8179 10.9736 14.8181 11.4484 14.5254 11.7412L14.5215 11.7451C14.2287 12.0379 13.7539 12.0377 13.4609 11.7451L13.4561 11.7412C13.1633 11.4485 13.1636 10.9736 13.4561 10.6807L13.4609 10.6758L13.5176 10.625Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M8.29297 5.22266C8.98326 5.22266 9.54286 5.78239 9.54297 6.47266V8.79297C9.54297 9.20718 9.20718 9.54297 8.79297 9.54297H6.47266C5.78234 9.54292 5.22266 8.9833 5.22266 8.29297V6.47266C5.22277 5.78242 5.78241 5.2227 6.47266 5.22266H8.29297ZM6.72266 8.04297H8.04297V6.72266H6.72266V8.04297Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M13.5312 5.22266C14.2214 5.22278 14.7811 5.78247 14.7812 6.47266V8.79297C14.7812 9.20711 14.4454 9.54285 14.0312 9.54297H11.7109C11.0206 9.54297 10.4609 8.98332 10.4609 8.29297V6.47266C10.461 5.78239 11.0206 5.22266 11.7109 5.22266H13.5312ZM11.9609 8.04297H13.2812V6.72266H11.9609V8.04297Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M5.97363 2C6.3875 2.0004 6.72363 2.33604 6.72363 2.75C6.72342 3.16378 6.38737 3.4996 5.97363 3.5H5.16699C4.24645 3.50025 3.50025 4.24645 3.5 5.16699V5.97363C3.4996 6.38737 3.16378 6.72342 2.75 6.72363C2.33604 6.72363 2.0004 6.3875 2 5.97363V5.16699C2.00025 3.41802 3.41802 2.00025 5.16699 2H5.97363Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M14.8369 2C16.5861 2.00004 18.0046 3.4179 18.0049 5.16699V5.97363C18.0045 6.38736 17.6686 6.72339 17.2549 6.72363C16.8409 6.72363 16.5053 6.3875 16.5049 5.97363V5.16699C16.5046 4.24632 15.7576 3.50004 14.8369 3.5H14.0312C13.6172 3.5 13.2815 3.16403 13.2812 2.75C13.2812 2.33579 13.617 2 14.0312 2H14.8369Z"), + ) + }.build() + return _ic_scan_qr_20!! + } + +@Composable +@Preview(showBackground = true) +private fun IcScanQr20Preview() { + Icon( + imageVector = Icons.ic_scan_qr_20, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcScanQr24.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcScanQr24.kt new file mode 100644 index 0000000000..e26040cbb6 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcScanQr24.kt @@ -0,0 +1,102 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_scan_qr_24: ImageVector? = null + +val Icons.ic_scan_qr_24: ImageVector + get() { + if (_ic_scan_qr_24 != null) return _ic_scan_qr_24!! + _ic_scan_qr_24 = ImageVector.Builder( + name = "ic_scan_qr_24", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M3 16C3.55228 16 4 16.4477 4 17V18C4 19.1046 4.89543 20 6 20H7C7.55228 20 8 20.4477 8 21C8 21.5523 7.55228 22 7 22H6C3.79086 22 2 20.2091 2 18V17C2 16.4477 2.44772 16 3 16Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M21 16C21.5523 16 22 16.4477 22 17V18C22 20.2091 20.2091 22 18 22H17C16.4477 22 16 21.5523 16 21C16 20.4477 16.4477 20 17 20H18C19.1046 20 20 19.1046 20 18V17C20 16.4477 20.4477 16 21 16Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M12.8428 16.2881C13.2333 15.8976 13.8663 15.8977 14.2568 16.2881L14.2617 16.293C14.6522 16.6835 14.6522 17.3165 14.2617 17.707L14.2568 17.7119C13.8663 18.1023 13.2333 18.1024 12.8428 17.7119L12.8379 17.707C12.4475 17.3165 12.4475 16.6835 12.8379 16.293L12.8428 16.2881Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M16.2432 16.2881C16.6337 15.8977 17.2667 15.8976 17.6572 16.2881L17.6621 16.293C18.0525 16.6835 18.0525 17.3165 17.6621 17.707L17.6572 17.7119C17.2667 18.1024 16.6337 18.1023 16.2432 17.7119L16.2383 17.707C15.8478 17.3165 15.8478 16.6835 16.2383 16.293L16.2432 16.2881Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M10 12.5C10.8284 12.5 11.5 13.1716 11.5 14V17C11.5 17.5523 11.0523 18 10.5 18H7.5C6.67157 18 6 17.3284 6 16.5V14C6 13.1716 6.67157 12.5 7.5 12.5H10ZM8 16H9.5V14.5H8V16Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M14.543 14.5381C14.9335 14.1476 15.5665 14.1476 15.957 14.5381L15.9619 14.543C16.3524 14.9335 16.3524 15.5665 15.9619 15.957L15.957 15.9619C15.5665 16.3524 14.9335 16.3524 14.543 15.9619L14.5381 15.957C14.1476 15.5665 14.1476 14.9335 14.5381 14.543L14.543 14.5381Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M12.8428 12.7881C13.2333 12.3976 13.8663 12.3977 14.2568 12.7881L14.2617 12.793C14.6522 13.1835 14.6522 13.8165 14.2617 14.207L14.2568 14.2119C13.8663 14.6023 13.2333 14.6024 12.8428 14.2119L12.8379 14.207C12.4475 13.8165 12.4475 13.1835 12.8379 12.793L12.8428 12.7881Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M16.2432 12.7881C16.6337 12.3977 17.2667 12.3976 17.6572 12.7881L17.6621 12.793C18.0525 13.1835 18.0525 13.8165 17.6621 14.207L17.6572 14.2119C17.2667 14.6024 16.6337 14.6023 16.2432 14.2119L16.2383 14.207C15.8478 13.8165 15.8478 13.1835 16.2383 12.793L16.2432 12.7881Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M10 6C10.8284 6 11.5 6.67157 11.5 7.5V10.5C11.5 11.0523 11.0523 11.5 10.5 11.5H7.5C6.67157 11.5 6 10.8284 6 10V7.5C6 6.67157 6.67157 6 7.5 6H10ZM8 9.5H9.5V8H8V9.5Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M16.5 6C17.3284 6 18 6.67157 18 7.5V10.5C18 11.0523 17.5523 11.5 17 11.5H14C13.1716 11.5 12.5 10.8284 12.5 10V7.5C12.5 6.67157 13.1716 6 14 6H16.5ZM14.5 9.5H16V8H14.5V9.5Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M7 2C7.55228 2 8 2.44772 8 3C8 3.55228 7.55228 4 7 4H6C4.89543 4 4 4.89543 4 6V7C4 7.55228 3.55228 8 3 8C2.44772 8 2 7.55228 2 7V6C2 3.79086 3.79086 2 6 2H7Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M18 2C20.2091 2 22 3.79086 22 6V7C22 7.55228 21.5523 8 21 8C20.4477 8 20 7.55228 20 7V6C20 4.89543 19.1046 4 18 4H17C16.4477 4 16 3.55228 16 3C16 2.44772 16.4477 2 17 2H18Z"), + ) + }.build() + return _ic_scan_qr_24!! + } + +@Composable +@Preview(showBackground = true) +private fun IcScanQr24Preview() { + Icon( + imageVector = Icons.ic_scan_qr_24, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcShareAndroid20.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcShareAndroid20.kt index 4d55d4e24b..d396ef491d 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcShareAndroid20.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcShareAndroid20.kt @@ -31,7 +31,7 @@ val Icons.ic_share_android_20: ImageVector addPath( fill = SolidColor(Color.Black), pathFillType = PathFillType.NonZero, - pathData = addPathNodes("M12.3772 3.37816C13.5488 2.20706 15.4479 2.20684 16.6194 3.37816L16.7258 3.48949C17.7901 4.667 17.7541 6.48523 16.6194 7.62035C15.4478 8.79166 13.5487 8.79182 12.3772 7.62035C12.3273 7.57044 12.2803 7.51849 12.2346 7.46605L8.42995 9.3684C8.51871 9.78341 8.52065 10.2131 8.4319 10.6282L12.2346 12.5295C12.28 12.4774 12.3276 12.4258 12.3772 12.3762C13.5487 11.2052 15.4479 11.2051 16.6194 12.3762L16.7258 12.4875C17.7901 13.665 17.7541 15.4833 16.6194 16.6184C15.4478 17.7899 13.5488 17.7899 12.3772 16.6184C11.6315 15.8725 11.3629 14.8323 11.5667 13.8723L7.75709 11.968C7.71291 12.0185 7.66947 12.0712 7.62135 12.1194C6.44983 13.2909 4.55077 13.2907 3.37916 12.1194C2.20761 10.9478 2.20758 9.04876 3.37916 7.87719C4.5508 6.70628 6.44997 6.70588 7.62135 7.87719L7.7278 7.98851C7.73882 8.00071 7.74826 8.01427 7.75905 8.0266L11.5667 6.12328C11.3633 5.16358 11.6318 4.1237 12.3772 3.37816ZM15.4456 13.3332C14.8565 12.853 13.9869 12.8881 13.4378 13.4368C13.3456 13.5289 13.2685 13.6312 13.2053 13.7385C13.1944 13.7708 13.1829 13.8039 13.1673 13.8352C13.1508 13.8681 13.1301 13.8986 13.1096 13.928C12.8874 14.4705 12.9973 15.1172 13.4378 15.5578C14.0235 16.1436 14.973 16.1436 15.5589 15.5578C16.1075 15.0088 16.1424 14.14 15.6624 13.551L15.5589 13.4368L15.4456 13.3332ZM6.44752 8.83422C5.85857 8.35384 4.98896 8.38916 4.43971 8.93773C3.85392 9.52352 3.85394 10.473 4.43971 11.0588C5.02553 11.6444 5.97507 11.6445 6.56081 11.0588C6.65438 10.9651 6.73153 10.8605 6.79518 10.7512C6.80525 10.7231 6.81762 10.6946 6.83131 10.6672C6.8454 10.6391 6.8612 10.6117 6.87819 10.5862C7.03699 10.2133 7.03917 9.78966 6.88209 9.41625C6.86365 9.38907 6.84643 9.36048 6.83131 9.33031C6.81696 9.30158 6.80458 9.27196 6.7942 9.24242C6.75582 9.1768 6.71353 9.11238 6.66432 9.05199L6.56081 8.93773L6.44752 8.83422ZM15.4456 4.33519C14.8565 3.85483 13.987 3.88996 13.4378 4.43871C12.9973 4.87938 12.8883 5.52603 13.1106 6.06859C13.1311 6.09809 13.1507 6.12926 13.1673 6.16234C13.1825 6.19282 13.1936 6.22472 13.2044 6.25609C13.2678 6.36431 13.3449 6.46696 13.4378 6.5598C14.0235 7.14549 14.973 7.14532 15.5589 6.5598C16.1075 6.01072 16.1424 5.14197 15.6624 4.55297L15.5589 4.43871L15.4456 4.33519Z"), + pathData = addPathNodes("M12.3772 3.37816C13.5488 2.20706 15.4479 2.20684 16.6194 3.37816L16.7258 3.48949C17.7901 4.667 17.7541 6.48523 16.6194 7.62035C15.4478 8.79166 13.5487 8.79182 12.3772 7.62035C12.3273 7.57044 12.2803 7.5185 12.2346 7.46605L8.42995 9.3684C8.51871 9.78341 8.52065 10.2131 8.4319 10.6282L12.2346 12.5295C12.28 12.4774 12.3276 12.4258 12.3772 12.3762C13.5487 11.2052 15.4479 11.2051 16.6194 12.3762L16.7258 12.4875C17.7901 13.665 17.7541 15.4833 16.6194 16.6184C15.4478 17.7899 13.5488 17.7899 12.3772 16.6184C11.6315 15.8725 11.3629 14.8323 11.5667 13.8723L7.75709 11.968C7.71291 12.0185 7.66948 12.0712 7.62135 12.1194C6.44983 13.2909 4.55078 13.2907 3.37916 12.1194C2.20761 10.9478 2.20758 9.04876 3.37916 7.87719C4.5508 6.70628 6.44997 6.70588 7.62135 7.87719L7.7278 7.98851C7.73882 8.00071 7.74826 8.01427 7.75905 8.0266L11.5667 6.12328C11.3633 5.16358 11.6318 4.1237 12.3772 3.37816ZM15.4456 13.3332C14.8565 12.853 13.9869 12.8881 13.4378 13.4368C13.3456 13.5289 13.2685 13.6312 13.2053 13.7385C13.1944 13.7708 13.1829 13.8039 13.1673 13.8352C13.1508 13.8681 13.1301 13.8986 13.1096 13.928C12.8874 14.4705 12.9973 15.1172 13.4378 15.5578C14.0235 16.1436 14.973 16.1436 15.5589 15.5578C16.1075 15.0088 16.1424 14.14 15.6624 13.551L15.5589 13.4368L15.4456 13.3332ZM6.44752 8.83422C5.85857 8.35384 4.98896 8.38916 4.43971 8.93773C3.85392 9.52352 3.85394 10.473 4.43971 11.0588C5.02553 11.6444 5.97507 11.6445 6.56081 11.0588C6.65438 10.9651 6.73153 10.8605 6.79518 10.7512C6.80525 10.7231 6.81762 10.6946 6.83131 10.6672C6.8454 10.6391 6.8612 10.6117 6.87819 10.5862C7.03699 10.2133 7.03917 9.78966 6.88209 9.41625C6.86365 9.38907 6.84643 9.36048 6.83131 9.33031C6.81696 9.30158 6.80458 9.27196 6.7942 9.24242C6.75582 9.1768 6.71353 9.11238 6.66432 9.05199L6.56081 8.93773L6.44752 8.83422ZM15.4456 4.33519C14.8565 3.85483 13.987 3.88996 13.4378 4.43871C12.9973 4.87938 12.8883 5.52603 13.1106 6.06859C13.1311 6.09809 13.1507 6.12926 13.1673 6.16234C13.1825 6.19282 13.1936 6.22472 13.2044 6.25609C13.2678 6.36431 13.3449 6.46696 13.4378 6.5598C14.0235 7.14549 14.973 7.14532 15.5589 6.5598C16.1075 6.01072 16.1424 5.14197 15.6624 4.55297L15.5589 4.43871L15.4456 4.33519Z"), ) }.build() return _ic_share_android_20!! diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcShieldCheckmark24.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcShieldCheckmark24.kt index 64a7e6da71..375d16095c 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcShieldCheckmark24.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcShieldCheckmark24.kt @@ -31,7 +31,7 @@ val Icons.ic_shield_checkmark_24: ImageVector addPath( fill = SolidColor(Color.Black), pathFillType = PathFillType.NonZero, - pathData = addPathNodes("M14.2705 9.317C14.6482 8.91414 15.2807 8.89353 15.6836 9.2711C16.0862 9.6488 16.107 10.2813 15.7295 10.6842L11.9795 14.6842C11.7905 14.8857 11.5263 15.0006 11.25 15.0006C10.9737 15.0006 10.7095 14.8857 10.5205 14.6842L8.27051 12.2848C7.89285 11.8819 7.91371 11.2485 8.31641 10.8707C8.71932 10.4933 9.35187 10.5139 9.72949 10.9166L11.25 12.5387L14.2705 9.317Z"), + pathData = addPathNodes("M14.2705 9.317C14.6482 8.91414 15.2807 8.89353 15.6836 9.2711C16.0862 9.64881 16.107 10.2813 15.7295 10.6842L11.9795 14.6842C11.7905 14.8857 11.5263 15.0006 11.25 15.0006C10.9737 15.0006 10.7095 14.8857 10.5205 14.6842L8.27051 12.2848C7.89285 11.8819 7.91371 11.2485 8.31641 10.8707C8.71932 10.4933 9.35187 10.5139 9.72949 10.9166L11.25 12.5387L14.2705 9.317Z"), ) addPath( fill = SolidColor(Color.Black), diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcSnowflake12.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcSnowflake12.kt new file mode 100644 index 0000000000..e7f979d6da --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcSnowflake12.kt @@ -0,0 +1,47 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_snowflake_12: ImageVector? = null + +val Icons.ic_snowflake_12: ImageVector + get() { + if (_ic_snowflake_12 != null) return _ic_snowflake_12!! + _ic_snowflake_12 = ImageVector.Builder( + name = "ic_snowflake_12", + defaultWidth = 12.dp, + defaultHeight = 12.dp, + viewportWidth = 12f, + viewportHeight = 12f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M5.99906 1C6.27506 1.00001 6.49884 1.22406 6.49906 1.5V2.08887L6.73051 1.85742C6.92576 1.66235 7.24232 1.66227 7.43754 1.85742C7.6325 2.05264 7.63257 2.36925 7.43754 2.56445L6.49906 3.50293V5.13379L7.91215 4.31738L8.2559 3.03711C8.32749 2.77082 8.60177 2.61155 8.86821 2.68262C9.13442 2.75409 9.29343 3.0286 9.2227 3.29492L9.13774 3.61035L9.74906 3.25781C9.9882 3.11974 10.2946 3.2013 10.4327 3.44043C10.5703 3.67948 10.488 3.98506 10.2491 4.12305L9.63676 4.47559L9.95219 4.56055C10.2186 4.63207 10.3768 4.90637 10.3057 5.17285C10.2341 5.4392 9.95988 5.59754 9.6934 5.52637L8.41215 5.18262L6.99809 5.99902L8.41117 6.81445L9.6934 6.47168C9.95995 6.40059 10.2343 6.55966 10.3057 6.82617C10.3769 7.09266 10.2185 7.36687 9.95219 7.43848L9.63676 7.52246L10.2491 7.87598C10.4879 8.01413 10.5706 8.32055 10.4327 8.55957C10.2946 8.79848 9.9881 8.87999 9.74906 8.74219L9.13774 8.38965L9.2227 8.70312C9.29384 8.96959 9.13454 9.24383 8.86821 9.31543C8.60169 9.38654 8.3274 9.22834 8.2559 8.96191L7.91313 7.68164L6.49906 6.86523V8.49609L7.43754 9.43457C7.63246 9.62978 7.63246 9.94642 7.43754 10.1416C7.24232 10.3368 6.92575 10.3367 6.73051 10.1416L6.49906 9.91016V10.498C6.49906 10.7742 6.27519 10.998 5.99906 10.998C5.72305 10.9979 5.49906 10.7741 5.49906 10.498V9.91211L5.26957 10.1416C5.07441 10.3368 4.75785 10.3366 4.56254 10.1416C4.36745 9.94636 4.36738 9.6298 4.56254 9.43457L5.49906 8.49805V6.86523L4.085 7.68164L3.7432 8.96191C3.67158 9.22831 3.39743 9.38671 3.1309 9.31543C2.86449 9.24388 2.70621 8.96964 2.77738 8.70312L2.86039 8.38867L2.25004 8.74219C2.01109 8.88016 1.70567 8.79826 1.56742 8.55957C1.42933 8.32043 1.5109 8.01406 1.75004 7.87598L2.36137 7.52246L2.04789 7.43848C1.78131 7.36709 1.6223 7.09276 1.6934 6.82617C1.7649 6.55977 2.03921 6.40061 2.3057 6.47168L3.58695 6.81445L4.99906 5.99902L3.58598 5.18262L2.3057 5.52637C2.03919 5.59746 1.7649 5.43928 1.6934 5.17285C1.62219 4.90621 1.78126 4.63195 2.04789 4.56055L2.36137 4.47559L1.75004 4.12305C1.51106 3.98495 1.42949 3.6795 1.56742 3.44043C1.70555 3.2014 2.01095 3.11975 2.25004 3.25781L2.86137 3.61035L2.77738 3.29492C2.70652 3.02854 2.86462 2.75411 3.1309 2.68262C3.39744 2.61143 3.67167 2.77067 3.7432 3.03711L4.08598 4.31641L5.49906 5.13281V3.50098L4.56254 2.56445C4.36733 2.36918 4.3673 2.05265 4.56254 1.85742C4.75782 1.66228 5.07436 1.66221 5.26957 1.85742L5.49906 2.08691V1.5C5.49929 1.22414 5.72318 1.00015 5.99906 1Z"), + ) + }.build() + return _ic_snowflake_12!! + } + +@Composable +@Preview(showBackground = true) +private fun IcSnowflake12Preview() { + Icon( + imageVector = Icons.ic_snowflake_12, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcSnowflake20.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcSnowflake20.kt index d27bdad8a5..9de4bd7793 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcSnowflake20.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcSnowflake20.kt @@ -31,7 +31,7 @@ val Icons.ic_snowflake_20: ImageVector addPath( fill = SolidColor(Color.Black), pathFillType = PathFillType.NonZero, - pathData = addPathNodes("M10.0011 2.5C10.4149 2.50024 10.7509 2.83611 10.7511 3.25V4.18066L11.1749 3.77441C11.4736 3.48781 11.9486 3.49748 12.2354 3.7959C12.5221 4.0946 12.5124 4.56958 12.2139 4.85645L10.7511 6.25977V8.72461L13.0245 7.46387L13.5597 5.54785C13.6712 5.14923 14.0847 4.91518 14.4835 5.02637C14.882 5.13773 15.1148 5.55155 15.004 5.9502L14.8653 6.44336L15.8878 5.87793C16.2499 5.67781 16.7066 5.80896 16.9073 6.1709C17.1077 6.53284 16.9768 6.98943 16.6153 7.19043L15.672 7.71191L16.1632 7.83887C16.5639 7.94204 16.8057 8.35115 16.7032 8.75195C16.6002 9.15291 16.1911 9.39467 15.7901 9.29199L13.7618 8.77051L11.547 9.99707L13.7647 11.2256L15.7901 10.7061C16.1911 10.6034 16.6002 10.8452 16.7032 11.2461C16.8059 11.647 16.5639 12.056 16.1632 12.1592L15.6729 12.2842L16.6153 12.8066C16.9768 13.0076 17.1075 13.4642 16.9073 13.8262C16.7065 14.1882 16.2499 14.3186 15.8878 14.1182L14.8653 13.5508L15.004 14.0479C15.1149 14.4465 14.8821 14.8603 14.4835 14.9717C14.0847 15.0829 13.6711 14.8489 13.5597 14.4502L13.0235 12.5303L10.7511 11.2705V13.7354L12.2139 15.1396C12.5125 15.4264 12.522 15.9014 12.2354 16.2002C11.9486 16.4989 11.4737 16.5085 11.1749 16.2217L10.7511 15.8145V16.7461C10.7511 17.1602 10.4151 17.4959 10.0011 17.4961C9.58685 17.4961 9.25106 17.1603 9.25106 16.7461V15.8135L8.82625 16.2217C8.5275 16.5085 8.05259 16.4989 7.7657 16.2002C7.47919 15.9014 7.48855 15.4264 7.78719 15.1396L9.25106 13.7344V11.2715L6.9786 12.5303L6.44344 14.4502C6.33208 14.8488 5.91835 15.0826 5.51961 14.9717C5.12084 14.8604 4.88724 14.4466 4.99813 14.0479L5.13582 13.5508L4.11434 14.1182C3.75214 14.3188 3.29465 14.1883 3.09383 13.8262C2.89345 13.464 3.02566 13.0073 3.38778 12.8066L4.32918 12.2842L3.83992 12.1592C3.4389 12.0561 3.19708 11.6471 3.29988 11.2461C3.403 10.845 3.81187 10.6031 4.21297 10.7061L6.23836 11.2256L8.4532 9.99805L6.23934 8.77051L4.21297 9.29199C3.81187 9.39498 3.403 9.15304 3.29988 8.75195C3.19723 8.351 3.43898 7.94192 3.83992 7.83887L4.33016 7.71191L3.38778 7.19043C3.0256 6.9897 2.8933 6.53313 3.09383 6.1709C3.29462 5.80887 3.75216 5.67744 4.11434 5.87793L5.13582 6.44336L4.99813 5.9502C4.88739 5.55148 5.12092 5.13759 5.51961 5.02637C5.91827 4.91548 6.33201 5.14932 6.44344 5.54785L6.9786 7.46484L9.25106 8.72461V6.26074L7.78719 4.85645C7.48874 4.56953 7.47893 4.09457 7.7657 3.7959C8.05251 3.49741 8.52752 3.4879 8.82625 3.77441L9.25106 4.18164V3.25C9.25126 2.83596 9.58697 2.5 10.0011 2.5Z"), + pathData = addPathNodes("M10.0011 2.5C10.4149 2.50024 10.7509 2.83611 10.7511 3.25V4.18066L11.1749 3.77441C11.4736 3.48781 11.9486 3.49748 12.2354 3.7959C12.5221 4.0946 12.5124 4.56958 12.2139 4.85645L10.7511 6.25977V8.72461L13.0245 7.46387L13.5597 5.54785C13.6712 5.14923 14.0847 4.91518 14.4835 5.02637C14.882 5.13773 15.1148 5.55155 15.004 5.9502L14.8653 6.44336L15.8878 5.87793C16.2499 5.67781 16.7066 5.80896 16.9073 6.1709C17.1077 6.53284 16.9768 6.98943 16.6153 7.19043L15.672 7.71191L16.1632 7.83887C16.5639 7.94204 16.8057 8.35115 16.7032 8.75195C16.6002 9.15291 16.1911 9.39467 15.7901 9.29199L13.7618 8.77051L11.547 9.99707L13.7647 11.2256L15.7901 10.7061C16.1911 10.6034 16.6002 10.8452 16.7032 11.2461C16.8059 11.647 16.5639 12.056 16.1632 12.1592L15.6729 12.2842L16.6153 12.8066C16.9768 13.0076 17.1075 13.4642 16.9073 13.8262C16.7065 14.1882 16.2499 14.3186 15.8878 14.1182L14.8653 13.5508L15.004 14.0479C15.1149 14.4465 14.8821 14.8603 14.4835 14.9717C14.0847 15.0829 13.6711 14.8489 13.5597 14.4502L13.0235 12.5303L10.7511 11.2705V13.7354L12.2139 15.1396C12.5125 15.4264 12.522 15.9014 12.2354 16.2002C11.9486 16.4989 11.4737 16.5085 11.1749 16.2217L10.7511 15.8145V16.7461C10.7511 17.1602 10.4151 17.4958 10.0011 17.4961C9.58685 17.4961 9.25106 17.1603 9.25106 16.7461V15.8135L8.82625 16.2217C8.5275 16.5085 8.05259 16.4989 7.7657 16.2002C7.47919 15.9014 7.48855 15.4264 7.78719 15.1396L9.25106 13.7344V11.2715L6.9786 12.5303L6.44344 14.4502C6.33208 14.8488 5.91835 15.0826 5.51961 14.9717C5.12084 14.8604 4.88724 14.4466 4.99813 14.0479L5.13582 13.5508L4.11434 14.1182C3.75214 14.3188 3.29465 14.1883 3.09383 13.8262C2.89345 13.464 3.02566 13.0073 3.38778 12.8066L4.32918 12.2842L3.83992 12.1592C3.4389 12.0561 3.19708 11.6471 3.29988 11.2461C3.403 10.845 3.81187 10.6031 4.21297 10.7061L6.23836 11.2256L8.4532 9.99805L6.23934 8.77051L4.21297 9.29199C3.81187 9.39498 3.403 9.15304 3.29988 8.75195C3.19723 8.351 3.43898 7.94192 3.83992 7.83887L4.33016 7.71191L3.38778 7.19043C3.0256 6.9897 2.8933 6.53313 3.09383 6.1709C3.29462 5.80887 3.75216 5.67744 4.11434 5.87793L5.13582 6.44336L4.99813 5.9502C4.88739 5.55148 5.12092 5.13759 5.51961 5.02637C5.91827 4.91548 6.33201 5.14932 6.44344 5.54785L6.9786 7.46484L9.25106 8.72461V6.26074L7.78719 4.85645C7.48874 4.56953 7.47893 4.09457 7.7657 3.7959C8.05251 3.49741 8.52752 3.4879 8.82625 3.77441L9.25106 4.18164V3.25C9.25126 2.83596 9.58697 2.5 10.0011 2.5Z"), ) }.build() return _ic_snowflake_20!! diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcSnowflake24.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcSnowflake24.kt index 426f9a8266..65bbe5a46d 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcSnowflake24.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcSnowflake24.kt @@ -31,7 +31,7 @@ val Icons.ic_snowflake_24: ImageVector addPath( fill = SolidColor(Color.Black), pathFillType = PathFillType.NonZero, - pathData = addPathNodes("M11.9998 2C12.5521 2.00006 12.9998 2.44775 12.9998 3V4.1748L13.4608 3.71387C13.8511 3.32351 14.4843 3.3237 14.8748 3.71387C15.2652 4.1043 15.2651 4.73737 14.8748 5.12793L12.9998 7.00293V10.2666L15.8279 8.63281L16.5135 6.07422C16.6564 5.54089 17.2047 5.22438 17.7381 5.36719C18.2713 5.5102 18.5879 6.05848 18.4451 6.5918L18.2762 7.21973L19.4998 6.51367C19.978 6.23773 20.5898 6.40187 20.866 6.87988C21.142 7.35808 20.9779 7.96992 20.4998 8.24609L19.2752 8.95215L19.9061 9.12207C20.4393 9.26507 20.7559 9.81333 20.6131 10.3467C20.4702 10.88 19.9219 11.1964 19.3885 11.0537L16.826 10.3672L13.9988 11.999L16.826 13.6318L19.3885 12.9463C19.9219 12.8036 20.4702 13.12 20.6131 13.6533C20.7558 14.1866 20.4393 14.7349 19.9061 14.8779L19.2752 15.0459L20.4998 15.7539C20.978 16.0301 21.1422 16.6419 20.866 17.1201C20.5899 17.5982 19.978 17.7622 19.4998 17.4863L18.2762 16.7793L18.4451 17.4082C18.5878 17.9415 18.2713 18.4898 17.7381 18.6328C17.2047 18.7756 16.6564 18.4591 16.5135 17.9258L15.8279 15.3652L12.9998 13.7324V16.9961L14.8748 18.8721C15.2651 19.2626 15.2652 19.8957 14.8748 20.2861C14.4842 20.6763 13.8511 20.6765 13.4608 20.2861L12.9998 19.8242V21C12.9998 21.5522 12.5521 21.9999 11.9998 22C11.4477 21.9998 10.9998 21.5522 10.9998 21V19.8242L10.5389 20.2861C10.1484 20.6764 9.51533 20.6764 9.12482 20.2861C8.73455 19.8957 8.73468 19.2626 9.12482 18.8721L10.9998 16.9961V13.7324L8.1717 15.3643L7.48713 17.9258C7.34426 18.4591 6.79586 18.7754 6.26252 18.6328C5.7292 18.4899 5.41272 17.9416 5.55549 17.4082L5.72346 16.7793L4.49982 17.4863C4.02164 17.7622 3.40974 17.5982 3.13361 17.1201C2.85762 16.6419 3.02176 16.0301 3.49982 15.7539L4.72346 15.0459L4.09455 14.8779C3.56116 14.735 3.2447 14.1867 3.38752 13.6533C3.53045 13.1199 4.07869 12.8034 4.61213 12.9463L7.17267 13.6318L9.99885 11.999L7.17365 10.3672L4.61213 11.0537C4.07867 11.1966 3.53043 10.8801 3.38752 10.3467C3.24465 9.81323 3.56112 9.26498 4.09455 9.12207L4.72346 8.95312L3.49982 8.24609C3.02183 7.96986 2.85763 7.35803 3.13361 6.87988C3.40974 6.40181 4.02164 6.23784 4.49982 6.51367L5.72346 7.21973L5.55549 6.5918C5.41268 6.05838 5.72916 5.51011 6.26252 5.36719C6.79585 5.22457 7.34424 5.54095 7.48713 6.07422L8.1717 8.63379L10.9998 10.2666V7.00293L9.12482 5.12793C8.73467 4.73738 8.73452 4.10425 9.12482 3.71387C9.51532 3.32361 10.1484 3.32364 10.5389 3.71387L10.9998 4.1748V3C10.9998 2.44781 11.4477 2.00015 11.9998 2Z"), + pathData = addPathNodes("M11.9998 2C12.5521 2.00006 12.9998 2.44775 12.9998 3V4.1748L13.4608 3.71387C13.8511 3.32351 14.4843 3.3237 14.8748 3.71387C15.2652 4.1043 15.2651 4.73737 14.8748 5.12793L12.9998 7.00293V10.2666L15.8279 8.63281L16.5135 6.07422C16.6564 5.54089 17.2047 5.22438 17.7381 5.36719C18.2713 5.5102 18.5879 6.05848 18.4451 6.5918L18.2762 7.21973L19.4998 6.51367C19.978 6.23773 20.5898 6.40187 20.866 6.87988C21.142 7.35808 20.9779 7.96992 20.4998 8.24609L19.2752 8.95215L19.9061 9.12207C20.4393 9.26507 20.7559 9.81333 20.6131 10.3467C20.4702 10.88 19.9219 11.1964 19.3885 11.0537L16.826 10.3672L13.9988 11.999L16.826 13.6318L19.3885 12.9463C19.9219 12.8036 20.4702 13.12 20.6131 13.6533C20.7558 14.1866 20.4393 14.7349 19.9061 14.8779L19.2752 15.0459L20.4998 15.7539C20.978 16.0301 21.1422 16.6419 20.866 17.1201C20.5899 17.5982 19.978 17.7622 19.4998 17.4863L18.2762 16.7793L18.4451 17.4082C18.5878 17.9415 18.2713 18.4898 17.7381 18.6328C17.2047 18.7756 16.6564 18.4591 16.5135 17.9258L15.8279 15.3652L12.9998 13.7324V16.9961L14.8748 18.8721C15.2651 19.2626 15.2652 19.8957 14.8748 20.2861C14.4842 20.6763 13.8511 20.6765 13.4608 20.2861L12.9998 19.8242V21C12.9998 21.5522 12.5521 21.9999 11.9998 22C11.4477 21.9998 10.9998 21.5522 10.9998 21V19.8242L10.5389 20.2861C10.1484 20.6764 9.51533 20.6764 9.12482 20.2861C8.73455 19.8957 8.73468 19.2626 9.12482 18.8721L10.9998 16.9961V13.7324L8.1717 15.3643L7.48713 17.9258C7.34426 18.4591 6.79586 18.7754 6.26252 18.6328C5.7292 18.4899 5.41272 17.9416 5.55549 17.4082L5.72346 16.7793L4.49982 17.4863C4.02164 17.7622 3.40974 17.5982 3.13361 17.1201C2.85762 16.6419 3.02176 16.0301 3.49982 15.7539L4.72346 15.0459L4.09455 14.8779C3.56116 14.735 3.2447 14.1867 3.38752 13.6533C3.53045 13.1199 4.07869 12.8034 4.61213 12.9463L7.17267 13.6318L9.99885 11.999L7.17365 10.3672L4.61213 11.0537C4.07867 11.1966 3.53043 10.8801 3.38752 10.3467C3.24465 9.81323 3.56112 9.26498 4.09455 9.12207L4.72346 8.95312L3.49982 8.24609C3.02183 7.96986 2.85763 7.35803 3.13361 6.87988C3.40974 6.4018 4.02164 6.23784 4.49982 6.51367L5.72346 7.21973L5.55549 6.5918C5.41268 6.05838 5.72916 5.51011 6.26252 5.36719C6.79585 5.22457 7.34424 5.54095 7.48713 6.07422L8.1717 8.63379L10.9998 10.2666V7.00293L9.12482 5.12793C8.73467 4.73738 8.73452 4.10425 9.12482 3.71387C9.51532 3.32361 10.1484 3.32364 10.5389 3.71387L10.9998 4.1748V3C10.9998 2.44781 11.4477 2.00015 11.9998 2Z"), ) }.build() return _ic_snowflake_24!! diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcSnowflake28.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcSnowflake28.kt new file mode 100644 index 0000000000..e7f45bb1ee --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcSnowflake28.kt @@ -0,0 +1,47 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_snowflake_28: ImageVector? = null + +val Icons.ic_snowflake_28: ImageVector + get() { + if (_ic_snowflake_28 != null) return _ic_snowflake_28!! + _ic_snowflake_28 = ImageVector.Builder( + name = "ic_snowflake_28", + defaultWidth = 28.dp, + defaultHeight = 28.dp, + viewportWidth = 28f, + viewportHeight = 28f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M13.9993 2C14.6894 2.00029 15.2493 2.55982 15.2493 3.25V4.55957L15.7659 4.05371C16.2588 3.57068 17.0502 3.57868 17.5335 4.07129C18.0166 4.56423 18.0087 5.35561 17.5159 5.83887L15.2493 8.05957V11.8555L18.6399 9.93555L19.47 6.90137C19.6519 6.23549 20.3393 5.84266 21.0051 6.02441C21.671 6.20634 22.0629 6.89369 21.8811 7.55957L21.7053 8.20117L23.1331 7.39355C23.7337 7.05386 24.4961 7.26486 24.8362 7.86523C25.1758 8.46585 24.9649 9.22929 24.3645 9.56934L23.0012 10.3398L23.635 10.5068C24.3027 10.6822 24.7019 11.3655 24.5266 12.0332C24.3511 12.7006 23.6678 13.1 23.0003 12.9248L19.8821 12.1055L16.5364 13.999L19.8821 15.8926L23.0003 15.0742C23.668 14.8989 24.3513 15.2981 24.5266 15.9658C24.7017 16.6335 24.3026 17.3169 23.635 17.4922L23.0012 17.6582L24.3645 18.4297C24.9648 18.7699 25.1761 19.5332 24.8362 20.1338C24.4961 20.734 23.7336 20.9449 23.1331 20.6055L21.7053 19.7969L21.8811 20.4385C22.0628 21.1043 21.6709 21.7917 21.0051 21.9736C20.3394 22.1553 19.652 21.7633 19.47 21.0977L18.6399 18.0615L15.2493 16.1426V19.9365L17.5159 22.1582C18.0088 22.6414 18.0165 23.4328 17.5335 23.9258C17.0502 24.4186 16.2588 24.4266 15.7659 23.9434L15.2493 23.4365V24.749C15.249 25.439 14.6892 25.9987 13.9993 25.999C13.3091 25.999 12.7495 25.4392 12.7493 24.749V23.4355L12.2317 23.9434C11.7387 24.4264 10.9473 24.4187 10.4641 23.9258C9.98108 23.4329 9.98898 22.6414 10.4817 22.1582L12.7493 19.9346V16.1436L9.35572 18.0635L8.5276 21.0977C8.34549 21.7633 7.65824 22.1555 6.99244 21.9736C6.32674 21.7917 5.93482 21.1042 6.11647 20.4385L6.29029 19.7988L4.86647 20.6055C4.26592 20.9454 3.50263 20.734 3.16236 20.1338C2.82236 19.5331 3.03346 18.7698 3.63404 18.4297L4.99635 17.6582L4.36256 17.4922C3.69497 17.3169 3.29593 16.6334 3.47096 15.9658C3.64625 15.2981 4.32961 14.899 4.99733 15.0742L8.11549 15.8926L11.4612 13.999L8.11549 12.1055L4.99733 12.9248C4.3298 13.0999 3.64641 12.7006 3.47096 12.0332C3.29571 11.3656 3.69498 10.6822 4.36256 10.5068L4.99635 10.3398L3.63404 9.56934C3.03358 9.22922 2.82255 8.46588 3.16236 7.86523C3.50252 7.2647 4.26579 7.05356 4.86647 7.39355L6.29029 8.19922L6.11647 7.55957C5.93469 6.89373 6.32666 6.20639 6.99244 6.02441C7.6584 5.8425 8.34569 6.23541 8.5276 6.90137L9.35572 9.93457L12.7493 11.8545V8.06152L10.4817 5.83887C9.98904 5.35556 9.98096 4.56416 10.4641 4.07129C10.9473 3.57847 11.7387 3.57077 12.2317 4.05371L12.7493 4.56055V3.25C12.7493 2.55964 13.3089 2 13.9993 2Z"), + ) + }.build() + return _ic_snowflake_28!! + } + +@Composable +@Preview(showBackground = true) +private fun IcSnowflake28Preview() { + Icon( + imageVector = Icons.ic_snowflake_28, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcSun16.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcSun16.kt index 9e77e87061..bf90810ef1 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcSun16.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcSun16.kt @@ -46,7 +46,7 @@ val Icons.ic_sun_16: ImageVector addPath( fill = SolidColor(Color.Black), pathFillType = PathFillType.NonZero, - pathData = addPathNodes("M8.00098 5.41699C9.42763 5.41721 10.5839 6.57431 10.584 8.00098C10.5838 9.42764 9.42763 10.5838 8.00098 10.584C6.5743 10.5838 5.41713 9.42766 5.41699 8.00098C5.41712 6.57429 6.5743 5.41718 8.00098 5.41699ZM8.00098 6.66699C7.26474 6.66718 6.66712 7.26456 6.66699 8.00098C6.66713 8.73739 7.26474 9.3338 8.00098 9.33398C8.73718 9.33377 9.33385 8.73737 9.33398 8.00098C9.33385 7.26458 8.73719 6.66721 8.00098 6.66699Z"), + pathData = addPathNodes("M8.00098 5.41699C9.42763 5.41721 10.5839 6.57431 10.584 8.00098C10.5838 9.42764 9.42763 10.5838 8.00098 10.584C6.5743 10.5838 5.41713 9.42766 5.41699 8.00098C5.41712 6.57429 6.5743 5.41718 8.00098 5.41699ZM8.00098 6.66699C7.26474 6.66718 6.66712 7.26456 6.66699 8.00098C6.66713 8.73739 7.26474 9.3338 8.00098 9.33398C8.73719 9.33377 9.33385 8.73737 9.33398 8.00098C9.33385 7.26458 8.73719 6.66721 8.00098 6.66699Z"), ) addPath( fill = SolidColor(Color.Black), diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcSun28.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcSun28.kt new file mode 100644 index 0000000000..986153afbe --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcSun28.kt @@ -0,0 +1,87 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_sun_28: ImageVector? = null + +val Icons.ic_sun_28: ImageVector + get() { + if (_ic_sun_28 != null) return _ic_sun_28!! + _ic_sun_28 = ImageVector.Builder( + name = "ic_sun_28", + defaultWidth = 28.dp, + defaultHeight = 28.dp, + viewportWidth = 28f, + viewportHeight = 28f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M13.999 21.1104C14.6892 21.1104 15.2488 21.6702 15.249 22.3604V24.749C15.2488 25.4392 14.6892 25.999 13.999 25.999C13.309 25.9988 12.7493 25.439 12.749 24.749V22.3604C12.7493 21.6704 13.309 21.1106 13.999 21.1104Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M7.20312 19.0264C7.69107 18.5388 8.48261 18.539 8.9707 19.0264C9.45874 19.5144 9.45853 20.3067 8.9707 20.7949L7.28223 22.4834C6.79408 22.9716 6.00279 22.9716 5.51465 22.4834C5.02677 21.9952 5.02659 21.2039 5.51465 20.7158L7.20312 19.0264Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M19.0273 19.0264C19.5155 18.5385 20.3078 18.5383 20.7959 19.0264L22.4844 20.7158C22.9721 21.2039 22.9721 21.9953 22.4844 22.4834C21.9963 22.9715 21.205 22.9712 20.7168 22.4834L19.0273 20.7949C18.5393 20.3068 18.5392 19.5145 19.0273 19.0264Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.EvenOdd, + pathData = addPathNodes("M14 9.16602C16.669 9.16628 18.8329 11.33 18.833 13.999C18.8329 16.6681 16.669 18.8318 14 18.832C11.3308 18.832 9.16707 16.6682 9.16699 13.999C9.1671 11.3299 11.3308 9.16605 14 9.16602ZM14 11.666C12.7116 11.666 11.6671 12.7105 11.667 13.999C11.6671 15.2876 12.7116 16.332 14 16.332C15.2882 16.3318 16.3329 15.2874 16.333 13.999C16.3329 12.7106 15.2882 11.6663 14 11.666Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M5.63867 12.748C6.32858 12.7483 6.88828 13.3082 6.88867 13.998C6.88867 14.6883 6.32882 15.2478 5.63867 15.248H3.25C2.55976 15.2479 2 14.6883 2 13.998C2.00039 13.3081 2.56 12.7482 3.25 12.748H5.63867Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M24.749 12.748C25.4388 12.7484 25.9986 13.3083 25.999 13.998C25.999 14.6882 25.4391 15.2477 24.749 15.248H22.3604C21.67 15.248 21.1104 14.6884 21.1104 13.998C21.1107 13.308 21.6702 12.748 22.3604 12.748H24.749Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M5.51465 5.51367C6.00282 5.02577 6.79515 5.0256 7.2832 5.51367L8.97168 7.20312C9.45925 7.69124 9.4594 8.48268 8.97168 8.9707C8.48367 9.45856 7.69223 9.45836 7.2041 8.9707L5.51465 7.28125C5.02685 6.79314 5.02682 6.00177 5.51465 5.51367Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M20.7158 5.51367C21.2038 5.02598 21.9953 5.02608 22.4834 5.51367C22.9714 6.00173 22.9712 6.79306 22.4834 7.28125L20.7949 8.9707C20.3068 9.4588 19.5155 9.45865 19.0273 8.9707C18.5395 8.48252 18.5393 7.6912 19.0273 7.20312L20.7158 5.51367Z"), + ) + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M13.999 2C14.6892 2 15.2488 2.55985 15.249 3.25V5.63867C15.2488 6.32888 14.6893 6.88867 13.999 6.88867C13.309 6.8884 12.7492 6.32871 12.749 5.63867V3.25C12.7493 2.56002 13.309 2.00027 13.999 2Z"), + ) + }.build() + return _ic_sun_28!! + } + +@Composable +@Preview(showBackground = true) +private fun IcSun28Preview() { + Icon( + imageVector = Icons.ic_sun_28, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcTrashBin12.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcTrashBin12.kt new file mode 100644 index 0000000000..3a1eebf071 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcTrashBin12.kt @@ -0,0 +1,47 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_trash_bin_12: ImageVector? = null + +val Icons.ic_trash_bin_12: ImageVector + get() { + if (_ic_trash_bin_12 != null) return _ic_trash_bin_12!! + _ic_trash_bin_12 = ImageVector.Builder( + name = "ic_trash_bin_12", + defaultWidth = 12.dp, + defaultHeight = 12.dp, + viewportWidth = 12f, + viewportHeight = 12f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M6.97266 1C7.72075 1.00051 8.32402 1.60894 8.32422 2.35449V2.70215H9.87891C10.224 2.70215 10.5038 2.98209 10.5039 3.32715C10.5039 3.67232 10.2241 3.95215 9.87891 3.95215H9.77832V9.40527C9.77832 10.2855 9.06597 11.0028 8.18359 11.0029H3.82227C2.93977 11.0029 2.22754 10.2856 2.22754 9.40527V3.95215H2.125C1.77994 3.95202 1.50001 3.67224 1.5 3.32715C1.50014 2.98217 1.78002 2.70228 2.125 2.70215H3.68164V2.35449C3.68184 1.60876 4.28486 1.00022 5.0332 1H6.97266ZM3.47754 9.40527C3.47754 9.59922 3.63411 9.75293 3.82227 9.75293H8.18359C8.37164 9.7528 8.52832 9.59914 8.52832 9.40527V3.95215H3.47754V9.40527ZM5.0332 2.25C4.97916 2.25022 4.93184 2.29516 4.93164 2.35449V2.70215H7.07422V2.35449C7.07402 2.29536 7.02648 2.25052 6.97266 2.25H5.0332Z"), + ) + }.build() + return _ic_trash_bin_12!! + } + +@Composable +@Preview(showBackground = true) +private fun IcTrashBin12Preview() { + Icon( + imageVector = Icons.ic_trash_bin_12, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcTrashBin16.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcTrashBin16.kt new file mode 100644 index 0000000000..98c2982d15 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcTrashBin16.kt @@ -0,0 +1,47 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_trash_bin_16: ImageVector? = null + +val Icons.ic_trash_bin_16: ImageVector + get() { + if (_ic_trash_bin_16 != null) return _ic_trash_bin_16!! + _ic_trash_bin_16 = ImageVector.Builder( + name = "ic_trash_bin_16", + defaultWidth = 16.dp, + defaultHeight = 16.dp, + viewportWidth = 16f, + viewportHeight = 16f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M9.34277 1.5C10.2274 1.50021 10.9746 2.20188 10.9746 3.10449V3.78516H13.374C13.7189 3.78539 13.9989 4.06525 13.999 4.41016C13.9988 4.75499 13.7189 5.03492 13.374 5.03516H12.9912V12.5713C12.9911 13.6541 12.0925 14.5017 11.0225 14.502H4.97656C3.90634 14.502 3.00795 13.6542 3.00781 12.5713V5.03516H2.625C2.27997 5.03516 2.00024 4.75513 2 4.41016C2.00015 4.06511 2.27992 3.78516 2.625 3.78516H5.02246V3.10449C5.02246 2.20182 5.77059 1.50013 6.65527 1.5H9.34277ZM4.25781 12.5713C4.25795 12.9305 4.56286 13.252 4.97656 13.252H11.0225C11.4359 13.2517 11.7411 12.9304 11.7412 12.5713V5.03516H4.25781V12.5713ZM6.65527 2.75C6.42712 2.75013 6.27246 2.92553 6.27246 3.10449V3.78516H9.72461V3.10449C9.72461 2.92557 9.57084 2.7502 9.34277 2.75H6.65527Z"), + ) + }.build() + return _ic_trash_bin_16!! + } + +@Composable +@Preview(showBackground = true) +private fun IcTrashBin16Preview() { + Icon( + imageVector = Icons.ic_trash_bin_16, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcTrashBin20.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcTrashBin20.kt new file mode 100644 index 0000000000..0241133ce9 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcTrashBin20.kt @@ -0,0 +1,47 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_trash_bin_20: ImageVector? = null + +val Icons.ic_trash_bin_20: ImageVector + get() { + if (_ic_trash_bin_20 != null) return _ic_trash_bin_20!! + _ic_trash_bin_20 = ImageVector.Builder( + name = "ic_trash_bin_20", + defaultWidth = 20.dp, + defaultHeight = 20.dp, + viewportWidth = 20f, + viewportHeight = 20f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M11.5625 2C12.6456 2 13.4844 2.89922 13.4844 3.95898V4.82031H16.251C16.665 4.82055 17.001 5.15626 17.001 5.57031C17.0008 5.98419 16.6649 6.32008 16.251 6.32031H15.8291V15.6426C15.8289 16.9246 14.8152 18.0046 13.5166 18.0049H6.48438C5.18564 18.0048 4.17204 16.9247 4.17188 15.6426V6.32031H3.75C3.33592 6.32031 3.00022 5.98434 3 5.57031C3.00001 5.15611 3.33579 4.82031 3.75 4.82031H6.51562V3.95898C6.51562 2.89933 7.35451 2.00018 8.4375 2H11.5625ZM5.67188 15.6426C5.67204 16.1403 6.05739 16.5048 6.48438 16.5049H13.5166C13.9434 16.5046 14.3289 16.1401 14.3291 15.6426V6.32031H5.67188V15.6426ZM8.4375 3.5C8.22625 3.50018 8.01562 3.68376 8.01562 3.95898V4.82031H11.9844V3.95898C11.9844 3.68362 11.7739 3.5 11.5625 3.5H8.4375Z"), + ) + }.build() + return _ic_trash_bin_20!! + } + +@Composable +@Preview(showBackground = true) +private fun IcTrashBin20Preview() { + Icon( + imageVector = Icons.ic_trash_bin_20, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcTrashBin24.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcTrashBin24.kt new file mode 100644 index 0000000000..990132d129 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcTrashBin24.kt @@ -0,0 +1,47 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_trash_bin_24: ImageVector? = null + +val Icons.ic_trash_bin_24: ImageVector + get() { + if (_ic_trash_bin_24 != null) return _ic_trash_bin_24!! + _ic_trash_bin_24 = ImageVector.Builder( + name = "ic_trash_bin_24", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M14 2C15.3807 2 16.5 3.11929 16.5 4.5V5.5H20C20.5523 5.5 21 5.94772 21 6.5C21 7.05228 20.5523 7.5 20 7.5H19.5V19C19.5 20.6569 18.1569 22 16.5 22H7.5C5.84315 22 4.5 20.6569 4.5 19V7.5H4C3.44772 7.5 3 7.05228 3 6.5C3 5.94772 3.44772 5.5 4 5.5H7.5V4.5C7.5 3.11929 8.61929 2 10 2H14ZM6.5 19C6.5 19.5523 6.94772 20 7.5 20H16.5C17.0523 20 17.5 19.5523 17.5 19V7.5H6.5V19ZM10 4C9.72386 4 9.5 4.22386 9.5 4.5V5.5H14.5V4.5C14.5 4.22386 14.2761 4 14 4H10Z"), + ) + }.build() + return _ic_trash_bin_24!! + } + +@Composable +@Preview(showBackground = true) +private fun IcTrashBin24Preview() { + Icon( + imageVector = Icons.ic_trash_bin_24, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcTrashBin28.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcTrashBin28.kt new file mode 100644 index 0000000000..c5425d7308 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcTrashBin28.kt @@ -0,0 +1,47 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_trash_bin_28: ImageVector? = null + +val Icons.ic_trash_bin_28: ImageVector + get() { + if (_ic_trash_bin_28 != null) return _ic_trash_bin_28!! + _ic_trash_bin_28 = ImageVector.Builder( + name = "ic_trash_bin_28", + defaultWidth = 28.dp, + defaultHeight = 28.dp, + viewportWidth = 28f, + viewportHeight = 28f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M16.3135 2C17.9434 2.00012 19.2974 3.30677 19.2979 4.95801V5.98633H23.252C23.9419 5.98659 24.5016 6.54642 24.502 7.23633C24.502 7.92652 23.9421 8.48606 23.252 8.48633H22.7676V21.4727C22.7676 23.4387 21.1545 25.0006 19.2051 25.001H8.79688C6.84731 25.0008 5.23438 23.4388 5.23438 21.4727V8.48633H4.75C4.05964 8.48633 3.5 7.92668 3.5 7.23633C3.50033 6.54626 4.05985 5.98633 4.75 5.98633H8.70312V4.95801C8.70361 3.30676 10.0575 2.00011 11.6875 2H16.3135ZM7.73438 21.4727C7.73438 22.0224 8.19208 22.5008 8.79688 22.501H19.2051C19.8097 22.5006 20.2676 22.0223 20.2676 21.4727V8.48633H7.73438V21.4727ZM11.6875 4.5C11.4024 4.50011 11.2036 4.72306 11.2031 4.95801V5.98633H16.7979V4.95801C16.7974 4.72306 16.5986 4.50012 16.3135 4.5H11.6875Z"), + ) + }.build() + return _ic_trash_bin_28!! + } + +@Composable +@Preview(showBackground = true) +private fun IcTrashBin28Preview() { + Icon( + imageVector = Icons.ic_trash_bin_28, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcWallet16.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcWallet16.kt new file mode 100644 index 0000000000..0cd6fb2914 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcWallet16.kt @@ -0,0 +1,47 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_wallet_16: ImageVector? = null + +val Icons.ic_wallet_16: ImageVector + get() { + if (_ic_wallet_16 != null) return _ic_wallet_16!! + _ic_wallet_16 = ImageVector.Builder( + name = "ic_wallet_16", + defaultWidth = 16.dp, + defaultHeight = 16.dp, + viewportWidth = 16f, + viewportHeight = 16f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M12.1094 3C13.1045 3.00018 13.999 3.75613 13.999 4.79199V11.209C13.999 12.2448 13.1045 13.0008 12.1094 13.001H3.88965C2.89442 13.001 2.00007 12.2449 2 11.209V4.79199C2 3.75601 2.89438 3 3.88965 3H12.1094ZM3.25 11.209C3.25008 11.4618 3.48817 11.751 3.88965 11.751H12.1094C12.5106 11.7508 12.7489 11.4617 12.749 11.209V10.668H11.4766C10.4816 10.6678 9.58724 9.91158 9.58691 8.87598C9.58691 7.84008 10.4814 7.08411 11.4766 7.08398H12.749V6.58398H3.88965C3.66831 6.58398 3.45263 6.54317 3.25 6.47363V11.209ZM11.4766 8.33398C11.0752 8.3341 10.8369 8.62322 10.8369 8.87598C10.8373 9.12859 11.0755 9.41785 11.4766 9.41797H12.749V8.33398H11.4766ZM3.88965 4.25C3.48811 4.25 3.25 4.53919 3.25 4.79199L3.26074 4.8877C3.31186 5.11231 3.53839 5.33398 3.88965 5.33398H12.749V4.79199C12.749 4.53926 12.5107 4.25017 12.1094 4.25H3.88965Z"), + ) + }.build() + return _ic_wallet_16!! + } + +@Composable +@Preview(showBackground = true) +private fun IcWallet16Preview() { + Icon( + imageVector = Icons.ic_wallet_16, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcWallet20.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcWallet20.kt new file mode 100644 index 0000000000..e6f143fffd --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcWallet20.kt @@ -0,0 +1,47 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_wallet_20: ImageVector? = null + +val Icons.ic_wallet_20: ImageVector + get() { + if (_ic_wallet_20 != null) return _ic_wallet_20!! + _ic_wallet_20 = ImageVector.Builder( + name = "ic_wallet_20", + defaultWidth = 20.dp, + defaultHeight = 20.dp, + viewportWidth = 20f, + viewportHeight = 20f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M15.5439 3C16.8835 3.00026 18 4.06618 18 5.41699V14.583C17.9996 15.9335 16.8833 16.9988 15.5439 16.999H4.45703C3.1175 16.999 2.00037 15.9337 2 14.583V5.41699C2 4.06602 3.11728 3 4.45703 3H15.5439ZM3.50098 14.583C3.50135 15.0726 3.91289 15.499 4.45703 15.499H15.5439C16.0878 15.4988 16.4996 15.0724 16.5 14.583V13.666H14.6914C13.3519 13.666 12.2358 12.6006 12.2354 11.25C12.2354 9.89902 13.3517 8.83301 14.6914 8.83301H16.5V7.83301H4.45703C4.11954 7.83301 3.79581 7.76437 3.50098 7.6416V14.583ZM14.6914 10.333C14.147 10.333 13.7354 10.7601 13.7354 11.25C13.7358 11.7395 14.1473 12.166 14.6914 12.166H16.5V10.333H14.6914ZM4.45703 4.5C3.91264 4.5 3.50098 4.92713 3.50098 5.41699L3.50586 5.50781C3.55335 5.95804 3.9469 6.33301 4.45703 6.33301H16.5V5.41699C16.5 4.92728 16.0881 4.50026 15.5439 4.5H4.45703Z"), + ) + }.build() + return _ic_wallet_20!! + } + +@Composable +@Preview(showBackground = true) +private fun IcWallet20Preview() { + Icon( + imageVector = Icons.ic_wallet_20, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcWallet24.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcWallet24.kt new file mode 100644 index 0000000000..e91433b2c6 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcWallet24.kt @@ -0,0 +1,47 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_wallet_24: ImageVector? = null + +val Icons.ic_wallet_24: ImageVector + get() { + if (_ic_wallet_24 != null) return _ic_wallet_24!! + _ic_wallet_24 = ImageVector.Builder( + name = "ic_wallet_24", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M18.8809 3C20.6094 3 21.9979 4.40955 21.998 6.13281V11.4482C21.9981 11.4541 21.999 11.46 21.999 11.4658C21.999 11.4714 21.9981 11.4769 21.998 11.4824V15.7148C21.9981 15.7207 21.999 15.7266 21.999 15.7324C21.999 15.738 21.9981 15.7435 21.998 15.749V17.8652C21.9979 19.5885 20.6094 20.999 18.8809 20.999H5.11719C3.38873 20.9989 2.00014 19.5884 2 17.8652V6.13281C2.00018 4.40964 3.38875 3.00015 5.11719 3H18.8809ZM4 17.8652C4.00014 18.4978 4.50718 18.9989 5.11719 18.999H18.8809C19.491 18.999 19.9979 18.4979 19.998 17.8652V16.7324H17.8223C16.0937 16.7323 14.7051 15.3219 14.7051 13.5986C14.7053 11.8755 16.0938 10.466 17.8223 10.4658H19.998V9.2666H5.11719C4.72279 9.26657 4.34655 9.19139 4 9.05762V17.8652ZM17.8223 12.4658C17.2123 12.466 16.7053 12.9661 16.7051 13.5986C16.7051 14.2313 17.2122 14.7323 17.8223 14.7324H19.998V12.4658H17.8223ZM5.11719 5C4.50721 5.00015 4.00018 5.50027 4 6.13281C4.00014 6.76538 4.50718 7.26645 5.11719 7.2666H19.998V6.13281C19.9979 5.50018 19.491 5 18.8809 5H5.11719Z"), + ) + }.build() + return _ic_wallet_24!! + } + +@Composable +@Preview(showBackground = true) +private fun IcWallet24Preview() { + Icon( + imageVector = Icons.ic_wallet_24, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcWallet28.kt b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcWallet28.kt new file mode 100644 index 0000000000..e896f73775 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/res/generated/icons/IcWallet28.kt @@ -0,0 +1,47 @@ +@file:Suppress("all") + +package com.tangem.core.ui.res.generated.icons + +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Auto-generated from design tokens. Do not edit manually. + */ + +private var _ic_wallet_28: ImageVector? = null + +val Icons.ic_wallet_28: ImageVector + get() { + if (_ic_wallet_28 != null) return _ic_wallet_28!! + _ic_wallet_28 = ImageVector.Builder( + name = "ic_wallet_28", + defaultWidth = 28.dp, + defaultHeight = 28.dp, + viewportWidth = 28f, + viewportHeight = 28f, + ).apply { + addPath( + fill = SolidColor(Color.Black), + pathFillType = PathFillType.NonZero, + pathData = addPathNodes("M21.4561 4C23.4333 4.00015 25 5.62509 25 7.58398V20.4189C24.9997 22.3776 23.4332 24.0028 21.4561 24.0029H6.54395C4.56679 24.0028 3.00027 22.3776 3 20.4189V7.58398C3 5.62506 4.56662 4.0001 6.54395 4H21.4561ZM5.5 20.4189C5.50027 21.0373 5.98757 21.5028 6.54395 21.5029H21.4561C22.0124 21.5028 22.4997 21.0373 22.5 20.4189V19.334H20.3086C18.3315 19.3337 16.7648 17.7097 16.7646 15.751C16.7649 13.7923 18.3315 12.1672 20.3086 12.167H22.5V11.167H6.54395C6.17972 11.167 5.82913 11.112 5.5 11.0098V20.4189ZM20.3086 14.667C19.7523 14.6672 19.2649 15.1327 19.2646 15.751C19.2648 16.3694 19.7523 16.8337 20.3086 16.834H22.5V14.667H20.3086ZM6.54395 6.5C5.98741 6.50011 5.5 6.96534 5.5 7.58398L5.50586 7.69824C5.56185 8.2586 6.02248 8.66689 6.54395 8.66699H22.5V7.58398C22.5 6.96537 22.0126 6.50015 21.4561 6.5H6.54395Z"), + ) + }.build() + return _ic_wallet_28!! + } + +@Composable +@Preview(showBackground = true) +private fun IcWallet28Preview() { + Icon( + imageVector = Icons.ic_wallet_28, + contentDescription = null, + ) +} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt index 1d461a65c9..b7b864b77e 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/entity/StoryBookPage.kt @@ -9,6 +9,7 @@ import com.tangem.core.ui.ds.topbar.TangemTopBarType import com.tangem.core.ui.ds2.badge.TangemBadge import com.tangem.core.ui.ds2.button.TangemButton import com.tangem.core.ui.ds2.fade.TangemFade +import com.tangem.core.ui.ds2.glowring.TangemGlowRing import com.tangem.core.ui.ds2.loader.TangemLoaderSize import com.tangem.core.ui.ds2.row.TangemRowContentLead import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment @@ -333,6 +334,25 @@ internal data class TangemCheckmarkStory( val onEnabledToggle: () -> Unit, ) : DsStoryBookPage +internal data class TangemGlowRingStory( + val variant: TangemGlowRing.Variant, + val quality: TangemGlowRing.Quality, + val background: Background, + val isAnimated: Boolean, + val onVariantChange: (TangemGlowRing.Variant) -> Unit, + val onQualityChange: (TangemGlowRing.Quality) -> Unit, + val onBackgroundChange: (Background) -> Unit, + val onAnimatedToggle: () -> Unit, +) : DsStoryBookPage { + + /** Backdrop the glow-ring preview is rendered on top of. */ + enum class Background(val label: String) { + BgPrimary("bg.primary"), + BgSecondary("bg.secondary"), + BgInverse("bg.inverse"), + } +} + internal data class TangemBadgeV2Story( val variant: TangemBadge.Variant, val status: TangemBadge.Status, diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/DsComponentsListScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/DsComponentsListScreen.kt index 1083082fea..fc0a092152 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/DsComponentsListScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/DsComponentsListScreen.kt @@ -20,6 +20,7 @@ import com.tangem.feature.tester.presentation.storybook.page.ds.button.tangemBut import com.tangem.feature.tester.presentation.storybook.page.ds.checkbox.tangemCheckboxV2StoryFactory import com.tangem.feature.tester.presentation.storybook.page.ds.checkmark.tangemCheckmarkStoryFactory import com.tangem.feature.tester.presentation.storybook.page.ds.fade.tangemFadeStoryFactory +import com.tangem.feature.tester.presentation.storybook.page.ds.glowring.tangemGlowRingStoryFactory import com.tangem.feature.tester.presentation.storybook.page.ds.loader.tangemLoaderStoryFactory import com.tangem.feature.tester.presentation.storybook.page.ds.row.tangemRowStoryFactory import com.tangem.feature.tester.presentation.storybook.page.ds.search.tangemSearchStoryFactory @@ -39,6 +40,7 @@ private fun buildDsStories() = listOf( DsStoryItem(title = "✨ TangemShimmer", factory = tangemShimmerStoryFactory), DsStoryItem(title = "🌫️ TangemFade", factory = tangemFadeStoryFactory), DsStoryItem(title = "🧭 TangemTopNavigation", factory = tangemTopNavigationStoryFactory), + DsStoryItem(title = "💫 TangemGlowRing", factory = tangemGlowRingStoryFactory), ) @Composable diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/glowring/Build.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/glowring/Build.kt new file mode 100644 index 0000000000..879601eb75 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/glowring/Build.kt @@ -0,0 +1,30 @@ +package com.tangem.feature.tester.presentation.storybook.page.ds.glowring + +import com.tangem.core.ui.ds2.glowring.TangemGlowRing +import com.tangem.feature.tester.presentation.storybook.entity.TangemGlowRingStory +import com.tangem.feature.tester.presentation.storybook.viewmodel.StateUpdater +import com.tangem.feature.tester.presentation.storybook.viewmodel.storyPageFactory + +internal fun StateUpdater.build(): TangemGlowRingStory { + return TangemGlowRingStory( + variant = TangemGlowRing.Variant.Magic, + quality = TangemGlowRing.Quality.Auto, + background = TangemGlowRingStory.Background.BgPrimary, + isAnimated = true, + onVariantChange = { variant -> + updateStory { it.copy(variant = variant) } + }, + onQualityChange = { quality -> + updateStory { it.copy(quality = quality) } + }, + onBackgroundChange = { background -> + updateStory { it.copy(background = background) } + }, + onAnimatedToggle = { + updateStory { it.copy(isAnimated = !it.isAnimated) } + }, + ) +} + +internal val tangemGlowRingStoryFactory + get() = storyPageFactory(StateUpdater::build) \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/glowring/TangemGlowRingStory.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/glowring/TangemGlowRingStory.kt new file mode 100644 index 0000000000..6e019d42e0 --- /dev/null +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/page/ds/glowring/TangemGlowRingStory.kt @@ -0,0 +1,227 @@ +@file:Suppress("MagicNumber") + +package com.tangem.feature.tester.presentation.storybook.page.ds.glowring + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +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.unit.dp +import com.tangem.core.ui.ds2.glowring.TangemGlowRing +import com.tangem.core.ui.res.TangemTheme +import com.tangem.feature.tester.presentation.storybook.entity.TangemGlowRingStory +import com.tangem.feature.tester.presentation.storybook.entity.TangemGlowRingStory.Background + +@Composable +internal fun TangemGlowRingStory(state: TangemGlowRingStory, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxSize() + .background(TangemTheme.colors.background.primary) + .padding(vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Preview stays pinned at the top. + ComponentPreview(state = state) + // Only the controls scroll. + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + VariantSelector(selected = state.variant, onSelect = state.onVariantChange) + QualitySelector(selected = state.quality, onSelect = state.onQualityChange) + BackgroundSelector(selected = state.background, onSelect = state.onBackgroundChange) + Toggles(state = state) + } + } +} + +@Composable +private fun PreviewBackground(background: Background, modifier: Modifier = Modifier) { + when (background) { + Background.BgPrimary -> Box(modifier.background(TangemTheme.colors3.bg.primary)) + Background.BgSecondary -> Box(modifier.background(TangemTheme.colors3.bg.secondary)) + Background.BgInverse -> Box(modifier.background(TangemTheme.colors3.bg.inverse)) + } +} + +@Composable +private fun ComponentPreview(state: TangemGlowRingStory) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clip(RoundedCornerShape(16.dp)), + ) { + PreviewBackground( + background = state.background, + modifier = Modifier.matchParentSize(), + ) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 48.dp), + ) { + TangemGlowRing( + modifier = Modifier.size(width = 200.dp, height = 120.dp), + variant = state.variant, + animated = state.isAnimated, + quality = state.quality, + ) + } + } +} + +@Composable +private fun VariantSelector(selected: TangemGlowRing.Variant, onSelect: (TangemGlowRing.Variant) -> Unit) { + Section(label = "Variant") { + ChipGrid( + items = TangemGlowRing.Variant.entries, + label = { it.name }, + isSelected = { it == selected }, + onSelect = onSelect, + ) + } +} + +@Composable +private fun QualitySelector(selected: TangemGlowRing.Quality, onSelect: (TangemGlowRing.Quality) -> Unit) { + Section(label = "Quality (renderer)") { + ChipGrid( + items = TangemGlowRing.Quality.entries, + label = { it.name }, + isSelected = { it == selected }, + onSelect = onSelect, + ) + } +} + +@Composable +private fun BackgroundSelector(selected: Background, onSelect: (Background) -> Unit) { + Section(label = "Background") { + ChipGrid( + items = Background.entries, + label = { it.label }, + isSelected = { it == selected }, + onSelect = onSelect, + ) + } +} + +@Composable +private fun Toggles(state: TangemGlowRingStory) { + Section(label = "Flags") { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + ToggleRow(label = "animated", checked = state.isAnimated, onToggle = state.onAnimatedToggle) + } + } +} + +@Composable +private fun Section(label: String, content: @Composable () -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + modifier = Modifier.padding(horizontal = 16.dp), + text = label, + style = TangemTheme.typography.subtitle1, + color = TangemTheme.colors.text.primary1, + ) + content() + } +} + +@Composable +private fun ChipGrid(items: List, label: (T) -> String, isSelected: (T) -> Boolean, onSelect: (T) -> Unit) { + val shape = RoundedCornerShape(50) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clip(shape) + .background(TangemTheme.colors2.surface.level2) + .border( + width = 1.dp, + color = TangemTheme.colors2.border.neutral.secondary, + shape = shape, + ) + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + items.forEach { item -> + Chip( + label = label(item), + selected = isSelected(item), + onClick = { onSelect(item) }, + modifier = Modifier.weight(1f), + ) + } + } +} + +@Composable +private fun Chip(label: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { + val chipShape = RoundedCornerShape(50) + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .clip(chipShape) + .background( + if (selected) TangemTheme.colors2.surface.level3 else TangemTheme.colors2.surface.level2, + ) + .clickable(onClick = onClick) + .padding(vertical = 8.dp, horizontal = 4.dp), + ) { + Text( + text = label, + style = TangemTheme.typography.caption2, + color = if (selected) TangemTheme.colors.text.primary1 else TangemTheme.colors.text.secondary, + ) + } +} + +@Composable +private fun ToggleRow(label: String, checked: Boolean, onToggle: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(TangemTheme.colors2.surface.level2) + .clickable(onClick = onToggle) + .padding(horizontal = 12.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = label, + style = TangemTheme.typography.body2, + color = TangemTheme.colors.text.primary1, + ) + Text( + text = if (checked) "ON" else "OFF", + style = TangemTheme.typography.caption2, + color = if (checked) TangemTheme.colors.text.accent else TangemTheme.colors.text.secondary, + ) + } +} \ No newline at end of file diff --git a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt index fb4ee3a9a7..be9720bd99 100644 --- a/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt +++ b/features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/ui/StoryBookScreen.kt @@ -42,6 +42,7 @@ import com.tangem.feature.tester.presentation.storybook.page.ds.button.TangemBut import com.tangem.feature.tester.presentation.storybook.page.ds.checkbox.TangemCheckboxV2Story import com.tangem.feature.tester.presentation.storybook.page.ds.checkmark.TangemCheckmarkStory import com.tangem.feature.tester.presentation.storybook.page.ds.fade.TangemFadeStory +import com.tangem.feature.tester.presentation.storybook.page.ds.glowring.TangemGlowRingStory import com.tangem.feature.tester.presentation.storybook.page.ds.loader.TangemLoaderStory import com.tangem.feature.tester.presentation.storybook.page.ds.row.TangemRowStory import com.tangem.feature.tester.presentation.storybook.page.ds.search.TangemSearchStory @@ -96,6 +97,7 @@ internal fun StoryBookScreen(state: StoryBookUM, modifier: Modifier = Modifier) is TangemBadgeV2Story -> TangemBadgeV2Story(state = storyState) is TangemCheckboxV2Story -> TangemCheckboxV2Story(state = storyState) is TangemCheckmarkStory -> TangemCheckmarkStory(state = storyState) + is TangemGlowRingStory -> TangemGlowRingStory(state = storyState) is TangemRowStory -> TangemRowStory(state = storyState) is TangemSearchStory -> TangemSearchStory(state = storyState) is TangemShimmerStory -> TangemShimmerStory(state = storyState) From 110ff5c745934ef2ec15bdc0d0a04d799df56a97 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 24 Jun 2026 18:00:16 +0500 Subject: [PATCH 032/210] Updated on 2026-08-14 --- .../impl/DefaultGiveApprovalComponent.kt | 6 ++-- .../approval/impl/model/GiveApprovalModel.kt | 6 ++-- .../approval/impl/ui/GiveApprovalContent.kt | 2 +- .../ui/PreviewFeeSelectorBlockComponent.kt | 4 +-- .../api/callbacks/FeeSelectorModelCallback.kt | 7 ---- .../feeSelector}/FeeSelectorBlockComponent.kt | 6 ++-- .../feeSelector}/FeeSelectorComponent.kt | 4 +-- .../callbacks/FeeSelectorModelCallback.kt | 7 ++++ .../feeSelector}/entity/CustomFeeFieldUM.kt | 2 +- .../feeSelector}/entity/FeeSelectorUM.kt | 2 +- .../feeSelector}/params/FeeSelectorParams.kt | 6 ++-- .../feeSelector/utils/FeeCalculationUtils.kt | 4 +-- .../SendNotificationsComponent.kt | 2 +- .../SendNotificationsUpdateListener.kt | 1 - .../SendNotificationsUpdateTrigger.kt | 2 -- .../send/common/ui/FeeBlockSuccess.kt | 2 +- .../features/send/di/SendFeatureModule.kt | 2 +- .../DefaultFeeSelectorBlockComponent.kt | 8 ++--- .../DefaultFeeSelectorComponent.kt | 4 +-- .../component/FeeSelectorComponentParams.kt | 4 +-- .../extended/entity/FeeExtendedSelectorUM.kt | 4 +-- .../model/FeeExtendedSelectorModel.kt | 2 +- .../extended/ui/FeeExtendedSelectorContent.kt | 10 +++--- .../speed/FeeSpeedSelectorComponent.kt | 2 +- .../speed/model/FeeSpeedSelectorModel.kt | 2 +- .../speed/ui/FeeSpeedSelectorContent.kt | 12 +++---- .../token/entity/FeeTokenSelectorUM.kt | 2 +- .../token/model/FeeTokenSelectorModel.kt | 2 +- .../token/ui/FeeTokenSelectorContent.kt | 10 +++--- .../di/FeeSelectorFeatureModule.kt | 4 +-- .../model/FeeSelectorAlertFactory.kt | 4 +-- .../model/FeeSelectorBlockModel.kt | 6 ++-- .../feeselector/model/FeeSelectorIntents.kt | 2 +- .../feeselector/model/FeeSelectorLogic.kt | 8 ++--- .../feeselector/model/FeeSelectorModel.kt | 6 ++-- .../model/transformers/FeeItemConverter.kt | 4 +-- .../FeeItemSelectedTransformer.kt | 4 +-- .../FeeSelectorCustomFieldConverter.kt | 4 +-- ...eeSelectorCustomValueChangedTransformer.kt | 4 +-- .../FeeSelectorErrorTransformer.kt | 2 +- .../FeeSelectorLoadedTransformer.kt | 12 +++---- .../FeeSelectorLoadingTransformer.kt | 2 +- .../FeeSelectorNonceChangeTransformer.kt | 4 +-- .../FeeSelectorRemoveSuggestedTransformer.kt | 4 +-- .../FeeSelectorTokenSelectedTransformer.kt | 4 +-- .../feeselector/ui/FeeSelectorBlockContent.kt | 10 +++--- .../ui/FeeSelectorModalBottomSheet.kt | 6 ++-- .../send/send/DefaultSendComponent.kt | 2 +- .../send/send/analytics/SendAnalyticHelper.kt | 4 +-- .../send/send/confirm/SendConfirmComponent.kt | 9 +++--- .../send/confirm/model/SendConfirmModel.kt | 14 ++++---- ...dConfirmationNotificationsTransformerV2.kt | 2 +- .../send/confirm/ui/SendConfirmContent.kt | 2 +- .../features/send/send/model/SendModel.kt | 2 +- .../features/send/send/ui/state/SendUM.kt | 2 +- .../analytics/NFTSendAnalyticHelper.kt | 4 +-- .../confirm/NFTSendConfirmComponent.kt | 8 ++--- .../confirm/model/NFTSendConfirmModel.kt | 8 ++--- ...dConfirmationNotificationsTransformerV2.kt | 2 +- .../confirm/ui/NFTSendConfirmContent.kt | 2 +- .../send/sendnft/model/NFTSendModel.kt | 2 +- .../send/sendnft/ui/state/NFTSendUM.kt | 2 +- .../converters/custom/CustomFeeConverter.kt | 2 +- .../bitcoin/BitcoinCustomFeeConverter.kt | 2 +- .../BaseEthereumCustomFeeConverter.kt | 2 +- .../ethereum/EthereumCustomFeeConverter.kt | 2 +- .../ethereum/EthereumEIPCustomFeeConverter.kt | 2 +- .../EthereumLegacyCustomFeeConverter.kt | 2 +- .../custom/kaspa/KaspaCustomFeeConverter.kt | 2 +- .../DefaultNotificationsUpdateTrigger.kt | 2 +- .../DefaultSendNotificationsComponent.kt | 4 +-- .../notifications/model/NotificationsModel.kt | 4 +-- ...firmationNotificationsTransformerV2Test.kt | 14 ++++---- ...firmationNotificationsTransformerV2Test.kt | 13 ++++---- ...tinationValidationResultTransformerTest.kt | 1 - .../swap/v2/impl/common/entity/ConfirmUM.kt | 2 +- .../confirm/SendWithSwapConfirmComponent.kt | 6 ++-- .../confirm/model/SendWithSwapConfirmModel.kt | 10 +++--- ...wapConfirmationNotificationsTransformer.kt | 2 +- .../confirm/ui/SendWithSwapConfirmContent.kt | 4 +-- .../impl/sendviaswap/entity/SendWithSwapUM.kt | 2 +- .../sendviaswap/model/SendWithSwapModel.kt | 2 +- .../success/ui/SendWithSwapSuccessContent.kt | 8 ++--- .../SwapFeeSelectorBlockComponent.kt | 6 ++-- .../tangem/feature/swap/model/SwapModel.kt | 4 +-- .../tangem/feature/swap/ui/StateBuilder.kt | 2 +- .../feature/swap/ui/SwapSuccessScreen.kt | 2 +- .../SwapModelApprovalSelectorCallbackTest.kt | 2 +- .../swap/model/SwapModelHandleFeeErrorTest.kt | 2 +- .../DefaultPromoDeeplinkHandlerTest.kt | 32 +++++++++++++------ .../routing/DefaultWcRoutingComponent.kt | 4 +-- .../PreviewFeeSelectorBlockComponent.kt | 4 +-- .../components/common/WcNavigationUtils.kt | 6 ++-- .../send/WcSendTransactionComponent.kt | 6 ++-- .../WcSendTransactionContainerComponent.kt | 4 +-- .../converter/WcSendTransactionUMConverter.kt | 2 +- .../entity/send/WcSendTransactionUM.kt | 2 +- .../model/WcSendTransactionModel.kt | 6 ++-- .../ui/common/WcSendTransactionItems.kt | 4 +-- .../send/WcSendTransactionModalBottomSheet.kt | 4 +-- .../utils/WcNotificationsFactory.kt | 2 +- 101 files changed, 235 insertions(+), 229 deletions(-) delete mode 100644 features/send/api/src/main/java/com/tangem/features/send/api/callbacks/FeeSelectorModelCallback.kt rename features/send/api/src/main/java/com/tangem/features/send/api/{ => subcomponents/feeSelector}/FeeSelectorBlockComponent.kt (68%) rename features/send/api/src/main/java/com/tangem/features/send/api/{ => subcomponents/feeSelector}/FeeSelectorComponent.kt (73%) create mode 100644 features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/feeSelector/callbacks/FeeSelectorModelCallback.kt rename features/send/api/src/main/java/com/tangem/features/send/api/{ => subcomponents/feeSelector}/entity/CustomFeeFieldUM.kt (89%) rename features/send/api/src/main/java/com/tangem/features/send/api/{ => subcomponents/feeSelector}/entity/FeeSelectorUM.kt (98%) rename features/send/api/src/main/java/com/tangem/features/send/api/{ => subcomponents/feeSelector}/params/FeeSelectorParams.kt (93%) rename features/send/api/src/main/java/com/tangem/features/send/api/{ => subcomponents/notifications}/SendNotificationsComponent.kt (96%) diff --git a/features/approval/impl/src/main/java/com/tangem/features/approval/impl/DefaultGiveApprovalComponent.kt b/features/approval/impl/src/main/java/com/tangem/features/approval/impl/DefaultGiveApprovalComponent.kt index 03e2bf29c2..9e42edab58 100644 --- a/features/approval/impl/src/main/java/com/tangem/features/approval/impl/DefaultGiveApprovalComponent.kt +++ b/features/approval/impl/src/main/java/com/tangem/features/approval/impl/DefaultGiveApprovalComponent.kt @@ -17,10 +17,10 @@ import com.tangem.core.ui.res.TangemTheme import com.tangem.features.approval.api.GiveApprovalComponent import com.tangem.features.approval.impl.model.GiveApprovalModel import com.tangem.features.approval.impl.ui.GiveApprovalContent -import com.tangem.features.send.api.FeeSelectorBlockComponent +import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockComponent import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents -import com.tangem.features.send.api.entity.FeeSelectorUM -import com.tangem.features.send.api.params.FeeSelectorParams +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject diff --git a/features/approval/impl/src/main/java/com/tangem/features/approval/impl/model/GiveApprovalModel.kt b/features/approval/impl/src/main/java/com/tangem/features/approval/impl/model/GiveApprovalModel.kt index ceabc3db34..e5a8cf2341 100644 --- a/features/approval/impl/src/main/java/com/tangem/features/approval/impl/model/GiveApprovalModel.kt +++ b/features/approval/impl/src/main/java/com/tangem/features/approval/impl/model/GiveApprovalModel.kt @@ -33,9 +33,9 @@ import com.tangem.domain.transaction.usecase.gasless.GetFeeForGaslessUseCase import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.approval.api.GiveApprovalComponent -import com.tangem.features.send.api.callbacks.FeeSelectorModelCallback -import com.tangem.features.send.api.entity.FeeItem -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.callbacks.FeeSelectorModelCallback +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.logging.TangemLogger diff --git a/features/approval/impl/src/main/java/com/tangem/features/approval/impl/ui/GiveApprovalContent.kt b/features/approval/impl/src/main/java/com/tangem/features/approval/impl/ui/GiveApprovalContent.kt index 5d6e9c3384..f10531e8f7 100644 --- a/features/approval/impl/src/main/java/com/tangem/features/approval/impl/ui/GiveApprovalContent.kt +++ b/features/approval/impl/src/main/java/com/tangem/features/approval/impl/ui/GiveApprovalContent.kt @@ -34,7 +34,7 @@ import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview import com.tangem.features.approval.impl.model.GiveApprovalUM -import com.tangem.features.send.api.FeeSelectorBlockComponent +import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockComponent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf diff --git a/features/approval/impl/src/main/java/com/tangem/features/approval/impl/ui/PreviewFeeSelectorBlockComponent.kt b/features/approval/impl/src/main/java/com/tangem/features/approval/impl/ui/PreviewFeeSelectorBlockComponent.kt index 7d4fd8af04..3aabef7629 100644 --- a/features/approval/impl/src/main/java/com/tangem/features/approval/impl/ui/PreviewFeeSelectorBlockComponent.kt +++ b/features/approval/impl/src/main/java/com/tangem/features/approval/impl/ui/PreviewFeeSelectorBlockComponent.kt @@ -2,8 +2,8 @@ package com.tangem.features.approval.impl.ui import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import com.tangem.features.send.api.FeeSelectorBlockComponent -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockComponent +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM internal class PreviewFeeSelectorBlockComponent : FeeSelectorBlockComponent { override fun updateState(feeSelectorUM: FeeSelectorUM) { diff --git a/features/send/api/src/main/java/com/tangem/features/send/api/callbacks/FeeSelectorModelCallback.kt b/features/send/api/src/main/java/com/tangem/features/send/api/callbacks/FeeSelectorModelCallback.kt deleted file mode 100644 index 429b0bf0e4..0000000000 --- a/features/send/api/src/main/java/com/tangem/features/send/api/callbacks/FeeSelectorModelCallback.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.tangem.features.send.api.callbacks - -import com.tangem.features.send.api.entity.FeeSelectorUM - -interface FeeSelectorModelCallback { - fun onFeeResult(feeSelectorUM: FeeSelectorUM) -} \ No newline at end of file diff --git a/features/send/api/src/main/java/com/tangem/features/send/api/FeeSelectorBlockComponent.kt b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/feeSelector/FeeSelectorBlockComponent.kt similarity index 68% rename from features/send/api/src/main/java/com/tangem/features/send/api/FeeSelectorBlockComponent.kt rename to features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/feeSelector/FeeSelectorBlockComponent.kt index ba927c8159..c4591a5fd2 100644 --- a/features/send/api/src/main/java/com/tangem/features/send/api/FeeSelectorBlockComponent.kt +++ b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/feeSelector/FeeSelectorBlockComponent.kt @@ -1,9 +1,9 @@ -package com.tangem.features.send.api +package com.tangem.features.send.api.subcomponents.feeSelector import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.features.send.api.entity.FeeSelectorUM -import com.tangem.features.send.api.params.FeeSelectorParams +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams interface FeeSelectorBlockComponent : ComposableContentComponent { diff --git a/features/send/api/src/main/java/com/tangem/features/send/api/FeeSelectorComponent.kt b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/feeSelector/FeeSelectorComponent.kt similarity index 73% rename from features/send/api/src/main/java/com/tangem/features/send/api/FeeSelectorComponent.kt rename to features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/feeSelector/FeeSelectorComponent.kt index ec13f68cb9..edd11d22c5 100644 --- a/features/send/api/src/main/java/com/tangem/features/send/api/FeeSelectorComponent.kt +++ b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/feeSelector/FeeSelectorComponent.kt @@ -1,8 +1,8 @@ -package com.tangem.features.send.api +package com.tangem.features.send.api.subcomponents.feeSelector import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.ui.decompose.ComposableBottomSheetComponent -import com.tangem.features.send.api.params.FeeSelectorParams +import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams interface FeeSelectorComponent : ComposableBottomSheetComponent { interface Factory { diff --git a/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/feeSelector/callbacks/FeeSelectorModelCallback.kt b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/feeSelector/callbacks/FeeSelectorModelCallback.kt new file mode 100644 index 0000000000..894bc190eb --- /dev/null +++ b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/feeSelector/callbacks/FeeSelectorModelCallback.kt @@ -0,0 +1,7 @@ +package com.tangem.features.send.api.subcomponents.feeSelector.callbacks + +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM + +interface FeeSelectorModelCallback { + fun onFeeResult(feeSelectorUM: FeeSelectorUM) +} \ No newline at end of file diff --git a/features/send/api/src/main/java/com/tangem/features/send/api/entity/CustomFeeFieldUM.kt b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/feeSelector/entity/CustomFeeFieldUM.kt similarity index 89% rename from features/send/api/src/main/java/com/tangem/features/send/api/entity/CustomFeeFieldUM.kt rename to features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/feeSelector/entity/CustomFeeFieldUM.kt index 7fbc332f08..7929b04fee 100644 --- a/features/send/api/src/main/java/com/tangem/features/send/api/entity/CustomFeeFieldUM.kt +++ b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/feeSelector/entity/CustomFeeFieldUM.kt @@ -1,4 +1,4 @@ -package com.tangem.features.send.api.entity +package com.tangem.features.send.api.subcomponents.feeSelector.entity import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions diff --git a/features/send/api/src/main/java/com/tangem/features/send/api/entity/FeeSelectorUM.kt b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/feeSelector/entity/FeeSelectorUM.kt similarity index 98% rename from features/send/api/src/main/java/com/tangem/features/send/api/entity/FeeSelectorUM.kt rename to features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/feeSelector/entity/FeeSelectorUM.kt index dbbdb74b2a..e370e2bd8a 100644 --- a/features/send/api/src/main/java/com/tangem/features/send/api/entity/FeeSelectorUM.kt +++ b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/feeSelector/entity/FeeSelectorUM.kt @@ -1,4 +1,4 @@ -package com.tangem.features.send.api.entity +package com.tangem.features.send.api.subcomponents.feeSelector.entity import androidx.compose.runtime.Immutable import com.tangem.blockchain.common.Amount diff --git a/features/send/api/src/main/java/com/tangem/features/send/api/params/FeeSelectorParams.kt b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/feeSelector/params/FeeSelectorParams.kt similarity index 93% rename from features/send/api/src/main/java/com/tangem/features/send/api/params/FeeSelectorParams.kt rename to features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/feeSelector/params/FeeSelectorParams.kt index 138dcbbf64..39bec2048a 100644 --- a/features/send/api/src/main/java/com/tangem/features/send/api/params/FeeSelectorParams.kt +++ b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/feeSelector/params/FeeSelectorParams.kt @@ -1,4 +1,4 @@ -package com.tangem.features.send.api.params +package com.tangem.features.send.api.subcomponents.feeSelector.params import arrow.core.Either import com.tangem.blockchain.common.transaction.Fee @@ -9,8 +9,8 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents -import com.tangem.features.send.api.callbacks.FeeSelectorModelCallback -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.callbacks.FeeSelectorModelCallback +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM sealed class FeeSelectorParams { abstract val state: FeeSelectorUM diff --git a/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/feeSelector/utils/FeeCalculationUtils.kt b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/feeSelector/utils/FeeCalculationUtils.kt index 0890d36b63..65835565d1 100644 --- a/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/feeSelector/utils/FeeCalculationUtils.kt +++ b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/feeSelector/utils/FeeCalculationUtils.kt @@ -4,8 +4,8 @@ import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.core.ui.utils.parseToBigDecimal import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.features.send.api.entity.FeeItem -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.utils.extensions.isZero import java.math.BigDecimal import java.math.RoundingMode diff --git a/features/send/api/src/main/java/com/tangem/features/send/api/SendNotificationsComponent.kt b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/notifications/SendNotificationsComponent.kt similarity index 96% rename from features/send/api/src/main/java/com/tangem/features/send/api/SendNotificationsComponent.kt rename to features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/notifications/SendNotificationsComponent.kt index 1a468d979c..157fdbdb0f 100644 --- a/features/send/api/src/main/java/com/tangem/features/send/api/SendNotificationsComponent.kt +++ b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/notifications/SendNotificationsComponent.kt @@ -1,4 +1,4 @@ -package com.tangem.features.send.api +package com.tangem.features.send.api.subcomponents.notifications import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.ui.Modifier diff --git a/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/notifications/SendNotificationsUpdateListener.kt b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/notifications/SendNotificationsUpdateListener.kt index 74354016a5..b0099072c3 100644 --- a/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/notifications/SendNotificationsUpdateListener.kt +++ b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/notifications/SendNotificationsUpdateListener.kt @@ -1,6 +1,5 @@ package com.tangem.features.send.api.subcomponents.notifications -import com.tangem.features.send.api.SendNotificationsComponent import kotlinx.coroutines.flow.Flow interface SendNotificationsUpdateListener { diff --git a/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/notifications/SendNotificationsUpdateTrigger.kt b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/notifications/SendNotificationsUpdateTrigger.kt index aad417f873..f6e8e4a273 100644 --- a/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/notifications/SendNotificationsUpdateTrigger.kt +++ b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/notifications/SendNotificationsUpdateTrigger.kt @@ -1,7 +1,5 @@ package com.tangem.features.send.api.subcomponents.notifications -import com.tangem.features.send.api.SendNotificationsComponent - interface SendNotificationsUpdateTrigger { /** Trigger return callback with check result */ suspend fun callbackHasError(hasError: Boolean) diff --git a/features/send/impl/src/main/java/com/tangem/features/send/common/ui/FeeBlockSuccess.kt b/features/send/impl/src/main/java/com/tangem/features/send/common/ui/FeeBlockSuccess.kt index 4bd2d8d965..9e1dd361d2 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/common/ui/FeeBlockSuccess.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/common/ui/FeeBlockSuccess.kt @@ -15,7 +15,7 @@ import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.fee import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.res.TangemTheme -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.impl.R @Composable diff --git a/features/send/impl/src/main/java/com/tangem/features/send/di/SendFeatureModule.kt b/features/send/impl/src/main/java/com/tangem/features/send/di/SendFeatureModule.kt index e3dd12db2f..7efcca40ff 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/di/SendFeatureModule.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/di/SendFeatureModule.kt @@ -5,7 +5,7 @@ import com.tangem.features.send.api.NFTSendComponent import com.tangem.features.send.api.SendComponent import com.tangem.features.send.api.SendEntryPointComponent import com.tangem.features.send.api.SendFeatureToggles -import com.tangem.features.send.api.SendNotificationsComponent +import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent import com.tangem.features.send.entrypoint.DefaultSendEntryPointComponent import com.tangem.features.send.send.DefaultSendComponent import com.tangem.features.send.sendnft.DefaultNFTSendComponent diff --git a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/DefaultFeeSelectorBlockComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/DefaultFeeSelectorBlockComponent.kt index 7c659cabe7..0d36c2717f 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/DefaultFeeSelectorBlockComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/DefaultFeeSelectorBlockComponent.kt @@ -12,10 +12,10 @@ import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.extensions.conditional -import com.tangem.features.send.api.FeeSelectorBlockComponent -import com.tangem.features.send.api.FeeSelectorComponent -import com.tangem.features.send.api.entity.FeeSelectorUM -import com.tangem.features.send.api.params.FeeSelectorParams +import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockComponent +import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorComponent +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams import com.tangem.features.send.feeselector.model.FeeSelectorBlockModel import com.tangem.features.send.feeselector.ui.FeeSelectorBlockContent import com.tangem.utils.extensions.isSingleItem diff --git a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/DefaultFeeSelectorComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/DefaultFeeSelectorComponent.kt index 83f48ddc42..b7da023cbe 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/DefaultFeeSelectorComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/DefaultFeeSelectorComponent.kt @@ -12,8 +12,8 @@ import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.decompose.navigation.inner.InnerRouter import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.features.send.api.FeeSelectorComponent -import com.tangem.features.send.api.params.FeeSelectorParams +import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorComponent +import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams import com.tangem.features.send.feeselector.component.FeeSelectorComponentParams import com.tangem.features.send.feeselector.component.extended.FeeExtendedSelectorComponent import com.tangem.features.send.feeselector.component.speed.FeeSpeedSelectorComponent diff --git a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/component/FeeSelectorComponentParams.kt b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/component/FeeSelectorComponentParams.kt index c3d08ae74a..0ea1d30cc8 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/component/FeeSelectorComponentParams.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/component/FeeSelectorComponentParams.kt @@ -1,7 +1,7 @@ package com.tangem.features.send.feeselector.component -import com.tangem.features.send.api.entity.FeeSelectorUM -import com.tangem.features.send.api.params.FeeSelectorParams +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams import com.tangem.features.send.feeselector.model.FeeSelectorIntents import kotlinx.coroutines.flow.MutableStateFlow diff --git a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/component/extended/entity/FeeExtendedSelectorUM.kt b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/component/extended/entity/FeeExtendedSelectorUM.kt index b9d3ec3795..61522ca99a 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/component/extended/entity/FeeExtendedSelectorUM.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/component/extended/entity/FeeExtendedSelectorUM.kt @@ -2,8 +2,8 @@ package com.tangem.features.send.feeselector.component.extended.entity import androidx.compose.runtime.Immutable import com.tangem.core.ui.components.token.state.TokenItemState -import com.tangem.features.send.api.entity.FeeItem -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM @Immutable data class FeeExtendedSelectorUM( diff --git a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/component/extended/model/FeeExtendedSelectorModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/component/extended/model/FeeExtendedSelectorModel.kt index 65a0fb932e..f561191964 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/component/extended/model/FeeExtendedSelectorModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/component/extended/model/FeeExtendedSelectorModel.kt @@ -8,7 +8,7 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.feeselector.component.FeeSelectorComponentParams import com.tangem.features.send.feeselector.component.extended.entity.FeeExtendedSelectorUM import com.tangem.features.send.feeselector.route.FeeSelectorRoute diff --git a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/component/extended/ui/FeeExtendedSelectorContent.kt b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/component/extended/ui/FeeExtendedSelectorContent.kt index a9c2c36ea4..7ffb08ae15 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/component/extended/ui/FeeExtendedSelectorContent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/component/extended/ui/FeeExtendedSelectorContent.kt @@ -33,11 +33,11 @@ import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network -import com.tangem.features.send.api.entity.FeeExtraInfo -import com.tangem.features.send.api.entity.FeeFiatRateUM -import com.tangem.features.send.api.entity.FeeItem -import com.tangem.features.send.api.entity.FeeNonce -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeExtraInfo +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeFiatRateUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeNonce +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.feeselector.component.extended.entity.FeeExtendedSelectorUM import com.tangem.features.send.feeselector.component.speed.ui.RegularFeeItemContent import kotlinx.collections.immutable.persistentListOf diff --git a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/component/speed/FeeSpeedSelectorComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/component/speed/FeeSpeedSelectorComponent.kt index 92f17a436f..114f1e6a30 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/component/speed/FeeSpeedSelectorComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/component/speed/FeeSpeedSelectorComponent.kt @@ -7,7 +7,7 @@ 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.ComposableContentComponent -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.feeselector.component.FeeSelectorComponentParams import com.tangem.features.send.feeselector.component.speed.model.FeeSpeedSelectorModel import com.tangem.features.send.feeselector.component.speed.ui.FeeSpeedSelectorContent diff --git a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/component/speed/model/FeeSpeedSelectorModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/component/speed/model/FeeSpeedSelectorModel.kt index cf1d5205d9..886bc2f9fe 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/component/speed/model/FeeSpeedSelectorModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/component/speed/model/FeeSpeedSelectorModel.kt @@ -6,7 +6,7 @@ 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.navigation.url.UrlOpener -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.feeselector.component.FeeSelectorComponentParams import com.tangem.features.send.feeselector.component.speed.FeeSpeedSelectorIntents import com.tangem.features.send.feeselector.model.FeeSelectorIntents diff --git a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/component/speed/ui/FeeSpeedSelectorContent.kt b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/component/speed/ui/FeeSpeedSelectorContent.kt index acf86c17b2..a83404da68 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/component/speed/ui/FeeSpeedSelectorContent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/component/speed/ui/FeeSpeedSelectorContent.kt @@ -54,12 +54,12 @@ import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network -import com.tangem.features.send.api.entity.CustomFeeFieldUM -import com.tangem.features.send.api.entity.FeeExtraInfo -import com.tangem.features.send.api.entity.FeeFiatRateUM -import com.tangem.features.send.api.entity.FeeItem -import com.tangem.features.send.api.entity.FeeNonce -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.CustomFeeFieldUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeExtraInfo +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeFiatRateUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeNonce +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.feeselector.component.speed.FeeSpeedSelectorIntents import com.tangem.features.send.feeselector.component.speed.StubFeeSpeedSelectorIntents import com.tangem.features.send.impl.R diff --git a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/component/token/entity/FeeTokenSelectorUM.kt b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/component/token/entity/FeeTokenSelectorUM.kt index eda8e96501..08ce22781e 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/component/token/entity/FeeTokenSelectorUM.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/component/token/entity/FeeTokenSelectorUM.kt @@ -1,6 +1,6 @@ package com.tangem.features.send.feeselector.component.token.entity -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import kotlinx.collections.immutable.ImmutableList internal data class FeeTokenSelectorUM( diff --git a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/component/token/model/FeeTokenSelectorModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/component/token/model/FeeTokenSelectorModel.kt index 74412b11e3..e89e0ceeff 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/component/token/model/FeeTokenSelectorModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/component/token/model/FeeTokenSelectorModel.kt @@ -10,7 +10,7 @@ import com.tangem.core.navigation.url.UrlOpener import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.feeselector.component.FeeSelectorComponentParams import com.tangem.features.send.feeselector.component.token.FeeTokenSelectorIntents import com.tangem.features.send.feeselector.component.token.entity.FeeTokenItemState diff --git a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/component/token/ui/FeeTokenSelectorContent.kt b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/component/token/ui/FeeTokenSelectorContent.kt index ea55af5078..d037607d2c 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/component/token/ui/FeeTokenSelectorContent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/component/token/ui/FeeTokenSelectorContent.kt @@ -36,11 +36,11 @@ import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network -import com.tangem.features.send.api.entity.FeeExtraInfo -import com.tangem.features.send.api.entity.FeeFiatRateUM -import com.tangem.features.send.api.entity.FeeItem -import com.tangem.features.send.api.entity.FeeNonce -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeExtraInfo +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeFiatRateUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeNonce +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.feeselector.component.token.FeeTokenSelectorIntents import com.tangem.features.send.feeselector.component.token.StubFeeTokenSelectorIntents import com.tangem.features.send.feeselector.component.token.entity.FeeTokenItemState diff --git a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/di/FeeSelectorFeatureModule.kt b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/di/FeeSelectorFeatureModule.kt index dbe05e5489..46f9b441e9 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/di/FeeSelectorFeatureModule.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/di/FeeSelectorFeatureModule.kt @@ -1,7 +1,7 @@ package com.tangem.features.send.feeselector.di -import com.tangem.features.send.api.FeeSelectorBlockComponent -import com.tangem.features.send.api.FeeSelectorComponent +import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockComponent +import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorComponent import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorCheckReloadListener import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorCheckReloadTrigger import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadListener diff --git a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/FeeSelectorAlertFactory.kt b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/FeeSelectorAlertFactory.kt index 3f4a77ef8f..f3d172f9c8 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/FeeSelectorAlertFactory.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/FeeSelectorAlertFactory.kt @@ -7,8 +7,8 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.message.DialogMessage import com.tangem.core.ui.message.EventMessageAction -import com.tangem.features.send.api.entity.FeeItem -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.api.subcomponents.feeSelector.utils.FeeCalculationUtils import com.tangem.features.send.impl.R import java.math.BigDecimal diff --git a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/FeeSelectorBlockModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/FeeSelectorBlockModel.kt index 4a6cd83145..207cdc1138 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/FeeSelectorBlockModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/FeeSelectorBlockModel.kt @@ -13,9 +13,9 @@ import com.tangem.core.navigation.url.UrlOpener import com.tangem.domain.settings.NeverShowTapHelpUseCase import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents.SendScreenSource -import com.tangem.features.send.api.callbacks.FeeSelectorModelCallback -import com.tangem.features.send.api.entity.FeeSelectorUM -import com.tangem.features.send.api.params.FeeSelectorParams +import com.tangem.features.send.api.subcomponents.feeSelector.callbacks.FeeSelectorModelCallback +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch diff --git a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/FeeSelectorIntents.kt b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/FeeSelectorIntents.kt index 91b38c7ea4..951d7ed573 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/FeeSelectorIntents.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/FeeSelectorIntents.kt @@ -2,7 +2,7 @@ package com.tangem.features.send.feeselector.model import androidx.compose.runtime.Stable import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.features.send.api.entity.FeeItem +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem @Stable internal interface FeeSelectorIntents { diff --git a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/FeeSelectorLogic.kt b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/FeeSelectorLogic.kt index b7f6258ee8..2f91b062fb 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/FeeSelectorLogic.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/FeeSelectorLogic.kt @@ -21,10 +21,10 @@ import com.tangem.domain.transaction.usecase.gasless.GetAvailableFeeTokensUseCas import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents.NonceInserted -import com.tangem.features.send.api.entity.FeeItem -import com.tangem.features.send.api.entity.FeeNonce -import com.tangem.features.send.api.entity.FeeSelectorUM -import com.tangem.features.send.api.params.FeeSelectorParams +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeNonce +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorCheckReloadListener import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorCheckReloadTrigger import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadListener diff --git a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/FeeSelectorModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/FeeSelectorModel.kt index 548b0d7cb2..a5e24c65b7 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/FeeSelectorModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/FeeSelectorModel.kt @@ -10,9 +10,9 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.utils.stack import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents -import com.tangem.features.send.api.entity.FeeItem -import com.tangem.features.send.api.entity.FeeSelectorUM -import com.tangem.features.send.api.params.FeeSelectorParams +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams import com.tangem.features.send.feeselector.route.FeeSelectorRoute import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.extensions.isSingleItem diff --git a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/transformers/FeeItemConverter.kt b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/transformers/FeeItemConverter.kt index c448124c08..0643b62c3c 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/transformers/FeeItemConverter.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/transformers/FeeItemConverter.kt @@ -4,8 +4,8 @@ import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.features.send.api.entity.FeeItem -import com.tangem.features.send.api.params.FeeSelectorParams +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem +import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams import com.tangem.features.send.feeselector.model.FeeSelectorIntents import com.tangem.utils.converter.Converter import kotlinx.collections.immutable.ImmutableList diff --git a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/transformers/FeeItemSelectedTransformer.kt b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/transformers/FeeItemSelectedTransformer.kt index ae47af1087..6d13550602 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/transformers/FeeItemSelectedTransformer.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/transformers/FeeItemSelectedTransformer.kt @@ -1,7 +1,7 @@ package com.tangem.features.send.feeselector.model.transformers -import com.tangem.features.send.api.entity.FeeItem -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.utils.transformer.Transformer internal class FeeItemSelectedTransformer(private val selectedFeeItem: FeeItem) : Transformer { diff --git a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorCustomFieldConverter.kt b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorCustomFieldConverter.kt index ffbbeb9d55..28946cd5da 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorCustomFieldConverter.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorCustomFieldConverter.kt @@ -5,8 +5,8 @@ import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.core.ui.utils.parseToBigDecimal import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.features.send.api.entity.CustomFeeFieldUM -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.CustomFeeFieldUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.feeselector.model.FeeSelectorIntents import com.tangem.features.send.subcomponents.fee.model.converters.custom.bitcoin.BitcoinCustomFeeConverter import com.tangem.features.send.subcomponents.fee.model.converters.custom.ethereum.EthereumCustomFeeConverter diff --git a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorCustomValueChangedTransformer.kt b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorCustomValueChangedTransformer.kt index 847e634471..d3efc25f19 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorCustomValueChangedTransformer.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorCustomValueChangedTransformer.kt @@ -3,8 +3,8 @@ package com.tangem.features.send.feeselector.model.transformers import com.tangem.core.ui.utils.parseToBigDecimal import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.features.send.api.entity.FeeItem -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.feeselector.model.FeeSelectorIntents import com.tangem.utils.extensions.isZero import com.tangem.utils.transformer.Transformer diff --git a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorErrorTransformer.kt b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorErrorTransformer.kt index 9b6d41ac15..b6d5f6ad11 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorErrorTransformer.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorErrorTransformer.kt @@ -1,7 +1,7 @@ package com.tangem.features.send.feeselector.model.transformers import com.tangem.domain.transaction.error.GetFeeError -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.utils.transformer.Transformer internal class FeeSelectorErrorTransformer(private val error: GetFeeError) : Transformer { diff --git a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorLoadedTransformer.kt b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorLoadedTransformer.kt index f1b4d6bdf2..691ee4e80e 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorLoadedTransformer.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorLoadedTransformer.kt @@ -4,12 +4,12 @@ import com.tangem.blockchain.common.transaction.Fee import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.features.send.api.entity.FeeExtraInfo -import com.tangem.features.send.api.entity.FeeFiatRateUM -import com.tangem.features.send.api.entity.FeeItem -import com.tangem.features.send.api.entity.FeeNonce -import com.tangem.features.send.api.entity.FeeSelectorUM -import com.tangem.features.send.api.params.FeeSelectorParams +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeExtraInfo +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeFiatRateUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeNonce +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams import com.tangem.features.send.feeselector.model.FeeSelectorIntents import com.tangem.features.send.feeselector.model.FeeSelectorLogic import com.tangem.lib.crypto.BlockchainUtils.isTron diff --git a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorLoadingTransformer.kt b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorLoadingTransformer.kt index 9ae1f81ee4..41b5cdcdea 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorLoadingTransformer.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorLoadingTransformer.kt @@ -1,6 +1,6 @@ package com.tangem.features.send.feeselector.model.transformers -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.utils.transformer.Transformer internal object FeeSelectorLoadingTransformer : Transformer { diff --git a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorNonceChangeTransformer.kt b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorNonceChangeTransformer.kt index 5225d3abaf..75081f5f55 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorNonceChangeTransformer.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorNonceChangeTransformer.kt @@ -1,7 +1,7 @@ package com.tangem.features.send.feeselector.model.transformers -import com.tangem.features.send.api.entity.FeeNonce -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeNonce +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.utils.transformer.Transformer internal class FeeSelectorNonceChangeTransformer( diff --git a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorRemoveSuggestedTransformer.kt b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorRemoveSuggestedTransformer.kt index 7c6cae3fd0..530acc9494 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorRemoveSuggestedTransformer.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorRemoveSuggestedTransformer.kt @@ -1,7 +1,7 @@ package com.tangem.features.send.feeselector.model.transformers -import com.tangem.features.send.api.entity.FeeItem -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.utils.transformer.Transformer import kotlinx.collections.immutable.toImmutableList diff --git a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorTokenSelectedTransformer.kt b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorTokenSelectedTransformer.kt index e732f6629c..56bef3a944 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorTokenSelectedTransformer.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorTokenSelectedTransformer.kt @@ -1,8 +1,8 @@ package com.tangem.features.send.feeselector.model.transformers import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.features.send.api.entity.FeeItem -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.utils.transformer.Transformer import kotlinx.collections.immutable.persistentListOf diff --git a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/ui/FeeSelectorBlockContent.kt b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/ui/FeeSelectorBlockContent.kt index 2ea1b4e78d..ccec69fca1 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/ui/FeeSelectorBlockContent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/ui/FeeSelectorBlockContent.kt @@ -46,11 +46,11 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network import com.tangem.domain.transaction.error.GetFeeError -import com.tangem.features.send.api.entity.FeeExtraInfo -import com.tangem.features.send.api.entity.FeeFiatRateUM -import com.tangem.features.send.api.entity.FeeItem -import com.tangem.features.send.api.entity.FeeNonce -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeExtraInfo +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeFiatRateUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeNonce +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.impl.R import com.tangem.utils.extensions.isSingleItem import kotlinx.collections.immutable.persistentListOf diff --git a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/ui/FeeSelectorModalBottomSheet.kt b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/ui/FeeSelectorModalBottomSheet.kt index 7a710a0ca9..2e867817c8 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/feeselector/ui/FeeSelectorModalBottomSheet.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/feeselector/ui/FeeSelectorModalBottomSheet.kt @@ -20,9 +20,9 @@ import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetWi import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme -import com.tangem.features.send.api.entity.FeeItem -import com.tangem.features.send.api.entity.FeeSelectorUM -import com.tangem.features.send.api.params.FeeSelectorParams +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams import com.tangem.features.send.feeselector.model.FeeSelectorIntents import com.tangem.features.send.feeselector.route.FeeSelectorRoute import com.tangem.features.send.impl.R diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/DefaultSendComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/DefaultSendComponent.kt index cda575b53c..398b98b780 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/DefaultSendComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/DefaultSendComponent.kt @@ -28,12 +28,12 @@ import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.models.account.derivationIndex -import com.tangem.features.send.api.FeeSelectorBlockComponent import com.tangem.features.send.api.SendComponent import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponent import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM +import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockComponent import com.tangem.features.send.common.CommonSendRoute import com.tangem.features.send.common.ui.SendContent import com.tangem.features.send.common.ui.state.ConfirmUM diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/analytics/SendAnalyticHelper.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/analytics/SendAnalyticHelper.kt index 051fada063..6fbdf1342d 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/analytics/SendAnalyticHelper.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/analytics/SendAnalyticHelper.kt @@ -8,8 +8,8 @@ import com.tangem.domain.account.status.usecase.GetAccountCurrencyByAddressUseCa import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.derivationIndex import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.features.send.api.entity.FeeNonce -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeNonce +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.api.subcomponents.destination.entity.DestinationTextFieldUM import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM import com.tangem.features.send.send.ui.state.SendUM diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/SendConfirmComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/SendConfirmComponent.kt index 0b2def8145..24b5e89ce0 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/SendConfirmComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/SendConfirmComponent.kt @@ -16,13 +16,12 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.models.TransactionFeeExtended -import com.tangem.features.send.api.FeeSelectorBlockComponent -import com.tangem.features.send.api.SendNotificationsComponent -import com.tangem.features.send.api.SendNotificationsComponent.Params.NotificationData import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.entity.PredefinedValues -import com.tangem.features.send.api.params.FeeSelectorParams import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams.DestinationBlockParams +import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockComponent +import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams +import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent import com.tangem.features.send.common.CommonSendRoute import com.tangem.features.send.common.ui.state.ConfirmUM import com.tangem.features.send.send.confirm.model.SendConfirmModel @@ -107,7 +106,7 @@ internal class SendConfirmComponent( cryptoCurrencyStatus = params.cryptoCurrencyStatus, appCurrency = params.appCurrency, callback = model, - notificationData = NotificationData( + notificationData = SendNotificationsComponent.Params.NotificationData( destinationAddress = model.confirmData.enteredDestination.orEmpty(), memo = model.confirmData.enteredMemo, amountValue = model.confirmData.enteredAmount.orZero(), diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/SendConfirmModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/SendConfirmModel.kt index 90b2c60464..2eded657e2 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/SendConfirmModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/SendConfirmModel.kt @@ -43,24 +43,25 @@ import com.tangem.domain.transaction.usecase.SendTransactionUseCase import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.domain.utils.convertToSdkAmount -import com.tangem.features.send.api.SendNotificationsComponent -import com.tangem.features.send.api.SendNotificationsComponent.Params.NotificationData import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents.SendScreenSource -import com.tangem.features.send.api.callbacks.FeeSelectorModelCallback -import com.tangem.features.send.api.entity.FeeNonce -import com.tangem.features.send.api.params.FeeSelectorParams.FeeStateConfiguration import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorCheckReloadListener import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorCheckReloadTrigger import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger +import com.tangem.features.send.api.subcomponents.feeSelector.callbacks.FeeSelectorModelCallback +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeNonce +import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams.FeeStateConfiguration import com.tangem.features.send.api.subcomponents.feeSelector.utils.FeeCalculationUtils.checkAndCalculateSubtractedAmount +import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent +import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent.Params.NotificationData import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateListener import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateTrigger import com.tangem.features.send.common.CommonSendRoute import com.tangem.features.send.common.SendBalanceUpdater import com.tangem.features.send.common.SendConfirmAlertFactory import com.tangem.features.send.common.ui.state.ConfirmUM +import com.tangem.features.send.impl.R import com.tangem.features.send.send.analytics.SendAnalyticHelper import com.tangem.features.send.send.confirm.SendConfirmComponent import com.tangem.features.send.send.confirm.model.transformers.SendConfirmInitialStateTransformer @@ -69,7 +70,6 @@ import com.tangem.features.send.send.confirm.model.transformers.SendConfirmSentS import com.tangem.features.send.send.confirm.model.transformers.SendConfirmationNotificationsTransformerV2 import com.tangem.features.send.send.ui.state.SendUM import com.tangem.features.send.subcomponents.amount.SendAmountReduceTrigger -import com.tangem.features.send.impl.R import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.extensions.orZero import com.tangem.utils.extensions.stripZeroPlainString @@ -79,7 +79,7 @@ import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import java.math.BigDecimal import javax.inject.Inject -import com.tangem.features.send.api.entity.FeeSelectorUM as FeeSelectorUMRedesigned +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM as FeeSelectorUMRedesigned @Suppress("LongParameterList", "LargeClass") @Stable diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2.kt index 141bcab743..f6e8b1f7e8 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2.kt @@ -13,7 +13,7 @@ import com.tangem.core.ui.format.bigdecimal.format import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.api.subcomponents.feeSelector.utils.FeeCalculationUtils import com.tangem.features.send.api.utils.formatFooterFiatFee import com.tangem.features.send.api.utils.getTronTokenFeeSendingText diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/ui/SendConfirmContent.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/ui/SendConfirmContent.kt index 2c36876bda..443ef0407d 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/ui/SendConfirmContent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/ui/SendConfirmContent.kt @@ -14,7 +14,7 @@ import androidx.compose.ui.unit.dp import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.res.TangemTheme -import com.tangem.features.send.api.FeeSelectorBlockComponent +import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockComponent import com.tangem.features.send.common.ui.state.ConfirmUM import com.tangem.features.send.common.ui.tapHelp import com.tangem.features.send.send.ui.state.SendUM diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/model/SendModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/model/SendModel.kt index 84f2df688a..c1b9d32985 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/model/SendModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/model/SendModel.kt @@ -46,11 +46,11 @@ import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.send.api.SendComponent import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents.SendScreenSource -import com.tangem.features.send.api.entity.FeeSelectorUM import com.tangem.features.send.api.entity.PredefinedValues import com.tangem.features.send.api.entity.isFromMainScreenQr import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponent import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.common.CommonSendRoute import com.tangem.features.send.common.SendConfirmAlertFactory import com.tangem.features.send.common.ui.state.ConfirmUM diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/ui/state/SendUM.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/ui/state/SendUM.kt index 4ae371cce7..0ebe64eceb 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/ui/state/SendUM.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/ui/state/SendUM.kt @@ -2,7 +2,7 @@ package com.tangem.features.send.send.ui.state import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.navigationButtons.NavigationUM -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM import com.tangem.features.send.common.ui.state.ConfirmUM import com.tangem.features.send.send.confirm.model.ConfirmData diff --git a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/analytics/NFTSendAnalyticHelper.kt b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/analytics/NFTSendAnalyticHelper.kt index a98e44d9b7..b9615cf967 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/analytics/NFTSendAnalyticHelper.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/analytics/NFTSendAnalyticHelper.kt @@ -6,8 +6,8 @@ import com.tangem.core.analytics.models.Basic import com.tangem.core.decompose.di.ModelScoped import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents.Companion.NFT_SEND_CATEGORY -import com.tangem.features.send.api.entity.FeeNonce -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeNonce +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.api.subcomponents.destination.entity.DestinationTextFieldUM import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM import com.tangem.features.send.sendnft.ui.state.NFTSendUM diff --git a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/NFTSendConfirmComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/NFTSendConfirmComponent.kt index df7d35b7b7..1093c06d6c 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/NFTSendConfirmComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/NFTSendConfirmComponent.kt @@ -19,12 +19,12 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.nft.models.NFTAsset import com.tangem.domain.transaction.error.GetFeeError import com.tangem.features.nft.component.NFTDetailsBlockComponent -import com.tangem.features.send.api.FeeSelectorBlockComponent -import com.tangem.features.send.api.SendNotificationsComponent +import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockComponent +import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.entity.PredefinedValues -import com.tangem.features.send.api.params.FeeSelectorParams -import com.tangem.features.send.api.params.FeeSelectorParams.FeeStateConfiguration +import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams +import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams.FeeStateConfiguration import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams.DestinationBlockParams import com.tangem.features.send.common.CommonSendRoute import com.tangem.features.send.common.ui.state.ConfirmUM diff --git a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/model/NFTSendConfirmModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/model/NFTSendConfirmModel.kt index cbb6353620..cec3676b65 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/model/NFTSendConfirmModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/model/NFTSendConfirmModel.kt @@ -32,11 +32,11 @@ import com.tangem.domain.transaction.usecase.CreateNFTTransferTransactionUseCase import com.tangem.domain.transaction.usecase.SendTransactionUseCase import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.features.nft.entity.NFTSendSuccessTrigger -import com.tangem.features.send.api.SendNotificationsComponent -import com.tangem.features.send.api.SendNotificationsComponent.Params.NotificationData +import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent +import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent.Params.NotificationData import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents.SendScreenSource -import com.tangem.features.send.api.callbacks.FeeSelectorModelCallback +import com.tangem.features.send.api.subcomponents.feeSelector.callbacks.FeeSelectorModelCallback import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorCheckReloadListener import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorCheckReloadTrigger @@ -62,7 +62,7 @@ import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import java.math.BigDecimal import javax.inject.Inject -import com.tangem.features.send.api.entity.FeeSelectorUM as FeeSelectorUMRedesigned +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM as FeeSelectorUMRedesigned @Suppress("LongParameterList", "LargeClass") @ModelScoped diff --git a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/model/transformers/NFTSendConfirmationNotificationsTransformerV2.kt b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/model/transformers/NFTSendConfirmationNotificationsTransformerV2.kt index 9a209b8edf..3586fee3f0 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/model/transformers/NFTSendConfirmationNotificationsTransformerV2.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/model/transformers/NFTSendConfirmationNotificationsTransformerV2.kt @@ -9,7 +9,7 @@ import com.tangem.core.ui.extensions.wrappedList import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.api.subcomponents.feeSelector.utils.FeeCalculationUtils import com.tangem.features.send.api.utils.formatFooterFiatFee import com.tangem.features.send.api.utils.getTronTokenFeeSendingText diff --git a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/ui/NFTSendConfirmContent.kt b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/ui/NFTSendConfirmContent.kt index 867d877dc6..be82f1c7e7 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/ui/NFTSendConfirmContent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/ui/NFTSendConfirmContent.kt @@ -13,7 +13,7 @@ import androidx.compose.ui.unit.dp import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.ui.res.TangemTheme import com.tangem.features.nft.component.NFTDetailsBlockComponent -import com.tangem.features.send.api.FeeSelectorBlockComponent +import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockComponent import com.tangem.features.send.common.ui.state.ConfirmUM import com.tangem.features.send.common.ui.tapHelp import com.tangem.features.send.sendnft.ui.state.NFTSendUM diff --git a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/model/NFTSendModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/model/NFTSendModel.kt index e4069a5e5e..46af42fff2 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/model/NFTSendModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/model/NFTSendModel.kt @@ -36,7 +36,7 @@ import com.tangem.domain.transaction.usecase.GetFeeUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.nft.entity.NFTSendSuccessTrigger import com.tangem.features.send.api.NFTSendComponent -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponent import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM import com.tangem.features.send.common.CommonSendRoute diff --git a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/ui/state/NFTSendUM.kt b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/ui/state/NFTSendUM.kt index e81c5a16b9..83701052e9 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/ui/state/NFTSendUM.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/ui/state/NFTSendUM.kt @@ -1,7 +1,7 @@ package com.tangem.features.send.sendnft.ui.state import com.tangem.common.ui.navigationButtons.NavigationUM -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM import com.tangem.features.send.common.ui.state.ConfirmUM diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/CustomFeeConverter.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/CustomFeeConverter.kt index 208f6c8958..a32ccd682a 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/CustomFeeConverter.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/CustomFeeConverter.kt @@ -1,7 +1,7 @@ package com.tangem.features.send.subcomponents.fee.model.converters.custom import com.tangem.blockchain.common.transaction.Fee -import com.tangem.features.send.api.entity.CustomFeeFieldUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.CustomFeeFieldUM import com.tangem.utils.converter.Converter import kotlinx.collections.immutable.ImmutableList diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/bitcoin/BitcoinCustomFeeConverter.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/bitcoin/BitcoinCustomFeeConverter.kt index b46f34254d..dff135db11 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/bitcoin/BitcoinCustomFeeConverter.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/bitcoin/BitcoinCustomFeeConverter.kt @@ -11,7 +11,7 @@ import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.core.ui.utils.parseToBigDecimal import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.features.send.api.entity.CustomFeeFieldUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.CustomFeeFieldUM import com.tangem.features.send.api.subcomponents.feeSelector.utils.FeeCalculationUtils.checkExceedBalance import com.tangem.features.send.subcomponents.fee.model.converters.custom.CustomFeeConverter import com.tangem.features.send.impl.R diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/ethereum/BaseEthereumCustomFeeConverter.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/ethereum/BaseEthereumCustomFeeConverter.kt index ad0a9617c8..895a54208b 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/ethereum/BaseEthereumCustomFeeConverter.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/ethereum/BaseEthereumCustomFeeConverter.kt @@ -2,7 +2,7 @@ package com.tangem.features.send.subcomponents.fee.model.converters.custom.ether import com.tangem.blockchain.common.transaction.Fee import com.tangem.features.send.subcomponents.fee.model.converters.custom.CustomFeeConverter -import com.tangem.features.send.api.entity.CustomFeeFieldUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.CustomFeeFieldUM import kotlinx.collections.immutable.ImmutableList /** diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/ethereum/EthereumCustomFeeConverter.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/ethereum/EthereumCustomFeeConverter.kt index d42363bd99..87e8df9782 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/ethereum/EthereumCustomFeeConverter.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/ethereum/EthereumCustomFeeConverter.kt @@ -10,7 +10,7 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.features.send.api.entity.CustomFeeFieldUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.CustomFeeFieldUM import com.tangem.features.send.api.subcomponents.feeSelector.utils.FeeCalculationUtils.checkExceedBalance import com.tangem.features.send.impl.R import kotlinx.collections.immutable.ImmutableList diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/ethereum/EthereumEIPCustomFeeConverter.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/ethereum/EthereumEIPCustomFeeConverter.kt index 975d2712e7..65e40f67c9 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/ethereum/EthereumEIPCustomFeeConverter.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/ethereum/EthereumEIPCustomFeeConverter.kt @@ -11,7 +11,7 @@ import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.core.ui.utils.parseToBigDecimal import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.features.send.api.entity.CustomFeeFieldUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.CustomFeeFieldUM import com.tangem.features.send.api.subcomponents.feeSelector.utils.FeeCalculationUtils.checkExceedBalance import com.tangem.features.send.subcomponents.fee.model.converters.custom.setEmpty import com.tangem.features.send.impl.R diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/ethereum/EthereumLegacyCustomFeeConverter.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/ethereum/EthereumLegacyCustomFeeConverter.kt index 54084639bc..2cea86bd55 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/ethereum/EthereumLegacyCustomFeeConverter.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/ethereum/EthereumLegacyCustomFeeConverter.kt @@ -11,7 +11,7 @@ import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.core.ui.utils.parseToBigDecimal import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.features.send.api.entity.CustomFeeFieldUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.CustomFeeFieldUM import com.tangem.features.send.api.subcomponents.feeSelector.utils.FeeCalculationUtils.checkExceedBalance import com.tangem.features.send.subcomponents.fee.model.converters.custom.setEmpty import com.tangem.features.send.impl.R diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/kaspa/KaspaCustomFeeConverter.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/kaspa/KaspaCustomFeeConverter.kt index 4edb9bf359..b671ead4a3 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/kaspa/KaspaCustomFeeConverter.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/kaspa/KaspaCustomFeeConverter.kt @@ -11,7 +11,7 @@ import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.core.ui.utils.parseToBigDecimal import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.features.send.api.entity.CustomFeeFieldUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.CustomFeeFieldUM import com.tangem.features.send.subcomponents.fee.model.converters.custom.CustomFeeConverter import com.tangem.features.send.impl.R import kotlinx.collections.immutable.ImmutableList diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/notifications/DefaultNotificationsUpdateTrigger.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/notifications/DefaultNotificationsUpdateTrigger.kt index 4173587eba..c0738aabc6 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/notifications/DefaultNotificationsUpdateTrigger.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/notifications/DefaultNotificationsUpdateTrigger.kt @@ -1,6 +1,6 @@ package com.tangem.features.send.subcomponents.notifications -import com.tangem.features.send.api.SendNotificationsComponent.Params.NotificationData +import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent.Params.NotificationData import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateListener import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateTrigger import kotlinx.coroutines.flow.MutableSharedFlow diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/notifications/DefaultSendNotificationsComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/notifications/DefaultSendNotificationsComponent.kt index f20fa251a3..7e15ddef4b 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/notifications/DefaultSendNotificationsComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/notifications/DefaultSendNotificationsComponent.kt @@ -5,8 +5,8 @@ import androidx.compose.ui.Modifier import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.features.send.api.SendNotificationsComponent -import com.tangem.features.send.api.SendNotificationsComponent.Params +import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent +import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent.Params import com.tangem.features.send.subcomponents.notifications import com.tangem.features.send.subcomponents.notifications.model.NotificationsModel import dagger.assisted.Assisted diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/notifications/model/NotificationsModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/notifications/model/NotificationsModel.kt index e831713c99..b54ae939af 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/notifications/model/NotificationsModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/notifications/model/NotificationsModel.kt @@ -35,8 +35,8 @@ import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck import com.tangem.domain.transaction.usecase.ValidateTransactionUseCase import com.tangem.domain.utils.convertToSdkAmount -import com.tangem.features.send.api.SendNotificationsComponent -import com.tangem.features.send.api.SendNotificationsComponent.Params.NotificationData +import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent +import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent.Params.NotificationData import com.tangem.features.send.api.subcomponents.feeSelector.utils.FeeCalculationUtils.checkAndCalculateSubtractedAmount import com.tangem.features.send.api.subcomponents.feeSelector.utils.FeeCalculationUtils.checkFeeCoverage import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateListener diff --git a/features/send/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/NFTSendConfirmationNotificationsTransformerV2Test.kt b/features/send/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/NFTSendConfirmationNotificationsTransformerV2Test.kt index 394a9dc9e1..3d27923356 100644 --- a/features/send/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/NFTSendConfirmationNotificationsTransformerV2Test.kt +++ b/features/send/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/NFTSendConfirmationNotificationsTransformerV2Test.kt @@ -10,12 +10,12 @@ import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network -import com.tangem.features.send.api.entity.CustomFeeFieldUM -import com.tangem.features.send.api.entity.FeeExtraInfo -import com.tangem.features.send.api.entity.FeeFiatRateUM -import com.tangem.features.send.api.entity.FeeItem -import com.tangem.features.send.api.entity.FeeNonce -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.CustomFeeFieldUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeExtraInfo +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeFiatRateUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeNonce +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.common.ui.state.ConfirmUM import com.tangem.features.send.sendnft.confirm.model.transformers.NFTSendConfirmationNotificationsTransformerV2 import io.mockk.mockk @@ -381,7 +381,7 @@ class NFTSendConfirmationNotificationsTransformerV2Test { isFeeApproximate = false, isFeeConvertibleToFiat = false, isTronToken = false, - feeCryptoCurrencyStatus = cryptoCurrencyStatus + feeCryptoCurrencyStatus = cryptoCurrencyStatus, ), feeFiatRateUM = FeeFiatRateUM( rate = BigDecimal("50000"), diff --git a/features/send/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2Test.kt b/features/send/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2Test.kt index 9694974592..05cd13af6c 100644 --- a/features/send/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2Test.kt +++ b/features/send/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2Test.kt @@ -12,14 +12,13 @@ import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network -import com.tangem.features.send.api.entity.CustomFeeFieldUM -import com.tangem.features.send.api.entity.FeeExtraInfo -import com.tangem.features.send.api.entity.FeeFiatRateUM -import com.tangem.features.send.api.entity.FeeItem -import com.tangem.features.send.api.entity.FeeNonce -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.CustomFeeFieldUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeExtraInfo +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeFiatRateUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeNonce +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.common.ui.state.ConfirmUM -import com.tangem.features.send.send.confirm.model.transformers.SendConfirmationNotificationsTransformerV2 import io.mockk.mockk import io.mockk.verify import kotlinx.collections.immutable.persistentListOf diff --git a/features/send/impl/src/test/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationValidationResultTransformerTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationValidationResultTransformerTest.kt index cc320a1341..7c18b9b971 100644 --- a/features/send/impl/src/test/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationValidationResultTransformerTest.kt +++ b/features/send/impl/src/test/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationValidationResultTransformerTest.kt @@ -12,7 +12,6 @@ import com.tangem.domain.transaction.error.AddressValidationResult import com.tangem.domain.transaction.error.ValidateMemoError import com.tangem.features.send.api.subcomponents.destination.entity.DestinationTextFieldUM import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM -import com.tangem.features.send.subcomponents.destination.model.transformers.SendDestinationValidationResultTransformer import com.tangem.features.send.impl.R import kotlinx.collections.immutable.persistentListOf import org.junit.jupiter.api.Test diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/common/entity/ConfirmUM.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/common/entity/ConfirmUM.kt index f99651def0..6847325b82 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/common/entity/ConfirmUM.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/common/entity/ConfirmUM.kt @@ -5,7 +5,7 @@ import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.ui.extensions.TextReference import com.tangem.domain.express.models.ExpressProvider import com.tangem.domain.swap.models.SwapDataModel -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM import kotlinx.collections.immutable.ImmutableList diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt index 424c5d9d76..5b4ab5611d 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt @@ -14,11 +14,11 @@ import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.swap.models.SwapDirection -import com.tangem.features.send.api.FeeSelectorBlockComponent -import com.tangem.features.send.api.SendNotificationsComponent +import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockComponent +import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.entity.PredefinedValues -import com.tangem.features.send.api.params.FeeSelectorParams.* +import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams.* import com.tangem.features.send.api.subcomponents.destination.SendDestinationBlockComponent import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt index d4f2c02323..7ef6c2aa13 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt @@ -37,12 +37,12 @@ import com.tangem.domain.transaction.usecase.EstimateFeeUseCase import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForGaslessTxUseCase import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForTokenUseCase import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase -import com.tangem.features.send.api.SendNotificationsComponent -import com.tangem.features.send.api.SendNotificationsComponent.Params.NotificationData +import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent +import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent.Params.NotificationData import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents.SendScreenSource -import com.tangem.features.send.api.callbacks.FeeSelectorModelCallback -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.callbacks.FeeSelectorModelCallback +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateListener @@ -76,7 +76,7 @@ import jakarta.inject.Inject import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import java.math.BigDecimal -import com.tangem.features.send.api.entity.FeeSelectorUM as FeeSelectorUMRedesigned +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM as FeeSelectorUMRedesigned import com.tangem.utils.transformer.update as transformerUpdate @Suppress("LongParameterList", "LargeClass") diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/transformers/SendWithSwapConfirmationNotificationsTransformer.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/transformers/SendWithSwapConfirmationNotificationsTransformer.kt index b274568ef8..c62ecc15db 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/transformers/SendWithSwapConfirmationNotificationsTransformer.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/transformers/SendWithSwapConfirmationNotificationsTransformer.kt @@ -10,7 +10,7 @@ import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format import com.tangem.domain.swap.models.SwapDirection.Companion.withSwapDirection -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.api.subcomponents.feeSelector.utils.FeeCalculationUtils.checkIfCustomFeeTooHigh import com.tangem.features.send.api.subcomponents.feeSelector.utils.FeeCalculationUtils.checkIfCustomFeeTooLow import com.tangem.features.send.api.utils.formatFooterFiatFee diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/ui/SendWithSwapConfirmContent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/ui/SendWithSwapConfirmContent.kt index 7f3cb239cf..c78d8ef0fd 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/ui/SendWithSwapConfirmContent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/ui/SendWithSwapConfirmContent.kt @@ -12,8 +12,8 @@ import androidx.compose.ui.unit.dp import com.tangem.common.ui.notifications.NotificationUM import com.tangem.common.ui.notifications.notifications import com.tangem.core.ui.components.SpacerH16 -import com.tangem.features.send.api.FeeSelectorBlockComponent -import com.tangem.features.send.api.SendNotificationsComponent +import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockComponent +import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent import com.tangem.features.send.api.subcomponents.destination.SendDestinationBlockComponent import com.tangem.features.swap.v2.impl.amount.SwapAmountBlockComponent import com.tangem.features.swap.v2.impl.common.entity.ConfirmUM diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/entity/SendWithSwapUM.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/entity/SendWithSwapUM.kt index 5c9d623672..1b3c333b80 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/entity/SendWithSwapUM.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/entity/SendWithSwapUM.kt @@ -1,7 +1,7 @@ package com.tangem.features.swap.v2.impl.sendviaswap.entity import com.tangem.common.ui.navigationButtons.NavigationUM -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM import com.tangem.features.swap.v2.impl.common.entity.ConfirmUM diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/model/SendWithSwapModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/model/SendWithSwapModel.kt index b8196ae1b3..671ae85c1d 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/model/SendWithSwapModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/model/SendWithSwapModel.kt @@ -20,7 +20,7 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.swap.models.SwapDirection import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponent import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM import com.tangem.features.swap.v2.api.SendWithSwapComponent diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/ui/SendWithSwapSuccessContent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/ui/SendWithSwapSuccessContent.kt index 2ecf5eeb36..bf24732fbe 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/ui/SendWithSwapSuccessContent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/ui/SendWithSwapSuccessContent.kt @@ -45,10 +45,10 @@ import com.tangem.domain.express.models.ExpressProviderType import com.tangem.domain.swap.models.SwapDataModel import com.tangem.domain.swap.models.SwapDataTransactionModel import com.tangem.domain.utils.convertToSdkAmount -import com.tangem.features.send.api.entity.FeeExtraInfo -import com.tangem.features.send.api.entity.FeeItem -import com.tangem.features.send.api.entity.FeeNonce -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeExtraInfo +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeNonce +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.api.subcomponents.destination.entity.DestinationTextFieldUM import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM import com.tangem.features.swap.v2.impl.R diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/component/SwapFeeSelectorBlockComponent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/component/SwapFeeSelectorBlockComponent.kt index 59a76db3af..e55fb35d6f 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/component/SwapFeeSelectorBlockComponent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/component/SwapFeeSelectorBlockComponent.kt @@ -12,10 +12,10 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.models.TransactionFeeExtended -import com.tangem.features.send.api.FeeSelectorBlockComponent +import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockComponent import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents -import com.tangem.features.send.api.entity.FeeSelectorUM -import com.tangem.features.send.api.params.FeeSelectorParams +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index 92f4f91db5..cec56c2b2f 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -109,8 +109,8 @@ import com.tangem.features.approval.api.SelectApprovalTypeComponent import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenAnalyticsPayload import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult -import com.tangem.features.send.api.entity.FeeItem -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger import com.tangem.features.send.impl.R import com.tangem.features.swap.SwapComponent diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index ca1d72018b..9f0f4886a7 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -50,7 +50,7 @@ import com.tangem.feature.swap.models.SwapButton.Mode import com.tangem.feature.swap.models.states.* import com.tangem.feature.swap.presentation.R import com.tangem.feature.swap.utils.formatToUIRepresentation -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.swap.SwapFeatureToggles import com.tangem.utils.Provider import com.tangem.utils.StringsSigns diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSuccessScreen.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSuccessScreen.kt index 85e544a3cc..109479d7be 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSuccessScreen.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapSuccessScreen.kt @@ -31,7 +31,7 @@ import com.tangem.core.ui.utils.toTimeFormat import com.tangem.feature.swap.models.SwapSuccessStateHolder import com.tangem.feature.swap.presentation.R import com.tangem.feature.swap.preview.SwapSuccessStatePreview -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.common.ui.FeeBlockSuccess @Composable diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/model/SwapModelApprovalSelectorCallbackTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/model/SwapModelApprovalSelectorCallbackTest.kt index d1518baeda..2649a4b1df 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/model/SwapModelApprovalSelectorCallbackTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/model/SwapModelApprovalSelectorCallbackTest.kt @@ -3,7 +3,7 @@ package com.tangem.feature.swap.model import com.google.common.truth.Truth.assertThat import com.tangem.common.ui.bottomsheet.permission.state.ApproveType import com.tangem.feature.swap.domain.models.ui.PermissionDataState -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import io.mockk.coVerify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/model/SwapModelHandleFeeErrorTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/model/SwapModelHandleFeeErrorTest.kt index 03c4242987..c80e408980 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/model/SwapModelHandleFeeErrorTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/model/SwapModelHandleFeeErrorTest.kt @@ -6,7 +6,7 @@ import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.domain.transaction.error.GetFeeError import com.tangem.feature.swap.analytics.SwapEvents import com.tangem.feature.swap.domain.models.ui.PermissionDataState -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import io.mockk.coVerify import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi diff --git a/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/deeplink/DefaultPromoDeeplinkHandlerTest.kt b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/deeplink/DefaultPromoDeeplinkHandlerTest.kt index 135cccb51e..b1d532d910 100644 --- a/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/deeplink/DefaultPromoDeeplinkHandlerTest.kt +++ b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/deeplink/DefaultPromoDeeplinkHandlerTest.kt @@ -82,7 +82,6 @@ class DefaultPromoDeeplinkHandlerTest { every { analyticsEventHandler.send(any()) } returns Unit messages = mutableListOf() every { uiMessageSender.send(capture(messages)) } just runs - } @Test @@ -499,13 +498,21 @@ class DefaultPromoDeeplinkHandlerTest { ) coEvery { multiNetworkStatusSupplier.invoke(any()) } returns flowOf(setOf(btcStatusCard, btcStatusCustom)) - val btcCoinCustom = buildCryptoCurrency(rawNetworkId = Blockchain.Bitcoin.toNetworkId(), derivationPath = dpCustom) - val btcCoinCard = buildCryptoCurrency(rawNetworkId = Blockchain.Bitcoin.toNetworkId(), derivationPath = dpCard) + val btcCoinCustom = + buildCryptoCurrency(rawNetworkId = Blockchain.Bitcoin.toNetworkId(), derivationPath = dpCustom) + val btcCoinCard = + buildCryptoCurrency(rawNetworkId = Blockchain.Bitcoin.toNetworkId(), derivationPath = dpCard) coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns setOf( btcCoinCustom, btcCoinCard, ) - coEvery { activateBitcoinPromocodeUseCase.invoke(any(), "bc1qcustom", promoCode) } returns Either.Right("ok") + coEvery { + activateBitcoinPromocodeUseCase.invoke( + any(), + "bc1qcustom", + promoCode + ) + } returns Either.Right("ok") val dispatcherProvider = testDispatcherProvider(testScheduler) @@ -556,7 +563,8 @@ class DefaultPromoDeeplinkHandlerTest { ) coEvery { multiNetworkStatusSupplier.invoke(any()) } returns flowOf(setOf(btcStatusCard, btcStatusCustom)) - val btcCoinCard = buildCryptoCurrency(rawNetworkId = Blockchain.Bitcoin.toNetworkId(), derivationPath = dpCard) + val btcCoinCard = + buildCryptoCurrency(rawNetworkId = Blockchain.Bitcoin.toNetworkId(), derivationPath = dpCard) coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns setOf(btcCoinCard) coEvery { activateBitcoinPromocodeUseCase.invoke(any(), "bc1qcard", promoCode) } returns Either.Right("ok") @@ -643,7 +651,7 @@ class DefaultPromoDeeplinkHandlerTest { Truth.assertThat(sent.title).isEqualTo(resourceReference(R.string.bitcoin_promo_no_address_title)) Truth.assertThat(sent.message).isEqualTo(resourceReference(R.string.bitcoin_promo_no_address)) - coVerify(exactly = 0) { activateBitcoinPromocodeUseCase.invoke(any(),any(), any()) } + coVerify(exactly = 0) { activateBitcoinPromocodeUseCase.invoke(any(), any(), any()) } verify( exactly = 1, @@ -673,8 +681,10 @@ class DefaultPromoDeeplinkHandlerTest { ) coEvery { multiNetworkStatusSupplier.invoke(any()) } returns flowOf(setOf(btcStatusCard)) - val btcCoinCustom = buildCryptoCurrency(rawNetworkId = Blockchain.Bitcoin.toNetworkId(), derivationPath = dpCustom) - val btcCoinCard = buildCryptoCurrency(rawNetworkId = Blockchain.Bitcoin.toNetworkId(), derivationPath = dpCard) + val btcCoinCustom = + buildCryptoCurrency(rawNetworkId = Blockchain.Bitcoin.toNetworkId(), derivationPath = dpCustom) + val btcCoinCard = + buildCryptoCurrency(rawNetworkId = Blockchain.Bitcoin.toNetworkId(), derivationPath = dpCard) coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns setOf( btcCoinCustom, btcCoinCard, @@ -732,7 +742,8 @@ class DefaultPromoDeeplinkHandlerTest { ) coEvery { multiNetworkStatusSupplier.invoke(any()) } returns flowOf(setOf(btcStatusCustom)) - val btcCoinCard = buildCryptoCurrency(rawNetworkId = Blockchain.Bitcoin.toNetworkId(), derivationPath = dpCard) + val btcCoinCard = + buildCryptoCurrency(rawNetworkId = Blockchain.Bitcoin.toNetworkId(), derivationPath = dpCard) coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns setOf(btcCoinCard) val dispatcherProvider = testDispatcherProvider(testScheduler) @@ -787,7 +798,8 @@ class DefaultPromoDeeplinkHandlerTest { ) coEvery { multiNetworkStatusSupplier.invoke(any()) } returns flowOf(setOf(btcStatusCard)) - val btcCoinCustom = buildCryptoCurrency(rawNetworkId = Blockchain.Bitcoin.toNetworkId(), derivationPath = dpCustom) + val btcCoinCustom = + buildCryptoCurrency(rawNetworkId = Blockchain.Bitcoin.toNetworkId(), derivationPath = dpCustom) coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any()) } returns setOf(btcCoinCustom) val dispatcherProvider = testDispatcherProvider(testScheduler) diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routing/DefaultWcRoutingComponent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routing/DefaultWcRoutingComponent.kt index 7d579aa7b1..f4af23a286 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routing/DefaultWcRoutingComponent.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/connections/routing/DefaultWcRoutingComponent.kt @@ -15,8 +15,8 @@ import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.features.commonfeatures.api.portfolioselector.PortfolioSelectorComponent -import com.tangem.features.send.api.FeeSelectorBlockComponent -import com.tangem.features.send.api.FeeSelectorComponent +import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockComponent +import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorComponent import com.tangem.features.walletconnect.components.WcRoutingComponent import com.tangem.features.walletconnect.connections.components.AlertsComponent import com.tangem.features.walletconnect.connections.components.AlertsComponent.AlertType.* diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/PreviewFeeSelectorBlockComponent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/PreviewFeeSelectorBlockComponent.kt index 9aa7cdf947..e6e22a52e5 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/PreviewFeeSelectorBlockComponent.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/PreviewFeeSelectorBlockComponent.kt @@ -17,8 +17,8 @@ import com.tangem.core.ui.components.audits.AuditLabelUM import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme -import com.tangem.features.send.api.FeeSelectorBlockComponent -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockComponent +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.walletconnect.impl.R internal class PreviewFeeSelectorBlockComponent : FeeSelectorBlockComponent { diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/common/WcNavigationUtils.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/common/WcNavigationUtils.kt index fcb776bd14..e3a43c45ba 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/common/WcNavigationUtils.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/common/WcNavigationUtils.kt @@ -3,10 +3,10 @@ package com.tangem.features.walletconnect.transaction.components.common import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.domain.walletconnect.WcAnalyticEvents -import com.tangem.features.send.api.FeeSelectorComponent +import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorComponent import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents -import com.tangem.features.send.api.params.FeeSelectorParams.FeeDisplaySource -import com.tangem.features.send.api.params.FeeSelectorParams.FeeSelectorDetailsParams +import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams.FeeDisplaySource +import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams.FeeSelectorDetailsParams import com.tangem.features.walletconnect.connections.components.AlertsComponentV2 import com.tangem.features.walletconnect.connections.utils.WcAlertsFactory.createCommonTransactionAppInfoAlertUM import com.tangem.features.walletconnect.transaction.components.send.WcCustomAllowanceComponent diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendTransactionComponent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendTransactionComponent.kt index 9173c11909..a242b33bfd 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendTransactionComponent.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendTransactionComponent.kt @@ -7,10 +7,10 @@ import com.arkivanov.essenty.lifecycle.doOnResume import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.domain.walletconnect.WcAnalyticEvents -import com.tangem.features.send.api.FeeSelectorBlockComponent +import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockComponent import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents -import com.tangem.features.send.api.entity.FeeSelectorUM -import com.tangem.features.send.api.params.FeeSelectorParams +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams import com.tangem.features.walletconnect.transaction.entity.common.WcTransactionFeeState import com.tangem.features.walletconnect.transaction.model.WcSendTransactionModel import com.tangem.features.walletconnect.transaction.ui.send.WcSendTransactionModalBottomSheet diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendTransactionContainerComponent.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendTransactionContainerComponent.kt index f7b4cc1549..9835a4c144 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendTransactionContainerComponent.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/components/send/WcSendTransactionContainerComponent.kt @@ -5,8 +5,8 @@ import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.ui.decompose.ComposableBottomSheetComponent -import com.tangem.features.send.api.FeeSelectorBlockComponent -import com.tangem.features.send.api.FeeSelectorComponent +import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockComponent +import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorComponent import com.tangem.features.walletconnect.transaction.components.common.WcCommonTransactionComponentDelegate import com.tangem.features.walletconnect.transaction.components.common.WcTransactionModelParams import com.tangem.features.walletconnect.transaction.components.common.getWcCommonScreen diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSendTransactionUMConverter.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSendTransactionUMConverter.kt index 6decf0a891..6bd7e45e31 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSendTransactionUMConverter.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/converter/WcSendTransactionUMConverter.kt @@ -12,7 +12,7 @@ import com.tangem.domain.walletconnect.usecase.method.BlockAidTransactionCheck import com.tangem.domain.walletconnect.usecase.method.WcMethodContext import com.tangem.domain.walletconnect.usecase.method.WcSignState import com.tangem.domain.walletconnect.usecase.method.WcSignStep -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.walletconnect.transaction.entity.blockaid.WcSendReceiveTransactionCheckResultsUM import com.tangem.features.walletconnect.transaction.entity.common.WcTransactionActionsUM import com.tangem.features.walletconnect.transaction.entity.common.WcTransactionFeeState diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/send/WcSendTransactionUM.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/send/WcSendTransactionUM.kt index 63194291e0..44d556afff 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/send/WcSendTransactionUM.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/entity/send/WcSendTransactionUM.kt @@ -4,7 +4,7 @@ import com.domain.blockaid.models.transaction.ValidationResult import com.tangem.common.ui.account.AccountTitleUM import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.walletconnect.transaction.entity.approve.WcSpendAllowanceUM import com.tangem.features.walletconnect.transaction.entity.blockaid.WcSendReceiveTransactionCheckResultsUM import com.tangem.features.walletconnect.transaction.entity.common.WcCommonTransactionUM diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSendTransactionModel.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSendTransactionModel.kt index 89a265f09e..98ba26f87e 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSendTransactionModel.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/model/WcSendTransactionModel.kt @@ -44,9 +44,9 @@ import com.tangem.domain.walletconnect.model.WcPsbtOutput import com.tangem.domain.walletconnect.model.WcRequestError import com.tangem.domain.walletconnect.model.WcRequestError.Companion.message import com.tangem.domain.walletconnect.usecase.method.* -import com.tangem.features.send.api.callbacks.FeeSelectorModelCallback -import com.tangem.features.send.api.entity.FeeSelectorUM -import com.tangem.features.send.api.params.FeeSelectorParams.FeeStateConfiguration +import com.tangem.features.send.api.subcomponents.feeSelector.callbacks.FeeSelectorModelCallback +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams.FeeStateConfiguration import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorData import com.tangem.features.walletconnect.connections.routing.WcInnerRoute diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/common/WcSendTransactionItems.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/common/WcSendTransactionItems.kt index 6a74692291..017fba171b 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/common/WcSendTransactionItems.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/common/WcSendTransactionItems.kt @@ -15,8 +15,8 @@ import com.tangem.common.ui.account.AccountTitleUM import com.tangem.core.ui.components.divider.DividerWithPadding import com.tangem.core.ui.extensions.clickableSingle import com.tangem.core.ui.res.TangemTheme -import com.tangem.features.send.api.FeeSelectorBlockComponent -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockComponent +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.walletconnect.transaction.entity.common.WcNetworkInfoUM import com.tangem.features.walletconnect.transaction.entity.common.WcTransactionFeeState diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/send/WcSendTransactionModalBottomSheet.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/send/WcSendTransactionModalBottomSheet.kt index 6f7f7fbff3..81d38d3315 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/send/WcSendTransactionModalBottomSheet.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/transaction/ui/send/WcSendTransactionModalBottomSheet.kt @@ -31,8 +31,8 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.features.send.api.FeeSelectorBlockComponent -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockComponent +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.walletconnect.connections.entity.VerifiedDAppState import com.tangem.features.walletconnect.connections.ui.WcAppInfoItem import com.tangem.features.walletconnect.impl.R diff --git a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/utils/WcNotificationsFactory.kt b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/utils/WcNotificationsFactory.kt index 1726479f92..50efc58e85 100644 --- a/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/utils/WcNotificationsFactory.kt +++ b/features/walletconnect/impl/src/main/kotlin/com/tangem/features/walletconnect/utils/WcNotificationsFactory.kt @@ -5,7 +5,7 @@ import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.features.send.api.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.api.subcomponents.feeSelector.utils.FeeCalculationUtils import com.tangem.features.walletconnect.impl.R import javax.inject.Inject From e7b7c086bbd2096308711100e127edea1f6230e4 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 24 Jun 2026 18:31:37 +0500 Subject: [PATCH 033/210] Updated on 2026-08-14 --- .../pushes/TokenDetailsPushHandlerTest.kt | 2 +- ...pProblematicWalletForAddressUseCaseTest.kt | 2 +- .../models/errors}/GetUserWalletError.kt | 2 +- .../usecase/GetSelectedWalletSyncUseCase.kt | 2 +- .../usecase/GetSelectedWalletUseCase.kt | 2 +- .../wallets/usecase/GetUserWalletUseCase.kt | 2 +- .../model/WalletBackupModelTest.kt | 2 +- .../api/subcomponents/amount/AmountRoute.kt | 8 + .../amount/SendAmountBlockComponent.kt | 19 ++ .../amount/SendAmountComponent.kt | 21 ++ .../amount/SendAmountComponentParams.kt | 8 +- .../amount/SendAmountReduceTrigger.kt | 39 ++++ .../features/send/common/CommonSendRoute.kt | 3 +- .../entrypoint/model/SendEntryPointModel.kt | 2 +- .../send/send/DefaultSendComponent.kt | 15 +- .../send/send/confirm/SendConfirmComponent.kt | 6 +- .../send/confirm/model/SendConfirmModel.kt | 2 +- .../send/confirm/ui/SendConfirmContent.kt | 6 +- .../features/send/send/model/SendModel.kt | 6 +- ....kt => DefaultSendAmountBlockComponent.kt} | 30 ++- ...onent.kt => DefaultSendAmountComponent.kt} | 29 +-- ...r.kt => DefaultSendAmountReduceTrigger.kt} | 39 +--- .../amount/di/SendAmountModule.kt | 17 +- .../amount/model/SendAmountModel.kt | 28 ++- .../amount/model/SendAmountNavigationTest.kt | 179 ++++++++++++++++++ .../DefaultTokenDetailsDeepLinkHandlerTest.kt | 2 +- .../DefaultPromoDeeplinkHandlerTest.kt | 2 +- 27 files changed, 373 insertions(+), 102 deletions(-) rename domain/wallets/{src/main/java/com/tangem/domain/wallets/models => models/src/main/java/com/tangem/domain/wallets/models/errors}/GetUserWalletError.kt (66%) create mode 100644 features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/amount/AmountRoute.kt create mode 100644 features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/amount/SendAmountBlockComponent.kt create mode 100644 features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/amount/SendAmountComponent.kt rename features/send/{impl/src/main/java/com/tangem/features/send => api/src/main/java/com/tangem/features/send/api}/subcomponents/amount/SendAmountComponentParams.kt (92%) create mode 100644 features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/amount/SendAmountReduceTrigger.kt rename features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/{SendAmountBlockComponent.kt => DefaultSendAmountBlockComponent.kt} (55%) rename features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/{SendAmountComponent.kt => DefaultSendAmountComponent.kt} (55%) rename features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/{SendAmountReduceTrigger.kt => DefaultSendAmountReduceTrigger.kt} (55%) create mode 100644 features/send/impl/src/test/java/com/tangem/features/send/subcomponents/amount/model/SendAmountNavigationTest.kt diff --git a/app/src/test/kotlin/com/tangem/tap/common/pushes/TokenDetailsPushHandlerTest.kt b/app/src/test/kotlin/com/tangem/tap/common/pushes/TokenDetailsPushHandlerTest.kt index 84779067f5..01710b9388 100644 --- a/app/src/test/kotlin/com/tangem/tap/common/pushes/TokenDetailsPushHandlerTest.kt +++ b/app/src/test/kotlin/com/tangem/tap/common/pushes/TokenDetailsPushHandlerTest.kt @@ -14,7 +14,7 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isLocked import com.tangem.domain.models.wallet.isMultiCurrency -import com.tangem.domain.wallets.models.GetUserWalletError +import com.tangem.domain.wallets.models.errors.GetUserWalletError import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.utils.coroutines.AppCoroutineScope diff --git a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetBackupProblematicWalletForAddressUseCaseTest.kt b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetBackupProblematicWalletForAddressUseCaseTest.kt index f20cd59184..3c682fac25 100644 --- a/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetBackupProblematicWalletForAddressUseCaseTest.kt +++ b/domain/account/status/src/test/kotlin/com/tangem/domain/account/status/usecase/GetBackupProblematicWalletForAddressUseCaseTest.kt @@ -9,7 +9,7 @@ import com.tangem.domain.account.status.model.AccountCryptoCurrency import com.tangem.domain.card.IsWalletBackupProblematicUseCase import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.wallets.models.GetUserWalletError +import com.tangem.domain.wallets.models.errors.GetUserWalletError import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import io.mockk.clearMocks import io.mockk.coEvery diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/models/GetUserWalletError.kt b/domain/wallets/models/src/main/java/com/tangem/domain/wallets/models/errors/GetUserWalletError.kt similarity index 66% rename from domain/wallets/src/main/java/com/tangem/domain/wallets/models/GetUserWalletError.kt rename to domain/wallets/models/src/main/java/com/tangem/domain/wallets/models/errors/GetUserWalletError.kt index 3e7e62a562..745399666a 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/models/GetUserWalletError.kt +++ b/domain/wallets/models/src/main/java/com/tangem/domain/wallets/models/errors/GetUserWalletError.kt @@ -1,4 +1,4 @@ -package com.tangem.domain.wallets.models +package com.tangem.domain.wallets.models.errors sealed class GetUserWalletError { diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetSelectedWalletSyncUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetSelectedWalletSyncUseCase.kt index 071bc36c87..754a063118 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetSelectedWalletSyncUseCase.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetSelectedWalletSyncUseCase.kt @@ -4,7 +4,7 @@ import arrow.core.Either import arrow.core.raise.either import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.wallets.models.GetUserWalletError +import com.tangem.domain.wallets.models.errors.GetUserWalletError /** * Use case for getting selected wallet. diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetSelectedWalletUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetSelectedWalletUseCase.kt index 19a253810c..30e17a1483 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetSelectedWalletUseCase.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetSelectedWalletUseCase.kt @@ -4,7 +4,7 @@ import arrow.core.Either import arrow.core.raise.either import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.models.wallet.UserWallet -import com.tangem.domain.wallets.models.GetUserWalletError +import com.tangem.domain.wallets.models.errors.GetUserWalletError import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.filterNotNull diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetUserWalletUseCase.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetUserWalletUseCase.kt index 577f7d8b93..14c5077096 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetUserWalletUseCase.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/GetUserWalletUseCase.kt @@ -10,7 +10,7 @@ import com.tangem.domain.common.wallets.requireUserWalletsSync import com.tangem.domain.core.utils.EitherFlow import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId -import com.tangem.domain.wallets.models.GetUserWalletError +import com.tangem.domain.wallets.models.errors.GetUserWalletError import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.transformLatest diff --git a/features/hot-wallet/impl/src/test/kotlin/com/tangem/features/hotwallet/walletbackup/model/WalletBackupModelTest.kt b/features/hot-wallet/impl/src/test/kotlin/com/tangem/features/hotwallet/walletbackup/model/WalletBackupModelTest.kt index ddf4d1d425..b8d9a569c8 100644 --- a/features/hot-wallet/impl/src/test/kotlin/com/tangem/features/hotwallet/walletbackup/model/WalletBackupModelTest.kt +++ b/features/hot-wallet/impl/src/test/kotlin/com/tangem/features/hotwallet/walletbackup/model/WalletBackupModelTest.kt @@ -14,7 +14,7 @@ import com.tangem.core.ui.message.DialogMessage import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.wallets.analytics.WalletSettingsAnalyticEvents -import com.tangem.domain.wallets.models.GetUserWalletError +import com.tangem.domain.wallets.models.errors.GetUserWalletError import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.domain.wallets.usecase.UnlockHotWalletContextualUseCase import com.tangem.features.hotwallet.WalletBackupComponent diff --git a/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/amount/AmountRoute.kt b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/amount/AmountRoute.kt new file mode 100644 index 0000000000..afcdebbae2 --- /dev/null +++ b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/amount/AmountRoute.kt @@ -0,0 +1,8 @@ +package com.tangem.features.send.api.subcomponents.amount + +/** + * Common route for amount + */ +interface AmountRoute { + val isEditMode: Boolean +} \ No newline at end of file diff --git a/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/amount/SendAmountBlockComponent.kt b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/amount/SendAmountBlockComponent.kt new file mode 100644 index 0000000000..ebed359d91 --- /dev/null +++ b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/amount/SendAmountBlockComponent.kt @@ -0,0 +1,19 @@ +package com.tangem.features.send.api.subcomponents.amount + +import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.ui.decompose.ComposableContentComponent + +interface SendAmountBlockComponent : ComposableContentComponent { + + fun updateState(amountUM: AmountState) + + interface Factory { + fun create( + context: AppComponentContext, + params: SendAmountComponentParams.AmountBlockParams, + onClick: () -> Unit, + onResult: (AmountState) -> Unit, + ): SendAmountBlockComponent + } +} \ No newline at end of file diff --git a/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/amount/SendAmountComponent.kt b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/amount/SendAmountComponent.kt new file mode 100644 index 0000000000..79e3d44a57 --- /dev/null +++ b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/amount/SendAmountComponent.kt @@ -0,0 +1,21 @@ +package com.tangem.features.send.api.subcomponents.amount + +import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.common.ui.navigationButtons.NavigationModelCallback +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.wallets.models.errors.GetUserWalletError + +interface SendAmountComponent : ComposableContentComponent { + + fun updateState(amountUM: AmountState) + + interface ModelCallback : NavigationModelCallback { + fun onAmountResult(amountUM: AmountState, isResetPredefined: Boolean) + fun onConvertToAnotherToken(lastAmount: String, isEnterInFiatSelected: Boolean) + fun resetSendNavigation() + fun onError(error: GetUserWalletError) + } + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/SendAmountComponentParams.kt b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/amount/SendAmountComponentParams.kt similarity index 92% rename from features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/SendAmountComponentParams.kt rename to features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/amount/SendAmountComponentParams.kt index 88427bed4c..bda588bfee 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/SendAmountComponentParams.kt +++ b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/amount/SendAmountComponentParams.kt @@ -1,4 +1,4 @@ -package com.tangem.features.send.subcomponents.amount +package com.tangem.features.send.api.subcomponents.amount import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.domain.appcurrency.model.AppCurrency @@ -9,10 +9,10 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.entity.PredefinedValues -import com.tangem.features.send.common.CommonSendRoute +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow -internal sealed class SendAmountComponentParams { +sealed class SendAmountComponentParams { abstract val state: AmountState abstract val analyticsCategoryName: String @@ -39,7 +39,7 @@ internal sealed class SendAmountComponentParams { override val accountFlow: StateFlow, override val isAccountModeFlow: StateFlow, val callback: SendAmountComponent.ModelCallback, - val currentRoute: StateFlow, + val currentRoute: Flow, ) : SendAmountComponentParams() data class AmountBlockParams( diff --git a/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/amount/SendAmountReduceTrigger.kt b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/amount/SendAmountReduceTrigger.kt new file mode 100644 index 0000000000..5f923b8d46 --- /dev/null +++ b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/amount/SendAmountReduceTrigger.kt @@ -0,0 +1,39 @@ +package com.tangem.features.send.api.subcomponents.amount + +import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer +import kotlinx.coroutines.flow.Flow +import java.math.BigDecimal + +/** + * Trigger for reducing amount from another component + */ +interface SendAmountReduceTrigger { + suspend fun triggerReduceBy(reduceBy: AmountReduceByTransformer.ReduceByData) + suspend fun triggerReduceTo(reduceTo: BigDecimal) + suspend fun triggerIgnoreReduce() +} + +/** + * Trigger for reducing amount from another component + */ +interface SendAmountReduceListener { + val reduceToTriggerFlow: Flow + val reduceByTriggerFlow: Flow + val ignoreReduceTriggerFlow: Flow +} + +/** + * Trigger amount change from another component. + * Different from another triggers because it takes raw string instead of BigDecimal + */ +interface SendAmountUpdateTrigger { + suspend fun triggerUpdateAmount(amountValue: String, isEnterInFiatSelected: Boolean?) +} + +/** + * Trigger amount change from another component. + * Different from another triggers because it takes raw string instead of BigDecimal + */ +interface SendAmountUpdateListener { + val updateAmountTriggerFlow: Flow> +} \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/common/CommonSendRoute.kt b/features/send/impl/src/main/java/com/tangem/features/send/common/CommonSendRoute.kt index 398176faea..c098d9b414 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/common/CommonSendRoute.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/common/CommonSendRoute.kt @@ -1,6 +1,7 @@ package com.tangem.features.send.common import com.tangem.core.decompose.navigation.Route +import com.tangem.features.send.api.subcomponents.amount.AmountRoute import com.tangem.features.send.api.subcomponents.destination.DestinationRoute import kotlinx.serialization.Serializable @@ -31,5 +32,5 @@ internal sealed class CommonSendRoute : Route { @Serializable data class Amount( override val isEditMode: Boolean, - ) : CommonSendRoute() + ) : CommonSendRoute(), AmountRoute } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/entrypoint/model/SendEntryPointModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/entrypoint/model/SendEntryPointModel.kt index 0572ea6bd3..c759ab49fe 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/entrypoint/model/SendEntryPointModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/entrypoint/model/SendEntryPointModel.kt @@ -10,7 +10,7 @@ import com.tangem.features.managetokens.component.ChooseManagedTokensComponent import com.tangem.features.send.api.SendComponent import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.entry.SendEntryRoute -import com.tangem.features.send.subcomponents.amount.SendAmountUpdateTrigger +import com.tangem.features.send.api.subcomponents.amount.SendAmountUpdateTrigger import com.tangem.features.swap.v2.api.SendWithSwapComponent import com.tangem.features.swap.v2.api.subcomponents.SwapAmountUpdateTrigger import com.tangem.utils.coroutines.CoroutineDispatcherProvider diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/DefaultSendComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/DefaultSendComponent.kt index 398b98b780..a44f29903c 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/DefaultSendComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/DefaultSendComponent.kt @@ -30,6 +30,8 @@ import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.models.account.derivationIndex import com.tangem.features.send.api.SendComponent import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents +import com.tangem.features.send.api.subcomponents.amount.SendAmountComponent +import com.tangem.features.send.api.subcomponents.amount.SendAmountComponentParams import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponent import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM @@ -41,14 +43,12 @@ import com.tangem.features.send.impl.R import com.tangem.features.send.send.confirm.SendConfirmComponent import com.tangem.features.send.send.model.SendModel import com.tangem.features.send.send.success.SendConfirmSuccessComponent -import com.tangem.features.send.subcomponents.amount.SendAmountComponent -import com.tangem.features.send.subcomponents.amount.SendAmountComponentParams +import com.tangem.features.send.subcomponents.amount.DefaultSendAmountComponent import com.tangem.features.send.subcomponents.destination.DefaultSendDestinationBlockComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.launch @@ -57,6 +57,7 @@ internal class DefaultSendComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, @Assisted private val params: SendComponent.Params, private val analyticsEventHandler: AnalyticsEventHandler, + private val amountComponentFactory: SendAmountComponent.Factory, private val feeSelectorComponentFactory: FeeSelectorBlockComponent.Factory, private val sendDestinationComponentFactory: SendDestinationComponent.Factory, ) : SendComponent, AppComponentContext by appComponentContext { @@ -113,7 +114,7 @@ internal class DefaultSendComponent @AssistedInject constructor( activeComponent.updateState(model.uiState.value) } } - is SendAmountComponent -> { + is DefaultSendAmountComponent -> { analyticsEventHandler.send( CommonSendAnalyticEvents.AmountScreenOpened( categoryName = model.analyticCategoryName, @@ -180,11 +181,11 @@ internal class DefaultSendComponent @AssistedInject constructor( ) private fun getAmountComponent(factoryContext: AppComponentContext): ComposableContentComponent { - return SendAmountComponent( - appComponentContext = factoryContext, + return amountComponentFactory.create( + context = factoryContext, params = SendAmountComponentParams.AmountParams( state = model.uiState.value.amountUM, - currentRoute = model.currentRoute.asStateFlow(), + currentRoute = model.currentRoute.filterIsInstance(), isBalanceHidingFlow = model.isBalanceHiddenFlow, analyticsCategoryName = model.analyticCategoryName, appCurrency = model.appCurrency, diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/SendConfirmComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/SendConfirmComponent.kt index 24b5e89ce0..6e369f2e78 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/SendConfirmComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/SendConfirmComponent.kt @@ -27,8 +27,8 @@ import com.tangem.features.send.common.ui.state.ConfirmUM import com.tangem.features.send.send.confirm.model.SendConfirmModel import com.tangem.features.send.send.confirm.ui.SendConfirmContent import com.tangem.features.send.send.ui.state.SendUM -import com.tangem.features.send.subcomponents.amount.SendAmountBlockComponent -import com.tangem.features.send.subcomponents.amount.SendAmountComponentParams +import com.tangem.features.send.subcomponents.amount.DefaultSendAmountBlockComponent +import com.tangem.features.send.api.subcomponents.amount.SendAmountComponentParams import com.tangem.features.send.subcomponents.destination.DefaultSendDestinationBlockComponent import com.tangem.features.send.subcomponents.notifications.DefaultSendNotificationsComponent import com.tangem.utils.extensions.orZero @@ -60,7 +60,7 @@ internal class SendConfirmComponent( onClick = model::showEditDestination, ) - private val amountBlockComponent = SendAmountBlockComponent( + private val amountBlockComponent = DefaultSendAmountBlockComponent( appComponentContext = child("sendConfirmAmountBlock"), params = SendAmountComponentParams.AmountBlockParams( state = model.uiState.value.amountUM, diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/SendConfirmModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/SendConfirmModel.kt index 2eded657e2..bf529097bc 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/SendConfirmModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/SendConfirmModel.kt @@ -45,6 +45,7 @@ import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.domain.utils.convertToSdkAmount import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents.SendScreenSource +import com.tangem.features.send.api.subcomponents.amount.SendAmountReduceTrigger import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorCheckReloadListener import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorCheckReloadTrigger @@ -69,7 +70,6 @@ import com.tangem.features.send.send.confirm.model.transformers.SendConfirmSendi import com.tangem.features.send.send.confirm.model.transformers.SendConfirmSentStateTransformer import com.tangem.features.send.send.confirm.model.transformers.SendConfirmationNotificationsTransformerV2 import com.tangem.features.send.send.ui.state.SendUM -import com.tangem.features.send.subcomponents.amount.SendAmountReduceTrigger import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.extensions.orZero import com.tangem.utils.extensions.stripZeroPlainString diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/ui/SendConfirmContent.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/ui/SendConfirmContent.kt index 443ef0407d..7da1330172 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/ui/SendConfirmContent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/ui/SendConfirmContent.kt @@ -18,7 +18,7 @@ import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockCo import com.tangem.features.send.common.ui.state.ConfirmUM import com.tangem.features.send.common.ui.tapHelp import com.tangem.features.send.send.ui.state.SendUM -import com.tangem.features.send.subcomponents.amount.SendAmountBlockComponent +import com.tangem.features.send.subcomponents.amount.DefaultSendAmountBlockComponent import com.tangem.features.send.subcomponents.destination.DefaultSendDestinationBlockComponent import com.tangem.features.send.subcomponents.notifications import com.tangem.features.send.subcomponents.notifications.DefaultSendNotificationsComponent @@ -31,7 +31,7 @@ private const val BLOCKS_KEY = "BLOCKS_KEY" internal fun SendConfirmContent( sendUM: SendUM, destinationBlockComponent: DefaultSendDestinationBlockComponent, - amountBlockComponent: SendAmountBlockComponent, + amountBlockComponent: DefaultSendAmountBlockComponent, feeSelectorBlockComponent: FeeSelectorBlockComponent, notificationsComponent: DefaultSendNotificationsComponent, notificationsUM: ImmutableList, @@ -73,7 +73,7 @@ internal fun SendConfirmContent( private fun LazyListScope.blocks( destinationBlockComponent: DefaultSendDestinationBlockComponent, - amountBlockComponent: SendAmountBlockComponent, + amountBlockComponent: DefaultSendAmountBlockComponent, feeSelectorBlockComponent: FeeSelectorBlockComponent, ) { item(key = BLOCKS_KEY) { diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/model/SendModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/model/SendModel.kt index c1b9d32985..643b2cfae1 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/model/SendModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/model/SendModel.kt @@ -41,13 +41,15 @@ import com.tangem.domain.transaction.usecase.GetFeeUseCase import com.tangem.domain.transaction.usecase.gasless.GetFeeForGaslessUseCase import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase import com.tangem.domain.utils.convertToSdkAmount -import com.tangem.domain.wallets.models.GetUserWalletError +import com.tangem.domain.wallets.models.errors.GetUserWalletError import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.send.api.SendComponent import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents.SendScreenSource import com.tangem.features.send.api.entity.PredefinedValues import com.tangem.features.send.api.entity.isFromMainScreenQr +import com.tangem.features.send.api.subcomponents.amount.SendAmountComponent +import com.tangem.features.send.api.subcomponents.amount.SendAmountUpdateTrigger import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponent import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM @@ -58,8 +60,6 @@ import com.tangem.features.send.send.analytics.SendAnalyticEvents import com.tangem.features.send.send.confirm.SendConfirmComponent import com.tangem.features.send.send.success.SendConfirmSuccessComponent import com.tangem.features.send.send.ui.state.SendUM -import com.tangem.features.send.subcomponents.amount.SendAmountComponent -import com.tangem.features.send.subcomponents.amount.SendAmountUpdateTrigger import com.tangem.features.send.subcomponents.destination.model.transformers.SendDestinationInitialStateTransformer import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/SendAmountBlockComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/DefaultSendAmountBlockComponent.kt similarity index 55% rename from features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/SendAmountBlockComponent.kt rename to features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/DefaultSendAmountBlockComponent.kt index bb842f8b59..d0ee3243ac 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/SendAmountBlockComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/DefaultSendAmountBlockComponent.kt @@ -8,18 +8,22 @@ import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.ui.AmountBlockV2 import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.features.send.api.entity.PredefinedValues +import com.tangem.features.send.api.subcomponents.amount.SendAmountBlockComponent +import com.tangem.features.send.api.subcomponents.amount.SendAmountComponentParams import com.tangem.features.send.subcomponents.amount.model.SendAmountModel +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -internal class SendAmountBlockComponent( - appComponentContext: AppComponentContext, - private val params: SendAmountComponentParams.AmountBlockParams, - val onResult: (AmountState) -> Unit, - val onClick: () -> Unit, -) : ComposableContentComponent, AppComponentContext by appComponentContext { +internal class DefaultSendAmountBlockComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted private val params: SendAmountComponentParams.AmountBlockParams, + @Assisted val onResult: (AmountState) -> Unit, + @Assisted val onClick: () -> Unit, +) : SendAmountBlockComponent, AppComponentContext by appComponentContext { private val model: SendAmountModel = getOrCreateModel(params = params) @@ -29,7 +33,7 @@ internal class SendAmountBlockComponent( }.launchIn(componentScope) } - fun updateState(amountUM: AmountState) = model.updateState(amountUM) + override fun updateState(amountUM: AmountState) = model.updateState(amountUM) @Composable override fun Content(modifier: Modifier) { @@ -44,4 +48,14 @@ internal class SendAmountBlockComponent( modifier = modifier, ) } + + @AssistedFactory + interface Factory : SendAmountBlockComponent.Factory { + override fun create( + context: AppComponentContext, + params: SendAmountComponentParams.AmountBlockParams, + onClick: () -> Unit, + onResult: (AmountState) -> Unit, + ): DefaultSendAmountBlockComponent + } } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/SendAmountComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/DefaultSendAmountComponent.kt similarity index 55% rename from features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/SendAmountComponent.kt rename to features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/DefaultSendAmountComponent.kt index b86140b9db..0732ec6492 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/SendAmountComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/DefaultSendAmountComponent.kt @@ -5,22 +5,24 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.common.ui.amountScreen.models.AmountState -import com.tangem.common.ui.navigationButtons.NavigationModelCallback import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.domain.wallets.models.GetUserWalletError +import com.tangem.features.send.api.subcomponents.amount.SendAmountComponent +import com.tangem.features.send.api.subcomponents.amount.SendAmountComponentParams import com.tangem.features.send.subcomponents.amount.model.SendAmountModel import com.tangem.features.send.subcomponents.amount.ui.SendAmountContent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject -internal class SendAmountComponent( - appComponentContext: AppComponentContext, - private val params: SendAmountComponentParams.AmountParams, -) : ComposableContentComponent, AppComponentContext by appComponentContext { +internal class DefaultSendAmountComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted private val params: SendAmountComponentParams.AmountParams, +) : SendAmountComponent, AppComponentContext by appComponentContext { private val model: SendAmountModel = getOrCreateModel(params = params) - fun updateState(amountUM: AmountState) = model.updateState(amountUM) + override fun updateState(amountUM: AmountState) = model.updateState(amountUM) @Composable override fun Content(modifier: Modifier) { @@ -35,10 +37,11 @@ internal class SendAmountComponent( ) } - interface ModelCallback : NavigationModelCallback { - fun onAmountResult(amountUM: AmountState, isResetPredefined: Boolean) - fun onConvertToAnotherToken(lastAmount: String, isEnterInFiatSelected: Boolean) - fun resetSendNavigation() - fun onError(error: GetUserWalletError) + @AssistedFactory + interface Factory : SendAmountComponent.Factory { + override fun create( + context: AppComponentContext, + params: SendAmountComponentParams.AmountParams, + ): DefaultSendAmountComponent } } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/SendAmountReduceTrigger.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/DefaultSendAmountReduceTrigger.kt similarity index 55% rename from features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/SendAmountReduceTrigger.kt rename to features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/DefaultSendAmountReduceTrigger.kt index fab7f0ba43..48aea6c92e 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/SendAmountReduceTrigger.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/DefaultSendAmountReduceTrigger.kt @@ -1,46 +1,15 @@ package com.tangem.features.send.subcomponents.amount import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer.ReduceByData -import kotlinx.coroutines.flow.Flow +import com.tangem.features.send.api.subcomponents.amount.SendAmountReduceListener +import com.tangem.features.send.api.subcomponents.amount.SendAmountReduceTrigger +import com.tangem.features.send.api.subcomponents.amount.SendAmountUpdateListener +import com.tangem.features.send.api.subcomponents.amount.SendAmountUpdateTrigger import kotlinx.coroutines.flow.MutableSharedFlow import java.math.BigDecimal import javax.inject.Inject import javax.inject.Singleton -/** - * Trigger for reducing amount from another component - */ -interface SendAmountReduceTrigger { - suspend fun triggerReduceBy(reduceBy: ReduceByData) - suspend fun triggerReduceTo(reduceTo: BigDecimal) - suspend fun triggerIgnoreReduce() -} - -/** - * Trigger for reducing amount from another component - */ -interface SendAmountReduceListener { - val reduceToTriggerFlow: Flow - val reduceByTriggerFlow: Flow - val ignoreReduceTriggerFlow: Flow -} - -/** - * Trigger amount change from another component. - * Different from another triggers because it takes raw string instead of BigDecimal - */ -interface SendAmountUpdateTrigger { - suspend fun triggerUpdateAmount(amountValue: String, isEnterInFiatSelected: Boolean?) -} - -/** - * Trigger amount change from another component. - * Different from another triggers because it takes raw string instead of BigDecimal - */ -interface SendAmountUpdateListener { - val updateAmountTriggerFlow: Flow> -} - @Singleton internal class DefaultSendAmountReduceTrigger @Inject constructor() : SendAmountReduceTrigger, diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/di/SendAmountModule.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/di/SendAmountModule.kt index 644c391e76..6b99065e38 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/di/SendAmountModule.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/di/SendAmountModule.kt @@ -1,10 +1,9 @@ package com.tangem.features.send.subcomponents.amount.di +import com.tangem.features.send.api.subcomponents.amount.* +import com.tangem.features.send.subcomponents.amount.DefaultSendAmountBlockComponent +import com.tangem.features.send.subcomponents.amount.DefaultSendAmountComponent import com.tangem.features.send.subcomponents.amount.DefaultSendAmountReduceTrigger -import com.tangem.features.send.subcomponents.amount.SendAmountReduceListener -import com.tangem.features.send.subcomponents.amount.SendAmountReduceTrigger -import com.tangem.features.send.subcomponents.amount.SendAmountUpdateListener -import com.tangem.features.send.subcomponents.amount.SendAmountUpdateTrigger import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -15,6 +14,16 @@ import javax.inject.Singleton @Module internal interface SendAmountModule { + @Singleton + @Binds + fun provideSendAmountComponentFactory(impl: DefaultSendAmountComponent.Factory): SendAmountComponent.Factory + + @Singleton + @Binds + fun provideSendAmountBlockComponentFactory( + impl: DefaultSendAmountBlockComponent.Factory, + ): SendAmountBlockComponent.Factory + @Singleton @Binds fun provideSendAmountReduceTrigger(impl: DefaultSendAmountReduceTrigger): SendAmountReduceTrigger diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/model/SendAmountModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/model/SendAmountModel.kt index bd11a2fc81..0b2b8231da 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/model/SendAmountModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/model/SendAmountModel.kt @@ -9,13 +9,13 @@ import com.tangem.common.ui.amountScreen.converters.field.AmountFieldSetMaxAmoun import com.tangem.common.ui.amountScreen.models.AmountParameters import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.common.ui.navigationButtons.NavigationButton import com.tangem.common.ui.navigationButtons.NavigationUM import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer -import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter import com.tangem.core.ui.extensions.WrappedList import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase @@ -29,19 +29,21 @@ import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.features.send.api.entity.PredefinedValues import com.tangem.features.send.api.entity.isFromMainScreenQr +import com.tangem.features.send.api.subcomponents.amount.AmountRoute +import com.tangem.features.send.api.subcomponents.amount.SendAmountComponentParams +import com.tangem.features.send.api.subcomponents.amount.SendAmountReduceListener +import com.tangem.features.send.api.subcomponents.amount.SendAmountUpdateListener import com.tangem.features.send.api.subcomponents.amount.analytics.CommonSendAmountAnalyticEvents import com.tangem.features.send.api.subcomponents.amount.analytics.CommonSendAmountAnalyticEvents.SelectedCurrencyType import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger import com.tangem.features.send.common.CommonSendRoute -import com.tangem.features.send.subcomponents.amount.SendAmountComponentParams -import com.tangem.features.send.subcomponents.amount.SendAmountReduceListener -import com.tangem.features.send.subcomponents.amount.SendAmountUpdateListener import com.tangem.features.send.impl.R import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.extensions.orZero import com.tangem.utils.isNullOrZero import com.tangem.utils.transformer.update import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch import javax.inject.Inject import kotlin.properties.Delegates @@ -271,13 +273,17 @@ internal class SendAmountModel @Inject constructor( override fun onConvertToAnotherToken() { val amountParams = params as? SendAmountComponentParams.AmountParams ?: return - if (amountParams.currentRoute.value.isEditMode) { - sendAmountAlertFactory.showResetSendingAlert { - params.callback.resetSendNavigation() + modelScope.launch { + var isEditMode = false + amountParams.currentRoute.collect { route -> isEditMode = route.isEditMode } + if (isEditMode) { + sendAmountAlertFactory.showResetSendingAlert { + params.callback.resetSendNavigation() + confirmConvertToToken() + } + } else { confirmConvertToToken() } - } else { - confirmConvertToToken() } } @@ -361,7 +367,9 @@ internal class SendAmountModel @Inject constructor( val params = params as? SendAmountComponentParams.AmountParams ?: return combine( flow = uiState, - flow2 = params.currentRoute.filterIsInstance(), + // Filter on the public AmountRoute interface (not the internal CommonSendRoute.Amount) so an + // external host (e.g. staking) that supplies its own AmountRoute is not silently dropped here. + flow2 = params.currentRoute.filterIsInstance(), transform = { state, route -> state to route }, ).onEach { (state, route) -> setSendWithSwapAvailability() diff --git a/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/amount/model/SendAmountNavigationTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/amount/model/SendAmountNavigationTest.kt new file mode 100644 index 0000000000..837e9d2ee3 --- /dev/null +++ b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/amount/model/SendAmountNavigationTest.kt @@ -0,0 +1,179 @@ +package com.tangem.features.send.subcomponents.amount.model + +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.Blockchain +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.common.ui.navigationButtons.NavigationUM +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.model.MutableParamsContainer +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.tokens.GetMinimumTransactionAmountSyncUseCase +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.domain.wallets.usecase.GetWalletsUseCase +import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents +import com.tangem.features.send.api.entity.PredefinedValues +import com.tangem.features.send.api.subcomponents.amount.AmountRoute +import com.tangem.features.send.api.subcomponents.amount.SendAmountComponent +import com.tangem.features.send.api.subcomponents.amount.SendAmountComponentParams +import com.tangem.features.send.api.subcomponents.amount.SendAmountReduceListener +import com.tangem.features.send.api.subcomponents.amount.SendAmountUpdateListener +import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger +import com.tangem.features.send.impl.R +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +/** + * Guards the route-decoupling fix: `SendAmountModel.configAmountNavigation()` filters its route flow on + * the public `AmountRoute` interface, not the `internal CommonSendRoute.Amount`. The test feeds a + * foreign `AmountRoute` (which is NOT a `CommonSendRoute.Amount`) and asserts the navigation result is + * still produced — before the fix the `combine`'s `filterIsInstance()` dropped + * it and `onNavigationResult` never fired, leaving an external host (e.g. staking) with a dead Next + * button. Also checks the `isEditMode` → back-icon / primary-button mapping is unaffected. + */ +@OptIn(ExperimentalCoroutinesApi::class) +internal class SendAmountNavigationTest { + + private val getMinimumTransactionAmountSyncUseCase: GetMinimumTransactionAmountSyncUseCase = mockk() + private val sendAmountReduceListener: SendAmountReduceListener = mockk() + private val sendAmountUpdateListener: SendAmountUpdateListener = mockk() + private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true) + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk() + private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger = mockk(relaxed = true) + private val getUserWalletUseCase: GetUserWalletUseCase = mockk() + private val sendAmountAlertFactory: SendAmountAlertFactory = mockk(relaxed = true) + private val getWalletsUseCase: GetWalletsUseCase = mockk(relaxed = true) + + private val callback: SendAmountComponent.ModelCallback = mockk(relaxed = true) + private val cryptoCurrency = MockCryptoCurrencyFactory().createCoin(Blockchain.Ethereum) + + private var model: SendAmountModel? = null + + @BeforeEach + fun setup() { + clearMocks( + getMinimumTransactionAmountSyncUseCase, + sendAmountReduceListener, + sendAmountUpdateListener, + getSelectedAppCurrencyUseCase, + getUserWalletUseCase, + callback, + ) + // No wallet → the model stays on AmountState.Empty (the heavy AmountStateConverter path is skipped), + // which is all the navigation block needs to emit. + every { getUserWalletUseCase.invokeFlow(any()) } returns emptyFlow() + every { sendAmountReduceListener.reduceToTriggerFlow } returns emptyFlow() + every { sendAmountReduceListener.reduceByTriggerFlow } returns emptyFlow() + every { sendAmountReduceListener.ignoreReduceTriggerFlow } returns emptyFlow() + every { sendAmountUpdateListener.updateAmountTriggerFlow } returns emptyFlow() + coEvery { getSelectedAppCurrencyUseCase.invokeSync() } returns AppCurrency.Default.right() + coEvery { getMinimumTransactionAmountSyncUseCase(any(), any()) } returns BigDecimal.ZERO.right() + } + + @AfterEach + fun tearDown() { + // Cancel modelScope so the long-lived navigation/status collectors stop between tests. + model?.onDestroy() + model = null + } + + @Test + fun `GIVEN a foreign AmountRoute WHEN model created THEN navigation produced with close icon and next button`() = + runTest { + // Arrange — a route that is NOT CommonSendRoute.Amount (the impl type the model used to filter on). + val navSlot = slot() + + // Act + createModel(testScope = this, route = TestAmountRoute(isEditMode = false)) + advanceUntilIdle() + + // Assert — before the fix this never fired for a non-CommonSendRoute.Amount route. + verify(atLeast = 1) { callback.onNavigationResult(capture(navSlot)) } + val content = navSlot.captured as NavigationUM.Content + assertThat(content.backIconRes).isEqualTo(R.drawable.ic_close_24) + assertThat(content.primaryButton.textReference).isEqualTo(resourceReference(R.string.common_next)) + } + + @Test + fun `GIVEN a foreign AmountRoute in edit mode WHEN model created THEN navigation has back icon and continue button`() = + runTest { + // Arrange + val navSlot = slot() + + // Act + createModel(testScope = this, route = TestAmountRoute(isEditMode = true)) + advanceUntilIdle() + + // Assert + verify(atLeast = 1) { callback.onNavigationResult(capture(navSlot)) } + val content = navSlot.captured as NavigationUM.Content + assertThat(content.backIconRes).isEqualTo(R.drawable.ic_back_24) + assertThat(content.primaryButton.textReference).isEqualTo(resourceReference(R.string.common_continue)) + } + + private fun createModel(testScope: TestScope, route: AmountRoute): SendAmountModel { + val params = SendAmountComponentParams.AmountParams( + state = AmountState.Empty, + analyticsCategoryName = "test", + userWalletId = UserWalletId(stringValue = "0123456789"), + appCurrency = AppCurrency.Default, + predefinedValues = PredefinedValues.Empty, + cryptoCurrency = cryptoCurrency, + cryptoCurrencyStatusFlow = MutableStateFlow(mockk(relaxed = true)), + isBalanceHidingFlow = MutableStateFlow(false), + analyticsSendSource = CommonSendAnalyticEvents.CommonSendSource.Send, + accountFlow = MutableStateFlow(null), + isAccountModeFlow = MutableStateFlow(false), + callback = callback, + currentRoute = MutableStateFlow(route), + ) + return SendAmountModel( + paramsContainer = MutableParamsContainer(value = params), + dispatchers = testScope.createTestingCoroutineDispatcherProvider(), + getMinimumTransactionAmountSyncUseCase = getMinimumTransactionAmountSyncUseCase, + sendAmountReduceListener = sendAmountReduceListener, + sendAmountUpdateListener = sendAmountUpdateListener, + analyticsEventHandler = analyticsEventHandler, + getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase, + feeSelectorReloadTrigger = feeSelectorReloadTrigger, + getUserWalletUseCase = getUserWalletUseCase, + sendAmountAlertFactory = sendAmountAlertFactory, + getWalletsUseCase = getWalletsUseCase, + ).also { model = it } + } + + private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider { + val testDispatcher = StandardTestDispatcher(testScheduler) + return TestingCoroutineDispatcherProvider( + main = testDispatcher, + mainImmediate = testDispatcher, + io = testDispatcher, + default = testDispatcher, + single = testDispatcher, + ) + } + + private data class TestAmountRoute(override val isEditMode: Boolean) : AmountRoute +} \ No newline at end of file diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandlerTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandlerTest.kt index deefc4ebdf..3301f14437 100644 --- a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandlerTest.kt +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/deeplink/DefaultTokenDetailsDeepLinkHandlerTest.kt @@ -22,7 +22,7 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isLocked import com.tangem.domain.models.wallet.isMultiCurrency import com.tangem.domain.tokens.wallet.WalletBalanceFetcher -import com.tangem.domain.wallets.models.GetUserWalletError +import com.tangem.domain.wallets.models.errors.GetUserWalletError import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.domain.wallets.usecase.SelectWalletUseCase diff --git a/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/deeplink/DefaultPromoDeeplinkHandlerTest.kt b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/deeplink/DefaultPromoDeeplinkHandlerTest.kt index b1d532d910..298beb8462 100644 --- a/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/deeplink/DefaultPromoDeeplinkHandlerTest.kt +++ b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/deeplink/DefaultPromoDeeplinkHandlerTest.kt @@ -24,8 +24,8 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier import com.tangem.domain.wallets.PromoCodeActivationResult -import com.tangem.domain.wallets.models.GetUserWalletError import com.tangem.domain.wallets.models.errors.ActivatePromoCodeError +import com.tangem.domain.wallets.models.errors.GetUserWalletError import com.tangem.domain.wallets.usecase.ActivateBitcoinPromocodeUseCase import com.tangem.domain.wallets.usecase.BindRefcodeWithWalletUseCase import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase From ef8a13cfaa42b3b298c12d86fc72c88007716ffa Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 24 Jun 2026 17:32:48 +0400 Subject: [PATCH 034/210] Updated on 2026-08-14 --- .../1.json | 81 ++++++++++++++++++- .../converter/OnrampCountryEntityConverter.kt | 23 ++++++ .../local/txhistory/db/TxHistoryDatabase.kt | 2 + .../txhistory/db/dao/ExpressHistoryDao.kt | 8 ++ .../db/entity/express/OnrampCountryEntity.kt | 52 ++++++++++++ .../data/onramp/DefaultOnrampRepository.kt | 8 +- data/txhistory/build.gradle.kts | 2 + .../fetcher/DefaultAppTxHistoryFetcher.kt | 18 +++-- .../RefactoredTxHistoryRepository.kt | 47 +++++++---- .../converter/ExpressTxHistoryConverter.kt | 3 + .../converter/OnrampCountryConverter.kt | 29 +++++++ .../fetcher/DefaultAppTxHistoryFetcherTest.kt | 36 ++++----- domain/express/models/build.gradle.kts | 1 + .../express/models/OnrampTransaction.kt | 3 + 14 files changed, 267 insertions(+), 46 deletions(-) create mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/converter/OnrampCountryEntityConverter.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/entity/express/OnrampCountryEntity.kt create mode 100644 data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/converter/OnrampCountryConverter.kt diff --git a/core/datasource/schemas/com.tangem.datasource.local.txhistory.db.TxHistoryDatabase/1.json b/core/datasource/schemas/com.tangem.datasource.local.txhistory.db.TxHistoryDatabase/1.json index 41c9ded541..cfd76c709e 100644 --- a/core/datasource/schemas/com.tangem.datasource.local.txhistory.db.TxHistoryDatabase/1.json +++ b/core/datasource/schemas/com.tangem.datasource.local.txhistory.db.TxHistoryDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "442ac578743a8b624777711cf49c77e2", + "identityHash": "55f2651d215126dd0465b9c711165cba", "entities": [ { "tableName": "express_provider", @@ -474,11 +474,88 @@ "address" ] } + }, + { + "tableName": "onramp_country", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`code` TEXT NOT NULL, `name` TEXT NOT NULL, `image` TEXT NOT NULL, `alpha3` TEXT NOT NULL, `continent` TEXT NOT NULL, `onramp_available` INTEGER NOT NULL, `currency_name` TEXT NOT NULL, `currency_code` TEXT NOT NULL, `currency_image` TEXT, `currency_precision` INTEGER NOT NULL, `currency_unit` TEXT NOT NULL, PRIMARY KEY(`code`))", + "fields": [ + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "alpha3", + "columnName": "alpha3", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "continent", + "columnName": "continent", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "onrampAvailable", + "columnName": "onramp_available", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultCurrency.name", + "columnName": "currency_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultCurrency.code", + "columnName": "currency_code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultCurrency.image", + "columnName": "currency_image", + "affinity": "TEXT" + }, + { + "fieldPath": "defaultCurrency.precision", + "columnName": "currency_precision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultCurrency.unit", + "columnName": "currency_unit", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "code" + ] + } } ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '442ac578743a8b624777711cf49c77e2')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '55f2651d215126dd0465b9c711165cba')" ] } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/converter/OnrampCountryEntityConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/converter/OnrampCountryEntityConverter.kt new file mode 100644 index 0000000000..56ec2a01f8 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/converter/OnrampCountryEntityConverter.kt @@ -0,0 +1,23 @@ +package com.tangem.datasource.local.converter + +import com.tangem.datasource.api.onramp.models.response.model.OnrampCountryDTO +import com.tangem.datasource.local.txhistory.db.entity.express.OnrampCountryEntity + +/** Maps an [OnrampCountryDTO] API response into its persisted [OnrampCountryEntity]. */ +fun OnrampCountryDTO.toEntity(): OnrampCountryEntity { + return OnrampCountryEntity( + code = code, + name = name, + image = image, + alpha3 = alpha3, + continent = continent, + isOnrampAvailable = onrampAvailable, + defaultCurrency = OnrampCountryEntity.CurrencyEmbedded( + name = defaultCurrency.name, + code = defaultCurrency.code, + image = defaultCurrency.image, + precision = defaultCurrency.precision, + unit = defaultCurrency.unit ?: defaultCurrency.code, + ), + ) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/TxHistoryDatabase.kt b/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/TxHistoryDatabase.kt index 549e980d64..331c2ce3e7 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/TxHistoryDatabase.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/TxHistoryDatabase.kt @@ -8,6 +8,7 @@ import com.tangem.datasource.local.txhistory.db.entity.express.ExpressSyncStateE import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity import com.tangem.datasource.local.txhistory.db.entity.express.ExpressProviderEntity +import com.tangem.datasource.local.txhistory.db.entity.express.OnrampCountryEntity @Database( version = 1, @@ -16,6 +17,7 @@ import com.tangem.datasource.local.txhistory.db.entity.express.ExpressProviderEn ExpressExchangeEntity::class, ExpressOnrampEntity::class, ExpressSyncStateEntity::class, + OnrampCountryEntity::class, ], ) abstract class TxHistoryDatabase : RoomDatabase() { diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/dao/ExpressHistoryDao.kt b/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/dao/ExpressHistoryDao.kt index 802598fc3d..9736bc2b17 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/dao/ExpressHistoryDao.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/dao/ExpressHistoryDao.kt @@ -8,6 +8,7 @@ import androidx.room.Query import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity import com.tangem.datasource.local.txhistory.db.entity.express.ExpressProviderEntity +import com.tangem.datasource.local.txhistory.db.entity.express.OnrampCountryEntity import kotlinx.coroutines.flow.Flow @Dao @@ -22,12 +23,19 @@ interface ExpressHistoryDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertOnramps(items: List) + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertCountries(items: List) + /** * All persisted providers keyed by [ExpressProviderEntity.id] */ @Query("SELECT * FROM express_provider") fun getProvidersById(): Flow> + /** All persisted onramp countries keyed by [OnrampCountryEntity.code]. */ + @Query("SELECT * FROM onramp_country") + fun getCountriesByCode(): Flow> + /** * Outgoing swaps: the viewed currency is the swap's `from` side, so the row is stored under this * address ([ExpressExchangeEntity.ownerAddress] == fromAddress). Join to on-chain by `payin_hash`. diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/entity/express/OnrampCountryEntity.kt b/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/entity/express/OnrampCountryEntity.kt new file mode 100644 index 0000000000..5ee6ac7ddd --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/txhistory/db/entity/express/OnrampCountryEntity.kt @@ -0,0 +1,52 @@ +package com.tangem.datasource.local.txhistory.db.entity.express + +import androidx.room.ColumnInfo +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** Persisted onramp country, matched to a transaction by [code] == [ExpressOnrampEntity.countryCode]. */ +@Entity(tableName = "onramp_country") +data class OnrampCountryEntity( + + @PrimaryKey + @ColumnInfo(name = "code") + val code: String, + + @ColumnInfo(name = "name") + val name: String, + + @ColumnInfo(name = "image") + val image: String, + + @ColumnInfo(name = "alpha3") + val alpha3: String, + + @ColumnInfo(name = "continent") + val continent: String, + + @ColumnInfo(name = "onramp_available") + val isOnrampAvailable: Boolean, + + @Embedded(prefix = "currency_") + val defaultCurrency: CurrencyEmbedded, +) { + + data class CurrencyEmbedded( + + @ColumnInfo(name = "name") + val name: String, + + @ColumnInfo(name = "code") + val code: String, + + @ColumnInfo(name = "image") + val image: String?, + + @ColumnInfo(name = "precision") + val precision: Int, + + @ColumnInfo(name = "unit") + val unit: String, + ) +} \ No newline at end of file diff --git a/data/onramp/src/main/java/com/tangem/data/onramp/DefaultOnrampRepository.kt b/data/onramp/src/main/java/com/tangem/data/onramp/DefaultOnrampRepository.kt index e783308ca6..6d69d7ab7b 100644 --- a/data/onramp/src/main/java/com/tangem/data/onramp/DefaultOnrampRepository.kt +++ b/data/onramp/src/main/java/com/tangem/data/onramp/DefaultOnrampRepository.kt @@ -115,7 +115,7 @@ internal class DefaultOnrampRepository( override suspend fun fetchCountries(userWallet: UserWallet): List = withContext(dispatchers.io) { if (!countriesStore.getSyncOrNull(COUNTRIES_KEY).isNullOrEmpty()) return@withContext emptyList() - val result = onrampApi.getCountries( + val response = onrampApi.getCountries( userWalletId = userWallet.walletId.stringValue, refCode = ExpressUtils.getRefCode( userWallet = userWallet, @@ -123,8 +123,12 @@ internal class DefaultOnrampRepository( ), ) .getOrThrow() - .map(countryConverter::convert) + if (txHistoryFeatureToggles.isNewTxHistoryEnabled) { + expressHistoryDao.upsertCountries(response.map { it.toEntity() }) + } + + val result = response.map(countryConverter::convert) countriesStore.store(COUNTRIES_KEY, result) result diff --git a/data/txhistory/build.gradle.kts b/data/txhistory/build.gradle.kts index 0320d9912c..54c7e3792f 100644 --- a/data/txhistory/build.gradle.kts +++ b/data/txhistory/build.gradle.kts @@ -31,6 +31,8 @@ dependencies { implementation(projects.domain.express.models) implementation(projects.domain.wallets.models) implementation(projects.domain.wallets) + implementation(projects.domain.onramp) + implementation(projects.domain.onramp.models) implementation(projects.domain.account) implementation(projects.domain.account.status) diff --git a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/fetcher/DefaultAppTxHistoryFetcher.kt b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/fetcher/DefaultAppTxHistoryFetcher.kt index c4e4e208a3..3141aa5a00 100644 --- a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/fetcher/DefaultAppTxHistoryFetcher.kt +++ b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/fetcher/DefaultAppTxHistoryFetcher.kt @@ -9,6 +9,7 @@ import com.tangem.domain.express.ExpressRepository import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isMultiCurrency +import com.tangem.domain.onramp.repositories.OnrampRepository import com.tangem.domain.txhistory.fetcher.AppTxHistoryFetcher import com.tangem.domain.txhistory.fetcher.TxHistoryFetchTrigger import com.tangem.domain.txhistory.fetcher.WalletTxHistoryFetcher @@ -22,6 +23,7 @@ import javax.inject.Inject internal class DefaultAppTxHistoryFetcher @Inject constructor( private val utils: TxHistoryFetcherUtils, private val expressRepository: ExpressRepository, + private val onrampRepository: OnrampRepository, private val getWalletsUseCase: GetWalletsUseCase, private val selectedWalletUseCase: GetSelectedWalletUseCase, private val walletTxHistoryFetcherFactory: DefaultWalletTxHistoryFetcher.Factory, @@ -30,9 +32,6 @@ internal class DefaultAppTxHistoryFetcher @Inject constructor( @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal val fetchers = ConcurrentHashMap() - /** Wallets whose express providers were already loaded — to load them at most once per wallet. */ - private val providersLoadedWallets = mutableSetOf() - init { defaultLaunchIn(buildFlow()) } @@ -53,11 +52,14 @@ internal class DefaultAppTxHistoryFetcher @Inject constructor( .stateIn(this) walletsFlow.value.keys.createForNewWallets() + walletsFlow.value.values.firstOrNull()?.let { wallet -> + loadExpressProviders(wallet) + loadOnrampCountries(wallet) + } selectedWalletUseCase.selectedFlow() .filter { wallet -> wallet.isMultiCurrency } // todo txhistory some init trigger? - .onEach { wallet -> loadExpressProviders(wallet) } .launchIn(this) walletsFlow @@ -79,13 +81,17 @@ internal class DefaultAppTxHistoryFetcher @Inject constructor( } private fun ProducerScope<*>.loadExpressProviders(wallet: UserWallet) { - // Load once per wallet: `add` returns false if this walletId was already loaded. - if (!providersLoadedWallets.add(wallet.walletId)) return flow { emit(expressRepository.getProviders(userWallet = wallet, filterProviderTypes = emptyList())) } .retryThreeTimes() .launchIn(this) } + private fun ProducerScope<*>.loadOnrampCountries(wallet: UserWallet) { + flow { emit(onrampRepository.fetchCountries(userWallet = wallet)) } + .retryThreeTimes() + .launchIn(this) + } + private fun Flow>.createForNewWallets() = onEach { ids -> ids.createForNewWallets() } private fun Set.createForNewWallets() = this.forEach { walletId -> getOrPutFetcher(walletId) } diff --git a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/RefactoredTxHistoryRepository.kt b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/RefactoredTxHistoryRepository.kt index 704a77c58c..4035351559 100644 --- a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/RefactoredTxHistoryRepository.kt +++ b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/RefactoredTxHistoryRepository.kt @@ -5,6 +5,7 @@ import com.tangem.data.common.converter.ExpressProviderConverter import com.tangem.data.txhistory.repository.converter.ExpressStatusMapper import com.tangem.data.txhistory.repository.converter.ExpressOnrampConverter import com.tangem.data.txhistory.repository.converter.ExpressSwapConverter +import com.tangem.data.txhistory.repository.converter.OnrampCountryConverter import com.tangem.data.txhistory.repository.factory.ExpressTransactionAssetFactory import com.tangem.data.txhistory.repository.factory.toAssetId import com.tangem.data.txhistory.repository.paging.TxHistoryPageBatchFetcher @@ -13,6 +14,7 @@ import com.tangem.datasource.local.txhistory.db.dao.ExpressHistoryDao import com.tangem.datasource.local.txhistory.db.entity.express.ExpressExchangeEntity import com.tangem.datasource.local.txhistory.db.entity.express.ExpressOnrampEntity import com.tangem.datasource.local.txhistory.db.entity.express.ExpressProviderEntity +import com.tangem.datasource.local.txhistory.db.entity.express.OnrampCountryEntity import com.tangem.domain.express.models.ExpressAsset import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.TxInfo @@ -50,6 +52,7 @@ internal class RefactoredTxHistoryRepository @Inject constructor( private val expressProviderConverter = ExpressProviderConverter() private val swapConverter = ExpressSwapConverter() private val onrampConverter = ExpressOnrampConverter() + private val onrampCountryConverter = OnrampCountryConverter() private val TxHistoryListConfig.storeKey get() = TxHistoryItemsStore.Key(userWalletId, currency) override fun getExpressHistory( @@ -90,35 +93,46 @@ internal class RefactoredTxHistoryRepository @Inject constructor( activeStatuses = ExpressStatusMapper.activeOnrampStatuses, ).distinctUntilChanged(), flow4 = expressHistoryDao.getProvidersById().distinctUntilChanged(), - transform = { outgoingSwaps, incomingSwaps, onramps, providers -> + flow5 = expressHistoryDao.getCountriesByCode().distinctUntilChanged(), + transform = { outgoingSwaps, incomingSwaps, onramps, providers, countries -> buildExpressHistory( userWalletId = userWalletId, - outgoingSwaps = outgoingSwaps, - incomingSwaps = incomingSwaps, - onramps = onramps, - providers = providers, + sources = ExpressHistorySources( + outgoingSwaps = outgoingSwaps, + incomingSwaps = incomingSwaps, + onramps = onramps, + providers = providers, + countries = countries, + ), ) }, ) emitAll(flow) }.flowOn(dispatchers.io) + /** The reactive express-history inputs gathered from the DB in a single [combine] tick. */ + private data class ExpressHistorySources( + val outgoingSwaps: List, + val incomingSwaps: List, + val onramps: List, + val providers: Map, + val countries: Map, + ) + private suspend fun buildExpressHistory( userWalletId: UserWalletId, - outgoingSwaps: List, - incomingSwaps: List, - onramps: List, - providers: Map, + sources: ExpressHistorySources, ): List { val currencies = expressTransactionAssetFactory.create( userWalletId = userWalletId, - outgoingSwaps = outgoingSwaps, - incomingSwaps = incomingSwaps, - onramps = onramps, + outgoingSwaps = sources.outgoingSwaps, + incomingSwaps = sources.incomingSwaps, + onramps = sources.onramps, ) - fun String.expressProvider() = providers[this]?.let(expressProviderConverter::convert) + fun String.expressProvider() = sources.providers[this]?.let(expressProviderConverter::convert) + fun String.onrampCountry() = sources.countries[this]?.let(onrampCountryConverter::convert) return buildList { - outgoingSwaps.forEach { entity -> + sources.outgoingSwaps.forEach { entity -> val input = ExpressSwapConverter.Input( entity = entity, provider = entity.providerId.expressProvider(), @@ -128,7 +142,7 @@ internal class RefactoredTxHistoryRepository @Inject constructor( ) add(swapConverter.convert(input)) } - incomingSwaps.forEach { entity -> + sources.incomingSwaps.forEach { entity -> val input = ExpressSwapConverter.Input( entity = entity, provider = entity.providerId.expressProvider(), @@ -138,11 +152,12 @@ internal class RefactoredTxHistoryRepository @Inject constructor( ) add(swapConverter.convert(input)) } - onramps.forEach { entity -> + sources.onramps.forEach { entity -> val input = ExpressOnrampConverter.Input( entity = entity, provider = entity.providerId.expressProvider(), toCurrency = currencies[entity.to.toAssetId()], + country = entity.countryCode.onrampCountry(), ) add(onrampConverter.convert(input)) } diff --git a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverter.kt b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverter.kt index 5f07b6dc5a..8e30176f4a 100644 --- a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverter.kt +++ b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverter.kt @@ -10,6 +10,7 @@ import com.tangem.domain.express.models.ExpressProvider import com.tangem.domain.express.models.ExpressTransactionAsset import com.tangem.domain.express.models.OnrampTransaction import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.onramp.model.OnrampCountry import com.tangem.domain.tokens.model.Amount import com.tangem.domain.tokens.model.AmountType import com.tangem.domain.txhistory.model.ExpressTx @@ -64,6 +65,7 @@ internal class ExpressOnrampConverter : Converter { + + override fun convert(value: OnrampCountryEntity): OnrampCountry { + return OnrampCountry( + id = "${value.alpha3}-${value.name}", + name = value.name, + code = value.code, + image = value.image, + alpha3 = value.alpha3, + continent = value.continent, + defaultCurrency = OnrampCurrency( + name = value.defaultCurrency.name, + code = value.defaultCurrency.code, + image = value.defaultCurrency.image, + precision = value.defaultCurrency.precision, + unit = value.defaultCurrency.unit, + ), + onrampAvailable = value.isOnrampAvailable, + ) + } +} \ No newline at end of file diff --git a/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/fetcher/DefaultAppTxHistoryFetcherTest.kt b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/fetcher/DefaultAppTxHistoryFetcherTest.kt index 886dbb1c83..45ce663c52 100644 --- a/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/fetcher/DefaultAppTxHistoryFetcherTest.kt +++ b/data/txhistory/src/test/kotlin/com/tangem/data/txhistory/fetcher/DefaultAppTxHistoryFetcherTest.kt @@ -7,6 +7,7 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.express.ExpressRepository import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.onramp.repositories.OnrampRepository import com.tangem.domain.txhistory.fetcher.TxHistoryFetchTrigger import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase import com.tangem.domain.wallets.usecase.GetWalletsUseCase @@ -14,7 +15,6 @@ import io.mockk.* import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.job import kotlinx.coroutines.test.* import org.junit.jupiter.api.BeforeEach @@ -29,14 +29,16 @@ internal class DefaultAppTxHistoryFetcherTest { private val selectedWalletUseCase: GetSelectedWalletUseCase = mockk() private val walletFetcherFactory: DefaultWalletTxHistoryFetcher.Factory = mockk() private val expressRepository: ExpressRepository = mockk() + private val onrampRepository: OnrampRepository = mockk() private val currency: CryptoCurrency = MockCryptoCurrencyFactory().ethereum @BeforeEach fun setup() { - clearMocks(getWalletsUseCase, selectedWalletUseCase, walletFetcherFactory, expressRepository) + clearMocks(getWalletsUseCase, selectedWalletUseCase, walletFetcherFactory, expressRepository, onrampRepository) every { selectedWalletUseCase.selectedFlow() } returns emptyFlow() coEvery { expressRepository.getProviders(any(), any()) } returns emptyList() + coEvery { onrampRepository.fetchCountries(any()) } returns emptyList() } @Test @@ -147,15 +149,16 @@ internal class DefaultAppTxHistoryFetcherTest { } @Test - fun `loads express providers when selected wallet is multi-currency`() = runTest { + fun `loads express providers and onramp countries for the first wallet on init`() = runTest { // Arrange val utils = createUtils() - every { getWalletsUseCase.invokeAsMap(any(), any()) } returns MutableStateFlow(linkedMapOf()) val wallet = mockk(relaxed = true) { every { isMultiCurrency } returns true every { walletId } returns WALLET_ID_1 } - every { selectedWalletUseCase.selectedFlow() } returns flowOf(wallet) + every { getWalletsUseCase.invokeAsMap(any(), any()) } returns + MutableStateFlow(linkedMapOf(WALLET_ID_1 to wallet)) + every { walletFetcherFactory.create(WALLET_ID_1) } returns relaxedWalletFetcher() // Act createFetcher(utils) @@ -163,48 +166,40 @@ internal class DefaultAppTxHistoryFetcherTest { // Assert coVerify(exactly = 1) { expressRepository.getProviders(wallet, emptyList()) } + coVerify(exactly = 1) { onrampRepository.fetchCountries(wallet) } } @Test - fun `loads express providers only once per wallet`() = runTest { + fun `does not load express data when there are no wallets`() = runTest { // Arrange val utils = createUtils() every { getWalletsUseCase.invokeAsMap(any(), any()) } returns MutableStateFlow(linkedMapOf()) - val wallet = mockk(relaxed = true) { - every { isMultiCurrency } returns true - every { walletId } returns WALLET_ID_1 - } - // Same wallet selected several times. - every { selectedWalletUseCase.selectedFlow() } returns flowOf(wallet, wallet, wallet) // Act createFetcher(utils) advanceUntilIdle() // Assert - coVerify(exactly = 1) { expressRepository.getProviders(wallet, emptyList()) } + coVerify(inverse = true) { expressRepository.getProviders(any(), any()) } + coVerify(inverse = true) { onrampRepository.fetchCountries(any()) } } @Test fun `provider loading failure does not break the wallet pipeline`() = runTest { // Arrange val utils = createUtils() - val walletsFlow = MutableStateFlow(linkedMapOf()) - every { getWalletsUseCase.invokeAsMap(any(), any()) } returns walletsFlow val wallet = mockk(relaxed = true) { every { isMultiCurrency } returns true every { walletId } returns WALLET_ID_1 } - every { selectedWalletUseCase.selectedFlow() } returns flowOf(wallet) + every { getWalletsUseCase.invokeAsMap(any(), any()) } returns + MutableStateFlow(linkedMapOf(WALLET_ID_1 to wallet)) coEvery { expressRepository.getProviders(any(), any()) } throws RuntimeException("boom") val walletFetcher1 = relaxedWalletFetcher() every { walletFetcherFactory.create(WALLET_ID_1) } returns walletFetcher1 - val fetcher = createFetcher(utils) - advanceUntilIdle() - // Act — the provider error is swallowed, so the wallet pipeline must keep working. - walletsFlow.value = linkedMapOf(WALLET_ID_1 to mockk()) + val fetcher = createFetcher(utils) advanceUntilIdle() // Assert @@ -220,6 +215,7 @@ internal class DefaultAppTxHistoryFetcherTest { private fun createFetcher(utils: DefaultTxHistoryFetcherUtils) = DefaultAppTxHistoryFetcher( utils = utils, expressRepository = expressRepository, + onrampRepository = onrampRepository, getWalletsUseCase = getWalletsUseCase, selectedWalletUseCase = selectedWalletUseCase, walletTxHistoryFetcherFactory = walletFetcherFactory, diff --git a/domain/express/models/build.gradle.kts b/domain/express/models/build.gradle.kts index a63174eff0..0ebcc0b441 100644 --- a/domain/express/models/build.gradle.kts +++ b/domain/express/models/build.gradle.kts @@ -9,4 +9,5 @@ dependencies { implementation(deps.kotlin.serialization) implementation(projects.domain.models) implementation(projects.domain.tokens.models) + implementation(projects.domain.onramp.models) } \ No newline at end of file diff --git a/domain/express/models/src/main/java/com/tangem/domain/express/models/OnrampTransaction.kt b/domain/express/models/src/main/java/com/tangem/domain/express/models/OnrampTransaction.kt index 4940b3d055..e6388dee75 100644 --- a/domain/express/models/src/main/java/com/tangem/domain/express/models/OnrampTransaction.kt +++ b/domain/express/models/src/main/java/com/tangem/domain/express/models/OnrampTransaction.kt @@ -1,5 +1,6 @@ package com.tangem.domain.express.models +import com.tangem.domain.onramp.model.OnrampCountry import com.tangem.domain.tokens.model.Amount import com.tangem.domain.tokens.model.AmountType @@ -15,6 +16,7 @@ import com.tangem.domain.tokens.model.AmountType * @property payoutHash On-chain hash of the payout (received) leg, if known. * @property fromFiat The fiat paid. * @property toAsset The crypto asset received. + * @property country The country the onramp was made from; `null` if not resolved. */ data class OnrampTransaction( val txId: String, @@ -25,4 +27,5 @@ data class OnrampTransaction( /** The [Amount.type] is [AmountType.FiatType] . */ val fromFiat: Amount, val toAsset: ExpressTransactionAsset, + val country: OnrampCountry? = null, ) \ No newline at end of file From 2f60ccf3fc9a24c7e6c1f84fe19c66fc7a251c49 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 24 Jun 2026 16:33:01 +0300 Subject: [PATCH 035/210] Updated on 2026-08-14 --- core/res/src/main/res/values/strings.xml | 2 + .../transactions/TransactionItem.kt | 218 ++++++++++--- .../transactions/state/TransactionItemUM.kt | 20 +- .../converter/ExpressTxHistoryConverter.kt | 15 +- .../ExpressTxHistoryConverterTest.kt | 27 +- .../express/models/ExpressTransactionAsset.kt | 4 +- .../factory/express/ExpressStatusFactory.kt | 19 +- .../ExpressTxToTransactionItemUMConverter.kt | 191 ++++++++++++ ...HistoryInfoToTransactionItemUMConverter.kt | 9 +- .../txhistory/model/TxHistoryModel.kt | 9 +- .../txhistory/utils/TxHistoryInfoMerger.kt | 46 --- ...pressTxToTransactionItemUMConverterTest.kt | 289 ++++++++++++++++++ ...oryItemToTransactionItemUMConverterTest.kt | 16 +- .../utils/TxHistoryInfoMergerTest.kt | 15 - 14 files changed, 732 insertions(+), 148 deletions(-) create mode 100644 features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/ExpressTxToTransactionItemUMConverter.kt create mode 100644 features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/ExpressTxToTransactionItemUMConverterTest.kt diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 8277d57104..91a906d6b8 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -2051,6 +2051,8 @@ Tangem Twin This action is irreversible. You will not have access to the old wallet. Tap the twin card with number %s and do not remove until the end of the operation + Top up + Topped up Please try again later. If the issue persists, please contact support. Something went wrong! We\'ve encountered an error. Error code: %s. Please contact our support. diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionItem.kt b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionItem.kt index a0c9c33daa..0cacb2de38 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionItem.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionItem.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -32,6 +33,8 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import com.tangem.core.ui.R +import com.tangem.core.ui.components.currency.icon.CurrencyIcon +import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.icons.identicon.IdentIcon import com.tangem.core.ui.components.transactions.state.TransactionItemUM import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Direction @@ -70,55 +73,92 @@ fun TransactionItem(state: TransactionItemUM, isBalanceHidden: Boolean, modifier @Composable private fun ContentItem(state: TransactionItemUM.Content, isBalanceHidden: Boolean, modifier: Modifier = Modifier) { - val rowModifier = modifier - .fillMaxWidth() - .clickable(onClick = state.onClick) - .testTag(TransactionHistoryItemTestTags.ITEM) - - TangemRowContainer( - modifier = rowModifier, - contentPadding = PaddingValues( - horizontal = TangemTheme.dimens2.x4, - vertical = TangemTheme.dimens2.x3, - ), + Column( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = state.onClick) + .testTag(TransactionHistoryItemTestTags.ITEM), ) { - StatusCircle( - iconRes = state.iconRes, - status = state.status, - modifier = Modifier - .layoutId(TangemRowLayoutId.HEAD) - .padding(end = TangemTheme.dimens2.x3) - .size(TangemTheme.dimens2.x10) - .testTag(TransactionHistoryItemTestTags.STATUS_PREFIX + state.status.testTagSuffix), + TangemRowContainer( + contentPadding = PaddingValues( + horizontal = TangemTheme.dimens2.x4, + vertical = TangemTheme.dimens2.x3, + ), + ) { + StatusCircle( + iconRes = state.iconRes, + status = state.status, + modifier = Modifier + .layoutId(TangemRowLayoutId.HEAD) + .padding(end = TangemTheme.dimens2.x3) + .size(TangemTheme.dimens2.x10) + .testTag(TransactionHistoryItemTestTags.STATUS_PREFIX + state.status.testTagSuffix), + ) + TitleText( + title = state.title, + status = state.status, + modifier = Modifier + .layoutId(TangemRowLayoutId.START_TOP) + .testTag(TransactionHistoryItemTestTags.TITLE), + ) + SubtitleText( + subtitle = state.subtitle, + status = state.status, + modifier = Modifier + .layoutId(TangemRowLayoutId.START_BOTTOM) + .padding(top = TangemTheme.dimens2.x0_5), + ) + state.amount?.let { amount -> + AmountText( + amount = amount, + status = state.status, + isBalanceHidden = isBalanceHidden, + modifier = Modifier + .layoutId(TangemRowLayoutId.END_TOP) + .testTag(TransactionHistoryItemTestTags.AMOUNT), + ) + } + CurrencyText( + symbol = state.currencySymbol, + modifier = Modifier + .layoutId(TangemRowLayoutId.END_BOTTOM) + .padding(top = TangemTheme.dimens2.x0_5) + .testTag(TransactionHistoryItemTestTags.CURRENCY), + ) + } + state.warning?.let { warning -> + WarningLine( + warning = warning, + modifier = Modifier.padding( + start = TangemTheme.dimens2.x4, + end = TangemTheme.dimens2.x4, + bottom = TangemTheme.dimens2.x3, + ), + ) + } + } +} + +@Composable +private fun WarningLine(warning: TextReference, modifier: Modifier = Modifier) { + val attention = TangemTheme.colors2.text.status.attention + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2), + ) { + Icon( + painter = painterResource(R.drawable.ic_alert_triangle_20), + contentDescription = null, + tint = attention, + modifier = Modifier.size(TangemTheme.dimens2.x5), ) - TitleText( - title = state.title, - status = state.status, - modifier = Modifier - .layoutId(TangemRowLayoutId.START_TOP) - .testTag(TransactionHistoryItemTestTags.TITLE), - ) - SubtitleText( - subtitle = state.subtitle, - status = state.status, - modifier = Modifier - .layoutId(TangemRowLayoutId.START_BOTTOM) - .padding(top = TangemTheme.dimens2.x0_5), - ) - AmountText( - amount = state.amount, - status = state.status, - isBalanceHidden = isBalanceHidden, - modifier = Modifier - .layoutId(TangemRowLayoutId.END_TOP) - .testTag(TransactionHistoryItemTestTags.AMOUNT), - ) - CurrencyText( - symbol = state.currencySymbol, - modifier = Modifier - .layoutId(TangemRowLayoutId.END_BOTTOM) - .padding(top = TangemTheme.dimens2.x0_5) - .testTag(TransactionHistoryItemTestTags.CURRENCY), + Text( + text = warning.resolveReference(), + color = attention, + style = TangemTheme.typography2.captionMedium12, + maxLines = 2, + overflow = TextOverflow.Ellipsis, ) } } @@ -262,6 +302,21 @@ private fun SubtitleText(subtitle: ContentSubtitle, status: Status, modifier: Mo modifier = Modifier.fillMaxSize(), ) } + is ContentSubtitle.Asset -> InlineImageSubtitle( + template = stringResourceSafe(subtitle.direction.templateResId(), subtitle.symbol), + color = tertiary, + afterIconColor = if (isFailed) tertiary else primary, + modifier = modifier, + ) { + subtitle.icon?.let { iconState -> + CurrencyIcon( + state = iconState, + shouldDisplayNetwork = false, + withFixedSize = false, + modifier = Modifier.fillMaxSize(), + ) + } + } } } @@ -491,4 +546,73 @@ private fun Preview_TransactionItem_Swap() { } } +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_TransactionItem_Express() { + TangemThemePreviewRedesign { + PreviewColumn( + items = listOf( + TransactionItemUM.Content( + txHash = "exp-swap-u", + amount = "-390.00", + currencySymbol = "USDT", + time = "", + status = Status.Unconfirmed, + direction = Direction.OUTGOING, + onClick = {}, + iconRes = R.drawable.ic_exchange_vertical_24, + title = stringReference("Swapping"), + subtitle = ContentSubtitle.Asset( + direction = ContentSubtitle.Direction.TO, + symbol = "POL", + icon = CurrencyIconState.CoinIcon( + url = null, + fallbackResId = R.drawable.ic_custom_token_44, + isGrayscale = false, + shouldShowCustomBadge = false, + ), + ), + timestamp = 0L, + warning = stringReference("KYC verification required by provider"), + ), + TransactionItemUM.Content( + txHash = "exp-onramp-c", + amount = "+0.006339", + currencySymbol = "BTC", + time = "", + status = Status.Confirmed, + direction = Direction.INCOMING, + onClick = {}, + iconRes = R.drawable.ic_tangem_card_24, + title = stringReference("Topped up"), + subtitle = ContentSubtitle.Asset( + direction = ContentSubtitle.Direction.FROM, + symbol = "SEK", + icon = null, + ), + timestamp = 0L, + ), + TransactionItemUM.Content( + txHash = "exp-onramp-f", + amount = "0.006339", + currencySymbol = "BTC", + time = "", + status = Status.Failed, + direction = Direction.INCOMING, + onClick = {}, + iconRes = R.drawable.ic_tangem_card_24, + title = stringReference("Top up failed"), + subtitle = ContentSubtitle.Asset( + direction = ContentSubtitle.Direction.FROM, + symbol = "SEK", + icon = null, + ), + timestamp = 0L, + ), + ), + ) + } +} + // endregion \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/state/TransactionItemUM.kt b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/state/TransactionItemUM.kt index 1c3adfcd0e..7bce8ba673 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/state/TransactionItemUM.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/state/TransactionItemUM.kt @@ -3,6 +3,7 @@ package com.tangem.core.ui.components.transactions.state import androidx.annotation.DrawableRes import androidx.compose.runtime.Immutable import androidx.compose.ui.graphics.Color +import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.ds.image.DeviceIconUM import com.tangem.core.ui.extensions.TextReference @@ -22,12 +23,13 @@ sealed interface TransactionItemUM { /** * Content state. * - * @property amount signed numeric value, e.g. "+0.500913" / "-350.31"; no currency symbol embedded + * @property amount signed numeric value, e.g. "+0.500913" / "-350.31"; no currency symbol embedded. + * `null` hides the numeric value while [currencySymbol] still shows. * @property currencySymbol currency symbol shown alongside [amount], e.g. "BTC", "USDT" */ data class Content( override val txHash: String, - val amount: String, + val amount: String?, val currencySymbol: String, val time: String, val status: Status, @@ -37,6 +39,7 @@ sealed interface TransactionItemUM { val title: TextReference, val subtitle: ContentSubtitle, val timestamp: Long, + val warning: TextReference? = null, ) : TransactionItemUM { @Immutable @@ -95,6 +98,19 @@ sealed interface TransactionItemUM { val deviceIconUM: DeviceIconUM, ) : ContentSubtitle + /** + * Counterparty asset ticker — renders as "to/from: ". Used for express rows + * (swap counterparty currency / onramp fiat), e.g. "to: ◎ POL" or "from: 🇸🇪 SEK". + * + * @property icon resolved counterparty currency icon, rendered via `CurrencyIcon`. `null` when no icon + * is available (e.g. onramp fiat carries no `CryptoCurrency`) — the ticker then renders without a leading icon. + */ + data class Asset( + val direction: Direction, + val symbol: String, + val icon: CurrencyIconState?, + ) : ContentSubtitle + enum class Direction { TO, FROM } } diff --git a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverter.kt b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverter.kt index 8e30176f4a..d911aa525b 100644 --- a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverter.kt +++ b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverter.kt @@ -55,13 +55,13 @@ internal class ExpressOnrampConverter : Converter it.activeStatus.isHidden - else -> false - } - }.toPersistentList() + + val expressTxsToDisplay = if (txHistoryFeatureToggles.isNewTxHistoryEnabled) { + persistentListOf() + } else { + expressTxs.filterNot { + when (it) { + is ExpressTransactionStateUM.OnrampUM -> it.activeStatus.isHidden + else -> false + } + }.toPersistentList() + } return state.copy( transactions = expressTxs, transactionsToDisplay = expressTxsToDisplay, diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/ExpressTxToTransactionItemUMConverter.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/ExpressTxToTransactionItemUMConverter.kt new file mode 100644 index 0000000000..46ade74b07 --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/ExpressTxToTransactionItemUMConverter.kt @@ -0,0 +1,191 @@ +package com.tangem.features.txhistory.converter + +import com.tangem.core.ui.components.transactions.state.TransactionItemUM +import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Direction as RowDirection +import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Status +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.core.ui.components.transactions.state.TransactionItemUM.ContentSubtitle +import com.tangem.core.ui.components.transactions.state.TransactionItemUM.ContentSubtitle.Direction as SubtitleDirection +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.utils.toTimeFormat +import com.tangem.domain.express.models.ExpressExchangeStatus +import com.tangem.domain.express.models.ExpressOnrampStatus +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.txhistory.model.ExpressTx +import com.tangem.features.txhistory.impl.R +import com.tangem.features.txhistory.utils.TxHistoryUiActions +import com.tangem.utils.StringsSigns +import com.tangem.utils.converter.Converter +import java.math.BigDecimal + +/** + * Maps an [ExpressTx] (swap / onramp) row directly to [TransactionItemUM.Content]. + * + * The viewed leg is [CryptoCurrency] ([currency], the token-details currency): outgoing swap shows the pay-in + * (`from`) amount with a minus, incoming swap / onramp shows the received (`to`) amount with a plus. The 26 typed + * express statuses collapse into the three [Status] buckets (those drive title/icon/amount colors in the row UI). + * + * The counterparty ticker symbol+icon come from the resolved [ExpressTransactionAsset.cryptoCurrency] (swap); + * onramp shows the real fiat code with no icon yet (fiat carries no `CryptoCurrency`). The row click opens the + * explorer. + */ +internal class ExpressTxToTransactionItemUMConverter( + private val currency: CryptoCurrency, + private val txHistoryUiActions: TxHistoryUiActions, +) : Converter { + + private val iconStateConverter = CryptoCurrencyToIconStateConverter() + + override fun convert(value: ExpressTx): TransactionItemUM = when (value) { + is ExpressTx.Swap -> swapContent(value) + is ExpressTx.Onramp -> onrampContent(value) + } + + private fun swapContent(swap: ExpressTx.Swap): TransactionItemUM.Content { + val status = swap.tx.status.toUiStatus() + val viewedAmount = if (swap.isOutgoing) swap.tx.fromAsset.amount else swap.tx.toAsset.amount + val counterparty = if (swap.isOutgoing) swap.tx.toAsset else swap.tx.fromAsset + val prefix = when { + status is Status.Failed -> "" + swap.isOutgoing -> StringsSigns.MINUS + else -> StringsSigns.PLUS + } + return buildContent( + tx = swap, + status = status, + amount = formatAmount(viewedAmount, prefix), + direction = if (swap.isOutgoing) RowDirection.OUTGOING else RowDirection.INCOMING, + iconRes = R.drawable.ic_exchange_vertical_24, + title = swapTitle(status), + subtitle = ContentSubtitle.Asset( + direction = if (swap.isOutgoing) SubtitleDirection.TO else SubtitleDirection.FROM, + symbol = counterparty.cryptoCurrency?.symbol ?: counterparty.id.networkId, + icon = counterparty.cryptoCurrency?.let(iconStateConverter::convert), + ), + // TODO: replace null to warning logic. + warning = null, + ) + } + + private fun onrampContent(onramp: ExpressTx.Onramp): TransactionItemUM.Content { + val status = onramp.tx.status.toUiStatus() + val prefix = when { + status is Status.Failed -> "" + status is Status.Confirmed -> StringsSigns.PLUS + else -> StringsSigns.TILDE_SIGN + } + return buildContent( + tx = onramp, + status = status, + amount = formatAmount(onramp.tx.toAsset.amount, prefix), + direction = RowDirection.INCOMING, + iconRes = R.drawable.ic_tangem_card_24, + title = onrampTitle(status), + subtitle = ContentSubtitle.Asset( + direction = SubtitleDirection.FROM, + symbol = onramp.tx.fromFiat.currencySymbol, + // TODO: fiat carries no OnrampCurrency, so no icon yet — render with a fiat country flag once available. + icon = null, + ), + // TODO: replace null to warning logic. + warning = null, + ) + } + + @Suppress("LongParameterList") + private fun buildContent( + tx: ExpressTx, + status: Status, + amount: String?, + direction: RowDirection, + iconRes: Int, + title: TextReference, + subtitle: ContentSubtitle, + warning: TextReference?, + ): TransactionItemUM.Content { + val explorerHash = tx.matchHash ?: tx.txId + return TransactionItemUM.Content( + txHash = explorerHash, + amount = amount, + currencySymbol = currency.symbol, + time = tx.timestampMillis.toTimeFormat(), + status = status, + direction = direction, + onClick = { txHistoryUiActions.openTxInExplorer(explorerHash) }, + iconRes = iconRes, + title = title, + subtitle = subtitle, + timestamp = tx.timestampMillis, + warning = warning, + ) + } + + private fun formatAmount(amount: BigDecimal?, prefix: String): String? = + amount?.let { prefix + it.format { crypto(symbol = "", decimals = currency.decimals) }.trim() } + + private fun swapTitle(status: Status): TextReference = when (status) { + is Status.Confirmed -> resourceReference(R.string.common_swapped) + is Status.Unconfirmed -> resourceReference(R.string.common_swapping) + is Status.Failed -> + resourceReference(R.string.common_action_failed, wrappedList(resourceReference(R.string.common_swapping))) + } + + private fun onrampTitle(status: Status): TextReference = when (status) { + is Status.Confirmed -> resourceReference(R.string.tx_history_onramp_topped_up) + is Status.Unconfirmed -> resourceReference(R.string.tx_history_onramp_top_up) + is Status.Failed -> resourceReference( + R.string.common_action_failed, + wrappedList(resourceReference(R.string.tx_history_onramp_top_up)), + ) + } +} + +// region Status mapping + +/** + * Collapses the typed swap status into a UI [Status] bucket: the single success state ([Finished][Confirmed]), + * the failure/return states ([Failed]/[TxFailed]/[Refunded]/[Expired]/[Unknown]) → Failed, everything in flight + * (incl. [Verifying] and [Paused]) → Unconfirmed. + */ +private fun ExpressExchangeStatus.toUiStatus(): Status = when (this) { + ExpressExchangeStatus.Finished -> Status.Confirmed + ExpressExchangeStatus.Failed, + ExpressExchangeStatus.TxFailed, + ExpressExchangeStatus.Refunded, + ExpressExchangeStatus.Expired, + ExpressExchangeStatus.Unknown, + -> Status.Failed + ExpressExchangeStatus.Preview, + ExpressExchangeStatus.Created, + ExpressExchangeStatus.ExchangeTxSent, + ExpressExchangeStatus.Waiting, + ExpressExchangeStatus.WaitingTxHash, + ExpressExchangeStatus.Confirming, + ExpressExchangeStatus.Exchanging, + ExpressExchangeStatus.Sending, + ExpressExchangeStatus.Verifying, + ExpressExchangeStatus.Paused, + -> Status.Unconfirmed +} + +private fun ExpressOnrampStatus.toUiStatus(): Status = when (this) { + ExpressOnrampStatus.Finished -> Status.Confirmed + ExpressOnrampStatus.Failed, + ExpressOnrampStatus.Expired, + ExpressOnrampStatus.Unknown, + -> Status.Failed + ExpressOnrampStatus.Created, + ExpressOnrampStatus.WaitingForPayment, + ExpressOnrampStatus.PaymentProcessing, + ExpressOnrampStatus.Verifying, + ExpressOnrampStatus.Paid, + ExpressOnrampStatus.Sending, + ExpressOnrampStatus.Paused, + -> Status.Unconfirmed +} + +// endregion \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTransactionItemUMConverter.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTransactionItemUMConverter.kt index 574f6658c0..a52b20b829 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTransactionItemUMConverter.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTransactionItemUMConverter.kt @@ -4,21 +4,20 @@ import com.tangem.core.ui.components.transactions.state.TransactionItemUM import com.tangem.domain.txhistory.model.ExpressTx import com.tangem.domain.txhistory.model.OnChainTx import com.tangem.domain.txhistory.model.TxHistoryInfo -import com.tangem.features.txhistory.utils.toSyntheticTxInfo import com.tangem.utils.converter.Converter /** - * Converts a merged [TxHistoryInfo] row to [TransactionItemUM], delegating to the on-chain - * [TxHistoryItemToTransactionItemUMConverter]: on-chain rows convert their `TxInfo` directly, express - * rows convert a synthesized `TxInfo` view (see [toSyntheticTxInfo]). + * Converts a merged [TxHistoryInfo] row to [TransactionItemUM]: on-chain rows convert their `TxInfo` via + * [TxHistoryItemToTransactionItemUMConverter]; express rows map directly via [ExpressTxToTransactionItemUMConverter]. */ internal class TxHistoryInfoToTransactionItemUMConverter( private val txInfoConverter: TxHistoryItemToTransactionItemUMConverter, + private val expressConverter: ExpressTxToTransactionItemUMConverter, ) : Converter { override fun convert(value: TxHistoryInfo): TransactionItemUM = when (value) { is OnChainTx -> convertOnChain(value) - is ExpressTx -> txInfoConverter.convert(value.toSyntheticTxInfo()) + is ExpressTx -> expressConverter.convert(value) } private fun convertOnChain(value: OnChainTx): TransactionItemUM = when (value) { diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt index b64a5d10d9..9826f149c0 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt @@ -28,6 +28,7 @@ import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase import com.tangem.domain.wallets.usecase.GetWalletIconUseCase import com.tangem.domain.txhistory.TxHistoryFeatureToggles import com.tangem.features.txhistory.component.TxHistoryComponent +import com.tangem.features.txhistory.converter.ExpressTxToTransactionItemUMConverter import com.tangem.features.txhistory.converter.TxHistoryInfoToTransactionItemUMConverter import com.tangem.features.txhistory.converter.TxHistoryItemToTransactionItemUMConverter import com.tangem.features.txhistory.converter.TxHistoryItemToTransactionStateConverter @@ -39,6 +40,7 @@ import com.tangem.features.txhistory.utils.HistoryTxListManager import com.tangem.features.txhistory.utils.TxHistoryListManager import com.tangem.features.txhistory.utils.TxHistoryUiActions import com.tangem.pagination.PaginationStatus +import com.tangem.utils.annotations.RemoveWithToggle import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.logging.TangemLogger import kotlinx.collections.immutable.ImmutableList @@ -100,9 +102,11 @@ internal class TxHistoryModel @Inject constructor( emptyFlow() } + @RemoveWithToggle("APP_REDESIGN_ENABLED") private val legacyTxHistoryItemConverter = TxHistoryItemToTransactionStateConverter(currency = params.currency, txHistoryUiActions = this) + @RemoveWithToggle("AND_15767_NEW_TX_HISTORY_ENABLED") private val txHistoryListManager: TxHistoryListManager? = if (!txHistoryFeatureToggle.isNewTxHistoryEnabled) { TxHistoryListManager( repository = repository, @@ -193,7 +197,6 @@ internal class TxHistoryModel @Inject constructor( } } - // Temporary: express rows are mapped to UI via a synthesized TxInfo (see ExpressTx.toSyntheticTxInfo). private fun buildUiItems( merged: List, lookup: TxHistoryLookupContext, @@ -204,6 +207,10 @@ internal class TxHistoryModel @Inject constructor( txHistoryUiActions = this, lookupContext = lookup, ), + expressConverter = ExpressTxToTransactionItemUMConverter( + currency = params.currency, + txHistoryUiActions = this, + ), ) val items = mutableListOf() diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryInfoMerger.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryInfoMerger.kt index e310aeca94..1830b1be74 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryInfoMerger.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryInfoMerger.kt @@ -1,7 +1,5 @@ package com.tangem.features.txhistory.utils -import com.tangem.domain.express.models.ExpressExchangeStatus -import com.tangem.domain.express.models.ExpressOnrampStatus import com.tangem.domain.models.network.TxInfo import com.tangem.domain.txhistory.model.ExpressTx import com.tangem.domain.txhistory.model.OnChainTx @@ -50,48 +48,4 @@ private fun ExpressTx.withMatchedTxInfo(txInfo: TxInfo): ExpressTx { is ExpressTx.Swap -> copy(txInfo = matched) is ExpressTx.Onramp -> copy(txInfo = matched) } -} - -/** - * Synthesizes a [TxInfo] view of an express op so it can be rendered by the existing - * [com.tangem.features.txhistory.converter.TxHistoryItemToTransactionItemUMConverter]. Rendered as a - * [TxInfo.TransactionType.Swap] for now (onramp included). The amount is the viewed-currency leg. - */ -internal fun ExpressTx.toSyntheticTxInfo(): TxInfo { - val viewedAmount = when (this) { - is ExpressTx.Swap -> if (isOutgoing) tx.fromAsset.amount else tx.toAsset.amount - is ExpressTx.Onramp -> tx.toAsset.amount - } - val isOutgoing = when (this) { - is ExpressTx.Swap -> this.isOutgoing - is ExpressTx.Onramp -> false - } - return TxInfo( - // matchHash is the on-chain hash (== the matched leg's hash, enables the explorer link); else txId. - txHash = matchHash ?: txId, - timestampInMillis = timestampMillis, - isOutgoing = isOutgoing, - destinationType = TxInfo.DestinationType.Single(TxInfo.AddressType.User(address = "")), - sourceType = TxInfo.SourceType.Single(address = ""), - interactionAddressType = null, - status = toTransactionStatus(), - type = TxInfo.TransactionType.Swap, - amount = viewedAmount, - ) -} - -/** - * Maps the typed express status to the on-chain-shaped [TxInfo.TransactionStatus] used by the UI: - * the single success state (`Finished`) → Confirmed, any other terminal state → Failed, in-progress → Unconfirmed. - */ -private fun ExpressTx.toTransactionStatus(): TxInfo.TransactionStatus { - val isFinished = when (this) { - is ExpressTx.Swap -> tx.status == ExpressExchangeStatus.Finished - is ExpressTx.Onramp -> tx.status == ExpressOnrampStatus.Finished - } - return when { - isFinished -> TxInfo.TransactionStatus.Confirmed - isTerminal -> TxInfo.TransactionStatus.Failed - else -> TxInfo.TransactionStatus.Unconfirmed - } } \ No newline at end of file diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/ExpressTxToTransactionItemUMConverterTest.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/ExpressTxToTransactionItemUMConverterTest.kt new file mode 100644 index 0000000000..ee4a06fb0f --- /dev/null +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/ExpressTxToTransactionItemUMConverterTest.kt @@ -0,0 +1,289 @@ +package com.tangem.features.txhistory.converter + +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import com.tangem.core.ui.components.transactions.state.TransactionItemUM +import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Status +import com.tangem.core.ui.components.transactions.state.TransactionItemUM.ContentSubtitle +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.express.models.ExchangeTransaction +import com.tangem.domain.express.models.ExpressAsset.ID as ExpressAssetId +import com.tangem.domain.express.models.ExpressExchangeStatus +import com.tangem.domain.express.models.ExpressOnrampStatus +import com.tangem.domain.express.models.ExpressTransactionAsset +import com.tangem.domain.express.models.OnrampTransaction +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.tokens.model.Amount +import com.tangem.domain.tokens.model.AmountType +import com.tangem.domain.txhistory.model.ExpressTx +import com.tangem.features.txhistory.impl.R +import com.tangem.features.txhistory.utils.TxHistoryUiActions +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class ExpressTxToTransactionItemUMConverterTest { + + private val txHistoryUiActions: TxHistoryUiActions = mockk(relaxed = true) + private val coin: CryptoCurrency.Coin = createCoin(symbol = "ETH", decimals = 18) + + private val converter = ExpressTxToTransactionItemUMConverter( + currency = coin, + txHistoryUiActions = txHistoryUiActions, + ) + + // region Status → bucket + + @Test + fun `GIVEN every swap status WHEN convert THEN mapped to expected status bucket`() { + val cases = mapOf( + ExpressExchangeStatus.Finished to Status.Confirmed, + ExpressExchangeStatus.Failed to Status.Failed, + ExpressExchangeStatus.TxFailed to Status.Failed, + ExpressExchangeStatus.Refunded to Status.Failed, + ExpressExchangeStatus.Expired to Status.Failed, + ExpressExchangeStatus.Unknown to Status.Failed, + ExpressExchangeStatus.Preview to Status.Unconfirmed, + ExpressExchangeStatus.Created to Status.Unconfirmed, + ExpressExchangeStatus.ExchangeTxSent to Status.Unconfirmed, + ExpressExchangeStatus.Waiting to Status.Unconfirmed, + ExpressExchangeStatus.WaitingTxHash to Status.Unconfirmed, + ExpressExchangeStatus.Confirming to Status.Unconfirmed, + ExpressExchangeStatus.Exchanging to Status.Unconfirmed, + ExpressExchangeStatus.Sending to Status.Unconfirmed, + ExpressExchangeStatus.Verifying to Status.Unconfirmed, + ExpressExchangeStatus.Paused to Status.Unconfirmed, + ) + // every enum entry is covered (guards against new statuses silently falling through) + assertThat(cases.keys).containsExactlyElementsIn(ExpressExchangeStatus.entries) + + cases.forEach { (status, expected) -> + val result = converter.convert(createSwap(status = status)) as TransactionItemUM.Content + assertWithMessage(status.name).that(result.status).isEqualTo(expected) + } + } + + @Test + fun `GIVEN every onramp status WHEN convert THEN mapped to expected status bucket`() { + val cases = mapOf( + ExpressOnrampStatus.Finished to Status.Confirmed, + ExpressOnrampStatus.Failed to Status.Failed, + ExpressOnrampStatus.Expired to Status.Failed, + ExpressOnrampStatus.Unknown to Status.Failed, + ExpressOnrampStatus.Created to Status.Unconfirmed, + ExpressOnrampStatus.WaitingForPayment to Status.Unconfirmed, + ExpressOnrampStatus.PaymentProcessing to Status.Unconfirmed, + ExpressOnrampStatus.Verifying to Status.Unconfirmed, + ExpressOnrampStatus.Paid to Status.Unconfirmed, + ExpressOnrampStatus.Sending to Status.Unconfirmed, + ExpressOnrampStatus.Paused to Status.Unconfirmed, + ) + assertThat(cases.keys).containsExactlyElementsIn(ExpressOnrampStatus.entries) + + cases.forEach { (status, expected) -> + val result = converter.convert(createOnramp(status = status)) as TransactionItemUM.Content + assertWithMessage(status.name).that(result.status).isEqualTo(expected) + } + } + + // endregion + + // region Amount sign / prefix + + @Test + fun `GIVEN outgoing swap WHEN convert THEN amount is negative from-leg`() { + val result = converter.convert( + createSwap(status = ExpressExchangeStatus.Waiting, isOutgoing = true), + ) as TransactionItemUM.Content + + assertThat(result.direction).isEqualTo(TransactionItemUM.Content.Direction.OUTGOING) + assertThat(result.amount).startsWith("-") + assertThat(result.amount).contains("1.5") + } + + @Test + fun `GIVEN incoming swap WHEN convert THEN amount is positive to-leg`() { + val result = converter.convert( + createSwap(status = ExpressExchangeStatus.Waiting, isOutgoing = false), + ) as TransactionItemUM.Content + + assertThat(result.direction).isEqualTo(TransactionItemUM.Content.Direction.INCOMING) + assertThat(result.amount).startsWith("+") + assertThat(result.amount).contains("0.001") + } + + @Test + fun `GIVEN finished onramp WHEN convert THEN amount prefixed with plus`() { + val result = converter.convert(createOnramp(status = ExpressOnrampStatus.Finished)) as TransactionItemUM.Content + assertThat(result.amount).startsWith("+") + } + + @Test + fun `GIVEN in-progress onramp WHEN convert THEN amount prefixed with tilde`() { + val result = converter.convert(createOnramp(status = ExpressOnrampStatus.Sending)) as TransactionItemUM.Content + assertThat(result.amount).startsWith("~") + } + + @Test + fun `GIVEN failed onramp WHEN convert THEN amount has no sign prefix`() { + val result = converter.convert(createOnramp(status = ExpressOnrampStatus.Failed)) as TransactionItemUM.Content + assertThat(requireNotNull(result.amount).first()) + .isIn(listOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9')) + } + + @Test + fun `GIVEN swap with null viewed amount WHEN convert THEN amount is null`() { + val result = converter.convert( + createSwap(status = ExpressExchangeStatus.Waiting, isOutgoing = true, fromAmount = null), + ) as TransactionItemUM.Content + + assertThat(result.amount).isNull() + } + + @Test + fun `GIVEN onramp with null amount WHEN convert THEN amount is null`() { + val result = converter.convert( + createOnramp(status = ExpressOnrampStatus.Sending, toAmount = null), + ) as TransactionItemUM.Content + + assertThat(result.amount).isNull() + } + + // endregion + + // region Title / subtitle / warning / click + + @Test + fun `GIVEN swap statuses WHEN convert THEN status-aware title`() { + val swapping = converter.convert(createSwap(status = ExpressExchangeStatus.Waiting)) as TransactionItemUM.Content + val swapped = converter.convert(createSwap(status = ExpressExchangeStatus.Finished)) as TransactionItemUM.Content + + assertThat(swapping.title).isEqualTo(resourceReference(R.string.common_swapping)) + assertThat(swapped.title).isEqualTo(resourceReference(R.string.common_swapped)) + } + + @Test + fun `GIVEN onramp statuses WHEN convert THEN status-aware title`() { + val topUp = converter.convert(createOnramp(status = ExpressOnrampStatus.Sending)) as TransactionItemUM.Content + val toppedUp = converter.convert(createOnramp(status = ExpressOnrampStatus.Finished)) as TransactionItemUM.Content + + assertThat(topUp.title).isEqualTo(resourceReference(R.string.tx_history_onramp_top_up)) + assertThat(toppedUp.title).isEqualTo(resourceReference(R.string.tx_history_onramp_topped_up)) + } + + @Test + fun `GIVEN outgoing swap WHEN convert THEN subtitle shows TO counterparty ticker`() { + val result = converter.convert( + createSwap(status = ExpressExchangeStatus.Waiting, isOutgoing = true), + ) as TransactionItemUM.Content + + val subtitle = result.subtitle as ContentSubtitle.Asset + assertThat(subtitle.direction).isEqualTo(ContentSubtitle.Direction.TO) + assertThat(subtitle.symbol).isEqualTo("btc") // mock: counterparty (to-leg) networkId + } + + @Test + fun `GIVEN onramp WHEN convert THEN subtitle shows FROM fiat code`() { + val result = converter.convert(createOnramp(status = ExpressOnrampStatus.Sending)) as TransactionItemUM.Content + + val subtitle = result.subtitle as ContentSubtitle.Asset + assertThat(subtitle.direction).isEqualTo(ContentSubtitle.Direction.FROM) + assertThat(subtitle.symbol).isEqualTo("SEK") + } + + @Test + fun `GIVEN matched on-chain leg WHEN row clicked THEN opens explorer by match hash`() { + val result = converter.convert( + createSwap(status = ExpressExchangeStatus.Waiting, matchHash = "0xhash", isOutgoing = true), + ) as TransactionItemUM.Content + + result.onClick() + + verify { txHistoryUiActions.openTxInExplorer("0xhash") } + } + + // endregion + + private fun createSwap( + status: ExpressExchangeStatus, + matchHash: String? = null, + isOutgoing: Boolean = true, + fromAmount: BigDecimal? = BigDecimal("1.5"), + toAmount: BigDecimal? = BigDecimal("0.001"), + ) = ExpressTx.Swap( + tx = ExchangeTransaction( + txId = "tx-1", + status = status, + createdAtMillis = 100, + provider = null, + payinHash = matchHash.takeIf { isOutgoing }, + payoutHash = matchHash.takeUnless { isOutgoing }, + fromAsset = ExpressTransactionAsset( + id = ExpressAssetId(networkId = "eth", contractAddress = "0"), + amount = fromAmount, + decimals = 18, + ), + toAsset = ExpressTransactionAsset( + id = ExpressAssetId(networkId = "btc", contractAddress = "0xt"), + amount = toAmount, + decimals = 8, + ), + ), + isOutgoing = isOutgoing, + txInfo = null, + ) + + private fun createOnramp( + status: ExpressOnrampStatus, + toAmount: BigDecimal? = BigDecimal("0.006339"), + ) = ExpressTx.Onramp( + tx = OnrampTransaction( + txId = "tx-2", + status = status, + createdAtMillis = 100, + provider = null, + payoutHash = null, + fromFiat = Amount( + currencySymbol = "SEK", + value = BigDecimal("100"), + decimals = 2, + type = AmountType.FiatType(code = "SEK"), + ), + toAsset = ExpressTransactionAsset( + id = ExpressAssetId(networkId = "btc", contractAddress = "0"), + amount = toAmount, + decimals = 8, + ), + ), + txInfo = null, + ) + + private fun createCoin(symbol: String, decimals: Int): CryptoCurrency.Coin = CryptoCurrency.Coin( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId(rawId = "ethereum"), + suffix = CryptoCurrency.ID.Suffix.RawID(rawId = "ethereum"), + ), + network = Network( + id = Network.ID(value = "ethereum", derivationPath = Network.DerivationPath.None), + name = "Ethereum", + currencySymbol = symbol, + derivationPath = Network.DerivationPath.None, + isTestnet = false, + standardType = Network.StandardType.ERC20, + hasFiatFeeRate = true, + canHandleTokens = true, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ), + name = "Ethereum", + symbol = symbol, + decimals = decimals, + iconUrl = null, + isCustom = false, + ) +} \ No newline at end of file diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionItemUMConverterTest.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionItemUMConverterTest.kt index ae76d9a485..ed577e236b 100644 --- a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionItemUMConverterTest.kt +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionItemUMConverterTest.kt @@ -161,8 +161,8 @@ internal class TxHistoryItemToTransactionItemUMConverterTest { assertThat(result.subtitle).isEqualTo( ContentSubtitle.Plain(resRef(R.string.transaction_history_earned_from_stake)), ) - assertThat(result.amount.startsWith(StringsSigns.PLUS)).isFalse() - assertThat(result.amount.startsWith(StringsSigns.MINUS)).isFalse() + assertThat(result.amount!!.startsWith(StringsSigns.PLUS)).isFalse() + assertThat(result.amount!!.startsWith(StringsSigns.MINUS)).isFalse() } @Test @@ -514,7 +514,7 @@ internal class TxHistoryItemToTransactionItemUMConverterTest { val result = coinConverter.convert(tx) as TransactionItemUM.Content - assertThat(result.amount.startsWith(StringsSigns.MINUS)).isTrue() + assertThat(result.amount!!.startsWith(StringsSigns.MINUS)).isTrue() } @Test @@ -528,7 +528,7 @@ internal class TxHistoryItemToTransactionItemUMConverterTest { val result = coinConverter.convert(tx) as TransactionItemUM.Content - assertThat(result.amount.startsWith(StringsSigns.PLUS)).isTrue() + assertThat(result.amount!!.startsWith(StringsSigns.PLUS)).isTrue() } @Test @@ -543,8 +543,8 @@ internal class TxHistoryItemToTransactionItemUMConverterTest { val result = coinConverter.convert(tx) as TransactionItemUM.Content - assertThat(result.amount.startsWith(StringsSigns.MINUS)).isFalse() - assertThat(result.amount.startsWith(StringsSigns.PLUS)).isFalse() + assertThat(result.amount!!.startsWith(StringsSigns.MINUS)).isFalse() + assertThat(result.amount!!.startsWith(StringsSigns.PLUS)).isFalse() } @Test @@ -558,8 +558,8 @@ internal class TxHistoryItemToTransactionItemUMConverterTest { val result = coinConverter.convert(tx) as TransactionItemUM.Content - assertThat(result.amount.startsWith(StringsSigns.MINUS)).isFalse() - assertThat(result.amount.startsWith(StringsSigns.PLUS)).isFalse() + assertThat(result.amount!!.startsWith(StringsSigns.MINUS)).isFalse() + assertThat(result.amount!!.startsWith(StringsSigns.PLUS)).isFalse() } // endregion diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/utils/TxHistoryInfoMergerTest.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/utils/TxHistoryInfoMergerTest.kt index 7413a5ae9f..f4a022d2c1 100644 --- a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/utils/TxHistoryInfoMergerTest.kt +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/utils/TxHistoryInfoMergerTest.kt @@ -85,21 +85,6 @@ internal class TxHistoryInfoMergerTest { assertThat(result.map { it.timestampMillis }).containsExactly(200L, 100L).inOrder() } - @Test - fun `GIVEN outgoing swap WHEN toSyntheticTxInfo THEN viewed from-leg amount and swap type`() { - // Arrange - val swap = createSwap(matchHash = "missing", status = ExpressExchangeStatus.Waiting, isOutgoing = true) - - // Act - val txInfo = swap.toSyntheticTxInfo() - - // Assert - assertThat(txInfo.isOutgoing).isTrue() - assertThat(txInfo.amount).isEqualTo(BigDecimal("1.5")) - assertThat(txInfo.type).isEqualTo(TxInfo.TransactionType.Swap) - assertThat(txInfo.status).isEqualTo(TxInfo.TransactionStatus.Unconfirmed) - } - private fun createTxInfo(txHash: String, timestamp: Long) = TxInfo( txHash = txHash, timestampInMillis = timestamp, From 4aac8dc94c34ace6a723f4b680b62ee04ea1920e Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 24 Jun 2026 16:33:17 +0300 Subject: [PATCH 036/210] Updated on 2026-08-14 --- .../converter/TxHistoryItemToTransactionStateConverter.kt | 3 +++ .../features/txhistory/utils/TxHistoryLegacyUiManager.kt | 3 +++ .../tangem/features/txhistory/utils/TxHistoryListManager.kt | 3 +++ .../tangem/features/txhistory/utils/TxHistoryListState.kt | 5 ++++- .../tangem/features/txhistory/utils/TxHistoryUiManager.kt | 3 +++ 5 files changed, 16 insertions(+), 1 deletion(-) diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionStateConverter.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionStateConverter.kt index 0c5e2cba45..ff5603d0eb 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionStateConverter.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryItemToTransactionStateConverter.kt @@ -14,10 +14,13 @@ import com.tangem.domain.models.network.TxInfo.TransactionType import com.tangem.features.txhistory.impl.R import com.tangem.features.txhistory.utils.TxHistoryUiActions import com.tangem.utils.StringsSigns +import com.tangem.utils.annotations.RemoveWithToggle import com.tangem.utils.converter.Converter import com.tangem.utils.extensions.isZero import com.tangem.utils.toBriefAddressFormat +@Deprecated("Remove with main toggle [DesignFeatureToggles.isRedesignEnabled]. Produces pre-redesign TransactionState.") +@RemoveWithToggle("APP_REDESIGN_ENABLED") internal class TxHistoryItemToTransactionStateConverter( private val currency: CryptoCurrency, private val txHistoryUiActions: TxHistoryUiActions, diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryLegacyUiManager.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryLegacyUiManager.kt index 8c5c5fd107..a4e1c8b90d 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryLegacyUiManager.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryLegacyUiManager.kt @@ -7,11 +7,14 @@ import com.tangem.features.txhistory.converter.TxHistoryItemToTransactionStateCo import com.tangem.features.txhistory.entity.TxHistoryUM import com.tangem.pagination.Batch import com.tangem.pagination.PaginationStatus +import com.tangem.utils.annotations.RemoveWithToggle import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.* +@Deprecated("Remove with main toggle [DesignFeatureToggles.isRedesignEnabled]. Renders pre-redesign tx-history UI.") +@RemoveWithToggle("APP_REDESIGN_ENABLED") internal class TxHistoryLegacyUiManager( private val state: MutableStateFlow, private val txHistoryItemConverter: TxHistoryItemToTransactionStateConverter, diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryListManager.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryListManager.kt index d3b0628dbf..06a014161f 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryListManager.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryListManager.kt @@ -16,6 +16,7 @@ import com.tangem.pagination.BatchAction import com.tangem.pagination.BatchFetchResult import com.tangem.pagination.BatchListState import com.tangem.pagination.PaginationStatus +import com.tangem.utils.annotations.RemoveWithToggle import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.JobHolder import com.tangem.utils.coroutines.saveIn @@ -26,6 +27,8 @@ import kotlinx.coroutines.flow.* private typealias TxHistoryBatchAction = BatchAction @Suppress("LongParameterList") +@Deprecated("Remove with toggle [TxHistoryFeatureToggles.isNewTxHistoryEnabled]. Replaced by HistoryTxListManager.") +@RemoveWithToggle("AND_15767_NEW_TX_HISTORY_ENABLED") internal class TxHistoryListManager( private val repository: TxHistoryRepositoryV2, private val dispatchers: CoroutineDispatcherProvider, diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryListState.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryListState.kt index e1b9a8ccf9..3d4bd8ffcb 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryListState.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryListState.kt @@ -6,8 +6,11 @@ import com.tangem.features.txhistory.entity.TxHistoryItemsUM import com.tangem.features.txhistory.entity.TxHistoryUM import com.tangem.pagination.Batch import com.tangem.pagination.PaginationStatus +import com.tangem.utils.annotations.RemoveWithToggle -data class TxHistoryListState( +@Deprecated("Remove with toggle [TxHistoryFeatureToggles.isNewTxHistoryEnabled]. Used only by TxHistoryListManager.") +@RemoveWithToggle("AND_15767_NEW_TX_HISTORY_ENABLED") +internal data class TxHistoryListState( val status: PaginationStatus<*> = PaginationStatus.None, val rawBatches: List>> = emptyList(), val uiBatches: List>> = emptyList(), diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryUiManager.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryUiManager.kt index 83b868fc40..d0de741860 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryUiManager.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryUiManager.kt @@ -8,11 +8,14 @@ import com.tangem.features.txhistory.converter.TxHistoryItemToTransactionItemUMC import com.tangem.features.txhistory.entity.TxHistoryItemsUM import com.tangem.pagination.Batch import com.tangem.pagination.PaginationStatus +import com.tangem.utils.annotations.RemoveWithToggle import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.* +@Deprecated("Remove with toggle [TxHistoryFeatureToggles.isNewTxHistoryEnabled]. Used only by TxHistoryListManager.") +@RemoveWithToggle("AND_15767_NEW_TX_HISTORY_ENABLED") internal class TxHistoryUiManager( private val state: MutableStateFlow, ) { From b392e4b176de72812f0589cebdba228864fcce0b Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 24 Jun 2026 17:33:46 +0400 Subject: [PATCH 037/210] Updated on 2026-08-14 --- features/home/impl/build.gradle.kts | 26 +---- features/home/impl/detekt-baseline-debug.xml | 10 -- .../features/home/impl/model/HomeModel.kt | 4 +- .../home/impl/ui/compose/HomeStoriesScreen.kt | 2 +- .../home/impl/ui/compose/StoriesAnimation.kt | 6 +- .../home/impl/ui/compose/StoriesScreenV2.kt | 2 +- .../compose/content/CurrenciesWeb3Content.kt | 71 ++++++------ .../ui/compose/content/FirstStoriesContent.kt | 32 ++---- .../compose/content/FloatingCardsContent.kt | 86 +++++++------- .../content/{Content.kt => StoriesContent.kt} | 29 ++--- .../home/impl/ui/compose/views/HomeButtons.kt | 105 ------------------ .../impl/ui/compose/views/HomeButtonsV2.kt | 2 +- .../compose/views/SearchCurrenciesButton.kt | 43 ------- .../ui/compose/views/StoriesProgressBar.kt | 10 +- .../features/home/impl/ui/state/HomeUM.kt | 10 +- .../features/home/impl/model/HomeModelTest.kt | 4 +- 16 files changed, 119 insertions(+), 323 deletions(-) delete mode 100644 features/home/impl/detekt-baseline-debug.xml rename features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/content/{Content.kt => StoriesContent.kt} (89%) delete mode 100644 features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/views/HomeButtons.kt delete mode 100644 features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/views/SearchCurrenciesButton.kt diff --git a/features/home/impl/build.gradle.kts b/features/home/impl/build.gradle.kts index b3051da10d..ff389bc6fd 100644 --- a/features/home/impl/build.gradle.kts +++ b/features/home/impl/build.gradle.kts @@ -13,59 +13,41 @@ android { dependencies { /** Api */ implementation(projects.features.home.api) - implementation(projects.features.hotWallet.api) /** Core modules */ implementation(projects.core.decompose) implementation(projects.core.ui) - implementation(projects.core.res) implementation(projects.core.analytics) implementation(projects.core.analytics.models) - implementation(projects.core.navigation) implementation(projects.core.utils) implementation(projects.core.configToggles) /** Common */ implementation(projects.common.routing) - + /** Domain */ implementation(projects.domain.common) implementation(projects.domain.models) - implementation(projects.domain.core) implementation(projects.domain.card) implementation(projects.domain.settings) - implementation(projects.domain.tokens) implementation(projects.domain.wallets) - implementation(projects.domain.wallets.models) - implementation(projects.domain.legacy) - implementation(projects.domain.feedback) - implementation(projects.domain.feedback.models) - implementation(projects.domain.referral) /** Referral */ implementation(projects.features.referral.domain) - /** AndroidX libraries */ - implementation(deps.androidx.activity.compose) - implementation(deps.lifecycle.runtime.ktx) - /** Compose libraries */ implementation(deps.compose.ui) implementation(deps.compose.ui.tooling) implementation(deps.compose.foundation) implementation(deps.compose.material3) implementation(deps.compose.animation) - implementation(deps.compose.coil) - implementation(deps.decompose.ext.compose) - + /** Tangem libraries */ - implementation(tangemDeps.card.android) implementation(tangemDeps.card.core) - implementation(tangemDeps.blockchain) - + /** Other libraries */ implementation(deps.kotlin.immutable.collections) - + /** DI */ implementation(deps.hilt.android) kapt(deps.hilt.kapt) diff --git a/features/home/impl/detekt-baseline-debug.xml b/features/home/impl/detekt-baseline-debug.xml deleted file mode 100644 index 7509758457..0000000000 --- a/features/home/impl/detekt-baseline-debug.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - BooleanPropertyNaming:HomeButtons.kt$HomeButtonsState$val btnScanStateInProgress: Boolean - BooleanPropertyNaming:HomeUM.kt$HomeUM$val scanInProgress: Boolean - MultilineLambdaItParameter:StoriesProgressBar.kt${ when (index) { currentStep -> it.fillMaxWidth(progress.value) in 0 until currentStep -> it.fillMaxWidth(fraction = 1f) else -> it } } - ReusedModifierInstance:HomeButtonsV2.kt$StoriesButton( modifier = modifier, text = stringResourceSafe(id = R.string.common_get_started), useDarkerColors = false, onClick = onGetStartedClick, ) - - diff --git a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt index 44a3b475ae..faf9bfc2be 100644 --- a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt +++ b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/model/HomeModel.kt @@ -85,7 +85,7 @@ internal class HomeModel @Inject constructor( private fun createInitialState(): HomeUM { val initialStories = getRestrictedStories().toImmutableList() return HomeUM( - scanInProgress = false, + isScanInProgress = false, isStoriesContainerEnabled = homeFeatureToggles.isStoriesContainerEnabled, stories = initialStories, storiesConfig = HomeStoriesConfig(stories = initialStories), @@ -204,7 +204,7 @@ internal class HomeModel @Inject constructor( } private fun setLoading(isLoading: Boolean) { - uiState.update { it.copy(scanInProgress = isLoading) } + uiState.update { it.copy(isScanInProgress = isLoading) } } private fun handleScanError(error: TangemError) { diff --git a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/HomeStoriesScreen.kt b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/HomeStoriesScreen.kt index 279753d624..d0345df0ae 100644 --- a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/HomeStoriesScreen.kt +++ b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/HomeStoriesScreen.kt @@ -50,7 +50,7 @@ internal fun HomeStoriesScreen(state: HomeUM, modifier: Modifier = Modifier) { StoriesContainer( modifier = Modifier.fillMaxSize(), config = state.storiesConfig, - isPauseStories = state.scanInProgress, + isPauseStories = state.isScanInProgress, ) { story, isPaused -> Column( modifier = Modifier diff --git a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/StoriesAnimation.kt b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/StoriesAnimation.kt index 1056d363be..a1fcf66dac 100644 --- a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/StoriesAnimation.kt +++ b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/StoriesAnimation.kt @@ -26,7 +26,7 @@ private const val SCALE_SWITCH_BARRIER = 1.15f @Suppress("LongParameterList") @Composable -fun HorizontalSlidingImage( +internal fun HorizontalSlidingImage( painter: Painter, paused: Boolean, duration: Int, @@ -52,7 +52,7 @@ fun HorizontalSlidingImage( } @Composable -fun StoriesTextAnimation( +internal fun StoriesTextAnimation( slideInDuration: Int = 500, slideInDelay: Int = 200, slideDistance: Dp = 60.dp, @@ -94,7 +94,7 @@ fun StoriesTextAnimation( } @Composable -fun StoriesBottomImageAnimation( +internal fun StoriesBottomImageAnimation( firstStepDuration: Int, totalDuration: Int, initialScale: Float = 2.5f, diff --git a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/StoriesScreenV2.kt b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/StoriesScreenV2.kt index 77d8dcb1ea..a9c380a56a 100644 --- a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/StoriesScreenV2.kt +++ b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/StoriesScreenV2.kt @@ -63,7 +63,7 @@ internal fun StoriesScreenV2(state: HomeUM, onGetStartedClick: () -> Unit, modif storiesSize = state.stories.lastIndex, currentStoryIndex = currentStoryIndex, currentStory = currentStory, - isScanInProgress = state.scanInProgress, + isScanInProgress = state.isScanInProgress, onGoToPreviousStory = goToPreviousStory, onGoToNextStory = goToNextStory, onGetStartedClick = onGetStartedClick, diff --git a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/content/CurrenciesWeb3Content.kt b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/content/CurrenciesWeb3Content.kt index 66f639bc51..149c446f45 100644 --- a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/content/CurrenciesWeb3Content.kt +++ b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/content/CurrenciesWeb3Content.kt @@ -3,7 +3,6 @@ package com.tangem.features.home.impl.ui.compose.content import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush @@ -22,33 +21,44 @@ import com.tangem.core.ui.R import com.tangem.core.ui.utils.dpSize import com.tangem.core.ui.utils.toPx -@Composable -fun StoriesCurrenciesContent(paused: Boolean, duration: Int) { - val currencyDrawableList = remember { - listOf( - R.drawable.currency0, - R.drawable.currency1, - R.drawable.currency2, - R.drawable.currency3, - R.drawable.currency4, - ) - } +private val currencyDrawables = listOf( + R.drawable.currency0, + R.drawable.currency1, + R.drawable.currency2, + R.drawable.currency3, + R.drawable.currency4, +) +private val web3DappDrawables = listOf( + R.drawable.dapps1, + R.drawable.dapps1, + R.drawable.dapps2, + R.drawable.dapps3, + R.drawable.dapps4, + R.drawable.dapps5, +) + +private val currencyDesignItemHeight = 82.dp +private val web3DesignItemHeight = 75.dp +private val currencyDecreaseRate = 1f / currencyDrawables.size +private val web3DecreaseRate = 1f / web3DappDrawables.size +private const val WEB3_CHESS_OFFSET_DIVIDER = 3 + +@Composable +internal fun StoriesCurrenciesContent(paused: Boolean, duration: Int) { val screenWidth = LocalConfiguration.current.screenWidthDp.dp - val decreaseRate = remember { 1f / currencyDrawableList.size } - val designItemHeight = remember { 82.dp } BoxWithGradient { Column(modifier = Modifier.graphicsLayer(clip = false)) { - currencyDrawableList.forEachIndexed { index, drawableResId -> + currencyDrawables.forEachIndexed { index, drawableResId -> val painter = painterResource(id = drawableResId) - val scaledItemSize = scaleToDesignSize(painter.dpSize(), designItemHeight = designItemHeight) + val scaledItemSize = scaleToDesignSize(painter.dpSize(), designItemHeight = currencyDesignItemHeight) val itemOversizedScreenWidthBy = scaledItemSize.width - screenWidth val moveItemToStartOfScreen = itemOversizedScreenWidthBy / 2 val chessOffset = if (index.isEven()) 0.dp else scaledItemSize.halfHeight() val animateFrom = chessOffset - moveItemToStartOfScreen - val animateTo = 50.dp - 50.dp * index * decreaseRate + val animateTo = 50.dp - 50.dp * index * currencyDecreaseRate HorizontalSlidingImage( paused = paused, @@ -65,34 +75,21 @@ fun StoriesCurrenciesContent(paused: Boolean, duration: Int) { } } -@Suppress("MagicNumber") @Composable -fun StoriesWeb3Content(paused: Boolean, duration: Int) { - val dappsItemList = remember { - listOf( - R.drawable.dapps1, - R.drawable.dapps1, - R.drawable.dapps2, - R.drawable.dapps3, - R.drawable.dapps4, - R.drawable.dapps5, - ) - } +internal fun StoriesWeb3Content(paused: Boolean, duration: Int) { val screenWidth = LocalConfiguration.current.screenWidthDp.dp - val decreaseRate = remember { 1f / dappsItemList.size } - val designItemHeight = 75.dp BoxWithGradient { Column(modifier = Modifier.graphicsLayer(clip = false)) { - dappsItemList.forEachIndexed { index, drawableResId -> + web3DappDrawables.forEachIndexed { index, drawableResId -> val painter = painterResource(id = drawableResId) - val scaledItemSize = scaleToDesignSize(painter.dpSize(), designItemHeight = designItemHeight) + val scaledItemSize = scaleToDesignSize(painter.dpSize(), designItemHeight = web3DesignItemHeight) val itemOversizedScreenWidthBy = scaledItemSize.width - screenWidth val moveItemToStartOfScreen = itemOversizedScreenWidthBy / 2 - val chessOffset = if (index.isEven()) 0.dp else scaledItemSize.width / 3 + val chessOffset = if (index.isEven()) 0.dp else scaledItemSize.width / WEB3_CHESS_OFFSET_DIVIDER val animateFrom = chessOffset - moveItemToStartOfScreen - val animateTo = 70.dp - 70.dp * index * decreaseRate + val animateTo = 70.dp - 70.dp * index * web3DecreaseRate HorizontalSlidingImage( paused = paused, @@ -138,6 +135,6 @@ private val BottomGradient: Brush = Brush.verticalGradient( ), ) -fun DpSize.halfHeight(): Dp = this.height / 2 +private fun DpSize.halfHeight(): Dp = this.height / 2 -fun Int.isEven() = this and 1 == 0 \ No newline at end of file +private fun Int.isEven() = this and 1 == 0 \ No newline at end of file diff --git a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/content/FirstStoriesContent.kt b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/content/FirstStoriesContent.kt index f7f6debd9c..768710d2ca 100644 --- a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/content/FirstStoriesContent.kt +++ b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/content/FirstStoriesContent.kt @@ -13,7 +13,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.TextStyle @@ -21,16 +20,21 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.sp +import com.tangem.core.ui.R import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemColorPalette import com.tangem.core.ui.res.TangemTheme import com.tangem.features.home.impl.ui.compose.StoriesTextAnimation -import com.tangem.core.ui.R -@Suppress("LongMethod", "ComplexMethod", "MagicNumber") +private val firstStoryTitleStyle = TextStyle( + fontSize = 46.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, +) + @Composable -fun FirstStoriesContent(isPaused: Boolean, duration: Int) { +internal fun FirstStoriesContent(isPaused: Boolean, duration: Int) { val progress = remember { Animatable(0f) } LaunchedEffect(isPaused) { @@ -47,16 +51,8 @@ fun FirstStoriesContent(isPaused: Boolean, duration: Int) { } } - val style = TextStyle( - fontSize = 46.sp, - fontWeight = FontWeight.SemiBold, - color = Color.White, - textAlign = TextAlign.Center, - ) - Column( - modifier = Modifier - .fillMaxSize(), + modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, ) { SpacerH(TangemTheme.dimens.spacing94) @@ -67,15 +63,14 @@ fun FirstStoriesContent(isPaused: Boolean, duration: Int) { Text( modifier = modifier, text = stringResourceSafe(R.string.story_meet_title), - style = style, + style = firstStoryTitleStyle, color = TangemColorPalette.White, textAlign = TextAlign.Center, ) } SpacerH(TangemTheme.dimens.spacing46) Image( - modifier = Modifier - .fillMaxWidth(), + modifier = Modifier.fillMaxWidth(), painter = painterResource(R.drawable.img_meet_tangem), contentScale = ContentScale.Inside, contentDescription = "Tangem Wallet card", @@ -86,8 +81,5 @@ fun FirstStoriesContent(isPaused: Boolean, duration: Int) { @Preview @Composable private fun FirstStoriesPreview() { - FirstStoriesContent( - false, - 8000, - ) + FirstStoriesContent(isPaused = false, duration = 8000) } \ No newline at end of file diff --git a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/content/FloatingCardsContent.kt b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/content/FloatingCardsContent.kt index e846781ec0..be1c61eda9 100644 --- a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/content/FloatingCardsContent.kt +++ b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/content/FloatingCardsContent.kt @@ -1,40 +1,56 @@ package com.tangem.features.home.impl.ui.compose.content +import androidx.annotation.DrawableRes import androidx.compose.foundation.Image import androidx.compose.foundation.layout.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.painterResource import com.tangem.core.ui.R import com.tangem.core.ui.utils.AnimatedValue -import com.tangem.core.ui.utils.asImageBitmap import com.tangem.core.ui.utils.toAnimatable /** [REDACTED_AUTHOR] */ @Composable -fun FloatingCardsContent(isPaused: Boolean, stepDuration: Int) { - val imageBitmap = asImageBitmap(R.drawable.img_card_placeholder_wallet_2) - val cards = listOf( - FloatingCard.first(), - FloatingCard.second(), - FloatingCard.third(), - ) - +internal fun FloatingCardsContent(isPaused: Boolean, stepDuration: Int) { Box(modifier = Modifier.fillMaxSize()) { - cards.forEach { floatingCard -> - FloatingCard.Item( + floatingCards.forEach { cardValues -> + FloatingCardItem( isPaused = isPaused, - imageBitmap = imageBitmap, - cardValues = floatingCard, + imageRes = R.drawable.img_card_placeholder_wallet_2, + cardValues = cardValues, stepDuration = stepDuration, ) } } } +@Composable +private fun FloatingCardItem( + isPaused: Boolean, + stepDuration: Int, + @DrawableRes imageRes: Int, + cardValues: CardValues, +) { + Image( + painter = painterResource(imageRes), + contentDescription = null, + modifier = Modifier + .graphicsLayer( + translationX = cardValues.translateX.toAnimatable(isPaused, stepDuration).value, + translationY = cardValues.translateY.toAnimatable(isPaused, stepDuration).value, + rotationX = cardValues.rotationX.toAnimatable(isPaused, stepDuration).value, + rotationY = cardValues.rotationY.toAnimatable(isPaused, stepDuration).value, + rotationZ = cardValues.rotationZ.toAnimatable(isPaused, stepDuration).value, + scaleX = cardValues.scale.toAnimatable(isPaused, stepDuration).value, + scaleY = cardValues.scale.toAnimatable(isPaused, stepDuration).value, + ), + ) +} + private data class CardValues( val translateX: AnimatedValue = AnimatedValue(0f, 0f), val translateY: AnimatedValue = AnimatedValue(0f, 0f), @@ -44,54 +60,30 @@ private data class CardValues( val scale: AnimatedValue = AnimatedValue(1f, 1f), ) -private object FloatingCard { - - @Suppress("TopLevelComposableFunctions") - @Composable - fun Item(isPaused: Boolean, stepDuration: Int, imageBitmap: ImageBitmap, cardValues: CardValues) { - Image( - bitmap = imageBitmap, - contentDescription = "Floating Tangem card", - modifier = Modifier - .graphicsLayer( - translationX = cardValues.translateX.toAnimatable(isPaused, stepDuration).value, - translationY = cardValues.translateY.toAnimatable(isPaused, stepDuration).value, - rotationX = cardValues.rotationX.toAnimatable(isPaused, stepDuration).value, - rotationY = cardValues.rotationY.toAnimatable(isPaused, stepDuration).value, - rotationZ = cardValues.rotationZ.toAnimatable(isPaused, stepDuration).value, - scaleX = cardValues.scale.toAnimatable(isPaused, stepDuration).value, - scaleY = cardValues.scale.toAnimatable(isPaused, stepDuration).value, - ), - ) - } - - @Suppress("MagicNumber") - fun first(): CardValues = CardValues( +@Suppress("MagicNumber") +private val floatingCards = listOf( + CardValues( translateX = -400f to -350f, translateY = 30f to 32f, rotationX = 10f to 15f, rotationY = 15f to 15f, rotationZ = 40f to 27f, scale = 0.6f to 0.6f, - ) - - @Suppress("MagicNumber") - fun second(): CardValues = CardValues( + ), + CardValues( translateX = 350f to 300f, translateY = -70f to 0f, rotationX = 30f to 48f, rotationY = 0f to 5f, rotationZ = -34f to -42f, scale = 0.47f to 0.35f, - ) - - @Suppress("MagicNumber") - fun third(): CardValues = CardValues( + ), + CardValues( translateX = 320f to 250f, translateY = 500f to 500f, rotationX = 0f to 3f, rotationY = 10f to 10f, rotationZ = -45f to -30f, scale = 0.6f to 0.75f, - ) -} \ No newline at end of file + ), +) \ No newline at end of file diff --git a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/content/Content.kt b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/content/StoriesContent.kt similarity index 89% rename from features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/content/Content.kt rename to features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/content/StoriesContent.kt index 33bf6db728..ff91838a5f 100644 --- a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/content/Content.kt +++ b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/content/StoriesContent.kt @@ -26,7 +26,7 @@ import com.tangem.features.home.impl.ui.compose.StoriesTextAnimation import com.tangem.core.ui.R @Composable -fun StoriesRevolutionaryWallet() { +internal fun StoriesRevolutionaryWallet() { SplitContent( topContent = { TopContent( @@ -45,7 +45,7 @@ fun StoriesRevolutionaryWallet() { } @Composable -fun StoriesUltraSecureBackup(isPaused: Boolean, stepDuration: Int) { +internal fun StoriesUltraSecureBackup(isPaused: Boolean, stepDuration: Int) { SplitContent( topContent = { TopContent( @@ -64,7 +64,7 @@ fun StoriesUltraSecureBackup(isPaused: Boolean, stepDuration: Int) { } @Composable -fun StoriesCurrencies(isPaused: Boolean, stepDuration: Int) { +internal fun StoriesCurrencies(isPaused: Boolean, stepDuration: Int) { SplitContent( topContent = { TopContent( @@ -80,7 +80,7 @@ fun StoriesCurrencies(isPaused: Boolean, stepDuration: Int) { } @Composable -fun StoriesWeb3(isPaused: Boolean, stepDuration: Int) { +internal fun StoriesWeb3(isPaused: Boolean, stepDuration: Int) { SplitContent( topContent = { TopContent( @@ -96,7 +96,7 @@ fun StoriesWeb3(isPaused: Boolean, stepDuration: Int) { } @Composable -fun StoriesWalletForEveryone(stepDuration: Int) { +internal fun StoriesWalletForEveryone(stepDuration: Int) { SplitContent( topContent = { TopContent( @@ -127,8 +127,7 @@ fun StoriesWalletForEveryone(stepDuration: Int) { @Composable private fun SplitContent(topContent: @Composable () -> Unit, bottomContent: @Composable () -> Unit) { Column( - modifier = Modifier - .fillMaxSize(), + modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Top, ) { @@ -140,16 +139,11 @@ private fun SplitContent(topContent: @Composable () -> Unit, bottomContent: @Com @Composable private fun TopContent(titleText: String, subtitleText: String) { SpacerH(TangemTheme.dimens.spacing36) - StoriesTitleText( - text = titleText, - ) + StoriesTitleText(text = titleText) SpacerH16() - StoriesSubtitleText( - subtitleText = subtitleText, - ) + StoriesSubtitleText(subtitleText = subtitleText) } -@Suppress("MagicNumber") @Composable private fun StoriesTitleText(text: String) { StoriesTextAnimation( @@ -157,8 +151,7 @@ private fun StoriesTitleText(text: String) { slideInDelay = 150, ) { modifier -> Text( - modifier = modifier - .padding(start = 40.dp, end = 40.dp), + modifier = modifier.padding(horizontal = 40.dp), text = text, style = TangemTheme.typography.head, color = TangemColorPalette.White, @@ -167,7 +160,6 @@ private fun StoriesTitleText(text: String) { } } -@Suppress("MagicNumber") @Composable private fun StoriesSubtitleText(subtitleText: String) { StoriesTextAnimation( @@ -175,8 +167,7 @@ private fun StoriesSubtitleText(subtitleText: String) { slideInDelay = 400, ) { modifier -> Text( - modifier = modifier - .padding(start = 40.dp, end = 40.dp), + modifier = modifier.padding(horizontal = 40.dp), text = subtitleText, style = TangemTheme.typography.body1, color = TangemColorPalette.Dark1, diff --git a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/views/HomeButtons.kt b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/views/HomeButtons.kt deleted file mode 100644 index 5b0615495b..0000000000 --- a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/views/HomeButtons.kt +++ /dev/null @@ -1,105 +0,0 @@ -package com.tangem.features.home.impl.ui.compose.views - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider -import com.tangem.core.ui.components.SpacerW12 -import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition -import com.tangem.core.ui.extensions.stringResourceSafe -import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.core.ui.test.StoriesScreenTestTags -import com.tangem.core.ui.R - -@Composable -internal fun HomeButtons( - btnScanStateInProgress: Boolean, - onScanButtonClick: () -> Unit, - onShopButtonClick: () -> Unit, - modifier: Modifier = Modifier, -) { - Row( - horizontalArrangement = Arrangement.SpaceEvenly, - modifier = modifier, - ) { - ScanCardButton( - modifier = Modifier - .weight(weight = 1f) - .testTag(StoriesScreenTestTags.SCAN_BUTTON), - showProgress = btnScanStateInProgress, - onClick = onScanButtonClick, - ) - SpacerW12() - OrderCardButton( - modifier = Modifier - .weight(weight = 1f) - .testTag(StoriesScreenTestTags.ORDER_BUTTON), - onClick = onShopButtonClick, - ) - } -} - -@Composable -private fun ScanCardButton(showProgress: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { - StoriesButton( - modifier = modifier, - text = stringResourceSafe(id = R.string.home_button_scan), - useDarkerColors = false, - icon = TangemButtonIconPosition.End(iconResId = R.drawable.ic_tangem_24), - onClick = onClick, - showProgress = showProgress, - ) -} - -@Composable -private fun OrderCardButton(onClick: () -> Unit, modifier: Modifier = Modifier) { - StoriesButton( - modifier = modifier, - text = stringResourceSafe(id = R.string.home_button_order), - useDarkerColors = true, - onClick = onClick, - ) -} - -// region Preview -@Preview(showBackground = true, widthDp = 360) -@Composable -private fun HomeButtonsPreview(@PreviewParameter(HomeButtonsParameterProvider::class) state: HomeButtonsState) { - TangemThemePreview { - Box( - modifier = Modifier.background(Color.Black), - ) { - HomeButtons( - btnScanStateInProgress = state.btnScanStateInProgress, - onScanButtonClick = {}, - onShopButtonClick = {}, - modifier = Modifier.padding(all = TangemTheme.dimens.spacing16), - ) - } - } -} - -private class HomeButtonsParameterProvider : CollectionPreviewParameterProvider( - collection = listOf( - HomeButtonsState( - btnScanStateInProgress = false, - ), - HomeButtonsState( - btnScanStateInProgress = true, - ), - ), -) - -private data class HomeButtonsState( - val btnScanStateInProgress: Boolean, -) -// endregion Preview \ No newline at end of file diff --git a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/views/HomeButtonsV2.kt b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/views/HomeButtonsV2.kt index cd2c7ae621..67efafc969 100644 --- a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/views/HomeButtonsV2.kt +++ b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/views/HomeButtonsV2.kt @@ -24,7 +24,7 @@ internal fun HomeButtonsV2(onGetStartedClick: () -> Unit, modifier: Modifier = M verticalArrangement = Arrangement.spacedBy(8.dp), ) { StoriesButton( - modifier = modifier, + modifier = Modifier.fillMaxWidth(), text = stringResourceSafe(id = R.string.common_get_started), useDarkerColors = false, onClick = onGetStartedClick, diff --git a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/views/SearchCurrenciesButton.kt b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/views/SearchCurrenciesButton.kt deleted file mode 100644 index 0987e2193b..0000000000 --- a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/views/SearchCurrenciesButton.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.tangem.features.home.impl.ui.compose.views - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.tooling.preview.Preview -import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition -import com.tangem.core.ui.extensions.stringResourceSafe -import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.core.ui.R - -@Composable -internal fun SearchCurrenciesButton(onClick: () -> Unit, modifier: Modifier = Modifier) { - StoriesButton( - modifier = modifier, - text = stringResourceSafe(id = R.string.common_search_tokens), - icon = TangemButtonIconPosition.Start(R.drawable.ic_search_24), - showProgress = false, - useDarkerColors = true, - onClick = onClick, - ) -} - -// region Preview -@Preview(showBackground = true, widthDp = 360) -@Composable -private fun SearchCurrenciesButtonPreview() { - TangemThemePreview { - Box( - modifier = Modifier - .background(color = Color.Black) - .padding(all = TangemTheme.dimens.spacing16), - ) { - SearchCurrenciesButton(modifier = Modifier.fillMaxWidth(), onClick = {}) - } - } -} -// endregion Preview \ No newline at end of file diff --git a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/views/StoriesProgressBar.kt b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/views/StoriesProgressBar.kt index 058d371b74..34886d6527 100644 --- a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/views/StoriesProgressBar.kt +++ b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/compose/views/StoriesProgressBar.kt @@ -23,7 +23,7 @@ import kotlinx.coroutines.delay private const val STORIES_ANIMATION_SPEED_ZERO_DURATION = 3000L @Composable -fun StoriesProgressBar( +internal fun StoriesProgressBar( steps: Int, currentStep: Int, paused: Boolean = false, @@ -82,11 +82,11 @@ fun StoriesProgressBar( .clip(RoundedCornerShape(TangemTheme.dimens.radius2)) .background(TangemColorPalette.White) .fillMaxHeight() - .let { + .let { progressModifier -> when (index) { - currentStep -> it.fillMaxWidth(progress.value) - in 0 until currentStep -> it.fillMaxWidth(fraction = 1f) - else -> it + currentStep -> progressModifier.fillMaxWidth(progress.value) + in 0 until currentStep -> progressModifier.fillMaxWidth(fraction = 1f) + else -> progressModifier } }, ) diff --git a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/state/HomeUM.kt b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/state/HomeUM.kt index 9ab0da9271..1189f5dca3 100644 --- a/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/state/HomeUM.kt +++ b/features/home/impl/src/main/kotlin/com/tangem/features/home/impl/ui/state/HomeUM.kt @@ -4,8 +4,8 @@ import com.tangem.core.ui.components.stories.model.StoriesContentConfig import com.tangem.core.ui.components.stories.model.StoryConfig import kotlinx.collections.immutable.ImmutableList -data class HomeUM( - val scanInProgress: Boolean, +internal data class HomeUM( + val isScanInProgress: Boolean, val isStoriesContainerEnabled: Boolean, val stories: ImmutableList, val storiesConfig: HomeStoriesConfig, @@ -20,13 +20,13 @@ data class HomeUM( * Config for the redesigned Home stories ([StoriesContainer]). The Home intro loops forever and is * not closable, so [isCloseButtonVisible] is `false` and [onClose] keeps its no-op default. */ -data class HomeStoriesConfig( +internal data class HomeStoriesConfig( override val stories: ImmutableList, override val isRestartable: Boolean = true, override val isCloseButtonVisible: Boolean = false, ) : StoriesContentConfig -enum class Stories(override val duration: Int = 6000) : StoryConfig { +internal enum class Stories(override val duration: Int = 6000) : StoryConfig { TangemIntro, RevolutionaryWallet, UltraSecureBackup, @@ -38,6 +38,6 @@ enum class Stories(override val duration: Int = 6000) : StoryConfig { /** * For FCA restriction stories */ -fun getRestrictedStories(): List { +internal fun getRestrictedStories(): List { return Stories.entries.filterNot { it == Stories.Currencies } } \ No newline at end of file diff --git a/features/home/impl/src/test/kotlin/com/tangem/features/home/impl/model/HomeModelTest.kt b/features/home/impl/src/test/kotlin/com/tangem/features/home/impl/model/HomeModelTest.kt index 089e0b31b4..4879b05357 100644 --- a/features/home/impl/src/test/kotlin/com/tangem/features/home/impl/model/HomeModelTest.kt +++ b/features/home/impl/src/test/kotlin/com/tangem/features/home/impl/model/HomeModelTest.kt @@ -157,13 +157,13 @@ internal class HomeModelTest { // Act + Assert — loading on progressSlot.captured.invoke(true) advanceUntilIdle() - assertThat(model.uiState.value.scanInProgress).isTrue() + assertThat(model.uiState.value.isScanInProgress).isTrue() assertThat(model.uiState.value.storiesConfig).isSameInstanceAs(initialConfig) // Act + Assert — loading off progressSlot.captured.invoke(false) advanceUntilIdle() - assertThat(model.uiState.value.scanInProgress).isFalse() + assertThat(model.uiState.value.isScanInProgress).isFalse() assertThat(model.uiState.value.storiesConfig).isSameInstanceAs(initialConfig) model.onDestroy() From 609b5733c1e0f018e83406e2ae8649ed51a332f0 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 24 Jun 2026 13:36:57 +0000 Subject: [PATCH 038/210] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 6aaa3d0243..074e02bb80 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,9 +5,9 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-6.0-1580" +tangemBlockchainSdk = "develop-1567" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-6.0-626" +tangemCardSdk = "develop-624" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "tangem-master-21" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ From a98997d55ffcd266edf40554447455d181cce124 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 24 Jun 2026 18:28:50 +0000 Subject: [PATCH 039/210] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index b298226c5c..f5f4f1626b 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,9 +5,9 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-6.0-1587" +tangemBlockchainSdk = "develop-1586" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-6.0-626" +tangemCardSdk = "develop-630" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "tangem-master-21" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ From 0c4d14650d8ce244af50b6af02b50cd6a891dc8c Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 25 Jun 2026 15:42:03 +0200 Subject: [PATCH 040/210] Updated on 2026-08-14 --- .../AddressBookContactsBlockComponent.kt | 3 - .../features/addressbook/MatchedContact.kt | 1 + features/address-book/impl/build.gradle.kts | 1 + .../addaddress/ui/AddAddressContent.kt | 56 ++++-- .../ui/AddressSelectorBottomSheet.kt | 1 + .../block/model/ContactsBlockModel.kt | 20 +- .../UpdateContactsBlockStateTransformer.kt | 4 + .../addressbook/block/ui/ContactsBlock.kt | 2 + .../addressbook/common/ContactMatcher.kt | 16 +- .../addressbook/common/ui/ContactRow.kt | 15 +- .../editcontact/ui/EditContactContent.kt | 3 +- .../list/model/AddressBookListModel.kt | 119 +++++++++--- ...UpdateAddressBookListContentTransformer.kt | 129 +++++++++++++ ...eAddressBookListInitialStateTransformer.kt | 22 --- ...ddressBookListSelectionStateTransformer.kt | 38 ---- .../converter/DefaultContactConverter.kt | 30 +++ .../converter/SelectorContactConverter.kt | 19 ++ .../addressbook/list/ui/AddressBookChip.kt | 68 +++++++ .../list/ui/AddressBookListScreen.kt | 175 +++++++++-------- .../preview/AddressBookListScreenPreview.kt | 106 ++++++++++ .../list/ui/state/AddressBookChipUM.kt | 14 ++ .../list/ui/state/AddressBookListUM.kt | 15 +- .../addressbook/list/ui/state/ContactUM.kt | 2 + ...teAddressBookListContentTransformerTest.kt | 182 ++++++++++++++++++ .../DefaultSendDestinationComponent.kt | 1 - 25 files changed, 829 insertions(+), 213 deletions(-) create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListContentTransformer.kt delete mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListInitialStateTransformer.kt delete mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListSelectionStateTransformer.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/converter/DefaultContactConverter.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/converter/SelectorContactConverter.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookChip.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/preview/AddressBookListScreenPreview.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/AddressBookChipUM.kt create mode 100644 features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListContentTransformerTest.kt diff --git a/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/AddressBookContactsBlockComponent.kt b/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/AddressBookContactsBlockComponent.kt index 7c8f04d753..23058e9d1d 100644 --- a/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/AddressBookContactsBlockComponent.kt +++ b/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/AddressBookContactsBlockComponent.kt @@ -3,7 +3,6 @@ package com.tangem.features.addressbook import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.domain.models.network.Network -import com.tangem.domain.models.wallet.UserWalletId import kotlinx.coroutines.flow.StateFlow /** @@ -16,7 +15,6 @@ interface AddressBookContactsBlockComponent : ComposableContentComponent { interface Factory : ComponentFactory /** - * @property userWalletId the sending wallet whose address book is shown * @property network the current send network; only contacts with an address in this network are shown * @property queryFlow the live recipient-input text used to filter the block * @property onContactClick invoked with the tapped contact and its network-matching entries; the host decides @@ -24,7 +22,6 @@ interface AddressBookContactsBlockComponent : ComposableContentComponent { * @property onSeeAllClick invoked when the user taps "See all" to open the full address book in selection mode */ data class Params( - val userWalletId: UserWalletId, val network: Network, val queryFlow: StateFlow, val onContactClick: (MatchedContact) -> Unit, diff --git a/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/MatchedContact.kt b/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/MatchedContact.kt index f5d9b0adff..d2b0031a27 100644 --- a/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/MatchedContact.kt +++ b/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/MatchedContact.kt @@ -12,6 +12,7 @@ import kotlinx.collections.immutable.ImmutableList data class MatchedContact( val contactId: String, + val walletId: String, val name: String, val icon: AccountIconUM.CryptoPortfolio, val networkId: String, diff --git a/features/address-book/impl/build.gradle.kts b/features/address-book/impl/build.gradle.kts index 2869945728..6cf352ac01 100644 --- a/features/address-book/impl/build.gradle.kts +++ b/features/address-book/impl/build.gradle.kts @@ -19,6 +19,7 @@ dependencies { implementation(projects.domain.account) implementation(projects.domain.addressBook) implementation(projects.domain.models) + implementation(projects.domain.wallets) /** Common */ implementation(projects.common.ui) diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/AddAddressContent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/AddAddressContent.kt index 6a8d9f5cfe..63f24efd33 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/AddAddressContent.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/AddAddressContent.kt @@ -3,7 +3,9 @@ package com.tangem.features.addressbook.addaddress.ui import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -44,26 +46,44 @@ internal fun AddAddressContent(state: AddAddressUM, modifier: Modifier = Modifie ) }, ) - - RecipientRow( - modifier = Modifier.padding(horizontal = 16.dp), - addressField = state.addressField, - onValueChange = state.onAddressChange, - onAddressClear = state.onAddressClear, - onQrClick = state.onQrClick, - onPasteClick = state.onPasteClick, - ) - SpacerH(20.dp) - NetworkBlock( + BoxWithConstraints( modifier = Modifier - .padding(horizontal = 16.dp) - .clip(RoundedCornerShape(16.dp)) .fillMaxWidth() - .background(color = TangemTheme.colors3.bg.secondary), - chosenNetworkStateUM = state.chosenNetworkStateUM, - onNetworkSelectClick = state.onNetworkClick, - ) - PrimaryButton(state.buttonUM) + .weight(1f), + ) { + val minContentHeight = maxHeight + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + ) { + Column( + modifier = Modifier + .heightIn(min = minContentHeight) + .imePadding(), + ) { + RecipientRow( + modifier = Modifier.padding(horizontal = 16.dp), + addressField = state.addressField, + onValueChange = state.onAddressChange, + onAddressClear = state.onAddressClear, + onQrClick = state.onQrClick, + onPasteClick = state.onPasteClick, + ) + SpacerH(20.dp) + NetworkBlock( + modifier = Modifier + .padding(horizontal = 16.dp) + .clip(RoundedCornerShape(16.dp)) + .fillMaxWidth() + .background(color = TangemTheme.colors3.bg.secondary), + chosenNetworkStateUM = state.chosenNetworkStateUM, + onNetworkSelectClick = state.onNetworkClick, + ) + PrimaryButton(state.buttonUM) + } + } + } } } diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addressselector/ui/AddressSelectorBottomSheet.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addressselector/ui/AddressSelectorBottomSheet.kt index cd305175a2..60ea57de1e 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addressselector/ui/AddressSelectorBottomSheet.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addressselector/ui/AddressSelectorBottomSheet.kt @@ -134,6 +134,7 @@ private fun Preview_AddressSelectorList() { AddressSelectorList( contact = MatchedContact( contactId = "1", + walletId = "00", name = "Binance", icon = AccountIconUM.CryptoPortfolio( value = CryptoPortfolioIcon.Icon.Letter, diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/model/ContactsBlockModel.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/model/ContactsBlockModel.kt index 1aa457aced..3ad200dc0a 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/model/ContactsBlockModel.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/model/ContactsBlockModel.kt @@ -4,6 +4,7 @@ import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.domain.addressbook.usecase.GetContactsUseCase +import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.features.addressbook.AddressBookContactsBlockComponent import com.tangem.features.addressbook.block.state.ContactsBlockStateController import com.tangem.features.addressbook.block.state.transformers.UpdateContactsBlockStateTransformer @@ -11,11 +12,7 @@ import com.tangem.features.addressbook.block.ui.state.ContactsBlockUM import com.tangem.features.addressbook.common.ContactMatcher import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.* import javax.inject.Inject @OptIn(ExperimentalCoroutinesApi::class) @@ -25,6 +22,7 @@ internal class ContactsBlockModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val stateController: ContactsBlockStateController, getContactsUseCase: GetContactsUseCase, + getWalletsUseCase: GetWalletsUseCase, ) : Model() { private val params = paramsContainer.require() @@ -32,13 +30,19 @@ internal class ContactsBlockModel @Inject constructor( val state: StateFlow get() = stateController.uiState init { - params.queryFlow - .flatMapLatest { query -> getContactsUseCase(query = query, userWalletId = params.userWalletId) } - .onEach { contacts -> + combine( + params.queryFlow.flatMapLatest { query -> + getContactsUseCase(query = query, userWalletId = null) + }, + getWalletsUseCase.invokeAsMap(isOnlyMultiCurrency = false, filterLocked = true), + ) { contacts, wallets -> contacts to wallets.values.toList() } + .onEach { (contacts, wallets) -> val matched = ContactMatcher.match(contacts = contacts, networkId = params.network.rawId) stateController.update( UpdateContactsBlockStateTransformer( matched = matched, + walletNamesById = wallets.associate { it.walletId.stringValue to it.name }, + shouldShowWalletName = matched.mapTo(HashSet()) { it.walletId }.size > 1, onSeeAllClick = params.onSeeAllClick, onContactClick = params.onContactClick, ), diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/state/transformers/UpdateContactsBlockStateTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/state/transformers/UpdateContactsBlockStateTransformer.kt index c267bfd65d..bf9e47bdd4 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/state/transformers/UpdateContactsBlockStateTransformer.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/state/transformers/UpdateContactsBlockStateTransformer.kt @@ -9,6 +9,8 @@ import kotlinx.collections.immutable.toImmutableList /** Builds the Send contacts block from the network-matching contacts; an empty result hides the block. */ internal class UpdateContactsBlockStateTransformer( private val matched: List, + private val walletNamesById: Map, + private val shouldShowWalletName: Boolean, private val onSeeAllClick: () -> Unit, private val onContactClick: (MatchedContact) -> Unit, ) : Transformer { @@ -29,9 +31,11 @@ internal class UpdateContactsBlockStateTransformer( private fun MatchedContact.toRowUM(): ContactUM = ContactUM( id = contactId, + walletId = walletId, name = name, icon = icon, networkAddressCount = entries.size, + walletName = if (shouldShowWalletName) walletNamesById[walletId] else null, onClick = { onContactClick(this) }, ) diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/ui/ContactsBlock.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/ui/ContactsBlock.kt index ca1526a0a5..d9d1366454 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/ui/ContactsBlock.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/block/ui/ContactsBlock.kt @@ -74,6 +74,7 @@ private fun Preview_ContactsBlock() { contacts = persistentListOf( ContactUM( id = "1", + walletId = "00", name = "Binance", icon = AccountIconUM.CryptoPortfolio( value = CryptoPortfolioIcon.Icon.Letter, @@ -84,6 +85,7 @@ private fun Preview_ContactsBlock() { ), ContactUM( id = "2", + walletId = "01", name = "Alice", icon = AccountIconUM.CryptoPortfolio( value = CryptoPortfolioIcon.Icon.Letter, diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/ContactMatcher.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/ContactMatcher.kt index c044f8e480..b2579dec4e 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/ContactMatcher.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/ContactMatcher.kt @@ -12,8 +12,6 @@ import kotlinx.collections.immutable.toImmutableList */ internal object ContactMatcher { - private val DEFAULT_ICON_COLOR = CryptoPortfolioIcon.Color.Azure - fun match(contacts: List, networkId: String): List { return contacts.mapNotNull { contact -> val entries = contact.addressEntries.filter { it.networkId.value == networkId } @@ -21,11 +19,9 @@ internal object ContactMatcher { MatchedContact( contactId = contact.id.value, + walletId = contact.walletId.stringValue, name = contact.name.value, - icon = AccountIconUM.CryptoPortfolio( - value = CryptoPortfolioIcon.Icon.Letter, - color = contact.resolveIconColor(), - ), + icon = contact.toAvatarIcon(), networkId = networkId, entries = entries.map { entry -> MatchedContact.ContactAddress( @@ -38,6 +34,10 @@ internal object ContactMatcher { } } - private fun Contact.resolveIconColor(): CryptoPortfolioIcon.Color = - CryptoPortfolioIcon.Color.entries.firstOrNull { it.name == iconColor } ?: DEFAULT_ICON_COLOR + /** Builds the contact avatar from the domain [Contact.icon] / [Contact.iconColor] (enum names), with fallbacks. */ + private fun Contact.toAvatarIcon(): AccountIconUM.CryptoPortfolio = AccountIconUM.CryptoPortfolio( + value = CryptoPortfolioIcon.Icon.entries.firstOrNull { it.name == icon } ?: CryptoPortfolioIcon.Icon.Letter, + color = CryptoPortfolioIcon.Color.entries.firstOrNull { it.name == iconColor } + ?: CryptoPortfolioIcon.Color.Azure, + ) } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/ui/ContactRow.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/ui/ContactRow.kt index 20855e8bb0..d154a91c57 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/ui/ContactRow.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/ui/ContactRow.kt @@ -12,6 +12,7 @@ import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.domain.models.account.CryptoPortfolioIcon import com.tangem.features.addressbook.list.ui.state.ContactUM +import com.tangem.utils.StringsSigns @Composable internal fun ContactRow(contact: ContactUM) { @@ -33,12 +34,14 @@ internal fun ContactRow(contact: ContactUM) { ) }, subtitleSlot = { + val addresses = pluralStringResourceSafe( + R.plurals.address_book_addresses, + contact.networkAddressCount, + contact.networkAddressCount, + ) TangemRowText( - text = pluralStringResourceSafe( - R.plurals.address_book_addresses, - contact.networkAddressCount, - contact.networkAddressCount, - ), + text = contact.walletName?.let { walletName -> "$addresses ${StringsSigns.DOT} $walletName" } + ?: addresses, role = TangemRowTextRole.Subtitle, ) }, @@ -52,11 +55,13 @@ private fun Preview_ContactRow() { ContactRow( ContactUM( id = "1", + walletId = "00", name = "Binance", icon = AccountIconUM.CryptoPortfolio( value = CryptoPortfolioIcon.Icon.Letter, color = CryptoPortfolioIcon.Color.Azure, ), + walletName = "Wallet 1", networkAddressCount = 1, onClick = {}, ), diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/EditContactContent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/EditContactContent.kt index c45223210c..4334ba0f66 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/EditContactContent.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/EditContactContent.kt @@ -49,6 +49,7 @@ internal fun EditContactContent(state: EditContactUM, modifier: Modifier = Modif modifier = modifier .fillMaxSize() .background(color = TangemTheme.colors3.bg.primary) + .imePadding() .systemBarsPadding(), horizontalAlignment = Alignment.CenterHorizontally, ) { @@ -216,7 +217,7 @@ private fun ContactColor(colors: EditContactUM.Colors) { FlowRow( maxItemsInEachRow = 6, modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, + horizontalArrangement = Arrangement.Center, verticalArrangement = Arrangement.spacedBy(18.dp), ) { colors.list.fastForEach { color -> diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt index e53a5d03d8..0f2cfb6a66 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt @@ -7,31 +7,33 @@ 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.navigation.Router -import com.tangem.domain.addressbook.usecase.GetContactsUseCase +import com.tangem.domain.addressbook.interactor.GetVerifiedContactsInteractor +import com.tangem.domain.addressbook.model.VerifiedContact +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.features.addressbook.ContactSelectionTrigger import com.tangem.features.addressbook.MatchedContact import com.tangem.features.addressbook.SelectedContact -import com.tangem.features.addressbook.common.ContactMatcher import com.tangem.features.addressbook.list.DefaultAddressBookListComponent import com.tangem.features.addressbook.list.state.AddressBookListStateController -import com.tangem.features.addressbook.list.state.transformers.UpdateAddressBookListInitialStateTransformer -import com.tangem.features.addressbook.list.state.transformers.UpdateAddressBookListSelectionStateTransformer +import com.tangem.features.addressbook.list.state.transformers.UpdateAddressBookListContentTransformer import com.tangem.features.addressbook.list.ui.state.AddressBookListUM import com.tangem.features.addressbook.route.AddressBookRoute import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.* import javax.inject.Inject /** * Backs the contacts list. The list content is the same however the address book was opened — the open * [AddressBookRoute.ListMode] only decides what tapping a contact does: - * - [AddressBookRoute.ListMode.Default]: browse / manage contacts (full UI is TODO [REDACTED_TASK_KEY]). + * - [AddressBookRoute.ListMode.Default]: browse / manage contacts (editor is TODO [REDACTED_TASK_KEY]). * - [AddressBookRoute.ListMode.Selector]: pick a recipient for the given network — a single matching address is * returned right away, several open the address selector first. */ +@Suppress("LongParameterList", "NamedArguments") +@OptIn(ExperimentalCoroutinesApi::class) @ModelScoped internal class AddressBookListModel @Inject constructor( paramsContainer: ParamsContainer, @@ -39,7 +41,8 @@ internal class AddressBookListModel @Inject constructor( private val stateController: AddressBookListStateController, private val router: Router, private val contactSelectionTrigger: ContactSelectionTrigger, - private val getContactsUseCase: GetContactsUseCase, + getVerifiedContactsInteractor: GetVerifiedContactsInteractor, + getWalletsUseCase: GetWalletsUseCase, ) : Model() { private val params = paramsContainer.require() @@ -49,32 +52,77 @@ internal class AddressBookListModel @Inject constructor( /** Address-selector bottom sheet, shown when a picked contact has more than one address in the target network. */ val selectorNavigation = SlotNavigation() - init { - when (val mode = params.mode) { - // Browse/manage: full list UI is TODO [REDACTED_TASK_KEY]. - AddressBookRoute.ListMode.Default -> stateController.update( - UpdateAddressBookListInitialStateTransformer(onAddContactClick = params.onAddContactClick), - ) - // Pick a recipient: same list, the tap returns the chosen address. - is AddressBookRoute.ListMode.Selector -> observeSelectionContacts(networkId = mode.networkId) - } - } + private val searchQuery = MutableStateFlow(value = "") + private val searchActive = MutableStateFlow(value = false) + private val selectedWalletId = MutableStateFlow(value = null) - private fun observeSelectionContacts(networkId: String) { - getContactsUseCase(query = "") - .onEach { contacts -> - stateController.update( - UpdateAddressBookListSelectionStateTransformer( - matched = ContactMatcher.match(contacts = contacts, networkId = networkId), - onAddContactClick = params.onAddContactClick, - onContactClick = ::onPickContact, - ), - ) - } + private val allContacts: SharedFlow> = + getVerifiedContactsInteractor(query = "", userWalletId = null) + .shareIn(modelScope, SharingStarted.Lazily, replay = 1) + + init { + val matchedContacts = searchQuery.flatMapLatest { query -> + if (query.isBlank()) allContacts else getVerifiedContactsInteractor(query = query, userWalletId = null) + } + combine( + allContacts, + matchedContacts, + searchQuery, + combine(selectedWalletId, searchActive) { selected, active -> selected to active }, + getWalletsUseCase.invokeAsMap(isOnlyMultiCurrency = false, filterLocked = true), + ) { all, matched, query, (selected, active), wallets -> + ListInputs( + allContacts = all, + matchedContacts = matched, + query = query, + selectedWalletId = selected, + isSearchActive = active, + wallets = wallets, + ) + } + .onEach(::updateState) .flowOn(dispatchers.default) .launchIn(modelScope) } + private fun updateState(inputs: ListInputs) { + stateController.update( + UpdateAddressBookListContentTransformer( + allContacts = inputs.allContacts, + matchedContacts = inputs.matchedContacts, + mode = params.mode, + wallets = inputs.wallets, + selectedWalletId = inputs.selectedWalletId, + query = inputs.query, + isSearchActive = inputs.isSearchActive, + onContactClick = params.onContactClick, + onPickContact = ::onPickContact, + onQueryChange = ::onQueryChange, + onActiveChange = ::onActiveChange, + onClearQuery = ::onClearQuery, + onChipSelected = ::onChipSelected, + onAddContactClick = params.onAddContactClick, + ), + ) + } + + private fun onQueryChange(query: String) { + searchQuery.value = query + } + + private fun onActiveChange(active: Boolean) { + searchActive.value = active + } + + private fun onClearQuery() { + searchQuery.value = "" + } + + private fun onChipSelected(walletId: String?) { + if (selectedWalletId.value == walletId) return + selectedWalletId.value = walletId + } + private fun onPickContact(contact: MatchedContact) { val singleEntry = contact.entries.singleOrNull() if (singleEntry != null) { @@ -89,4 +137,13 @@ internal class AddressBookListModel @Inject constructor( selectorNavigation.dismiss() router.pop() } + + private data class ListInputs( + val allContacts: List, + val matchedContacts: List, + val query: String, + val selectedWalletId: String?, + val isSearchActive: Boolean, + val wallets: Map, + ) } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListContentTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListContentTransformer.kt new file mode 100644 index 0000000000..c6eeaaf47d --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListContentTransformer.kt @@ -0,0 +1,129 @@ +package com.tangem.features.addressbook.list.state.transformers + +import com.tangem.core.ui.R +import com.tangem.core.ui.ds2.search.TangemSearch +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.addressbook.model.VerifiedContact +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.addressbook.MatchedContact +import com.tangem.features.addressbook.common.ContactMatcher +import com.tangem.features.addressbook.list.state.transformers.converter.DefaultContactConverter +import com.tangem.features.addressbook.list.state.transformers.converter.SelectorContactConverter +import com.tangem.features.addressbook.list.ui.state.AddressBookChipUM +import com.tangem.features.addressbook.list.ui.state.AddressBookListUM +import com.tangem.features.addressbook.list.ui.state.ContactUM +import com.tangem.features.addressbook.list.ui.state.ContentMode +import com.tangem.features.addressbook.route.AddressBookRoute +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +@Suppress("LongParameterList") +internal class UpdateAddressBookListContentTransformer( + wallets: Map, + private val allContacts: List, + private val matchedContacts: List, + private val mode: AddressBookRoute.ListMode, + private val selectedWalletId: String?, + private val query: String, + private val isSearchActive: Boolean, + private val onContactClick: (String) -> Unit, + private val onPickContact: (MatchedContact) -> Unit, + private val onQueryChange: (String) -> Unit, + private val onActiveChange: (Boolean) -> Unit, + private val onClearQuery: () -> Unit, + private val onChipSelected: (String?) -> Unit, + private val onAddContactClick: () -> Unit, +) : Transformer { + + private val orderedWalletIds: List = wallets.values.map { it.walletId.stringValue } + private val walletNamesById: Map = wallets.values.associate { it.walletId.stringValue to it.name } + + override fun transform(prevState: AddressBookListUM): AddressBookListUM { + val matchedItems = matchedItems() + + if (matchedItems.isEmpty() && query.isBlank()) { + return AddressBookListUM.Empty(onAddClick = onAddContactClick) + } + + val matchingWalletIds = matchedItems.map { it.walletId }.distinct() + val effectiveSelected = selectedWalletId.takeIf { it in matchingWalletIds } + val areChipsVisible = totalWalletIds().size >= 2 && matchedItems.isNotEmpty() + + // On the "All" chip of a multi-wallet book each contact shows which wallet it belongs to. + val shouldShowWalletName = areChipsVisible && effectiveSelected == null + + val displayContacts = matchedItems + .filter { effectiveSelected == null || it.walletId == effectiveSelected } + .map { if (shouldShowWalletName) it.copy(walletName = walletNamesById[it.walletId]) else it } + .toImmutableList() + + return AddressBookListUM.Content( + searchBar = buildSearchBar(), + chips = if (areChipsVisible) buildChips(matchingWalletIds, effectiveSelected) else persistentListOf(), + contacts = displayContacts, + isNothingFound = matchedItems.isEmpty(), + contentMode = contentMode(), + ) + } + + private fun matchedItems(): List = when (val mode = mode) { + AddressBookRoute.ListMode.Default -> + DefaultContactConverter(onContactClick).convertList(matchedContacts) + is AddressBookRoute.ListMode.Selector -> + SelectorContactConverter(onPickContact) + .convertList(ContactMatcher.match(matchedContacts.map { it.contact }, mode.networkId)) + } + + /** Wallets that own at least one contact (respecting the network filter in selector mode) — drives chip visibility. */ + private fun totalWalletIds(): Set = when (val mode = mode) { + AddressBookRoute.ListMode.Default -> allContacts.mapTo(mutableSetOf()) { it.contact.walletId.stringValue } + is AddressBookRoute.ListMode.Selector -> + ContactMatcher.match(allContacts.map { it.contact }, mode.networkId).mapTo(mutableSetOf()) { it.walletId } + } + + private fun contentMode(): ContentMode = when (mode) { + AddressBookRoute.ListMode.Default -> ContentMode.Default(onAddClick = onAddContactClick) + is AddressBookRoute.ListMode.Selector -> ContentMode.Select + } + + private fun buildSearchBar(): TangemSearch.State = TangemSearch.State( + placeholderText = resourceReference(R.string.common_search), + query = query, + onQueryChange = onQueryChange, + isActive = isSearchActive, + onActiveChange = onActiveChange, + onClearClick = onClearQuery, + onCloseClick = { onActiveChange(false) }, + ) + + private fun buildChips(matchingWalletIds: List, effectiveSelected: String?) = buildList { + add( + AddressBookChipUM( + id = ALL_CHIP_ID, + text = resourceReference(R.string.common_all), + isSelected = effectiveSelected == null, + onClick = { onChipSelected(null) }, + ), + ) + orderedWalletIds + .filter { it in matchingWalletIds } + .forEach { walletId -> + add( + AddressBookChipUM( + id = walletId, + text = stringReference(walletNamesById[walletId] ?: walletId), + isSelected = walletId == effectiveSelected, + onClick = { onChipSelected(walletId) }, + iconRes = R.drawable.ic_key_card_20, + ), + ) + } + }.toImmutableList() + + private companion object { + const val ALL_CHIP_ID = "all" + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListInitialStateTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListInitialStateTransformer.kt deleted file mode 100644 index db17c8e2e3..0000000000 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListInitialStateTransformer.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.tangem.features.addressbook.list.state.transformers - -import com.tangem.features.addressbook.list.ui.state.AddressBookListUM -import com.tangem.features.addressbook.list.ui.state.ContentMode -import com.tangem.utils.transformer.Transformer - -/** - * Wires the "add contact" callback owned by the container into the initial (empty) list state. - */ -internal class UpdateAddressBookListInitialStateTransformer( - private val onAddContactClick: () -> Unit, -) : Transformer { - - override fun transform(prevState: AddressBookListUM): AddressBookListUM { - return when (prevState) { - is AddressBookListUM.Empty -> prevState.copy(onAddClick = onAddContactClick) - is AddressBookListUM.Content -> prevState.copy( - contentMode = ContentMode.Default(onAddClick = onAddContactClick), - ) - } - } -} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListSelectionStateTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListSelectionStateTransformer.kt deleted file mode 100644 index fb09fff616..0000000000 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListSelectionStateTransformer.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.tangem.features.addressbook.list.state.transformers - -import com.tangem.features.addressbook.MatchedContact -import com.tangem.features.addressbook.list.ui.state.AddressBookListUM -import com.tangem.features.addressbook.list.ui.state.ContactUM -import com.tangem.features.addressbook.list.ui.state.ContentMode -import com.tangem.utils.transformer.Transformer -import kotlinx.collections.immutable.toImmutableList - -/** - * Builds the contacts list from the [matched] contacts. An empty result falls back to [AddressBookListUM.Empty] so the - * user can still add a contact. - */ -internal class UpdateAddressBookListSelectionStateTransformer( - private val matched: List, - private val onAddContactClick: () -> Unit, - private val onContactClick: (MatchedContact) -> Unit, -) : Transformer { - - override fun transform(prevState: AddressBookListUM): AddressBookListUM { - return if (matched.isEmpty()) { - AddressBookListUM.Empty(onAddClick = onAddContactClick) - } else { - AddressBookListUM.Content( - contacts = matched.map { it.toContactUM() }.toImmutableList(), - contentMode = ContentMode.Select, - ) - } - } - - private fun MatchedContact.toContactUM(): ContactUM = ContactUM( - id = contactId, - name = name, - icon = icon, - networkAddressCount = entries.size, - onClick = { onContactClick(this) }, - ) -} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/converter/DefaultContactConverter.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/converter/DefaultContactConverter.kt new file mode 100644 index 0000000000..5ce1adc36a --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/converter/DefaultContactConverter.kt @@ -0,0 +1,30 @@ +package com.tangem.features.addressbook.list.state.transformers.converter + +import com.tangem.common.ui.account.AccountIconUM +import com.tangem.domain.addressbook.model.VerifiedContact +import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.features.addressbook.list.ui.state.ContactUM +import com.tangem.utils.converter.Converter + +internal class DefaultContactConverter( + private val onContactClick: (String) -> Unit, +) : Converter { + + override fun convert(value: VerifiedContact): ContactUM { + val contact = value.contact + val name = contact.name.value + return ContactUM( + id = contact.id.value, + walletId = contact.walletId.stringValue, + name = name, + icon = AccountIconUM.CryptoPortfolio( + value = CryptoPortfolioIcon.Icon.entries.firstOrNull { it.name == contact.icon } + ?: CryptoPortfolioIcon.Icon.Letter, + color = CryptoPortfolioIcon.Color.entries.firstOrNull { it.name == contact.iconColor } + ?: CryptoPortfolioIcon.Color.Azure, + ), + networkAddressCount = contact.addressEntries.size, + onClick = { onContactClick(contact.id.value) }, + ) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/converter/SelectorContactConverter.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/converter/SelectorContactConverter.kt new file mode 100644 index 0000000000..15267b5089 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/converter/SelectorContactConverter.kt @@ -0,0 +1,19 @@ +package com.tangem.features.addressbook.list.state.transformers.converter + +import com.tangem.features.addressbook.MatchedContact +import com.tangem.features.addressbook.list.ui.state.ContactUM +import com.tangem.utils.converter.Converter + +internal class SelectorContactConverter( + private val onPickContact: (MatchedContact) -> Unit, +) : Converter { + + override fun convert(value: MatchedContact): ContactUM = ContactUM( + id = value.contactId, + walletId = value.walletId, + name = value.name, + icon = value.icon, + networkAddressCount = value.entries.size, + onClick = { onPickContact(value) }, + ) +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookChip.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookChip.kt new file mode 100644 index 0000000000..e760fd3383 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookChip.kt @@ -0,0 +1,68 @@ +package com.tangem.features.addressbook.list.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.addressbook.list.ui.state.AddressBookChipUM + +@Composable +internal fun AddressBookChip(state: AddressBookChipUM, modifier: Modifier = Modifier) { + val backgroundColor = if (state.isSelected) { + TangemTheme.colors2.tabs.backgroundPrimary + } else { + TangemTheme.colors2.tabs.backgroundSecondary + } + val textColor = if (state.isSelected) { + TangemTheme.colors2.tabs.textPrimary + } else { + TangemTheme.colors2.tabs.textSecondary + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .clip(shape = CircleShape) + .background(color = backgroundColor) + .selectable( + selected = state.isSelected, + onClick = state.onClick, + role = Role.Tab, + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(), + ) + .padding(horizontal = 12.dp, vertical = 8.dp), + ) { + Text( + text = state.text.resolveReference(), + style = TangemTheme.typography3.body.medium, + color = textColor, + maxLines = 1, + ) + if (state.iconRes != null) { + Icon( + modifier = Modifier + .padding(start = 4.dp) + .size(20.dp), + painter = painterResource(id = state.iconRes), + contentDescription = null, + tint = TangemTheme.colors2.graphic.neutral.tertiaryConstant, + ) + } + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookListScreen.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookListScreen.kt index b338c678e9..e999fdeac3 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookListScreen.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookListScreen.kt @@ -1,32 +1,38 @@ package com.tangem.features.addressbook.list.ui -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp -import com.tangem.common.ui.account.AccountIconUM import com.tangem.core.ui.R import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.ds.topbar.TangemTopBar import com.tangem.core.ui.ds2.button.TangemButton +import com.tangem.core.ui.ds2.search.TangemSearch import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign -import com.tangem.core.ui.res.generated.icons.Icons -import com.tangem.core.ui.res.generated.icons.ic_chevron_left_20 -import com.tangem.core.ui.res.generated.icons.ic_cross_20 -import com.tangem.core.ui.res.generated.icons.ic_sign_plus_20 -import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.core.ui.res.generated.icons.* import com.tangem.features.addressbook.common.ui.ContactRow +import com.tangem.features.addressbook.list.ui.preview.AddressBookListPreviewParameterProvider +import com.tangem.features.addressbook.list.ui.preview.AddressBookListPreviewScenario +import com.tangem.features.addressbook.list.ui.state.AddressBookChipUM import com.tangem.features.addressbook.list.ui.state.AddressBookListUM import com.tangem.features.addressbook.list.ui.state.ContactUM import com.tangem.features.addressbook.list.ui.state.ContentMode -import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.ImmutableList @Composable internal fun AddressBookListScreen( @@ -34,7 +40,7 @@ internal fun AddressBookListScreen( onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { - Column(modifier = modifier) { + Column(modifier = modifier.navigationBarsPadding()) { TangemTopBar( modifier = Modifier.statusBarsPadding(), title = resourceReference(R.string.address_book_title), @@ -68,76 +74,93 @@ internal fun AddressBookListScreen( ) }, ) - LazyColumn(modifier = Modifier.padding(horizontal = 16.dp)) { - items(items = state.contacts, key = ContactUM::id) { contact -> - ContactRow(contact = contact) + + TangemSearch( + state = state.searchBar, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + + if (state.chips.isNotEmpty()) { + WalletChips(chips = state.chips) + } + + if (state.isNothingFound) { + NothingFoundContent() + } else { + LazyColumn( + modifier = Modifier + .imePadding() + .padding(top = 16.dp) + .background( + color = TangemTheme.colors3.bg.secondary, + shape = RoundedCornerShape(24.dp), + ), + contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + bottom = 12.dp, + ), + ) { + items(items = state.contacts, key = ContactUM::id) { contact -> + ContactRow(contact = contact) + } } } } } @Composable -@Preview(showBackground = true, widthDp = 360) -private fun Preview_AddressBookListScreen() { - TangemThemePreviewRedesign { - Column(verticalArrangement = Arrangement.spacedBy(20.dp)) { - AddressBookListScreen( - state = AddressBookListUM.Content( - contacts = persistentListOf( - ContactUM( - id = "1", - name = "Binance", - icon = AccountIconUM.CryptoPortfolio( - value = CryptoPortfolioIcon.Icon.Letter, - color = CryptoPortfolioIcon.Color.Azure, - ), - networkAddressCount = 1, - onClick = {}, - ), - ContactUM( - id = "2", - name = "Alice", - icon = AccountIconUM.CryptoPortfolio( - value = CryptoPortfolioIcon.Icon.Letter, - color = CryptoPortfolioIcon.Color.UFOGreen, - ), - networkAddressCount = 3, - onClick = {}, - ), - ), - contentMode = ContentMode.Default(onAddClick = {}), - ), - onBackClick = {}, - ) - - AddressBookListScreen( - state = AddressBookListUM.Content( - contacts = persistentListOf( - ContactUM( - id = "1", - name = "Binance", - icon = AccountIconUM.CryptoPortfolio( - value = CryptoPortfolioIcon.Icon.Letter, - color = CryptoPortfolioIcon.Color.Azure, - ), - networkAddressCount = 1, - onClick = {}, - ), - ContactUM( - id = "2", - name = "Alice", - icon = AccountIconUM.CryptoPortfolio( - value = CryptoPortfolioIcon.Icon.Letter, - color = CryptoPortfolioIcon.Color.UFOGreen, - ), - networkAddressCount = 3, - onClick = {}, - ), - ), - contentMode = ContentMode.Select, - ), - onBackClick = {}, - ) +private fun WalletChips(chips: ImmutableList) { + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(items = chips, key = AddressBookChipUM::id) { chip -> + AddressBookChip(state = chip) } } +} + +@Composable +private fun ColumnScope.NothingFoundContent() { + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier + .size(80.dp) + .background(color = TangemTheme.colors3.bg.opaque.primary, shape = CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.ic_search_24, + contentDescription = null, + tint = TangemTheme.colors3.icon.secondary, + modifier = Modifier.size(28.dp), + ) + } + Text( + modifier = Modifier.padding(top = 32.dp), + text = stringResourceSafe(R.string.common_no_results), + color = TangemTheme.colors3.text.primary, + style = TangemTheme.typography3.heading.small, + ) + } +} + +@Composable +@Preview(showBackground = true, widthDp = 360, heightDp = 640) +private fun Preview_AddressBookListScreen( + @PreviewParameter(AddressBookListPreviewParameterProvider::class) scenario: AddressBookListPreviewScenario, +) { + TangemThemePreviewRedesign { + AddressBookListScreen( + state = scenario.state, + onBackClick = {}, + ) + } } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/preview/AddressBookListScreenPreview.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/preview/AddressBookListScreenPreview.kt new file mode 100644 index 0000000000..d018f9d910 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/preview/AddressBookListScreenPreview.kt @@ -0,0 +1,106 @@ +package com.tangem.features.addressbook.list.ui.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.tangem.common.ui.account.AccountIconUM +import com.tangem.core.ui.R +import com.tangem.core.ui.ds2.search.TangemSearch +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.features.addressbook.list.ui.state.AddressBookChipUM +import com.tangem.features.addressbook.list.ui.state.AddressBookListUM +import com.tangem.features.addressbook.list.ui.state.ContactUM +import com.tangem.features.addressbook.list.ui.state.ContentMode +import kotlinx.collections.immutable.persistentListOf + +internal data class AddressBookListPreviewScenario( + val title: String, + val state: AddressBookListUM.Content, +) + +internal object AddressBookListPreviewFixtures { + + private val scenarioDefaultWithContacts = AddressBookListPreviewScenario( + title = "Default – chips and contacts", + state = AddressBookListUM.Content( + searchBar = searchBar(query = ""), + chips = persistentListOf( + AddressBookChipUM( + id = "all", + text = resourceReference(R.string.common_all), + isSelected = true, + onClick = {}, + ), + AddressBookChipUM( + id = "00", + text = stringReference("Wallet 1"), + isSelected = false, + onClick = {}, + iconRes = R.drawable.ic_key_card_20, + ), + AddressBookChipUM( + id = "01", + text = stringReference("Wallet 2"), + isSelected = false, + onClick = {}, + iconRes = R.drawable.ic_key_card_20, + ), + ), + contacts = persistentListOf( + contact( + walletId = "00", + name = "Binance", + color = CryptoPortfolioIcon.Color.Azure, + count = 1, + ), + contact( + walletId = "01", + name = "Alice", + color = CryptoPortfolioIcon.Color.UFOGreen, + count = 3, + ), + ), + isNothingFound = false, + contentMode = ContentMode.Default(onAddClick = {}), + ), + ) + + val scenarioSelectNothingFound = AddressBookListPreviewScenario( + title = "Select – nothing found", + state = AddressBookListUM.Content( + searchBar = searchBar(query = "Antonio"), + chips = persistentListOf(), + contacts = persistentListOf(), + isNothingFound = true, + contentMode = ContentMode.Select, + ), + ) + + fun allScenarios(): List = listOf( + scenarioDefaultWithContacts, + scenarioSelectNothingFound, + ) + + private fun searchBar(query: String) = TangemSearch.State( + placeholderText = resourceReference(R.string.common_search), + query = query, + onQueryChange = {}, + isActive = false, + onActiveChange = {}, + ) + + private fun contact(walletId: String, name: String, color: CryptoPortfolioIcon.Color, count: Int) = ContactUM( + id = name + walletId, + walletId = walletId, + name = name, + icon = AccountIconUM.CryptoPortfolio(value = CryptoPortfolioIcon.Icon.Letter, color = color), + networkAddressCount = count, + onClick = {}, + ) +} + +/** All [AddressBookListPreviewScenario] values for the Preview Parameter dropdown in Android Studio. */ +internal class AddressBookListPreviewParameterProvider : PreviewParameterProvider { + override val values: Sequence + get() = AddressBookListPreviewFixtures.allScenarios().asSequence() +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/AddressBookChipUM.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/AddressBookChipUM.kt new file mode 100644 index 0000000000..22addbac27 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/AddressBookChipUM.kt @@ -0,0 +1,14 @@ +package com.tangem.features.addressbook.list.ui.state + +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.extensions.TextReference + +@Immutable +internal data class AddressBookChipUM( + val id: String, + val text: TextReference, + val isSelected: Boolean, + val onClick: () -> Unit, + @DrawableRes val iconRes: Int? = null, +) \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/AddressBookListUM.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/AddressBookListUM.kt index f8d7481037..08a4cbdfe2 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/AddressBookListUM.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/AddressBookListUM.kt @@ -1,20 +1,31 @@ package com.tangem.features.addressbook.list.ui.state import androidx.compose.runtime.Immutable +import com.tangem.core.ui.ds2.search.TangemSearch import kotlinx.collections.immutable.ImmutableList /** * UI state of the contacts list. The list itself is the same however the address book was opened — it is either - * [Empty] or shows [Content]. How the address book was opened (browse vs. pick a recipient) only changes what a - * contact tap does, which is captured by [ContactUM.onClick], not by a separate state. + * [Empty] (no contacts at all) or shows [Content]. How the address book was opened (browse vs. pick a recipient) only + * changes what a contact tap does, which is captured by [ContactUM.onClick] and [Content.contentMode]. */ @Immutable internal sealed interface AddressBookListUM { data class Empty(val onAddClick: () -> Unit) : AddressBookListUM + /** + * @property searchBar always shown so the user can filter contacts across all wallets. + * @property chips wallet filter chips (`All` + a chip per matching wallet); empty means the row is hidden. + * @property contacts contacts for the currently selected chip; empty together with [isNothingFound] = true. + * @property isNothingFound true when the active search matched nothing — show the "no results" stub instead of the + * list (the search bar stays visible so the query can be edited). + */ data class Content( + val searchBar: TangemSearch.State, + val chips: ImmutableList, val contacts: ImmutableList, + val isNothingFound: Boolean, val contentMode: ContentMode, ) : AddressBookListUM } diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/ContactUM.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/ContactUM.kt index d15a700047..fcb37f7eb9 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/ContactUM.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/state/ContactUM.kt @@ -6,8 +6,10 @@ import com.tangem.common.ui.account.AccountIconUM @Immutable internal data class ContactUM( val id: String, + val walletId: String, val name: String, val icon: AccountIconUM.CryptoPortfolio, val networkAddressCount: Int, + val walletName: String? = null, val onClick: () -> Unit, ) \ No newline at end of file diff --git a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListContentTransformerTest.kt b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListContentTransformerTest.kt new file mode 100644 index 0000000000..a37e5b1bdf --- /dev/null +++ b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListContentTransformerTest.kt @@ -0,0 +1,182 @@ +package com.tangem.features.addressbook.list.state.transformers + +import com.google.common.truth.Truth.assertThat +import com.tangem.common.test.domain.wallet.MockUserWalletFactory +import com.tangem.domain.addressbook.model.* +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.addressbook.list.ui.state.AddressBookListUM +import com.tangem.features.addressbook.route.AddressBookRoute +import org.junit.jupiter.api.Test + +internal class UpdateAddressBookListContentTransformerTest { + + private val wallet1 = "00" + private val wallet2 = "01" + + private val wallets: Map = linkedMapOf( + UserWalletId(stringValue = wallet1) to wallet(wallet1, "Wallet 1"), + UserWalletId(stringValue = wallet2) to wallet(wallet2, "Wallet 2"), + ) + + @Test + fun `GIVEN contacts in one wallet WHEN blank query THEN no chips`() { + // Arrange + val all = listOf(verified(wallet1, "Alice"), verified(wallet1, "Bob")) + + // Act + val result = transform(allContacts = all, matchedContacts = all) + + // Assert + val content = result as AddressBookListUM.Content + assertThat(content.chips).isEmpty() + assertThat(content.contacts).hasSize(2) + assertThat(content.isNothingFound).isFalse() + // Single-wallet book has no chips, so the wallet name is not shown. + assertThat(content.contacts.none { it.walletName != null }).isTrue() + } + + @Test + fun `GIVEN contacts in two wallets WHEN blank query THEN All plus per-wallet chips with All selected`() { + // Arrange + val all = listOf(verified(wallet1, "Alice"), verified(wallet2, "Bob")) + + // Act + val result = transform(allContacts = all, matchedContacts = all) + + // Assert + val content = result as AddressBookListUM.Content + assertThat(content.chips.map { it.id }).containsExactly("all", wallet1, wallet2).inOrder() + assertThat(content.chips.first().isSelected).isTrue() // All + assertThat(content.chips.drop(1).none { it.isSelected }).isTrue() + assertThat(content.contacts).hasSize(2) + // On the "All" chip of a multi-wallet book each contact shows its wallet name. + assertThat(content.contacts.map { it.walletName }).containsExactly("Wallet 1", "Wallet 2").inOrder() + } + + @Test + fun `GIVEN two wallets WHEN a wallet chip selected THEN list filtered but chips unchanged`() { + // Arrange + val all = listOf(verified(wallet1, "Alice"), verified(wallet2, "Bob")) + + // Act + val result = transform(allContacts = all, matchedContacts = all, selectedWalletId = wallet2) + + // Assert + val content = result as AddressBookListUM.Content + assertThat(content.chips.map { it.id }).containsExactly("all", wallet1, wallet2).inOrder() + assertThat(content.chips.first().isSelected).isFalse() // All not selected + assertThat(content.contacts.map { it.name }).containsExactly("Bob") + // A specific wallet is selected, so the (redundant) wallet name is not shown. + assertThat(content.contacts.single().walletName).isNull() + } + + @Test + fun `GIVEN two wallets WHEN query narrows to one wallet THEN chips kept as All plus that wallet`() { + // Arrange — the book spans two wallets, but the query matched only wallet1 in the domain + val all = listOf(verified(wallet1, "Antonio"), verified(wallet2, "Bob")) + val matched = listOf(verified(wallet1, "Antonio")) + + // Act + val result = transform(allContacts = all, matchedContacts = matched, query = "Anto") + + // Assert + val content = result as AddressBookListUM.Content + assertThat(content.chips.map { it.id }).containsExactly("all", wallet1).inOrder() + assertThat(content.contacts.map { it.name }).containsExactly("Antonio") + assertThat(content.isNothingFound).isFalse() + } + + @Test + fun `GIVEN selected wallet no longer matches query THEN falls back to All`() { + // Arrange + val all = listOf(verified(wallet1, "Antonio"), verified(wallet2, "Bob")) + val matched = listOf(verified(wallet1, "Antonio")) + + // Act — selected wallet2, but query matched only wallet1 + val result = transform( + allContacts = all, + matchedContacts = matched, + selectedWalletId = wallet2, + query = "Anto", + ) + + // Assert + val content = result as AddressBookListUM.Content + assertThat(content.chips.first().isSelected).isTrue() // All selected again + assertThat(content.contacts.map { it.name }).containsExactly("Antonio") + } + + @Test + fun `GIVEN no contacts WHEN blank query THEN Empty`() { + // Act + val result = transform(allContacts = emptyList(), matchedContacts = emptyList()) + + // Assert + assertThat(result).isInstanceOf(AddressBookListUM.Empty::class.java) + } + + @Test + fun `GIVEN query matches nothing WHEN non-blank query THEN nothing found and chips hidden`() { + // Arrange + val all = listOf(verified(wallet1, "Alice"), verified(wallet2, "Bob")) + + // Act + val result = transform(allContacts = all, matchedContacts = emptyList(), query = "Zzz") + + // Assert + val content = result as AddressBookListUM.Content + assertThat(content.isNothingFound).isTrue() + assertThat(content.contacts).isEmpty() + assertThat(content.chips).isEmpty() + } + + private fun transform( + allContacts: List, + matchedContacts: List, + selectedWalletId: String? = null, + query: String = "", + ): AddressBookListUM = UpdateAddressBookListContentTransformer( + allContacts = allContacts, + matchedContacts = matchedContacts, + mode = AddressBookRoute.ListMode.Default, + wallets = wallets, + selectedWalletId = selectedWalletId, + query = query, + isSearchActive = false, + onContactClick = {}, + onPickContact = {}, + onQueryChange = {}, + onActiveChange = {}, + onClearQuery = {}, + onChipSelected = {}, + onAddContactClick = {}, + ).transform(prevState = AddressBookListUM.Empty(onAddClick = {})) + + private fun wallet(id: String, name: String): UserWallet = + MockUserWalletFactory.create().copy(walletId = UserWalletId(stringValue = id), name = name) + + private fun verified(walletId: String, name: String): VerifiedContact = VerifiedContact( + contact = Contact( + id = ContactId(name + walletId), + walletId = UserWalletId(stringValue = walletId), + name = requireNotNull(ContactName(name).getOrNull()) { "invalid test name" }, + icon = "", + iconColor = "Azure", + createdAt = "2026-06-10T14:30:00.000Z", + updatedAt = "2026-06-10T14:30:00.000Z", + addressEntries = listOf( + AddressEntry( + id = AddressEntryId(name), + address = "addr-$name", + networkId = Network.RawID("ethereum"), + memo = null, + signature = "sig", + networkName = "Ethereum", + ), + ), + ), + invalidEntries = emptyList(), + ) +} \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/DefaultSendDestinationComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/DefaultSendDestinationComponent.kt index c6afc17f98..e2f98cccea 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/DefaultSendDestinationComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/DefaultSendDestinationComponent.kt @@ -38,7 +38,6 @@ internal class DefaultSendDestinationComponent @AssistedInject constructor( contactsBlockFactory.create( context = child("send_contacts_block"), params = AddressBookContactsBlockComponent.Params( - userWalletId = params.userWalletId, network = params.cryptoCurrency.network, queryFlow = model.addressQuery, onContactClick = model::onContactClick, From 2561a7eef6a4d0733cc5fc1d94e41a86098db631 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 24 Jun 2026 19:26:18 +0300 Subject: [PATCH 041/210] Updated on 2026-08-14 --- .../domain/txhistory/model/TxHistoryInfo.kt | 11 + features/txhistory/api/build.gradle.kts | 1 + .../component/TxHistoryDetailsSlotConfig.kt | 12 + features/txhistory/impl/build.gradle.kts | 1 + ...xpressExchangeStatusToUiStatusConverter.kt | 37 +++ .../ExpressOnrampStatusToUiStatusConverter.kt | 30 +++ ...istoryInfoToTxHistoryDetailsUMConverter.kt | 230 ++++++++++++++++++ .../TxInfoToTxHistoryDetailsUMConverter.kt | 5 +- .../txhistory/entity/TxHistoryDetailsUM.kt | 4 +- .../txhistory/utils/HistoryTxListManager.kt | 12 + ...ressOnrampStatusToUiStatusConverterTest.kt | 50 ++++ 11 files changed, 389 insertions(+), 4 deletions(-) create mode 100644 features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/component/TxHistoryDetailsSlotConfig.kt create mode 100644 features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/ExpressExchangeStatusToUiStatusConverter.kt create mode 100644 features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/ExpressOnrampStatusToUiStatusConverter.kt create mode 100644 features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverter.kt create mode 100644 features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/ExpressOnrampStatusToUiStatusConverterTest.kt diff --git a/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/model/TxHistoryInfo.kt b/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/model/TxHistoryInfo.kt index 73a0b7d670..20e26b2e38 100644 --- a/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/model/TxHistoryInfo.kt +++ b/domain/txhistory/src/main/kotlin/com/tangem/domain/txhistory/model/TxHistoryInfo.kt @@ -63,6 +63,17 @@ sealed interface OnChainTx : TxHistoryInfo { */ fun TxInfo.identityKey(): String = "$txHash|$type" +/** + * Hash of the matched on-chain leg, used to open the row in a block explorer; `null` when there is no + * blockchain tx to link to — an [ExpressTx] whose on-chain leg has not matched yet. The express `txId` + * must never stand in here: it is not an on-chain hash and would build a broken explorer URL. + */ +inline val TxHistoryInfo.explorerHash: String? + get() = when (this) { + is OnChainTx.BSDK -> txInfo.txHash + is ExpressTx -> matchHash + } + /** * A history row backed by an express operation. It is a thin wrapper over the standalone express * model ([ExchangeTransaction] / [OnrampTransaction]), adding only the history-view concerns: diff --git a/features/txhistory/api/build.gradle.kts b/features/txhistory/api/build.gradle.kts index b26a92aadc..3b5eceb638 100644 --- a/features/txhistory/api/build.gradle.kts +++ b/features/txhistory/api/build.gradle.kts @@ -15,6 +15,7 @@ dependencies { /** Domain models */ api(projects.domain.models) + api(projects.domain.txhistory) implementation(projects.domain.tokens.models) implementation(projects.domain.wallets.models) diff --git a/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/component/TxHistoryDetailsSlotConfig.kt b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/component/TxHistoryDetailsSlotConfig.kt new file mode 100644 index 0000000000..50b3057860 --- /dev/null +++ b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/component/TxHistoryDetailsSlotConfig.kt @@ -0,0 +1,12 @@ +package com.tangem.features.txhistory.component + +import com.tangem.domain.txhistory.model.TxHistoryInfo +import kotlinx.coroutines.flow.Flow + +/** + * Activation config for the transaction-details child slot owned by the host (e.g. token-details). + * + + * with `serializer = null` — the details sheet is intentionally not restored after process death. + */ +data class TxHistoryDetailsSlotConfig(val txHistoryInfo: Flow) \ No newline at end of file diff --git a/features/txhistory/impl/build.gradle.kts b/features/txhistory/impl/build.gradle.kts index 34f003cbab..245b8d24c9 100644 --- a/features/txhistory/impl/build.gradle.kts +++ b/features/txhistory/impl/build.gradle.kts @@ -67,6 +67,7 @@ dependencies { /* Tests */ testImplementation(projects.common.test) + testImplementation(projects.test.core) testImplementation(projects.domain.onramp.models) testImplementation(deps.test.junit5) testImplementation(deps.test.mockk) diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/ExpressExchangeStatusToUiStatusConverter.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/ExpressExchangeStatusToUiStatusConverter.kt new file mode 100644 index 0000000000..740abe27c4 --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/ExpressExchangeStatusToUiStatusConverter.kt @@ -0,0 +1,37 @@ +package com.tangem.features.txhistory.converter + +import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Status +import com.tangem.domain.express.models.ExpressExchangeStatus +import com.tangem.utils.converter.Converter + +/** + * Collapses the typed swap status into a UI [Status] bucket: the single success state + * ([Finished][ExpressExchangeStatus.Finished]), the failure/return states ([Failed][ExpressExchangeStatus.Failed]/ + * [TxFailed][ExpressExchangeStatus.TxFailed]/[Refunded][ExpressExchangeStatus.Refunded]/ + * [Expired][ExpressExchangeStatus.Expired]/[Unknown][ExpressExchangeStatus.Unknown]) → [Failed][Status.Failed], + * everything in flight (incl. [Verifying][ExpressExchangeStatus.Verifying] and [Paused][ExpressExchangeStatus.Paused]) + * → [Unconfirmed][Status.Unconfirmed]. + */ +internal class ExpressExchangeStatusToUiStatusConverter : Converter { + + override fun convert(value: ExpressExchangeStatus): Status = when (value) { + ExpressExchangeStatus.Finished -> Status.Confirmed + ExpressExchangeStatus.Failed, + ExpressExchangeStatus.TxFailed, + ExpressExchangeStatus.Refunded, + ExpressExchangeStatus.Expired, + ExpressExchangeStatus.Unknown, + -> Status.Failed + ExpressExchangeStatus.Preview, + ExpressExchangeStatus.Created, + ExpressExchangeStatus.ExchangeTxSent, + ExpressExchangeStatus.Waiting, + ExpressExchangeStatus.WaitingTxHash, + ExpressExchangeStatus.Confirming, + ExpressExchangeStatus.Exchanging, + ExpressExchangeStatus.Sending, + ExpressExchangeStatus.Verifying, + ExpressExchangeStatus.Paused, + -> Status.Unconfirmed + } +} \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/ExpressOnrampStatusToUiStatusConverter.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/ExpressOnrampStatusToUiStatusConverter.kt new file mode 100644 index 0000000000..4e8cea0f4f --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/ExpressOnrampStatusToUiStatusConverter.kt @@ -0,0 +1,30 @@ +package com.tangem.features.txhistory.converter + +import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Status +import com.tangem.domain.express.models.ExpressOnrampStatus +import com.tangem.utils.converter.Converter + +/** + * Collapses the typed onramp status into a UI [Status] bucket: the single success state + * ([Finished][ExpressOnrampStatus.Finished]), the failure states ([Failed][ExpressOnrampStatus.Failed]/ + * [Expired][ExpressOnrampStatus.Expired]/[Unknown][ExpressOnrampStatus.Unknown]) → [Failed][Status.Failed], + * everything in flight → [Unconfirmed][Status.Unconfirmed]. + */ +internal class ExpressOnrampStatusToUiStatusConverter : Converter { + + override fun convert(value: ExpressOnrampStatus): Status = when (value) { + ExpressOnrampStatus.Finished -> Status.Confirmed + ExpressOnrampStatus.Failed, + ExpressOnrampStatus.Expired, + ExpressOnrampStatus.Unknown, + -> Status.Failed + ExpressOnrampStatus.Created, + ExpressOnrampStatus.WaitingForPayment, + ExpressOnrampStatus.PaymentProcessing, + ExpressOnrampStatus.Verifying, + ExpressOnrampStatus.Paid, + ExpressOnrampStatus.Sending, + ExpressOnrampStatus.Paused, + -> Status.Unconfirmed + } +} \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverter.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverter.kt new file mode 100644 index 0000000000..71b734ba25 --- /dev/null +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverter.kt @@ -0,0 +1,230 @@ +package com.tangem.features.txhistory.converter + +import androidx.annotation.StringRes +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Status +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.utils.DateTimeFormatters +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.TxInfo +import com.tangem.domain.models.network.TxInfo.TransactionType +import com.tangem.domain.txhistory.model.ExpressTx +import com.tangem.domain.txhistory.model.OnChainTx +import com.tangem.domain.txhistory.model.TxHistoryInfo +import com.tangem.features.txhistory.entity.TxHistoryDetailsUM +import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.StatusBannerUM.Severity +import com.tangem.features.txhistory.impl.R +import com.tangem.utils.StringsSigns +import com.tangem.utils.converter.Converter +import com.tangem.utils.extensions.isZero +import com.tangem.utils.toBriefAddressFormat +import kotlinx.collections.immutable.persistentListOf +import org.joda.time.DateTime + +/** + * Converts a [TxHistoryInfo] row to a [TxHistoryDetailsUM] for the in-app transaction details card. + * + * The dispatch mirrors the row converters: an [OnChainTx.BSDK] always renders as [TxHistoryDetailsUM.SingleAsset] + * (a two-asset swap surfaces as [ExpressTx.Swap], handled separately), while an [ExpressTx] (swap / onramp) currently + * produces a header-only [TxHistoryDetailsUM.TwoAssets] with the express status banner. The express legs (`from`/`to` + * amounts, currencies, fiat) are populated in a follow-up ([REDACTED_TASK_KEY]). + */ +internal class TxHistoryInfoToTxHistoryDetailsUMConverter( + private val currency: CryptoCurrency, + private val onCopyAddress: (String) -> Unit, +) : Converter { + + private val iconStateConverter = CryptoCurrencyToIconStateConverter() + private val exchangeStatusConverter = ExpressExchangeStatusToUiStatusConverter() + private val onrampStatusConverter = ExpressOnrampStatusToUiStatusConverter() + + override fun convert(value: TxHistoryInfo): TxHistoryDetailsUM = when (value) { + is OnChainTx.BSDK -> convertOnChain(value.txInfo) + is ExpressTx.Swap -> convertExpressSwap(value) + is ExpressTx.Onramp -> convertExpressOnramp(value) + } + + // region On-chain (TxInfo) + + /** + * Every on-chain row renders as [TxHistoryDetailsUM.SingleAsset]. A two-asset swap always surfaces as + * [ExpressTx.Swap] (handled separately); an on-chain `TxInfo` of type `Swap` (e.g. a DEX swap with no express + * record) carries no legs, so it falls back to the single amount it does have rather than an empty two-asset card. + */ + private fun convertOnChain(value: TxInfo): TxHistoryDetailsUM = TxHistoryDetailsUM.SingleAsset( + header = value.toHeaderUM(), + amountBlock = value.toAmountBlockUM(), + counterparty = value.toCounterpartyUM(), + // TODO: TxInfo has no network fee / rate yet — empty until those fields are added to TxInfo. + rows = persistentListOf(), + ) + + private fun TxInfo.toHeaderUM(): TxHistoryDetailsUM.HeaderUM = TxHistoryDetailsUM.HeaderUM( + iconRes = headerIcon(), + status = status.toUiStatus(), + title = headerTitle(), + subtitle = headerSubtitle(timestampInMillis), + ) + + private fun TxInfo.toAmountBlockUM(): TxHistoryDetailsUM.AmountBlockUM = TxHistoryDetailsUM.AmountBlockUM( + currencyIcon = iconStateConverter.convert(currency), + amount = stringReference(signedAmount(currency)), + // TODO: TxInfo has no fiat amount yet — empty until the fiat field is added to TxInfo; a hardcoded + // placeholder would show a misleading value. + fiatAmount = TextReference.EMPTY, + isFailed = status is TxInfo.TransactionStatus.Failed, + ) + + /** + * Counterparty card ("Recipient" / "From"). Currently only the external-address avatar is produced — built from + * the `User` interaction address (the same source the history list uses for its external-address subtitle). + * + * The own-account / own-wallet avatars require the address->owner lookup the list assembles in + * `TxHistoryLookupContext`; wiring that into the detail model is a follow-up, so for now a counterparty that is not + * a plain external `User` address yields no card (`null`). + */ + private fun TxInfo.toCounterpartyUM(): TxHistoryDetailsUM.CounterpartyUM? { + val address = (interactionAddressType as? TxInfo.InteractionAddressType.User)?.address ?: return null + return TxHistoryDetailsUM.CounterpartyUM( + label = counterpartyLabel(), + title = stringReference(address.toBriefAddressFormat()), + avatar = TxHistoryDetailsUM.CounterpartyAvatar.Address(rawAddress = address), + onCopyClick = { onCopyAddress(address) }, + ) + } + + /** Section label above the counterparty: "Recipient" for outgoing transfers, "From" for incoming. */ + private fun TxInfo.counterpartyLabel(): TextReference = + if (isOutgoing) resourceReference(R.string.send_recipient) else resourceReference(R.string.common_from) + + // endregion + + // region Express (swap / onramp) + + private fun convertExpressSwap(swap: ExpressTx.Swap): TxHistoryDetailsUM.TwoAssets { + val status = exchangeStatusConverter.convert(swap.tx.status) + return TxHistoryDetailsUM.TwoAssets( + header = TxHistoryDetailsUM.HeaderUM( + iconRes = R.drawable.ic_exchange_vertical_24, + status = status, + title = status.statusAwareTitle(R.string.common_swapping, R.string.common_swapped), + subtitle = headerSubtitle(swap.timestampMillis), + ), + statusBanner = status.toStatusBannerUM(), + ) + } + + private fun convertExpressOnramp(onramp: ExpressTx.Onramp): TxHistoryDetailsUM.TwoAssets { + val status = onrampStatusConverter.convert(onramp.tx.status) + return TxHistoryDetailsUM.TwoAssets( + header = TxHistoryDetailsUM.HeaderUM( + iconRes = R.drawable.ic_tangem_card_24, + status = status, + title = status.statusAwareTitle( + R.string.tx_history_onramp_top_up, + R.string.tx_history_onramp_topped_up, + ), + subtitle = headerSubtitle(onramp.timestampMillis), + ), + statusBanner = status.toStatusBannerUM(), + ) + } + + // endregion +} + +// region Status helpers + +/** + * Express status plaque under the two-asset block, keyed on the collapsed UI [Status] bucket. + * + * A stopgap shared by on-chain swaps and express ops — [Severity.Warning] (verification) is not reachable here yet. + * [REDACTED_TODO_COMMENT] + */ +private fun Status.toStatusBannerUM(): TxHistoryDetailsUM.StatusBannerUM = when (this) { + is Status.Unconfirmed -> TxHistoryDetailsUM.StatusBannerUM( + severity = Severity.Info, + title = resourceReference(R.string.express_exchange_status_receiving_active), + isLoading = true, + ) + is Status.Confirmed -> TxHistoryDetailsUM.StatusBannerUM( + severity = Severity.Success, + title = resourceReference(R.string.express_exchange_status_exchanged), + isLoading = false, + ) + is Status.Failed -> TxHistoryDetailsUM.StatusBannerUM( + severity = Severity.Error, + title = resourceReference(R.string.express_exchange_status_failed), + subtitle = resourceReference(R.string.express_exchange_notification_failed_text), + isLoading = false, + ) +} + +private fun Status.statusAwareTitle(@StringRes pending: Int, @StringRes confirmed: Int): TextReference = when (this) { + is Status.Failed -> resourceReference(R.string.common_action_failed, wrappedList(resourceReference(pending))) + is Status.Unconfirmed -> resourceReference(pending) + is Status.Confirmed -> resourceReference(confirmed) +} + +// endregion + +// region Amount building helpers + +/** + * Signed crypto amount with inline symbol, e.g. `+ 350.31 USDT` / `- 350.31 USDT`. The sign is `-` for outgoing, `+` + * otherwise, and is dropped for zero amounts and for the failed state (a failed tx moved nothing) — the UI then only + * strikes the amount through and dims it via [TxHistoryDetailsUM.AmountBlockUM.isFailed]. + */ +private fun TxInfo.signedAmount(currency: CryptoCurrency): String { + val formatted = amount.format { crypto(cryptoCurrency = currency, ignoreSymbolPosition = true) } + val prefix = when { + status is TxInfo.TransactionStatus.Failed -> "" + amount.isZero() -> "" + isOutgoing -> "${StringsSigns.MINUS} " + else -> "${StringsSigns.PLUS} " + } + return (prefix + formatted).trim() +} + +// endregion + +// region Header building helpers + +/** Type glyph. Unlike the history list, the failed state keeps the type glyph (only the color changes). */ +private fun TxInfo.headerIcon(): Int = when (type) { + is TransactionType.Swap -> R.drawable.ic_exchange_vertical_24 + else -> if (isOutgoing) R.drawable.ic_arrow_up_24 else R.drawable.ic_arrow_down_24 +} + +private fun TxInfo.headerTitle(): TextReference = when (type) { + is TransactionType.Swap -> statusAwareTitle(R.string.common_swapping, R.string.common_swapped) + is TransactionType.Transfer -> statusAwareTitle(R.string.common_transfer, R.string.common_transferred) + else -> stringReference(type.toString()) +} + +private fun headerSubtitle(timestampMillis: Long): TextReference { + val dateTime = DateTime(timestampMillis) + val date = DateTimeFormatters.dateMMMdYYYY.print(dateTime) + val time = DateTimeFormatters.timeFormatter.print(dateTime) + return stringReference("$date, $time") +} + +private fun TxInfo.statusAwareTitle(@StringRes pending: Int, @StringRes confirmed: Int): TextReference = when (status) { + is TxInfo.TransactionStatus.Failed -> + resourceReference(R.string.common_action_failed, wrappedList(resourceReference(pending))) + is TxInfo.TransactionStatus.Unconfirmed -> resourceReference(pending) + is TxInfo.TransactionStatus.Confirmed -> resourceReference(confirmed) +} + +private fun TxInfo.TransactionStatus.toUiStatus(): Status = when (this) { + TxInfo.TransactionStatus.Confirmed -> Status.Confirmed + TxInfo.TransactionStatus.Failed -> Status.Failed + TxInfo.TransactionStatus.Unconfirmed -> Status.Unconfirmed +} + +// endregion \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxInfoToTxHistoryDetailsUMConverter.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxInfoToTxHistoryDetailsUMConverter.kt index 70bf982a6d..0afece079a 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxInfoToTxHistoryDetailsUMConverter.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxInfoToTxHistoryDetailsUMConverter.kt @@ -89,8 +89,9 @@ internal class TxInfoToTxHistoryDetailsUMConverter( private fun TxInfo.toAmountBlockUM(): TxHistoryDetailsUM.AmountBlockUM = TxHistoryDetailsUM.AmountBlockUM( currencyIcon = iconStateConverter.convert(currency), amount = stringReference(signedAmount(currency)), - // TODO: TxInfo has no fiat amount yet — placeholder until the fiat field is added to TxInfo. - fiatAmount = stringReference("\$0.00"), + // TODO: TxInfo has no fiat amount yet — empty until the fiat field is added to TxInfo; a hardcoded + // placeholder would show a misleading value. + fiatAmount = TextReference.EMPTY, isFailed = status is TxInfo.TransactionStatus.Failed, ) diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt index 3b3464e998..de525f1387 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt @@ -14,7 +14,7 @@ import kotlinx.collections.immutable.ImmutableList * UI model for the in-app transaction details ("Operation") card. * * One model for all transaction types; the layout family is chosen from the transaction type by - * `TxInfoToTxHistoryDetailsUMConverter`: + * `TxHistoryInfoToTxHistoryDetailsUMConverter`: * - [SingleAsset] — Receive / Send / Transfer * - [TwoAssets] — Swap / Onramp */ @@ -131,7 +131,7 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent { * * The layout is identical across counterparty kinds; the only variance is the [avatar] (see [CounterpartyAvatar]) * and whether copy is offered. Only the [CounterpartyAvatar.Address] kind is currently produced by - * [com.tangem.features.txhistory.converter.TxInfoToTxHistoryDetailsUMConverter]; the own-account / own-wallet + * [com.tangem.features.txhistory.converter.TxHistoryInfoToTxHistoryDetailsUMConverter]; the own-account / own-wallet * avatars are populated in a follow-up, once the detail model assembles the same address->owner lookup the list * uses (`TxHistoryLookupContext`). * diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/HistoryTxListManager.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/HistoryTxListManager.kt index cf23065cdc..fe82f69948 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/HistoryTxListManager.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/HistoryTxListManager.kt @@ -52,6 +52,18 @@ internal class HistoryTxListManager @AssistedInject constructor( val paginationStatus: Flow> = state.map { it.status }.distinctUntilChanged() + /** + * Reactive stream of a single row tracked by its [TxHistoryInfo.txId], for the in-app details sheet. + * + * Seeded with the tapped [item] so the sheet always has an immediate snapshot, then re-emits the matching row from + * the live merged list as its status changes. The seed also covers rows not present in [items] yet (e.g. a pending + * tx surfaced from the currency status), which would otherwise never resolve. + */ + fun txHistoryInfoFlow(item: TxHistoryInfo): Flow = items + .mapNotNull { list -> list.firstOrNull { it.txId == item.txId } } + .onStart { emit(item) } + .distinctUntilChanged() + @OptIn(ExperimentalCoroutinesApi::class) suspend fun init() { coroutineScope { diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/ExpressOnrampStatusToUiStatusConverterTest.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/ExpressOnrampStatusToUiStatusConverterTest.kt new file mode 100644 index 0000000000..406b89b603 --- /dev/null +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/ExpressOnrampStatusToUiStatusConverterTest.kt @@ -0,0 +1,50 @@ +package com.tangem.features.txhistory.converter + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Status +import com.tangem.domain.express.models.ExpressOnrampStatus +import com.tangem.test.core.ProvideTestModels +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class ExpressOnrampStatusToUiStatusConverterTest { + + private val converter = ExpressOnrampStatusToUiStatusConverter() + + @ParameterizedTest + @ProvideTestModels + fun convert(model: Model) { + // Act + val actual = converter.convert(model.status) + + // Assert + assertThat(actual).isEqualTo(model.expected) + } + + @Test + fun `GIVEN every onramp status WHEN listing test models THEN all enum entries are covered`() { + // Asserts the mapping table below stays in lockstep with the enum, so a newly added status + // can never silently fall through with an untested UI bucket. + val covered = provideTestModels().map { it.status }.toSet() + + assertThat(covered).containsExactlyElementsIn(ExpressOnrampStatus.entries) + } + + private fun provideTestModels() = listOf( + Model(status = ExpressOnrampStatus.Finished, expected = Status.Confirmed), + Model(status = ExpressOnrampStatus.Failed, expected = Status.Failed), + Model(status = ExpressOnrampStatus.Expired, expected = Status.Failed), + Model(status = ExpressOnrampStatus.Unknown, expected = Status.Failed), + Model(status = ExpressOnrampStatus.Created, expected = Status.Unconfirmed), + Model(status = ExpressOnrampStatus.WaitingForPayment, expected = Status.Unconfirmed), + Model(status = ExpressOnrampStatus.PaymentProcessing, expected = Status.Unconfirmed), + Model(status = ExpressOnrampStatus.Verifying, expected = Status.Unconfirmed), + Model(status = ExpressOnrampStatus.Paid, expected = Status.Unconfirmed), + Model(status = ExpressOnrampStatus.Sending, expected = Status.Unconfirmed), + Model(status = ExpressOnrampStatus.Paused, expected = Status.Unconfirmed), + ) + + internal data class Model(val status: ExpressOnrampStatus, val expected: Status) +} \ No newline at end of file From 105bad60515b5ea15db993d689b1582126b7fb9e Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 25 Jun 2026 19:41:53 +0500 Subject: [PATCH 042/210] Updated on 2026-08-14 --- .../configs/feature_toggles_config.json | 4 ++ features/for-you/api/.gitignore | 1 + features/for-you/api/build.gradle.kts | 20 ++++++++++ .../tangem/features/foryou/ForYouComponent.kt | 9 +++++ .../features/foryou/ForYouFeatureToggles.kt | 5 +++ features/for-you/impl/.gitignore | 1 + features/for-you/impl/build.gradle.kts | 30 +++++++++++++++ .../foryou/impl/DefaultForYouComponent.kt | 37 +++++++++++++++++++ .../foryou/impl/di/ForYouFeatureModule.kt | 33 +++++++++++++++++ .../DefaultForYouFeatureToggles.kt | 13 +++++++ settings.gradle.kts | 3 ++ 11 files changed, 156 insertions(+) create mode 100644 features/for-you/api/.gitignore create mode 100644 features/for-you/api/build.gradle.kts create mode 100644 features/for-you/api/src/main/kotlin/com/tangem/features/foryou/ForYouComponent.kt create mode 100644 features/for-you/api/src/main/kotlin/com/tangem/features/foryou/ForYouFeatureToggles.kt create mode 100644 features/for-you/impl/.gitignore create mode 100644 features/for-you/impl/build.gradle.kts create mode 100644 features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/DefaultForYouComponent.kt create mode 100644 features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/di/ForYouFeatureModule.kt create mode 100644 features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/featuretoggles/DefaultForYouFeatureToggles.kt diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index a6add9266b..51579fb873 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -170,5 +170,9 @@ { "name": "AND_14829_WARNINGS_REFACTORING_ENABLED", "version": "undefined" + }, + { + "name": "TWI_1469_FOR_YOU_ENABLED", + "version": "undefined" } ] diff --git a/features/for-you/api/.gitignore b/features/for-you/api/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/features/for-you/api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/features/for-you/api/build.gradle.kts b/features/for-you/api/build.gradle.kts new file mode 100644 index 0000000000..95c2a85416 --- /dev/null +++ b/features/for-you/api/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.serialization) + id("kotlin-parcelize") + id("configuration") +} + +android { + namespace = "com.tangem.features.foryou.api" +} + +dependencies { + /** Project - Core */ + implementation(projects.core.decompose) + implementation(projects.core.ui) + + /** Other dependencies */ + implementation(deps.compose.foundation) +} \ No newline at end of file diff --git a/features/for-you/api/src/main/kotlin/com/tangem/features/foryou/ForYouComponent.kt b/features/for-you/api/src/main/kotlin/com/tangem/features/foryou/ForYouComponent.kt new file mode 100644 index 0000000000..f9860f121a --- /dev/null +++ b/features/for-you/api/src/main/kotlin/com/tangem/features/foryou/ForYouComponent.kt @@ -0,0 +1,9 @@ +package com.tangem.features.foryou + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent + +interface ForYouComponent : ComposableModularBottomSheetContentComponent { + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/for-you/api/src/main/kotlin/com/tangem/features/foryou/ForYouFeatureToggles.kt b/features/for-you/api/src/main/kotlin/com/tangem/features/foryou/ForYouFeatureToggles.kt new file mode 100644 index 0000000000..d70be90e75 --- /dev/null +++ b/features/for-you/api/src/main/kotlin/com/tangem/features/foryou/ForYouFeatureToggles.kt @@ -0,0 +1,5 @@ +package com.tangem.features.foryou + +interface ForYouFeatureToggles { + val isForYouEnabled: Boolean +} \ No newline at end of file diff --git a/features/for-you/impl/.gitignore b/features/for-you/impl/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/features/for-you/impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/features/for-you/impl/build.gradle.kts b/features/for-you/impl/build.gradle.kts new file mode 100644 index 0000000000..86fa456438 --- /dev/null +++ b/features/for-you/impl/build.gradle.kts @@ -0,0 +1,30 @@ +plugins { + alias(deps.plugins.android.library) + alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.kapt) + alias(deps.plugins.hilt.android) + id("configuration") +} + +android { + namespace = "com.tangem.features.foryou.impl" +} + +dependencies { + + /** Features */ + implementation(projects.features.forYou.api) + + /** Core */ + implementation(projects.core.decompose) + implementation(projects.core.ui) + implementation(projects.core.configToggles) + + implementation(deps.compose.ui) + implementation(deps.compose.foundation) + implementation(deps.lifecycle.compose) + + /** DI */ + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) +} \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/DefaultForYouComponent.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/DefaultForYouComponent.kt new file mode 100644 index 0000000000..6962ef2efd --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/DefaultForYouComponent.kt @@ -0,0 +1,37 @@ +package com.tangem.features.foryou.impl + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.ui.Modifier +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState +import com.tangem.features.foryou.ForYouComponent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultForYouComponent @AssistedInject constructor( + @Assisted context: AppComponentContext, + @Suppress("UnusedPrivateMember") @Assisted params: Unit, +) : AppComponentContext by context, ForYouComponent { + + @Composable + override fun Title(bottomSheetState: State) { + TODO("Not yet implemented") + } + + @Composable + override fun Content( + bottomSheetState: State, + contentPadding: PaddingValues, + modifier: Modifier, + ) { + TODO("Not yet implemented") + } + + @AssistedFactory + interface Factory : ForYouComponent.Factory { + override fun create(context: AppComponentContext, params: Unit): DefaultForYouComponent + } +} \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/di/ForYouFeatureModule.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/di/ForYouFeatureModule.kt new file mode 100644 index 0000000000..043984089a --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/di/ForYouFeatureModule.kt @@ -0,0 +1,33 @@ +package com.tangem.features.foryou.impl.di + +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.features.foryou.ForYouComponent +import com.tangem.features.foryou.ForYouFeatureToggles +import com.tangem.features.foryou.impl.DefaultForYouComponent +import com.tangem.features.foryou.impl.featuretoggles.DefaultForYouFeatureToggles +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object ForYouFeatureModule { + + @Provides + @Singleton + fun provideForYouFeatureToggles(featureTogglesManager: FeatureTogglesManager): ForYouFeatureToggles { + return DefaultForYouFeatureToggles(featureTogglesManager = featureTogglesManager) + } +} + +@Module +@InstallIn(SingletonComponent::class) +internal interface ForYouComponentModule { + + @Binds + @Singleton + fun bindForYouComponent(factory: DefaultForYouComponent.Factory): ForYouComponent.Factory +} \ No newline at end of file diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/featuretoggles/DefaultForYouFeatureToggles.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/featuretoggles/DefaultForYouFeatureToggles.kt new file mode 100644 index 0000000000..0bdb939702 --- /dev/null +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/featuretoggles/DefaultForYouFeatureToggles.kt @@ -0,0 +1,13 @@ +package com.tangem.features.foryou.impl.featuretoggles + +import com.tangem.core.configtoggle.FeatureToggles +import com.tangem.core.configtoggle.feature.FeatureTogglesManager +import com.tangem.features.foryou.ForYouFeatureToggles +import javax.inject.Inject + +internal class DefaultForYouFeatureToggles @Inject constructor( + private val featureTogglesManager: FeatureTogglesManager, +) : ForYouFeatureToggles { + override val isForYouEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.TWI_1469_FOR_YOU_ENABLED) +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index b1fabd1412..19674f11e9 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -354,6 +354,9 @@ include(":features:virtual-accounts:details:impl") include(":features:common-features:api") include(":features:common-features:impl") + +include(":features:for-you:api") +include(":features:for-you:impl") // endregion Feature modules // region Domain modules From e95243ab79a34ac7a756b89402dba45721918b25 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 25 Jun 2026 20:02:05 +0500 Subject: [PATCH 043/210] Updated on 2026-08-14 --- .../DefaultUserWalletsListRepository.kt | 2 + .../tangem/tap/routing/utils/ChildFactory.kt | 6 +- .../configs/feature_toggles_config.json | 2 +- .../FeatureTogglesNamingConventionTest.kt | 1 - .../local/preferences/PreferencesKeys.kt | 2 + .../feedback/DefaultFeedbackFeatureToggles.kt | 2 +- .../domain/wallets/analytics/Settings.kt | 2 + .../preview/PreviewDetailsComponent.kt | 5 +- .../features/details/entity/DetailsUM.kt | 1 + .../entity/SelectContactSupportTypeBS.kt | 16 ++++ .../features/details/model/DetailsModel.kt | 49 +++++++++- .../features/details/ui/DetailsScreen.kt | 1 + .../ui/SelectContactSupportTypeBottomSheet.kt | 58 ++++++++++++ .../features/details/utils/ItemsBuilder.kt | 31 +----- .../details/model/DetailsModelFeedbackTest.kt | 10 +- .../details/model/DetailsModelInitTest.kt | 18 ---- .../model/DetailsModelNavigationTest.kt | 43 ++++++++- .../details/model/DetailsModelTestBase.kt | 10 +- .../details/utils/ItemsBuilderTest.kt | 8 +- .../feature/usedesk/api/UsedeskComponent.kt | 3 +- features/usedesk/impl/build.gradle.kts | 11 ++- .../usedesk/DefaultUsedeskComponent.kt | 89 +++++++++++++++--- .../analytics/UsedeskAnalyticsEvents.kt | 19 ++++ .../feature/usedesk/model/UsedeskModel.kt | 94 +++++++++++++++++-- .../feature/usedesk/model/UsedeskState.kt | 5 +- gradle/dependencies.toml | 3 - gradle/tangem_dependencies.toml | 7 +- settings.gradle.kts | 36 ++++++- 28 files changed, 422 insertions(+), 112 deletions(-) create mode 100644 features/details/impl/src/main/kotlin/com/tangem/features/details/entity/SelectContactSupportTypeBS.kt create mode 100644 features/details/impl/src/main/kotlin/com/tangem/features/details/ui/SelectContactSupportTypeBottomSheet.kt create mode 100644 features/usedesk/impl/src/main/java/com/tangem/feature/usedesk/analytics/UsedeskAnalyticsEvents.kt diff --git a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt index 5c6d10635a..4f02866bda 100644 --- a/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt +++ b/app/src/main/java/com/tangem/tap/domain/userWalletList/repository/DefaultUserWalletsListRepository.kt @@ -627,5 +627,7 @@ internal class DefaultUserWalletsListRepository( private suspend fun onAllWalletsDeleted() { // reset flag (that is set from AF deeplink) after removing the last wallet mobileWalletPromoRepository.setShouldShowMobileWalletPromo(false) + // wipe the Usedesk support-chat clientId so a fresh UUID is generated for the next wallet + appPreferencesStore.editData { it.remove(PreferencesKeys.USEDESK_CLIENT_ID_KEY) } } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index 4ef8ee038e..559a3bef90 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt @@ -487,10 +487,12 @@ internal class ChildFactory @Inject constructor( componentFactory = feedEntryComponentFactory, ) } - is AppRoute.Usedesk -> { // TODO [REDACTED_TASK_KEY] pass params + is AppRoute.Usedesk -> { createComponentChild( context = context, - params = UsedeskComponent.Params(), + params = UsedeskComponent.Params( + userWalletId = route.walletMetaInfo.userWalletId?.stringValue, + ), componentFactory = usedeskComponentFactory, ) } diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index a6add9266b..f5eb00d4a8 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -16,7 +16,7 @@ "version": "5.39" }, { - "name": "USEDESK_ENABLED", + "name": "TWI_485_USEDESK_ENABLED", "version": "undefined" }, { diff --git a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/feature/FeatureTogglesNamingConventionTest.kt b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/feature/FeatureTogglesNamingConventionTest.kt index b4d72d4288..7eea6b890e 100644 --- a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/feature/FeatureTogglesNamingConventionTest.kt +++ b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/feature/FeatureTogglesNamingConventionTest.kt @@ -50,7 +50,6 @@ internal class FeatureTogglesNamingConventionTest { "SOLANA_TX_HISTORY_ENABLED", "STAKING_ETH_ENABLED", "SWAP_AB_ENABLED", - "USEDESK_ENABLED", "VIRTUAL_ACCOUNTS_ENABLED", "VISA_ONBOARDING_ENABLED", "WALLET_CONNECT_BITCOIN_ENABLED", diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt index 96ea2c114d..32568b5de7 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt @@ -41,6 +41,8 @@ object PreferencesKeys { val USED_CARDS_INFO_KEY by lazy { stringPreferencesKey(name = "usedCardsInfo_v2") } + val USEDESK_CLIENT_ID_KEY by lazy { stringPreferencesKey(name = "usedeskClientId") } + val APP_THEME_MODE_KEY by lazy { stringPreferencesKey(name = "appThemeMode") } val SELECTED_APP_CURRENCY_KEY by lazy { stringPreferencesKey(name = "selectedAppCurrency") } diff --git a/data/feedback/src/main/java/com/tangem/data/feedback/DefaultFeedbackFeatureToggles.kt b/data/feedback/src/main/java/com/tangem/data/feedback/DefaultFeedbackFeatureToggles.kt index a58c715e2d..e4a87e8503 100644 --- a/data/feedback/src/main/java/com/tangem/data/feedback/DefaultFeedbackFeatureToggles.kt +++ b/data/feedback/src/main/java/com/tangem/data/feedback/DefaultFeedbackFeatureToggles.kt @@ -9,5 +9,5 @@ internal class DefaultFeedbackFeatureToggles( ) : FeedbackFeatureToggles { override val isUsedeskEnabled: Boolean - get() = featureTogglesManager.isFeatureEnabled(toggle = FeatureToggles.USEDESK_ENABLED) + get() = featureTogglesManager.isFeatureEnabled(toggle = FeatureToggles.TWI_485_USEDESK_ENABLED) } \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/analytics/Settings.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/analytics/Settings.kt index 7f8f5b9916..28ea914763 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/analytics/Settings.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/analytics/Settings.kt @@ -13,6 +13,8 @@ sealed class Settings( class ButtonManageTokens : Settings(event = "Button - Manage Tokens") + class ButtonOpenChat : Settings(event = "Button - Open Chat") + class ColdWalletAdded( source: AnalyticsParam.ScreensSources?, ) : Settings( diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewDetailsComponent.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewDetailsComponent.kt index 6b1b9e2655..8ca3706279 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewDetailsComponent.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewDetailsComponent.kt @@ -30,11 +30,9 @@ internal class PreviewDetailsComponent : DetailsComponent { ).buildAll( isWalletConnectAvailable = true, isAddressBookAvailable = true, - isSupportChatAvailable = true, hasAnyMobileWallet = true, userWalletId = UserWalletId(""), - onSupportEmailClick = {}, - onSupportChatClick = {}, + onSupportClick = {}, onBuyClick = {}, ) } @@ -48,6 +46,7 @@ internal class PreviewDetailsComponent : DetailsComponent { items = previewBlocks, footer = previewFooter, selectFeedbackEmailTypeBSConfig = TangemBottomSheetConfig.Empty, + selectContactSupportTypeBSConfig = TangemBottomSheetConfig.Empty, popBack = { /* no-op */ }, ) diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/DetailsUM.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/DetailsUM.kt index b7c390e3bd..317dff7122 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/DetailsUM.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/DetailsUM.kt @@ -7,5 +7,6 @@ internal data class DetailsUM( val items: ImmutableList, val footer: DetailsFooterUM, val selectFeedbackEmailTypeBSConfig: TangemBottomSheetConfig, + val selectContactSupportTypeBSConfig: TangemBottomSheetConfig, val popBack: () -> Unit, ) \ No newline at end of file diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/SelectContactSupportTypeBS.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/SelectContactSupportTypeBS.kt new file mode 100644 index 0000000000..f563e8d546 --- /dev/null +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/entity/SelectContactSupportTypeBS.kt @@ -0,0 +1,16 @@ +package com.tangem.features.details.entity + +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.details.impl.R + +internal data class SelectContactSupportTypeBS( + val onOptionClick: (Option) -> Unit, +) : TangemBottomSheetConfigContent { + + enum class Option(val text: TextReference) { + Mail(resourceReference(R.string.support_selector_view_email_button)), + Chat(resourceReference(R.string.support_selector_view_chat_button)), + } +} \ No newline at end of file diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt index e1538a81b3..6e8d03041f 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt @@ -24,6 +24,7 @@ import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase import com.tangem.domain.tangempay.TangemPayAnalyticsEvents import com.tangem.domain.walletconnect.CheckIsWalletConnectAvailableUseCase import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase +import com.tangem.domain.wallets.analytics.Settings import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.features.addressbook.AddressBookFeatureToggles @@ -31,6 +32,7 @@ import com.tangem.features.details.component.DetailsComponent import com.tangem.features.details.entity.DetailsFooterUM import com.tangem.features.details.entity.DetailsItemUM import com.tangem.features.details.entity.DetailsUM +import com.tangem.features.details.entity.SelectContactSupportTypeBS import com.tangem.features.details.entity.SelectEmailFeedbackTypeBS import com.tangem.features.details.utils.ItemsBuilder import com.tangem.features.details.utils.SocialsBuilder @@ -71,6 +73,8 @@ internal class DetailsModel @Inject constructor( private val params: DetailsComponent.Params = paramsContainer.require() + private val isUsedeskEnabled = feedbackFeatureToggles.isUsedeskEnabled + private val items: MutableStateFlow> val state: MutableStateFlow @@ -89,11 +93,9 @@ internal class DetailsModel @Inject constructor( itemsBuilder.buildAll( isWalletConnectAvailable = isWalletConnectAvailable, isAddressBookAvailable = addressBookFeatureToggles.isAddressBookEnabled, - isSupportChatAvailable = feedbackFeatureToggles.isUsedeskEnabled, hasAnyMobileWallet = getWalletsUseCase.invokeSync().any { it is UserWallet.Hot }, userWalletId = params.userWalletId, - onSupportEmailClick = ::sendFeedback, - onSupportChatClick = ::openUseDesk, + onSupportClick = ::onContactSupportClick, onBuyClick = ::onBuyClick, ), ) @@ -108,6 +110,7 @@ internal class DetailsModel @Inject constructor( appVersion = getAppVersion(), ), selectFeedbackEmailTypeBSConfig = TangemBottomSheetConfig.Empty, + selectContactSupportTypeBSConfig = TangemBottomSheetConfig.Empty, popBack = router::pop, ), ) @@ -159,6 +162,46 @@ internal class DetailsModel @Inject constructor( } } + private fun onContactSupportClick() { + // Offer the mail/chat choice only when the chat is available; otherwise open mail directly. + if (isUsedeskEnabled) { + showContactSupportChooserBS() + } else { + sendFeedback() + } + } + + private fun showContactSupportChooserBS() { + state.update { current -> + current.copy( + selectContactSupportTypeBSConfig = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = ::hideContactSupportChooserBS, + content = SelectContactSupportTypeBS( + onOptionClick = { option -> + hideContactSupportChooserBS() + when (option) { + SelectContactSupportTypeBS.Option.Mail -> sendFeedback() + SelectContactSupportTypeBS.Option.Chat -> { + analyticsEventHandler.send(Settings.ButtonOpenChat()) + openUseDesk() + } + } + }, + ), + ), + ) + } + } + + private fun hideContactSupportChooserBS() { + state.update { current -> + current.copy( + selectContactSupportTypeBSConfig = current.selectContactSupportTypeBSConfig.copy(isShown = false), + ) + } + } + private fun openUseDesk() { modelScope.launch { val userWallet = getSelectedWalletSyncUseCase().getOrNull() ?: error("Selected wallet is null") diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/DetailsScreen.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/DetailsScreen.kt index 48d51c0835..ada9cb0574 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/DetailsScreen.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/DetailsScreen.kt @@ -76,6 +76,7 @@ internal fun DetailsScreen( } SelectFeedbackEmailTypeBottomSheet(state.selectFeedbackEmailTypeBSConfig) + SelectContactSupportTypeBottomSheet(state.selectContactSupportTypeBSConfig) } @Composable diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/SelectContactSupportTypeBottomSheet.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/SelectContactSupportTypeBottomSheet.kt new file mode 100644 index 0000000000..9a94c95173 --- /dev/null +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/SelectContactSupportTypeBottomSheet.kt @@ -0,0 +1,58 @@ +package com.tangem.features.details.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig +import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet +import com.tangem.core.ui.components.inputrow.InputRowChecked +import com.tangem.core.ui.components.inputrow.inner.DividerContainer +import com.tangem.core.ui.decorations.roundedShapeItemDecoration +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.details.entity.SelectContactSupportTypeBS +import com.tangem.features.details.impl.R + +@Composable +internal fun SelectContactSupportTypeBottomSheet(config: TangemBottomSheetConfig) { + TangemBottomSheet( + config = config, + titleText = resourceReference(R.string.common_contact_support), + containerColor = TangemTheme.colors.background.tertiary, + content = { Content(it) }, + ) +} + +@Composable +private fun Content(content: SelectContactSupportTypeBS) { + Column( + modifier = Modifier + .padding( + start = TangemTheme.dimens.spacing16, + end = TangemTheme.dimens.spacing16, + bottom = TangemTheme.dimens.spacing16, + ), + ) { + SelectContactSupportTypeBS.Option.entries.forEachIndexed { index, type -> + DividerContainer( + modifier = Modifier + .roundedShapeItemDecoration( + currentIndex = index, + lastIndex = SelectContactSupportTypeBS.Option.entries.lastIndex, + addDefaultPadding = false, + ) + .background(TangemTheme.colors.background.action) + .clickable { content.onOptionClick(type) }, + showDivider = index != SelectContactSupportTypeBS.Option.entries.lastIndex, + ) { + InputRowChecked( + text = type.text, + checked = false, + ) + } + } + } +} \ No newline at end of file diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/ItemsBuilder.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/ItemsBuilder.kt index 99d34d298d..856b5f8dab 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/ItemsBuilder.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/ItemsBuilder.kt @@ -27,11 +27,9 @@ internal class ItemsBuilder @Inject constructor( fun buildAll( isWalletConnectAvailable: Boolean, isAddressBookAvailable: Boolean, - isSupportChatAvailable: Boolean, hasAnyMobileWallet: Boolean, userWalletId: UserWalletId, - onSupportEmailClick: () -> Unit, - onSupportChatClick: () -> Unit, + onSupportClick: () -> Unit, onBuyClick: () -> Unit, ): ImmutableList = buildList { if (isAddressBookAvailable) { @@ -50,11 +48,7 @@ internal class ItemsBuilder @Inject constructor( buildShopBlock(onBuyClick).let(::add) buildSettingsBlock().let(::add) - buildSupportBlock( - onSupportEmailClick = onSupportEmailClick, - onSupportChatClick = onSupportChatClick, - isSupportChatAvailable = isSupportChatAvailable, - ).let(::add) + buildSupportBlock(onSupportClick = onSupportClick).let(::add) }.toImmutableList() fun addTangemPayItem(items: ImmutableList, onClick: () -> Unit): ImmutableList { @@ -148,33 +142,18 @@ internal class ItemsBuilder @Inject constructor( }.toImmutableList(), ) - private fun buildSupportBlock( - onSupportEmailClick: () -> Unit, - onSupportChatClick: () -> Unit, - isSupportChatAvailable: Boolean, - ): DetailsItemUM = DetailsItemUM.Basic( + private fun buildSupportBlock(onSupportClick: () -> Unit): DetailsItemUM = DetailsItemUM.Basic( id = "support", items = buildList { DetailsItemUM.Basic.Item( - id = "support_email", + id = "contact_support", block = BlockUM( text = resourceReference(R.string.common_contact_support), iconRes = R.drawable.ic_comment_24, - onClick = onSupportEmailClick, + onClick = onSupportClick, ), ).let(::add) - if (isSupportChatAvailable) { - DetailsItemUM.Basic.Item( - id = "support_chat", - block = BlockUM( - text = resourceReference(R.string.details_row_title_contact_to_support_chat), - iconRes = R.drawable.ic_chat_24, - onClick = onSupportChatClick, - ), - ).let(::add) - } - DetailsItemUM.Basic.Item( id = "disclaimer", block = BlockUM( diff --git a/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelFeedbackTest.kt b/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelFeedbackTest.kt index bf6f2a1888..73478e1f2e 100644 --- a/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelFeedbackTest.kt +++ b/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelFeedbackTest.kt @@ -34,7 +34,7 @@ internal class DetailsModelFeedbackTest : DetailsModelTestBase() { // Act val model = createModel(this) advanceUntilIdle() - onEmailSlot.captured.invoke() + onSupportSlot.captured.invoke() advanceUntilIdle() // Assert @@ -54,7 +54,7 @@ internal class DetailsModelFeedbackTest : DetailsModelTestBase() { val model = createModel(this) advanceUntilIdle() - onEmailSlot.captured.invoke() + onSupportSlot.captured.invoke() advanceUntilIdle() verify { analyticsEventHandler.send(any()) } @@ -73,7 +73,7 @@ internal class DetailsModelFeedbackTest : DetailsModelTestBase() { val model = createModel(this) advanceUntilIdle() - onEmailSlot.captured.invoke() + onSupportSlot.captured.invoke() advanceUntilIdle() val bsConfig = model.state.value.selectFeedbackEmailTypeBSConfig @@ -93,7 +93,7 @@ internal class DetailsModelFeedbackTest : DetailsModelTestBase() { val model = createModel(this) advanceUntilIdle() - onEmailSlot.captured.invoke() + onSupportSlot.captured.invoke() advanceUntilIdle() coVerify(exactly = 0) { sendFeedbackEmailUseCase(any()) } @@ -183,7 +183,7 @@ internal class DetailsModelFeedbackTest : DetailsModelTestBase() { currentModel = createModel(this) advanceUntilIdle() - onEmailSlot.captured.invoke() + onSupportSlot.captured.invoke() advanceUntilIdle() return currentModel.state.value.selectFeedbackEmailTypeBSConfig.content as SelectEmailFeedbackTypeBS diff --git a/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelInitTest.kt b/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelInitTest.kt index 42af4c4f40..c770841dd7 100644 --- a/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelInitTest.kt +++ b/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelInitTest.kt @@ -65,24 +65,6 @@ internal class DetailsModelInitTest : DetailsModelTestBase() { assertThat(abSlot.captured).isFalse() } - @Test - fun `GIVEN usedesk enabled WHEN init THEN buildAll receives isSupportChatAvailable true`() = runTest { - every { feedbackFeatureToggles.isUsedeskEnabled } returns true - - createModel(this).also { advanceUntilIdle() }.onDestroy() - - assertThat(chatSlot.captured).isTrue() - } - - @Test - fun `GIVEN usedesk disabled WHEN init THEN buildAll receives isSupportChatAvailable false`() = runTest { - every { feedbackFeatureToggles.isUsedeskEnabled } returns false - - createModel(this).also { advanceUntilIdle() }.onDestroy() - - assertThat(chatSlot.captured).isFalse() - } - @Test fun `GIVEN a hot wallet present WHEN init THEN buildAll receives hasAnyMobileWallet true`() = runTest { every { getWalletsUseCase.invokeSync() } returns listOf(hotWallet(wallet1)) diff --git a/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelNavigationTest.kt b/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelNavigationTest.kt index 3ec4b6770b..2a9374dd58 100644 --- a/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelNavigationTest.kt +++ b/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelNavigationTest.kt @@ -5,7 +5,9 @@ import arrow.core.right import com.tangem.common.routing.AppRoute import com.tangem.core.analytics.models.Basic import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase +import com.tangem.features.details.entity.SelectContactSupportTypeBS import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -17,17 +19,19 @@ import org.junit.jupiter.api.Test internal class DetailsModelNavigationTest : DetailsModelTestBase() { @Test - fun `GIVEN selected wallet and meta WHEN support chat clicked THEN router pushes Usedesk`() = runTest { + fun `GIVEN usedesk enabled WHEN chat option selected THEN router pushes Usedesk`() = runTest { // Arrange val wallet = hotWallet(wallet1) val meta = metaInfo(wallet1) + every { feedbackFeatureToggles.isUsedeskEnabled } returns true every { getSelectedWalletSyncUseCase() } returns wallet.right() coEvery { getWalletMetaInfoUseCase(wallet1) } returns meta.right() // Act val model = createModel(this) advanceUntilIdle() - onChatSlot.captured.invoke() + onSupportSlot.captured.invoke() + selectContactSupportOption(model, SelectContactSupportTypeBS.Option.Chat) advanceUntilIdle() // Assert @@ -36,20 +40,51 @@ internal class DetailsModelNavigationTest : DetailsModelTestBase() { } @Test - fun `GIVEN meta info missing WHEN support chat clicked THEN no navigation`() = runTest { + fun `GIVEN meta info missing WHEN chat option selected THEN no navigation`() = runTest { val wallet = hotWallet(wallet1) + every { feedbackFeatureToggles.isUsedeskEnabled } returns true every { getSelectedWalletSyncUseCase() } returns wallet.right() coEvery { getWalletMetaInfoUseCase(wallet1) } returns Throwable().left() val model = createModel(this) advanceUntilIdle() - onChatSlot.captured.invoke() + onSupportSlot.captured.invoke() + selectContactSupportOption(model, SelectContactSupportTypeBS.Option.Chat) advanceUntilIdle() verify(exactly = 0) { router.push(route = any(), onComplete = any()) } model.onDestroy() } + @Test + fun `GIVEN usedesk enabled WHEN mail option selected THEN sends email and does not open Usedesk`() = runTest { + // Arrange + val wallet = hotWallet(wallet1) + val meta = metaInfo(wallet1) + every { feedbackFeatureToggles.isUsedeskEnabled } returns true + every { getWalletsUseCase.invokeSync() } returns listOf(wallet) + every { getSelectedWalletSyncUseCase() } returns wallet.right() + coEvery { getWalletMetaInfoUseCase(wallet1) } returns meta.right() + every { getTangemPayCustomerIdUseCase(wallet1) } returns customerId.right() + + // Act + val model = createModel(this) + advanceUntilIdle() + onSupportSlot.captured.invoke() + selectContactSupportOption(model, SelectContactSupportTypeBS.Option.Mail) + advanceUntilIdle() + + // Assert + coVerify { sendFeedbackEmailUseCase(any()) } + verify(exactly = 0) { router.push(route = AppRoute.Usedesk(meta), onComplete = any()) } + model.onDestroy() + } + + private fun selectContactSupportOption(model: DetailsModel, option: SelectContactSupportTypeBS.Option) { + val content = model.state.value.selectContactSupportTypeBSConfig.content as SelectContactSupportTypeBS + content.onOptionClick(option) + } + @Test fun `GIVEN buy link WHEN buy clicked THEN opens url and sends analytics`() = runTest { coEvery { diff --git a/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelTestBase.kt b/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelTestBase.kt index 6c48d43c14..d40b5d6c0a 100644 --- a/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelTestBase.kt +++ b/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelTestBase.kt @@ -70,11 +70,9 @@ internal abstract class DetailsModelTestBase { // Captured from itemsBuilder.buildAll(...) so the feature buttons can be driven. protected val wcSlot = slot() protected val abSlot = slot() - protected val chatSlot = slot() protected val mobileSlot = slot() protected val walletIdSlot = slot() - protected val onEmailSlot = slot<() -> Unit>() - protected val onChatSlot = slot<() -> Unit>() + protected val onSupportSlot = slot<() -> Unit>() protected val onBuySlot = slot<() -> Unit>() protected val onTangemPaySlot = slot<() -> Unit>() @@ -96,11 +94,9 @@ internal abstract class DetailsModelTestBase { itemsBuilder.buildAll( isWalletConnectAvailable = capture(wcSlot), isAddressBookAvailable = capture(abSlot), - isSupportChatAvailable = capture(chatSlot), hasAnyMobileWallet = capture(mobileSlot), userWalletId = capture(walletIdSlot), - onSupportEmailClick = capture(onEmailSlot), - onSupportChatClick = capture(onChatSlot), + onSupportClick = capture(onSupportSlot), onBuyClick = capture(onBuySlot), ) } returns persistentListOf() @@ -136,7 +132,7 @@ internal abstract class DetailsModelTestBase { protected fun stubBuildAllReturns(list: ImmutableList) { every { - itemsBuilder.buildAll(any(), any(), any(), any(), any(), any(), any(), any()) + itemsBuilder.buildAll(any(), any(), any(), any(), any(), any()) } returns list } diff --git a/features/details/impl/src/test/kotlin/com/tangem/features/details/utils/ItemsBuilderTest.kt b/features/details/impl/src/test/kotlin/com/tangem/features/details/utils/ItemsBuilderTest.kt index 6f4c22d644..5c7e721d20 100644 --- a/features/details/impl/src/test/kotlin/com/tangem/features/details/utils/ItemsBuilderTest.kt +++ b/features/details/impl/src/test/kotlin/com/tangem/features/details/utils/ItemsBuilderTest.kt @@ -192,7 +192,6 @@ internal class ItemsBuilderTest { val result = buildAll( isWalletConnectAvailable = false, isAddressBookAvailable = false, - isSupportChatAvailable = false, hasAnyMobileWallet = false, ) @@ -209,7 +208,7 @@ internal class ItemsBuilderTest { assertThat(shop.items.map { it.id }).containsExactly("buy_tangem_wallet") val support = result.single { it.id == "support" } as DetailsItemUM.Basic - assertThat(support.items.map { it.id }).containsExactly("support_email", "disclaimer").inOrder() + assertThat(support.items.map { it.id }).containsExactly("contact_support", "disclaimer").inOrder() } @Test @@ -254,16 +253,13 @@ internal class ItemsBuilderTest { private fun buildAll( isWalletConnectAvailable: Boolean = false, isAddressBookAvailable: Boolean = false, - isSupportChatAvailable: Boolean = false, hasAnyMobileWallet: Boolean = false, ): ImmutableList = itemsBuilder.buildAll( isWalletConnectAvailable = isWalletConnectAvailable, isAddressBookAvailable = isAddressBookAvailable, - isSupportChatAvailable = isSupportChatAvailable, hasAnyMobileWallet = hasAnyMobileWallet, userWalletId = USER_WALLET_ID, - onSupportEmailClick = {}, - onSupportChatClick = {}, + onSupportClick = {}, onBuyClick = {}, ) diff --git a/features/usedesk/api/src/main/java/com/tangem/feature/usedesk/api/UsedeskComponent.kt b/features/usedesk/api/src/main/java/com/tangem/feature/usedesk/api/UsedeskComponent.kt index 7542b8adcd..aa2cd6922c 100644 --- a/features/usedesk/api/src/main/java/com/tangem/feature/usedesk/api/UsedeskComponent.kt +++ b/features/usedesk/api/src/main/java/com/tangem/feature/usedesk/api/UsedeskComponent.kt @@ -6,7 +6,8 @@ import com.tangem.core.ui.decompose.ComposableContentComponent interface UsedeskComponent : ComposableContentComponent { data class Params( - val feedback: String? = null, // TODO [REDACTED_TASK_KEY] + /** User wallet id (hex string) sent to Usedesk as the client email to identify the user. */ + val userWalletId: String? = null, ) interface Factory : ComponentFactory diff --git a/features/usedesk/impl/build.gradle.kts b/features/usedesk/impl/build.gradle.kts index 8bba93f420..1a0b339e56 100644 --- a/features/usedesk/impl/build.gradle.kts +++ b/features/usedesk/impl/build.gradle.kts @@ -20,6 +20,13 @@ dependencies { implementation(projects.common.routing) implementation(projects.features.usedesk.api) + /** Core */ + implementation(projects.core.datasource) + implementation(projects.core.analytics) + + /** Domain */ + implementation(projects.domain.feedback) + /** DI */ implementation(deps.hilt.android) kapt(deps.hilt.kapt) @@ -29,8 +36,8 @@ dependencies { implementation(deps.compose.ui) /** Usedesk */ - implementation(deps.usedesk.chat.sdk) - implementation(deps.usedesk.chat.gui) + implementation(tangemDeps.usedesk.chat.sdk) + implementation(tangemDeps.usedesk.chat.gui) } \ No newline at end of file diff --git a/features/usedesk/impl/src/main/java/com/tangem/feature/usedesk/DefaultUsedeskComponent.kt b/features/usedesk/impl/src/main/java/com/tangem/feature/usedesk/DefaultUsedeskComponent.kt index 114b564964..8fe2ae5a87 100644 --- a/features/usedesk/impl/src/main/java/com/tangem/feature/usedesk/DefaultUsedeskComponent.kt +++ b/features/usedesk/impl/src/main/java/com/tangem/feature/usedesk/DefaultUsedeskComponent.kt @@ -1,11 +1,16 @@ package com.tangem.feature.usedesk import android.view.View +import android.view.inputmethod.InputMethodManager import androidx.compose.foundation.layout.* import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.FileProvider import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentContainerView import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -16,7 +21,7 @@ import com.tangem.feature.usedesk.model.UsedeskModel import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import ru.usedesk.chat_gui.chat.UsedeskChatScreen +import com.tangem.usedesk.chat_gui.chat.UsedeskChatScreen internal class DefaultUsedeskComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, @@ -25,9 +30,11 @@ internal class DefaultUsedeskComponent @AssistedInject constructor( private val model: UsedeskModel = getOrCreateModel(params) + @Suppress("NestedScopeFunctions") @Composable override fun Content(modifier: Modifier) { val state by model.state.collectAsStateWithLifecycle() + val configuration = state.usedeskChatConfiguration Box( modifier = Modifier @@ -35,21 +42,65 @@ internal class DefaultUsedeskComponent @AssistedInject constructor( .systemBarsPadding() .imePadding(), ) { - AndroidView( - factory = { context -> - FragmentContainerView(context).apply { - id = View.generateViewId() + // Show the chat only once the configuration is ready (clientId is loaded asynchronously). + if (configuration != null) { + val activity = LocalContext.current as? FragmentActivity ?: return@Box + val containerId = rememberSaveable { View.generateViewId() } - val fragment = UsedeskChatScreen.newInstance( - state.usedeskChatConfiguration, - ) - (context as FragmentActivity).supportFragmentManager.beginTransaction() - .replace(id, fragment) - .commit() + AndroidView( + factory = { context -> + FragmentContainerView(context).apply { + id = containerId + + val fragment = UsedeskChatScreen.newInstance( + usedeskChatConfiguration = configuration, + allowedFileExtensions = ALLOWED_FILE_EXTENSIONS, + cameraEnabled = false, + ).apply { + onChatLoaded = { model.onChatLoaded() } + onChatLoadError = { model.onChatLoadError() } + onAttachLogs = { onReady -> + model.provideLogsFile { file -> + val uri = file?.let { logsFile -> + FileProvider.getUriForFile( + context, + "${context.packageName}.provider", + logsFile, + ) + } + onReady(uri) + } + } + } + activity.supportFragmentManager.beginTransaction() + .replace(containerId, fragment) + .commit() + } + }, + modifier = Modifier.fillMaxSize(), + ) + + DisposableEffect(Unit) { + onDispose { + // When leaving the chat, hide the keyboard via the (still alive) activity + // window and remove the hosted fragment. Otherwise the input connection to + // the destroyed EditText leaks: the keyboard stays open and crashes the app + // when it is dismissed later. + activity.getSystemService(InputMethodManager::class.java) + ?.hideSoftInputFromWindow(activity.window.decorView.windowToken, 0) + + val fragmentManager = activity.supportFragmentManager + if (!fragmentManager.isStateSaved) { + fragmentManager.executePendingTransactions() + fragmentManager.findFragmentById(containerId)?.let { fragment -> + fragmentManager.beginTransaction() + .remove(fragment) + .commit() + } + } } - }, - modifier = Modifier.fillMaxSize(), - ) + } + } } } @@ -57,4 +108,14 @@ internal class DefaultUsedeskComponent @AssistedInject constructor( interface Factory : UsedeskComponent.Factory { override fun create(context: AppComponentContext, params: UsedeskComponent.Params): DefaultUsedeskComponent } + + private companion object { + // Allowed attachment types: zip, images (jpg/jpeg/png/gif/webp/heic), + // videos (mp4/mov/webm). jpeg is added as a synonym for jpg. + val ALLOWED_FILE_EXTENSIONS = listOf( + "zip", + "jpg", "jpeg", "png", "gif", "webp", "heic", + "mp4", "mov", "webm", + ) + } } \ No newline at end of file diff --git a/features/usedesk/impl/src/main/java/com/tangem/feature/usedesk/analytics/UsedeskAnalyticsEvents.kt b/features/usedesk/impl/src/main/java/com/tangem/feature/usedesk/analytics/UsedeskAnalyticsEvents.kt new file mode 100644 index 0000000000..e6041c9d19 --- /dev/null +++ b/features/usedesk/impl/src/main/java/com/tangem/feature/usedesk/analytics/UsedeskAnalyticsEvents.kt @@ -0,0 +1,19 @@ +package com.tangem.feature.usedesk.analytics + +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AnalyticsParam + +internal sealed class UsedeskAnalyticsEvents( + event: String, + params: Map = emptyMap(), +) : AnalyticsEvent(category = "Support", event = event, params = params) { + + class ChatScreenOpened(source: AnalyticsParam.ScreensSources) : UsedeskAnalyticsEvents( + event = "Chat Screen Opened", + params = mapOf(AnalyticsParam.SOURCE to source.value), + ) + + class ChatScreenError : UsedeskAnalyticsEvents(event = "Chat Screen Error") + + class ChatScreenClosed : UsedeskAnalyticsEvents(event = "Chat Screen Closed") +} \ No newline at end of file diff --git a/features/usedesk/impl/src/main/java/com/tangem/feature/usedesk/model/UsedeskModel.kt b/features/usedesk/impl/src/main/java/com/tangem/feature/usedesk/model/UsedeskModel.kt index 47139c815e..a0056b0721 100644 --- a/features/usedesk/impl/src/main/java/com/tangem/feature/usedesk/model/UsedeskModel.kt +++ b/features/usedesk/impl/src/main/java/com/tangem/feature/usedesk/model/UsedeskModel.kt @@ -1,35 +1,109 @@ package com.tangem.feature.usedesk.model import androidx.compose.runtime.Stable +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.PreferencesKeys +import com.tangem.domain.feedback.repository.FeedbackRepository +import com.tangem.feature.usedesk.analytics.UsedeskAnalyticsEvents +import com.tangem.feature.usedesk.api.UsedeskComponent +import com.tangem.usedesk.chat_sdk.entity.UsedeskChatConfiguration import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.runSuspendCatching import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import ru.usedesk.chat_sdk.entity.UsedeskChatConfiguration +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.util.UUID import javax.inject.Inject @Stable @ModelScoped internal class UsedeskModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, + private val appPreferencesStore: AppPreferencesStore, + private val analyticsEventHandler: AnalyticsEventHandler, + private val feedbackRepository: FeedbackRepository, + paramsContainer: ParamsContainer, ) : Model() { - private val _state = MutableStateFlow(getInitialState()) + private val params = paramsContainer.require() + + private val _state = MutableStateFlow(UsedeskState(usedeskChatConfiguration = null)) val state: StateFlow = _state - private fun getInitialState(): UsedeskState { - return UsedeskState( - UsedeskChatConfiguration( - companyId = COMPANY_ID, - channelId = CHANNEL_ID, - ), + // The chat screen opened/error event is sent only once. + private var isScreenLoadEventSent = false + + init { + modelScope.launch { + val clientId = getOrCreateClientId() + _state.value = UsedeskState( + UsedeskChatConfiguration( + urlChat = URL_CHAT, + urlChatApi = URL_CHAT_API, + companyId = COMPANY_ID, + channelId = CHANNEL_ID, + clientId = clientId, + clientEmail = params.userWalletId, + ), + ) + } + } + + fun onChatLoaded() { + if (isScreenLoadEventSent) return + isScreenLoadEventSent = true + analyticsEventHandler.send( + UsedeskAnalyticsEvents.ChatScreenOpened(source = AnalyticsParam.ScreensSources.Settings), ) } + fun onChatLoadError() { + if (isScreenLoadEventSent) return + isScreenLoadEventSent = true + analyticsEventHandler.send(UsedeskAnalyticsEvents.ChatScreenError()) + } + + /** + * Collects the app logs into a zip archive (the same one attached to the support email) + * and returns it via [onReady] on the main thread, or null if there are no logs. + */ + fun provideLogsFile(onReady: (File?) -> Unit) { + modelScope.launch { + val file = runSuspendCatching { feedbackRepository.getZipLogFile() }.getOrNull() + withContext(dispatchers.main) { onReady(file) } + } + } + + override fun onDestroy() { + analyticsEventHandler.send(UsedeskAnalyticsEvents.ChatScreenClosed()) + super.onDestroy() + } + + private suspend fun getOrCreateClientId(): String { + var clientId = "" + appPreferencesStore.editData { preferences -> + val existing = preferences[PreferencesKeys.USEDESK_CLIENT_ID_KEY] + clientId = if (existing.isNullOrBlank()) { + UUID.randomUUID().toString().also { preferences[PreferencesKeys.USEDESK_CLIENT_ID_KEY] = it } + } else { + existing + } + } + return clientId + } + private companion object { - const val COMPANY_ID = "170509" - const val CHANNEL_ID = "65637" + const val URL_CHAT = "https://pubsub.tangem.org" + const val URL_CHAT_API = "https://ud.tangem.org" + const val COMPANY_ID = "2" + const val CHANNEL_ID = "54" } } \ No newline at end of file diff --git a/features/usedesk/impl/src/main/java/com/tangem/feature/usedesk/model/UsedeskState.kt b/features/usedesk/impl/src/main/java/com/tangem/feature/usedesk/model/UsedeskState.kt index 26098b62e5..1a20bab10f 100644 --- a/features/usedesk/impl/src/main/java/com/tangem/feature/usedesk/model/UsedeskState.kt +++ b/features/usedesk/impl/src/main/java/com/tangem/feature/usedesk/model/UsedeskState.kt @@ -1,9 +1,10 @@ package com.tangem.feature.usedesk.model import androidx.compose.runtime.Stable -import ru.usedesk.chat_sdk.entity.UsedeskChatConfiguration +import com.tangem.usedesk.chat_sdk.entity.UsedeskChatConfiguration @Stable internal data class UsedeskState( - val usedeskChatConfiguration: UsedeskChatConfiguration, + // null while the clientId (UUID from AppPreferencesStore) is being prepared asynchronously. + val usedeskChatConfiguration: UsedeskChatConfiguration?, ) \ No newline at end of file diff --git a/gradle/dependencies.toml b/gradle/dependencies.toml index 79e299a9ae..c4f8dd88fc 100644 --- a/gradle/dependencies.toml +++ b/gradle/dependencies.toml @@ -96,7 +96,6 @@ decompose = "3.3.0" room = "2.7.2" markdown = "0.7.2" markdownComposeView = "0.5.4" -usedesk = "4.4.0" sumsub = "1.38.0" haze = "1.7.2" kotlinpoet = "1.18.1" @@ -306,8 +305,6 @@ room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } markdown = { module = "org.jetbrains:markdown", version.ref = "markdown" } markdown-composeview = { module = "com.github.jeziellago:compose-markdown", version.ref = "markdownComposeView" } -usedesk-chat-sdk = { module = "com.github.Usedesk.Android_SDK:chat-sdk", version.ref = "usedesk" } -usedesk-chat-gui = { module = "com.github.Usedesk.Android_SDK:chat-gui", version.ref = "usedesk" } sumsub-sdk = { module = "com.sumsub.sns:idensic-mobile-sdk", version.ref = "sumsub" } haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" } haze-materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" } diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index f5f4f1626b..8b7ac52f4f 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -13,8 +13,8 @@ tangemVico = "tangem-master-21" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ tangemHotSdk = "develop-550" #tangemHotSdk = "0.0.1" # Keep it! - used for local builds ^ - - +tangemUsedeskSdk = "main-9" +#tangemUsedeskSdk = "0.0.1" # Keep it! - used for local builds ^ [libraries] blockchain = { module = "com.tangem:blockchain", version.ref = "tangemBlockchainSdk" } @@ -23,6 +23,9 @@ card-core = { module = "com.tangem.tangem-sdk-kotlin:core", version.ref = "tange hot-core = { module = "com.tangem.tangem-hot-sdk-kotlin:core", version.ref = "tangemHotSdk" } hot-android = { module = "com.tangem.tangem-hot-sdk-kotlin:android", version.ref = "tangemHotSdk" } +usedesk-chat-sdk = { module = "com.tangem.usedesk:chat-sdk", version.ref = "tangemUsedeskSdk" } +usedesk-chat-gui = { module = "com.tangem.usedesk:chat-gui", version.ref = "tangemUsedeskSdk" } + vico-compose = { group = "com.tangem.vico", name = "compose", version.ref = "tangemVico" } vico-compose-m3 = { group = "com.tangem.vico", name = "compose-m3", version.ref = "tangemVico" } vico-core = { group = "com.tangem.vico", name = "core", version.ref = "tangemVico" } diff --git a/settings.gradle.kts b/settings.gradle.kts index b1fabd1412..e3a06c17ba 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -42,6 +42,7 @@ dependencyResolutionManagement { includeGroupAndSubgroups("com.tangem.tangem-sdk-kotlin") includeGroupAndSubgroups("com.tangem.tangem-hot-sdk-kotlin") includeGroupAndSubgroups("com.tangem.vico") + includeGroupAndSubgroups("com.tangem.usedesk") includeModule("com.tangem", "blstlib") includeModule("com.tangem", "blockchain") includeModule("com.tangem", "wallet-core-proto") @@ -129,6 +130,15 @@ dependencyResolutionManagement { includeGroupAndSubgroups("org.web3j") } } + maven { + // setting any repository from tangem project allows maven search all packages in the project + url = uri("https://maven.pkg.github.com/tangem/ud-android-sdk") + credentials { + username = properties.getProperty("gpr.user") ?: System.getenv("GITHUB_ACTOR") + password = properties.getProperty("gpr.key") ?: System.getenv("GITHUB_TOKEN") + } + content { includeGroupAndSubgroups("com.tangem.usedesk") } + } maven("https://jitpack.io") maven("https://maven.sumsub.com/repository/maven-public/") } @@ -162,6 +172,30 @@ if (properties.getProperty("blockchainSdk.local").toBoolean()) { } } +// Optional local composite build for the Usedesk SDK fork. +// Enable it from local.properties (which is git-ignored, so it never reaches CI/develop): +// +// usedesk.local=true +// usedesk.path=../ud-android-sdk # optional, this is the default +// +// When enabled, com.tangem.usedesk:* is resolved from local sources instead of the +// published Maven artifact (tangemUsedeskSdk in gradle/tangem_dependencies.toml). +if (properties.getProperty("usedesk.local").toBoolean()) { + val usedeskPath = properties.getProperty("usedesk.path") ?: "../ud-android-sdk" + println("Usedesk SDK: using local composite build from '$usedeskPath'") + includeBuild(usedeskPath) { + dependencySubstitution { + substitute(module("com.tangem.usedesk:common-sdk")).using(project(":common-sdk")) + substitute(module("com.tangem.usedesk:common-gui")).using(project(":common-gui")) + substitute(module("com.tangem.usedesk:chat-sdk")).using(project(":chat-sdk")) + substitute(module("com.tangem.usedesk:chat-gui")).using(project(":chat-gui")) + substitute(module("com.tangem.usedesk:knowledgebase-sdk")).using(project(":knowledgebase-sdk")) + substitute(module("com.tangem.usedesk:knowledgebase-gui")).using(project(":knowledgebase-gui")) + } + } +} + + enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") include(":app") @@ -480,4 +514,4 @@ include(":data:yield-supply") include(":data:news") include(":data:earn") include(":data:search") -// endregion Data modules \ No newline at end of file +// endregion Data modules From e70f8be7c8e6d758c11b92a562784da09cc3004f Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 24 Jun 2026 19:26:35 +0300 Subject: [PATCH 044/210] Updated on 2026-08-14 --- .../DefaultTokenDetailsComponent.kt | 28 ++ .../tokendetails/model/TokenDetailsModel.kt | 2 + .../txhistory/component/TxHistoryComponent.kt | 3 + .../component/TxHistoryDetailsComponent.kt | 4 +- .../ExpressTxToTransactionItemUMConverter.kt | 67 +---- ...HistoryInfoToTransactionItemUMConverter.kt | 16 +- .../TxInfoToTxHistoryDetailsUMConverter.kt | 175 ------------ .../txhistory/model/TxHistoryDetailsModel.kt | 6 +- .../txhistory/model/TxHistoryModel.kt | 21 ++ .../txhistory/utils/TxHistoryListManager.kt | 10 - .../txhistory/utils/TxHistoryUiActions.kt | 3 + ...pressTxToTransactionItemUMConverterTest.kt | 14 +- ...oryInfoToTransactionItemUMConverterTest.kt | 131 +++++++++ ...yInfoToTxHistoryDetailsUMConverterTest.kt} | 269 +++++++++++------- 14 files changed, 392 insertions(+), 357 deletions(-) delete mode 100644 features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxInfoToTxHistoryDetailsUMConverter.kt create mode 100644 features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTransactionItemUMConverterTest.kt rename features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/{TxInfoToTxHistoryDetailsUMConverterTest.kt => TxHistoryInfoToTxHistoryDetailsUMConverterTest.kt} (57%) diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt index aec7dd1d42..047dd80f26 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/DefaultTokenDetailsComponent.kt @@ -6,6 +6,7 @@ import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.slot.activate import com.arkivanov.decompose.router.slot.childSlot import com.arkivanov.decompose.router.slot.dismiss import com.tangem.core.decompose.context.AppComponentContext @@ -32,6 +33,8 @@ import com.tangem.features.tokendetails.ExpressTransactionsComponent import com.tangem.features.tokendetails.TokenDetailsComponent import com.tangem.features.tokenreceive.TokenReceiveComponent import com.tangem.features.txhistory.component.TxHistoryComponent +import com.tangem.features.txhistory.component.TxHistoryDetailsComponent +import com.tangem.features.txhistory.component.TxHistoryDetailsSlotConfig import com.tangem.features.yield.supply.api.YieldSupplyComponent import com.tangem.features.yield.supply.api.YieldSupplyDepositedWarningComponent import dagger.assisted.Assisted @@ -44,6 +47,7 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor( @Assisted params: TokenDetailsComponent.Params, tokenMarketBlockComponentFactory: TokenMarketBlockComponent.Factory, txHistoryComponentFactory: TxHistoryComponent.Factory, + private val txHistoryDetailsComponentFactory: TxHistoryDetailsComponent.Factory, expressTransactionsComponentFactory: ExpressTransactionsComponent.Factory, private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory, private val yieldSupplyWarningComponentFactory: YieldSupplyDepositedWarningComponent.Factory, @@ -59,6 +63,9 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor( userWalletId = params.userWalletId, currency = params.currency, openExplorer = model::onExploreClick, + onTxDetailsRequested = { txHistoryInfo -> + model.txDetailsNavigation.activate(TxHistoryDetailsSlotConfig(txHistoryInfo)) + }, ), ) @@ -88,6 +95,24 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor( }, ) + private val txHistoryDetailsSlot = childSlot( + key = TX_HISTORY_DETAILS_SLOT_KEY, + source = model.txDetailsNavigation, + serializer = null, + handleBackButton = true, + childFactory = { config, ctx -> + txHistoryDetailsComponentFactory.create( + context = childByContext(ctx), + params = TxHistoryDetailsComponent.Params( + txHistoryInfo = config.txHistoryInfo, + userWalletId = params.userWalletId, + currency = params.currency, + onDismiss = model.txDetailsNavigation::dismiss, + ), + ) + }, + ) + private val tokenMarketBlockComponent = params.currency.toTokenMarketParam()?.let { tokenMarketParams -> tokenMarketBlockComponentFactory.create( appComponentContext = child("tokenMarketBlockComponent"), @@ -109,6 +134,7 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor( override fun Content(modifier: Modifier) { val bottomSheet by bottomSheetSlot.subscribeAsState() val ratingSlotState by ratingSlot.subscribeAsState() + val txHistoryDetails by txHistoryDetailsSlot.subscribeAsState() NavigationBar3ButtonsScrim() if (LocalRedesignEnabled.current) { @@ -136,6 +162,7 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor( } bottomSheet.child?.instance?.BottomSheet() + txHistoryDetails.child?.instance?.BottomSheet() } private fun CryptoCurrency.toTokenMarketParam(): TokenMarketBlockComponent.Params? { @@ -204,5 +231,6 @@ internal class DefaultTokenDetailsComponent @AssistedInject constructor( companion object { private const val RATING_SLOT_KEY = "ratingSlot" + private const val TX_HISTORY_DETAILS_SLOT_KEY = "txHistoryDetailsSlot" } } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt index df605c6c7e..a63ccf6da6 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt @@ -128,6 +128,7 @@ import com.tangem.features.tokendetails.ExpressTransactionsEvent import com.tangem.features.tokendetails.ExpressTransactionsEventListener import com.tangem.features.tokendetails.TokenDetailsComponent import com.tangem.features.tokendetails.impl.R +import com.tangem.features.txhistory.component.TxHistoryDetailsSlotConfig import com.tangem.features.txhistory.entity.TxHistoryContentUpdateEmitter import com.tangem.features.yield.supply.api.YieldSupplyDepositedWarningComponent import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics @@ -232,6 +233,7 @@ internal class TokenDetailsModel @Inject constructor( val bottomSheetNavigation: SlotNavigation = SlotNavigation() val ratingSlotNavigation = SlotNavigation() + val txDetailsNavigation = SlotNavigation() private val stateFactory = TokenDetailsStateFactory( currentStateProvider = Provider { uiState.value }, diff --git a/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/component/TxHistoryComponent.kt b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/component/TxHistoryComponent.kt index 581f4ac2e2..ea9d747f7b 100644 --- a/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/component/TxHistoryComponent.kt +++ b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/component/TxHistoryComponent.kt @@ -6,8 +6,10 @@ import androidx.compose.runtime.Stable import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.txhistory.model.TxHistoryInfo import com.tangem.features.txhistory.entity.TxHistoryItemsUM import com.tangem.features.txhistory.entity.TxHistoryUM +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow @Stable @@ -25,6 +27,7 @@ interface TxHistoryComponent { val userWalletId: UserWalletId, val currency: CryptoCurrency, val openExplorer: () -> Unit, + val onTxDetailsRequested: (Flow) -> Unit, ) interface Factory : ComponentFactory diff --git a/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/component/TxHistoryDetailsComponent.kt b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/component/TxHistoryDetailsComponent.kt index eafab54206..add3fd0778 100644 --- a/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/component/TxHistoryDetailsComponent.kt +++ b/features/txhistory/api/src/main/kotlin/com/tangem/features/txhistory/component/TxHistoryDetailsComponent.kt @@ -3,14 +3,14 @@ package com.tangem.features.txhistory.component import com.tangem.core.decompose.factory.ComponentFactory import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.network.TxInfo import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.txhistory.model.TxHistoryInfo import kotlinx.coroutines.flow.Flow interface TxHistoryDetailsComponent : ComposableBottomSheetComponent { data class Params( - val txInfo: Flow, + val txHistoryInfo: Flow, val userWalletId: UserWalletId, val currency: CryptoCurrency, val onDismiss: () -> Unit, diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/ExpressTxToTransactionItemUMConverter.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/ExpressTxToTransactionItemUMConverter.kt index 46ade74b07..6cec8475d1 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/ExpressTxToTransactionItemUMConverter.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/ExpressTxToTransactionItemUMConverter.kt @@ -12,10 +12,9 @@ import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.utils.toTimeFormat -import com.tangem.domain.express.models.ExpressExchangeStatus -import com.tangem.domain.express.models.ExpressOnrampStatus import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.txhistory.model.ExpressTx +import com.tangem.domain.txhistory.model.explorerHash import com.tangem.features.txhistory.impl.R import com.tangem.features.txhistory.utils.TxHistoryUiActions import com.tangem.utils.StringsSigns @@ -30,8 +29,8 @@ import java.math.BigDecimal * express statuses collapse into the three [Status] buckets (those drive title/icon/amount colors in the row UI). * * The counterparty ticker symbol+icon come from the resolved [ExpressTransactionAsset.cryptoCurrency] (swap); - * onramp shows the real fiat code with no icon yet (fiat carries no `CryptoCurrency`). The row click opens the - * explorer. + * onramp shows the real fiat code with no icon yet (fiat carries no `CryptoCurrency`). The row click routes through + * [TxHistoryUiActions.onTransactionClick] (express rows open the in-app details sheet). */ internal class ExpressTxToTransactionItemUMConverter( private val currency: CryptoCurrency, @@ -39,6 +38,8 @@ internal class ExpressTxToTransactionItemUMConverter( ) : Converter { private val iconStateConverter = CryptoCurrencyToIconStateConverter() + private val exchangeStatusConverter = ExpressExchangeStatusToUiStatusConverter() + private val onrampStatusConverter = ExpressOnrampStatusToUiStatusConverter() override fun convert(value: ExpressTx): TransactionItemUM = when (value) { is ExpressTx.Swap -> swapContent(value) @@ -46,7 +47,7 @@ internal class ExpressTxToTransactionItemUMConverter( } private fun swapContent(swap: ExpressTx.Swap): TransactionItemUM.Content { - val status = swap.tx.status.toUiStatus() + val status = exchangeStatusConverter.convert(swap.tx.status) val viewedAmount = if (swap.isOutgoing) swap.tx.fromAsset.amount else swap.tx.toAsset.amount val counterparty = if (swap.isOutgoing) swap.tx.toAsset else swap.tx.fromAsset val prefix = when { @@ -72,7 +73,7 @@ internal class ExpressTxToTransactionItemUMConverter( } private fun onrampContent(onramp: ExpressTx.Onramp): TransactionItemUM.Content { - val status = onramp.tx.status.toUiStatus() + val status = onrampStatusConverter.convert(onramp.tx.status) val prefix = when { status is Status.Failed -> "" status is Status.Confirmed -> StringsSigns.PLUS @@ -107,15 +108,15 @@ internal class ExpressTxToTransactionItemUMConverter( subtitle: ContentSubtitle, warning: TextReference?, ): TransactionItemUM.Content { - val explorerHash = tx.matchHash ?: tx.txId + // Row identity (Compose key): the on-chain leg's hash when matched, else the express txId stands in. return TransactionItemUM.Content( - txHash = explorerHash, + txHash = tx.explorerHash ?: tx.txId, amount = amount, currencySymbol = currency.symbol, time = tx.timestampMillis.toTimeFormat(), status = status, direction = direction, - onClick = { txHistoryUiActions.openTxInExplorer(explorerHash) }, + onClick = { txHistoryUiActions.onTransactionClick(tx) }, iconRes = iconRes, title = title, subtitle = subtitle, @@ -142,50 +143,4 @@ internal class ExpressTxToTransactionItemUMConverter( wrappedList(resourceReference(R.string.tx_history_onramp_top_up)), ) } -} - -// region Status mapping - -/** - * Collapses the typed swap status into a UI [Status] bucket: the single success state ([Finished][Confirmed]), - * the failure/return states ([Failed]/[TxFailed]/[Refunded]/[Expired]/[Unknown]) → Failed, everything in flight - * (incl. [Verifying] and [Paused]) → Unconfirmed. - */ -private fun ExpressExchangeStatus.toUiStatus(): Status = when (this) { - ExpressExchangeStatus.Finished -> Status.Confirmed - ExpressExchangeStatus.Failed, - ExpressExchangeStatus.TxFailed, - ExpressExchangeStatus.Refunded, - ExpressExchangeStatus.Expired, - ExpressExchangeStatus.Unknown, - -> Status.Failed - ExpressExchangeStatus.Preview, - ExpressExchangeStatus.Created, - ExpressExchangeStatus.ExchangeTxSent, - ExpressExchangeStatus.Waiting, - ExpressExchangeStatus.WaitingTxHash, - ExpressExchangeStatus.Confirming, - ExpressExchangeStatus.Exchanging, - ExpressExchangeStatus.Sending, - ExpressExchangeStatus.Verifying, - ExpressExchangeStatus.Paused, - -> Status.Unconfirmed -} - -private fun ExpressOnrampStatus.toUiStatus(): Status = when (this) { - ExpressOnrampStatus.Finished -> Status.Confirmed - ExpressOnrampStatus.Failed, - ExpressOnrampStatus.Expired, - ExpressOnrampStatus.Unknown, - -> Status.Failed - ExpressOnrampStatus.Created, - ExpressOnrampStatus.WaitingForPayment, - ExpressOnrampStatus.PaymentProcessing, - ExpressOnrampStatus.Verifying, - ExpressOnrampStatus.Paid, - ExpressOnrampStatus.Sending, - ExpressOnrampStatus.Paused, - -> Status.Unconfirmed -} - -// endregion \ No newline at end of file +} \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTransactionItemUMConverter.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTransactionItemUMConverter.kt index a52b20b829..830bc48f9b 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTransactionItemUMConverter.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTransactionItemUMConverter.kt @@ -4,15 +4,21 @@ import com.tangem.core.ui.components.transactions.state.TransactionItemUM import com.tangem.domain.txhistory.model.ExpressTx import com.tangem.domain.txhistory.model.OnChainTx import com.tangem.domain.txhistory.model.TxHistoryInfo +import com.tangem.features.txhistory.utils.TxHistoryUiActions import com.tangem.utils.converter.Converter /** - * Converts a merged [TxHistoryInfo] row to [TransactionItemUM]: on-chain rows convert their `TxInfo` via - * [TxHistoryItemToTransactionItemUMConverter]; express rows map directly via [ExpressTxToTransactionItemUMConverter]. + * Converts a merged [TxHistoryInfo] row to [TransactionItemUM]. + * + * On-chain rows render via the plain [TxHistoryItemToTransactionItemUMConverter] (which knows only `TxInfo`); since + * that converter cannot reference the merged row, the row click is bound here to the **incoming** [OnChainTx] so the + * model resolves it in the live list by [TxHistoryInfo.txId]. Express rows carry their own [TxHistoryInfo] and wire + * the click themselves in [ExpressTxToTransactionItemUMConverter]. */ internal class TxHistoryInfoToTransactionItemUMConverter( private val txInfoConverter: TxHistoryItemToTransactionItemUMConverter, private val expressConverter: ExpressTxToTransactionItemUMConverter, + private val txHistoryUiActions: TxHistoryUiActions, ) : Converter { override fun convert(value: TxHistoryInfo): TransactionItemUM = when (value) { @@ -21,6 +27,10 @@ internal class TxHistoryInfoToTransactionItemUMConverter( } private fun convertOnChain(value: OnChainTx): TransactionItemUM = when (value) { - is OnChainTx.BSDK -> txInfoConverter.convert(value.txInfo) + is OnChainTx.BSDK -> when (val um = txInfoConverter.convert(value.txInfo)) { + // Content rows (transfer/swap/…) route through the details/explorer decision; pills stay on the explorer. + is TransactionItemUM.Content -> um.copy(onClick = { txHistoryUiActions.onTransactionClick(value) }) + else -> um + } } } \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxInfoToTxHistoryDetailsUMConverter.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxInfoToTxHistoryDetailsUMConverter.kt deleted file mode 100644 index 0afece079a..0000000000 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxInfoToTxHistoryDetailsUMConverter.kt +++ /dev/null @@ -1,175 +0,0 @@ -package com.tangem.features.txhistory.converter - -import androidx.annotation.StringRes -import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter -import com.tangem.core.ui.components.transactions.state.TransactionItemUM -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.stringReference -import com.tangem.core.ui.extensions.wrappedList -import com.tangem.core.ui.format.bigdecimal.crypto -import com.tangem.core.ui.format.bigdecimal.format -import com.tangem.core.ui.utils.DateTimeFormatters -import com.tangem.domain.models.currency.CryptoCurrency -import com.tangem.domain.models.network.TxInfo -import com.tangem.domain.models.network.TxInfo.TransactionType -import com.tangem.features.txhistory.entity.TxHistoryDetailsUM -import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.StatusBannerUM.Severity -import com.tangem.features.txhistory.impl.R -import com.tangem.utils.StringsSigns -import com.tangem.utils.converter.Converter -import com.tangem.utils.extensions.isZero -import com.tangem.utils.toBriefAddressFormat -import kotlinx.collections.immutable.persistentListOf -import org.joda.time.DateTime - -/** - * Converts a [TxInfo] to a [TxHistoryDetailsUM] for the in-app transaction details card. - * - * Single dispatch on [TxInfo.type] picks the layout family — mirroring the same `when(type)` used by - * [TxHistoryItemToTransactionItemUMConverter]: - * - [TransactionType.Swap] (and onramp once it lands in `TxInfo`) -> [TxHistoryDetailsUM.TwoAssets] - * - everything else -> [TxHistoryDetailsUM.SingleAsset] - */ -internal class TxInfoToTxHistoryDetailsUMConverter( - private val currency: CryptoCurrency, - private val onCopyAddress: (String) -> Unit, -) : Converter { - - private val iconStateConverter = CryptoCurrencyToIconStateConverter() - - override fun convert(value: TxInfo): TxHistoryDetailsUM = when (value.type) { - // TODO([REDACTED_TASK_KEY]): populate `from` / `to` legs once TxInfo exposes the swap legs (amounts, currencies, fiat). - // Until then the card falls back to the header-only placeholder (the TwoAssetsBlock UI is already wired). - is TransactionType.Swap -> TxHistoryDetailsUM.TwoAssets( - header = value.toHeaderUM(), - statusBanner = value.toStatusBannerUM(), - ) - else -> TxHistoryDetailsUM.SingleAsset( - header = value.toHeaderUM(), - amountBlock = value.toAmountBlockUM(), - counterparty = value.toCounterpartyUM(), - // TODO: TxInfo has no network fee / rate yet — empty until those fields are added to TxInfo. - rows = persistentListOf(), - ) - } - - private fun TxInfo.toHeaderUM(): TxHistoryDetailsUM.HeaderUM = TxHistoryDetailsUM.HeaderUM( - iconRes = headerIcon(), - status = status.toUiStatus(), - title = headerTitle(), - subtitle = headerSubtitle(), - ) - - /** - * Express status plaque under the swap block. A stopgap over the three generic [TxInfo.TransactionStatus] values — - * so [Severity.Warning] (verification) is not reachable yet. - * - * [REDACTED_TODO_COMMENT] - */ - private fun TxInfo.toStatusBannerUM(): TxHistoryDetailsUM.StatusBannerUM = when (status) { - is TxInfo.TransactionStatus.Unconfirmed -> TxHistoryDetailsUM.StatusBannerUM( - severity = Severity.Info, - title = resourceReference(R.string.express_exchange_status_receiving_active), - isLoading = true, - ) - is TxInfo.TransactionStatus.Confirmed -> TxHistoryDetailsUM.StatusBannerUM( - severity = Severity.Success, - title = resourceReference(R.string.express_exchange_status_exchanged), - isLoading = false, - ) - is TxInfo.TransactionStatus.Failed -> TxHistoryDetailsUM.StatusBannerUM( - severity = Severity.Error, - title = resourceReference(R.string.express_exchange_status_failed), - subtitle = resourceReference(R.string.express_exchange_notification_failed_text), - isLoading = false, - ) - } - - private fun TxInfo.toAmountBlockUM(): TxHistoryDetailsUM.AmountBlockUM = TxHistoryDetailsUM.AmountBlockUM( - currencyIcon = iconStateConverter.convert(currency), - amount = stringReference(signedAmount(currency)), - // TODO: TxInfo has no fiat amount yet — empty until the fiat field is added to TxInfo; a hardcoded - // placeholder would show a misleading value. - fiatAmount = TextReference.EMPTY, - isFailed = status is TxInfo.TransactionStatus.Failed, - ) - - /** - * Counterparty card ("Recipient" / "From"). Currently only the external-address avatar is produced — built from - * the `User` interaction address (the same source the history list uses for its external-address subtitle). - * - * The own-account / own-wallet avatars ([TxHistoryDetailsUM.CounterpartyAvatar.Account] / `Wallet`) require the - * address->owner lookup the list assembles in `TxHistoryLookupContext`; wiring that into the detail model is a - * follow-up, so for now a counterparty that is not a plain external `User` address yields no card (`null`). - */ - private fun TxInfo.toCounterpartyUM(): TxHistoryDetailsUM.CounterpartyUM? { - val address = (interactionAddressType as? TxInfo.InteractionAddressType.User)?.address ?: return null - return TxHistoryDetailsUM.CounterpartyUM( - label = counterpartyLabel(), - title = stringReference(address.toBriefAddressFormat()), - avatar = TxHistoryDetailsUM.CounterpartyAvatar.Address(rawAddress = address), - onCopyClick = { onCopyAddress(address) }, - ) - } - - /** Section label above the counterparty: "Recipient" for outgoing transfers, "From" for incoming. */ - private fun TxInfo.counterpartyLabel(): TextReference = - if (isOutgoing) resourceReference(R.string.send_recipient) else resourceReference(R.string.common_from) -} - -// region Amount building helpers - -/** - * Signed crypto amount with inline symbol, e.g. `+ 350.31 USDT` / `- 350.31 USDT`. The sign is `-` for outgoing, `+` - * otherwise, and is dropped for zero amounts and for the failed state (a failed tx moved nothing) — the UI then only - * strikes the amount through and dims it via [TxHistoryDetailsUM.AmountBlockUM.isFailed]. - */ -private fun TxInfo.signedAmount(currency: CryptoCurrency): String { - val formatted = amount.format { crypto(cryptoCurrency = currency, ignoreSymbolPosition = true) } - val prefix = when { - status is TxInfo.TransactionStatus.Failed -> "" - amount.isZero() -> "" - isOutgoing -> "${StringsSigns.MINUS} " - else -> "${StringsSigns.PLUS} " - } - return (prefix + formatted).trim() -} - -// endregion - -// region Header building helpers - -/** Type glyph. Unlike the history list, the failed state keeps the type glyph (only the color changes). */ -private fun TxInfo.headerIcon(): Int = when (type) { - is TransactionType.Swap -> R.drawable.ic_exchange_vertical_24 - else -> if (isOutgoing) R.drawable.ic_arrow_up_24 else R.drawable.ic_arrow_down_24 -} - -private fun TxInfo.headerTitle(): TextReference = when (type) { - is TransactionType.Swap -> statusAwareTitle(R.string.common_swapping, R.string.common_swapped) - is TransactionType.Transfer -> statusAwareTitle(R.string.common_transfer, R.string.common_transferred) - else -> stringReference(type.toString()) -} - -private fun TxInfo.headerSubtitle(): TextReference { - val dateTime = DateTime(timestampInMillis) - val date = DateTimeFormatters.dateMMMdYYYY.print(dateTime) - val time = DateTimeFormatters.timeFormatter.print(dateTime) - return stringReference("$date, $time") -} - -private fun TxInfo.statusAwareTitle(@StringRes pending: Int, @StringRes confirmed: Int): TextReference = when (status) { - is TxInfo.TransactionStatus.Failed -> - resourceReference(R.string.common_action_failed, wrappedList(resourceReference(pending))) - is TxInfo.TransactionStatus.Unconfirmed -> resourceReference(pending) - is TxInfo.TransactionStatus.Confirmed -> resourceReference(confirmed) -} - -private fun TxInfo.TransactionStatus.toUiStatus(): TransactionItemUM.Content.Status = when (this) { - TxInfo.TransactionStatus.Confirmed -> TransactionItemUM.Content.Status.Confirmed - TxInfo.TransactionStatus.Failed -> TransactionItemUM.Content.Status.Failed - TxInfo.TransactionStatus.Unconfirmed -> TransactionItemUM.Content.Status.Unconfirmed -} - -// endregion \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryDetailsModel.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryDetailsModel.kt index b6f45b5318..59c3845cb1 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryDetailsModel.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryDetailsModel.kt @@ -6,7 +6,7 @@ import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.ui.clipboard.ClipboardManager import com.tangem.features.txhistory.component.TxHistoryDetailsComponent -import com.tangem.features.txhistory.converter.TxInfoToTxHistoryDetailsUMConverter +import com.tangem.features.txhistory.converter.TxHistoryInfoToTxHistoryDetailsUMConverter import com.tangem.features.txhistory.entity.TxHistoryDetailsUM import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.SharingStarted @@ -26,12 +26,12 @@ internal class TxHistoryDetailsModel @Inject constructor( private val params: TxHistoryDetailsComponent.Params = paramsContainer.require() - private val converter = TxInfoToTxHistoryDetailsUMConverter( + private val converter = TxHistoryInfoToTxHistoryDetailsUMConverter( currency = params.currency, onCopyAddress = ::onCopyAddress, ) - val uiState: StateFlow = params.txInfo + val uiState: StateFlow = params.txHistoryInfo .map(converter::convert) .flowOn(dispatchers.default) .stateIn(modelScope, SharingStarted.WhileSubscribed(), initialValue = null) diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt index 9826f149c0..6193084ec0 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt @@ -20,7 +20,11 @@ import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.filterCryptoPortfolio import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.TxInfo +import com.tangem.domain.txhistory.model.ExpressTx +import com.tangem.domain.txhistory.model.OnChainTx import com.tangem.domain.txhistory.model.TxHistoryInfo +import com.tangem.domain.txhistory.model.explorerHash import com.tangem.domain.txhistory.models.TxHistoryStateError import com.tangem.domain.txhistory.repository.TxHistoryRepositoryV2 import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase @@ -211,6 +215,7 @@ internal class TxHistoryModel @Inject constructor( currency = params.currency, txHistoryUiActions = this, ), + txHistoryUiActions = this, ) val items = mutableListOf() @@ -361,4 +366,20 @@ internal class TxHistoryModel @Inject constructor( ifRight = { urlOpener.openUrl(url = it) }, ) } + + override fun onTransactionClick(item: TxHistoryInfo) { + // manager is non-null only under the new tx-history toggle; on the legacy path every tap falls to the explorer. + val manager = historyTxListManager + if (manager != null && item.opensInAppDetails()) { + params.onTxDetailsRequested(manager.txHistoryInfoFlow(item)) + } else { + item.explorerHash?.let(::openTxInExplorer) + } + } +} + +/** On-chain transfers/swaps and every express op open the in-app details sheet; everything else goes to the explorer. */ +private fun TxHistoryInfo.opensInAppDetails(): Boolean = when (this) { + is ExpressTx -> true + is OnChainTx.BSDK -> txInfo.type is TxInfo.TransactionType.Transfer || txInfo.type is TxInfo.TransactionType.Swap } \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryListManager.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryListManager.kt index 06a014161f..748d8b5c2f 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryListManager.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryListManager.kt @@ -120,16 +120,6 @@ internal class TxHistoryListManager( ) } - fun txInfoFlow(txHash: String, type: TxInfo.TransactionType): Flow = state - .map { st -> - st.rawBatches.asSequence() - .flatMap { it.data.items } - .firstOrNull { it.txHash == txHash && it.type == type } - } - .filterNotNull() - .distinctUntilChanged() - .flowOn(dispatchers.default) - private fun updateState( batchListState: BatchListState>, lookupContext: TxHistoryLookupContext?, diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryUiActions.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryUiActions.kt index 57a50a9014..bc04053286 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryUiActions.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/utils/TxHistoryUiActions.kt @@ -1,7 +1,10 @@ package com.tangem.features.txhistory.utils +import com.tangem.domain.txhistory.model.TxHistoryInfo + internal interface TxHistoryUiActions { fun openExplorer() fun openTxInExplorer(txHash: String) + fun onTransactionClick(item: TxHistoryInfo) } \ No newline at end of file diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/ExpressTxToTransactionItemUMConverterTest.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/ExpressTxToTransactionItemUMConverterTest.kt index ee4a06fb0f..8719768595 100644 --- a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/ExpressTxToTransactionItemUMConverterTest.kt +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/ExpressTxToTransactionItemUMConverterTest.kt @@ -196,21 +196,19 @@ internal class ExpressTxToTransactionItemUMConverterTest { } @Test - fun `GIVEN matched on-chain leg WHEN row clicked THEN opens explorer by match hash`() { - val result = converter.convert( - createSwap(status = ExpressExchangeStatus.Waiting, matchHash = "0xhash", isOutgoing = true), - ) as TransactionItemUM.Content + fun `GIVEN express row WHEN row clicked THEN opens in-app details for that tx`() { + val swap = createSwap(status = ExpressExchangeStatus.Waiting, isOutgoing = true) + val result = converter.convert(swap) as TransactionItemUM.Content result.onClick() - verify { txHistoryUiActions.openTxInExplorer("0xhash") } + verify { txHistoryUiActions.onTransactionClick(swap) } } // endregion private fun createSwap( status: ExpressExchangeStatus, - matchHash: String? = null, isOutgoing: Boolean = true, fromAmount: BigDecimal? = BigDecimal("1.5"), toAmount: BigDecimal? = BigDecimal("0.001"), @@ -220,8 +218,8 @@ internal class ExpressTxToTransactionItemUMConverterTest { status = status, createdAtMillis = 100, provider = null, - payinHash = matchHash.takeIf { isOutgoing }, - payoutHash = matchHash.takeUnless { isOutgoing }, + payinHash = null, + payoutHash = null, fromAsset = ExpressTransactionAsset( id = ExpressAssetId(networkId = "eth", contractAddress = "0"), amount = fromAmount, diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTransactionItemUMConverterTest.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTransactionItemUMConverterTest.kt new file mode 100644 index 0000000000..f1b8c4915e --- /dev/null +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTransactionItemUMConverterTest.kt @@ -0,0 +1,131 @@ +package com.tangem.features.txhistory.converter + +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.core.ui.components.transactions.state.TransactionItemUM +import com.tangem.domain.express.models.ExchangeTransaction +import com.tangem.domain.express.models.ExpressAsset.ID as ExpressAssetId +import com.tangem.domain.express.models.ExpressExchangeStatus +import com.tangem.domain.express.models.ExpressTransactionAsset +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.TxInfo +import com.tangem.domain.models.network.TxInfo.TransactionType +import com.tangem.domain.txhistory.model.ExpressTx +import com.tangem.domain.txhistory.model.OnChainTx +import com.tangem.features.txhistory.utils.TxHistoryUiActions +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class TxHistoryInfoToTransactionItemUMConverterTest { + + private val txHistoryUiActions: TxHistoryUiActions = mockk(relaxed = true) + private val currency: CryptoCurrency = MockCryptoCurrencyFactory().ethereum + + private val converter = TxHistoryInfoToTransactionItemUMConverter( + txInfoConverter = TxHistoryItemToTransactionItemUMConverter( + currency = currency, + txHistoryUiActions = txHistoryUiActions, + ), + expressConverter = ExpressTxToTransactionItemUMConverter( + currency = currency, + txHistoryUiActions = txHistoryUiActions, + ), + txHistoryUiActions = txHistoryUiActions, + ) + + @Test + fun `GIVEN on-chain content row WHEN row clicked THEN routes the incoming OnChainTx through onTransactionClick`() { + // Arrange + val item = OnChainTx.BSDK(txInfo(type = TransactionType.Transfer)) + + // Act + val result = converter.convert(item) as TransactionItemUM.Content + result.onClick() + + // Assert + verify { txHistoryUiActions.onTransactionClick(item) } + } + + @Test + fun `GIVEN on-chain swap row WHEN row clicked THEN routes the incoming OnChainTx through onTransactionClick`() { + // Arrange + val item = OnChainTx.BSDK(txInfo(type = TransactionType.Swap)) + + // Act + val result = converter.convert(item) as TransactionItemUM.Content + result.onClick() + + // Assert + verify { txHistoryUiActions.onTransactionClick(item) } + } + + @Test + fun `GIVEN on-chain pill row WHEN row clicked THEN stays on the explorer`() { + // Arrange + val item = OnChainTx.BSDK(txInfo(type = TransactionType.Approve)) + + // Act + val result = converter.convert(item) as TransactionItemUM.Pill + result.onClick() + + // Assert + verify { txHistoryUiActions.openTxInExplorer(TX_HASH) } + } + + @Test + fun `GIVEN express row WHEN row clicked THEN routes the incoming ExpressTx through onTransactionClick`() { + // Arrange + val item = expressSwap() + + // Act + val result = converter.convert(item) as TransactionItemUM.Content + result.onClick() + + // Assert + verify { txHistoryUiActions.onTransactionClick(item) } + } + + private fun txInfo(type: TransactionType): TxInfo = TxInfo( + txHash = TX_HASH, + timestampInMillis = TIMESTAMP, + isOutgoing = false, + destinationType = TxInfo.DestinationType.Single(addressType = TxInfo.AddressType.User(USER_ADDRESS)), + sourceType = TxInfo.SourceType.Single(address = USER_ADDRESS), + interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS), + status = TxInfo.TransactionStatus.Confirmed, + type = type, + amount = BigDecimal.ONE, + ) + + private fun expressSwap(): ExpressTx.Swap = ExpressTx.Swap( + tx = ExchangeTransaction( + txId = "swap-1", + status = ExpressExchangeStatus.Exchanging, + createdAtMillis = TIMESTAMP, + provider = null, + payinHash = null, + payoutHash = null, + fromAsset = ExpressTransactionAsset( + id = ExpressAssetId(networkId = "ethereum", contractAddress = "0"), + amount = BigDecimal("1.5"), + decimals = 18, + ), + toAsset = ExpressTransactionAsset( + id = ExpressAssetId(networkId = "bitcoin", contractAddress = "0"), + amount = BigDecimal("0.001"), + decimals = 8, + ), + ), + isOutgoing = true, + txInfo = null, + ) + + private companion object { + const val TX_HASH = "0xtxhash" + const val TIMESTAMP = 1_700_000_000_000L + const val USER_ADDRESS = "0x1234567890abcdef1234" + } +} \ No newline at end of file diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxInfoToTxHistoryDetailsUMConverterTest.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverterTest.kt similarity index 57% rename from features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxInfoToTxHistoryDetailsUMConverterTest.kt rename to features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverterTest.kt index 2ddf7a9b3a..a8330706ac 100644 --- a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxInfoToTxHistoryDetailsUMConverterTest.kt +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverterTest.kt @@ -6,10 +6,21 @@ import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory import com.tangem.core.ui.components.transactions.state.TransactionItemUM import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.express.models.ExchangeTransaction +import com.tangem.domain.express.models.ExpressAsset.ID as ExpressAssetId +import com.tangem.domain.express.models.ExpressExchangeStatus +import com.tangem.domain.express.models.ExpressOnrampStatus +import com.tangem.domain.express.models.ExpressTransactionAsset +import com.tangem.domain.express.models.OnrampTransaction import com.tangem.domain.models.network.TxInfo import com.tangem.domain.models.network.TxInfo.TransactionType +import com.tangem.domain.tokens.model.Amount +import com.tangem.domain.tokens.model.AmountType +import com.tangem.domain.txhistory.model.ExpressTx +import com.tangem.domain.txhistory.model.OnChainTx import com.tangem.features.txhistory.entity.TxHistoryDetailsUM import com.tangem.features.txhistory.impl.R +import com.tangem.test.core.ProvideTestModels import io.mockk.every import io.mockk.mockkStatic import io.mockk.unmockkStatic @@ -17,14 +28,15 @@ import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest import java.math.BigDecimal @TestInstance(TestInstance.Lifecycle.PER_CLASS) -internal class TxInfoToTxHistoryDetailsUMConverterTest { +internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest { private val currency = MockCryptoCurrencyFactory().ethereum private val copiedAddresses = mutableListOf() - private val converter = TxInfoToTxHistoryDetailsUMConverter( + private val converter = TxHistoryInfoToTxHistoryDetailsUMConverter( currency = currency, onCopyAddress = copiedAddresses::add, ) @@ -42,44 +54,49 @@ internal class TxInfoToTxHistoryDetailsUMConverterTest { unmockkStatic(DateFormat::class) } + // region On-chain (TxInfo) + @Test - fun `GIVEN Swap WHEN convert THEN TwoAssets`() { + fun `GIVEN on-chain Swap WHEN convert THEN SingleAsset fallback`() { + // A two-asset swap always surfaces as ExpressTx.Swap; an on-chain TxInfo of type Swap has no legs, + // so it falls back to the single amount it does carry rather than an empty two-asset card. // Arrange - val tx = txInfo(type = TransactionType.Swap) + val tx = onChain(type = TransactionType.Swap) // Act val result = converter.convert(tx) // Assert - assertThat(result).isInstanceOf(TxHistoryDetailsUM.TwoAssets::class.java) + assertThat(result).isInstanceOf(TxHistoryDetailsUM.SingleAsset::class.java) } - @Test - fun `GIVEN non-Swap TransactionType WHEN convert THEN SingleAsset`() { - val nonSwapTypes = listOf( - TransactionType.Transfer, - TransactionType.Approve, - TransactionType.Operation(name = "Mint NFT"), - TransactionType.UnknownOperation, - TransactionType.GaslessFee, - TransactionType.Staking.Stake, - TransactionType.Staking.ClaimRewards, - TransactionType.Staking.Vote(validatorAddress = VALIDATOR_ADDRESS), - TransactionType.YieldSupply.Topup, - TransactionType.YieldSupply.Enter(address = USER_ADDRESS), - ) + @ParameterizedTest + @ProvideTestModels + fun `GIVEN non-Swap TransactionType WHEN convert THEN SingleAsset`(type: TransactionType) { + // Act + val result = converter.convert(onChain(type = type)) - nonSwapTypes.forEach { type -> - val result = converter.convert(txInfo(type = type)) - - assertThat(result).isInstanceOf(TxHistoryDetailsUM.SingleAsset::class.java) - } + // Assert + assertThat(result).isInstanceOf(TxHistoryDetailsUM.SingleAsset::class.java) } + private fun provideTestModels() = listOf( + TransactionType.Transfer, + TransactionType.Approve, + TransactionType.Operation(name = "Mint NFT"), + TransactionType.UnknownOperation, + TransactionType.GaslessFee, + TransactionType.Staking.Stake, + TransactionType.Staking.ClaimRewards, + TransactionType.Staking.Vote(validatorAddress = VALIDATOR_ADDRESS), + TransactionType.YieldSupply.Topup, + TransactionType.YieldSupply.Enter(address = USER_ADDRESS), + ) + @Test fun `GIVEN incoming confirmed Transfer WHEN convert THEN header has down icon, confirmed status, transferred title`() { // Arrange - val tx = txInfo(type = TransactionType.Transfer) + val tx = onChain(type = TransactionType.Transfer) // Act val header = converter.convert(tx).header @@ -93,7 +110,7 @@ internal class TxInfoToTxHistoryDetailsUMConverterTest { @Test fun `GIVEN Swap WHEN convert THEN header has exchange icon`() { // Arrange - val tx = txInfo(type = TransactionType.Swap) + val tx = onChain(type = TransactionType.Swap) // Act val header = converter.convert(tx).header @@ -102,65 +119,10 @@ internal class TxInfoToTxHistoryDetailsUMConverterTest { assertThat(header.iconRes).isEqualTo(R.drawable.ic_exchange_vertical_24) } - @Test - fun `GIVEN unconfirmed Swap WHEN convert THEN info status banner with loader`() { - // Arrange - val tx = txInfo(type = TransactionType.Swap, status = TxInfo.TransactionStatus.Unconfirmed) - - // Act - val banner = (converter.convert(tx) as TxHistoryDetailsUM.TwoAssets).statusBanner - - // Assert - assertThat(banner).isEqualTo( - TxHistoryDetailsUM.StatusBannerUM( - severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Info, - title = resourceReference(R.string.express_exchange_status_receiving_active), - isLoading = true, - ), - ) - } - - @Test - fun `GIVEN confirmed Swap WHEN convert THEN success status banner without loader`() { - // Arrange - val tx = txInfo(type = TransactionType.Swap, status = TxInfo.TransactionStatus.Confirmed) - - // Act - val banner = (converter.convert(tx) as TxHistoryDetailsUM.TwoAssets).statusBanner - - // Assert - assertThat(banner).isEqualTo( - TxHistoryDetailsUM.StatusBannerUM( - severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Success, - title = resourceReference(R.string.express_exchange_status_exchanged), - isLoading = false, - ), - ) - } - - @Test - fun `GIVEN failed Swap WHEN convert THEN error status banner with refund subtitle`() { - // Arrange - val tx = txInfo(type = TransactionType.Swap, status = TxInfo.TransactionStatus.Failed) - - // Act - val banner = (converter.convert(tx) as TxHistoryDetailsUM.TwoAssets).statusBanner - - // Assert - assertThat(banner).isEqualTo( - TxHistoryDetailsUM.StatusBannerUM( - severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Error, - title = resourceReference(R.string.express_exchange_status_failed), - subtitle = resourceReference(R.string.express_exchange_notification_failed_text), - isLoading = false, - ), - ) - } - @Test fun `GIVEN incoming Transfer WHEN convert THEN amount block has plus sign and not failed`() { // Arrange - val tx = txInfo(type = TransactionType.Transfer, isOutgoing = false) + val tx = onChain(type = TransactionType.Transfer, isOutgoing = false) // Act val amountBlock = (converter.convert(tx) as TxHistoryDetailsUM.SingleAsset).amountBlock @@ -173,7 +135,7 @@ internal class TxInfoToTxHistoryDetailsUMConverterTest { @Test fun `GIVEN outgoing Transfer WHEN convert THEN amount block has minus sign`() { // Arrange - val tx = txInfo(type = TransactionType.Transfer, isOutgoing = true) + val tx = onChain(type = TransactionType.Transfer, isOutgoing = true) // Act val amountBlock = (converter.convert(tx) as TxHistoryDetailsUM.SingleAsset).amountBlock @@ -185,7 +147,7 @@ internal class TxInfoToTxHistoryDetailsUMConverterTest { @Test fun `GIVEN zero amount WHEN convert THEN amount block has no sign`() { // Arrange - val tx = txInfo(type = TransactionType.Transfer, isOutgoing = true, amount = BigDecimal.ZERO) + val tx = onChain(type = TransactionType.Transfer, isOutgoing = true, amount = BigDecimal.ZERO) // Act val amountBlock = (converter.convert(tx) as TxHistoryDetailsUM.SingleAsset).amountBlock @@ -199,7 +161,7 @@ internal class TxInfoToTxHistoryDetailsUMConverterTest { @Test fun `GIVEN failed outgoing Transfer WHEN convert THEN amount block is failed and drops the sign`() { // Arrange - val tx = txInfo( + val tx = onChain( type = TransactionType.Transfer, isOutgoing = true, status = TxInfo.TransactionStatus.Failed, @@ -218,7 +180,7 @@ internal class TxInfoToTxHistoryDetailsUMConverterTest { @Test fun `GIVEN no interaction address WHEN convert THEN counterparty is null`() { // Arrange - val tx = txInfo(type = TransactionType.Transfer, interactionAddressType = null) + val tx = onChain(type = TransactionType.Transfer, interactionAddressType = null) // Act val counterparty = (converter.convert(tx) as TxHistoryDetailsUM.SingleAsset).counterparty @@ -230,7 +192,7 @@ internal class TxInfoToTxHistoryDetailsUMConverterTest { @Test fun `GIVEN incoming Transfer with User address WHEN convert THEN address-avatar counterparty with From label`() { // Arrange - val tx = txInfo( + val tx = onChain( type = TransactionType.Transfer, isOutgoing = false, interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS), @@ -247,7 +209,7 @@ internal class TxInfoToTxHistoryDetailsUMConverterTest { @Test fun `GIVEN outgoing Transfer with User address WHEN convert THEN counterparty has Recipient label`() { // Arrange - val tx = txInfo( + val tx = onChain( type = TransactionType.Transfer, isOutgoing = true, interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS), @@ -263,7 +225,7 @@ internal class TxInfoToTxHistoryDetailsUMConverterTest { @Test fun `GIVEN address counterparty WHEN onCopyClick invoked THEN raw address is copied`() { // Arrange - val tx = txInfo( + val tx = onChain( type = TransactionType.Transfer, interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS), ) @@ -276,24 +238,131 @@ internal class TxInfoToTxHistoryDetailsUMConverterTest { assertThat(copiedAddresses).containsExactly(USER_ADDRESS) } - private fun txInfo( + // endregion + + // region Express (swap / onramp) + + @Test + fun `GIVEN express swap WHEN convert THEN TwoAssets with exchange icon`() { + // Act + val result = converter.convert(expressSwap(status = ExpressExchangeStatus.Exchanging)) + + // Assert + assertThat(result).isInstanceOf(TxHistoryDetailsUM.TwoAssets::class.java) + assertThat(result.header.iconRes).isEqualTo(R.drawable.ic_exchange_vertical_24) + } + + @Test + fun `GIVEN in-progress express swap WHEN convert THEN info status banner with loader`() { + // Act + val swap = converter.convert(expressSwap(status = ExpressExchangeStatus.Exchanging)) + val banner = (swap as TxHistoryDetailsUM.TwoAssets).statusBanner + + // Assert + assertThat(banner).isEqualTo( + TxHistoryDetailsUM.StatusBannerUM( + severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Info, + title = resourceReference(R.string.express_exchange_status_receiving_active), + isLoading = true, + ), + ) + } + + @Test + fun `GIVEN failed express swap WHEN convert THEN error status banner with refund subtitle`() { + // Act + val swap = converter.convert(expressSwap(status = ExpressExchangeStatus.Failed)) + val banner = (swap as TxHistoryDetailsUM.TwoAssets).statusBanner + + // Assert + assertThat(banner).isEqualTo( + TxHistoryDetailsUM.StatusBannerUM( + severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Error, + title = resourceReference(R.string.express_exchange_status_failed), + subtitle = resourceReference(R.string.express_exchange_notification_failed_text), + isLoading = false, + ), + ) + } + + @Test + fun `GIVEN finished express onramp WHEN convert THEN TwoAssets with success banner`() { + // Act + val result = converter.convert(expressOnramp(status = ExpressOnrampStatus.Finished)) as TxHistoryDetailsUM.TwoAssets + + // Assert + assertThat(result.header.iconRes).isEqualTo(R.drawable.ic_tangem_card_24) + assertThat(result.statusBanner).isEqualTo( + TxHistoryDetailsUM.StatusBannerUM( + severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Success, + title = resourceReference(R.string.express_exchange_status_exchanged), + isLoading = false, + ), + ) + } + + // endregion + + private fun onChain( type: TransactionType, isOutgoing: Boolean = false, status: TxInfo.TransactionStatus = TxInfo.TransactionStatus.Confirmed, amount: BigDecimal = BigDecimal.ONE, interactionAddressType: TxInfo.InteractionAddressType? = null, - ): TxInfo = TxInfo( - txHash = TX_HASH, - timestampInMillis = TIMESTAMP, - isOutgoing = isOutgoing, - destinationType = TxInfo.DestinationType.Single(addressType = TxInfo.AddressType.User(USER_ADDRESS)), - sourceType = TxInfo.SourceType.Single(address = USER_ADDRESS), - interactionAddressType = interactionAddressType, - status = status, - type = type, - amount = amount, + ): OnChainTx.BSDK = OnChainTx.BSDK( + TxInfo( + txHash = TX_HASH, + timestampInMillis = TIMESTAMP, + isOutgoing = isOutgoing, + destinationType = TxInfo.DestinationType.Single(addressType = TxInfo.AddressType.User(USER_ADDRESS)), + sourceType = TxInfo.SourceType.Single(address = USER_ADDRESS), + interactionAddressType = interactionAddressType, + status = status, + type = type, + amount = amount, + ), ) + private fun expressSwap(status: ExpressExchangeStatus): ExpressTx.Swap = ExpressTx.Swap( + tx = ExchangeTransaction( + txId = "swap-1", + status = status, + createdAtMillis = TIMESTAMP, + provider = null, + payinHash = null, + payoutHash = null, + fromAsset = expressAsset(networkId = "ethereum", amount = BigDecimal("1.5"), decimals = 18), + toAsset = expressAsset(networkId = "bitcoin", amount = BigDecimal("0.001"), decimals = 8), + ), + isOutgoing = true, + txInfo = null, + ) + + private fun expressOnramp(status: ExpressOnrampStatus): ExpressTx.Onramp = ExpressTx.Onramp( + tx = OnrampTransaction( + txId = "onramp-1", + status = status, + createdAtMillis = TIMESTAMP, + provider = null, + payoutHash = null, + fromFiat = Amount( + currencySymbol = "SEK", + value = BigDecimal("100"), + decimals = 2, + type = AmountType.FiatType(code = "SEK"), + ), + toAsset = expressAsset(networkId = "bitcoin", amount = BigDecimal("0.006"), decimals = 8), + ), + txInfo = null, + ) + + private fun expressAsset(networkId: String, amount: BigDecimal, decimals: Int): ExpressTransactionAsset = + ExpressTransactionAsset( + id = ExpressAssetId(networkId = networkId, contractAddress = "0"), + amount = amount, + decimals = decimals, + ) + private fun TextReference.resolveString(): String = (this as TextReference.Str).value private companion object { From 494c9c0127ab227ec46ad1587509e5399e45e63a Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 24 Jun 2026 14:29:08 +0300 Subject: [PATCH 045/210] Updated on 2026-08-14 --- .../entity/TangemPayDetailsStateFactory.kt | 57 ++++-- .../tangempay/entity/TangemPayDetailsUM.kt | 16 +- .../tangempay/model/TangemPayDetailsModel.kt | 27 ++- .../TangemPayActionButtonsTransformer.kt | 18 ++ ...TangemPayFreezeUnfreezeStateTransformer.kt | 24 --- .../tangempay/ui/TangemPayDetailsScreen.kt | 14 +- .../tangempay/ui/TangemPayDetailsScreenV2.kt | 16 +- .../utils/PaymentAccountStatusExt.kt | 6 + .../tangempay/ActionButtonsTestExt.kt | 18 ++ .../tangempay/TangemPayTestFixtures.kt | 22 +++ .../TangemPayDetailsStateFactoryTest.kt | 134 +++++++++++--- .../model/TangemPayDetailsModelTest.kt | 171 ++++++++++++++++++ .../TangemPayActionButtonsTransformerTest.kt | 79 ++++++++ 13 files changed, 508 insertions(+), 94 deletions(-) create mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayActionButtonsTransformer.kt delete mode 100644 features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayFreezeUnfreezeStateTransformer.kt create mode 100644 features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/ActionButtonsTestExt.kt create mode 100644 features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/TangemPayTestFixtures.kt create mode 100644 features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModelTest.kt create mode 100644 features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayActionButtonsTransformerTest.kt diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt index 735192a0ff..f2c556b185 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactory.kt @@ -10,7 +10,6 @@ import com.tangem.core.ui.extensions.themedColor import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.generated.icons.Icons import com.tangem.core.ui.res.generated.icons.ic_document_20 -import com.tangem.domain.models.StatusSource import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.pay.TangemPayCard import com.tangem.domain.models.pay.TangemPayCardFrozenState @@ -18,6 +17,8 @@ import com.tangem.domain.models.pay.TangemPayCardState import com.tangem.domain.models.pay.isFrozen import com.tangem.features.tangempay.details.impl.R import com.tangem.features.tangempay.utils.TangemPayDetailIntents +import com.tangem.features.tangempay.utils.hasWithdrawableAmount +import com.tangem.features.tangempay.utils.isFresh import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -60,11 +61,12 @@ internal class TangemPayDetailsStateFactory( } fun getLoadedState(status: PaymentAccountStatusValue.Loaded): TangemPayDetailsUM { - val isFresh = status.source == StatusSource.ACTUAL && status.error == null + val isFresh = status.isFresh val hasUnfrozenCard = status.cards.any { it.frozenState == TangemPayCardFrozenState.Unfrozen } val hasIssuingCard = status.cards.any { it.state == TangemPayCardState.Issuing } val isAddCardEnabled = isFresh && !hasIssuingCard val areActionButtonsEnabled = isFresh && hasUnfrozenCard + val hasWithdrawableBalance = status.balance.hasWithdrawableAmount return TangemPayDetailsUM( topBarConfig = TangemPayDetailsTopBarConfig( onBackClick = onBack, @@ -77,7 +79,10 @@ internal class TangemPayDetailsStateFactory( onRefresh = intents::onRefreshSwipe, ), balanceBlockState = TangemPayDetailsBalanceBlockState.Loading( - actionButtons = getActionButtonsConfig(isEnabled = areActionButtonsEnabled), + actionButtons = getActionButtonsConfig( + isAddFundsEnabled = areActionButtonsEnabled, + isWithdrawEnabled = areActionButtonsEnabled && hasWithdrawableBalance, + ), cardsBlockState = TangemPayDetailsBalanceBlockState.CardsBlockState( cards = status.cards .let { if (isMultipleCardsEnabled) it else it.take(1) } @@ -113,7 +118,7 @@ internal class TangemPayDetailsStateFactory( else -> null } - fun getDeactivatedState(): TangemPayDetailsUM { + fun getDeactivatedState(hasWithdrawableBalance: Boolean): TangemPayDetailsUM { return TangemPayDetailsUM( topBarConfig = TangemPayDetailsTopBarConfig( onBackClick = onBack, @@ -126,7 +131,10 @@ internal class TangemPayDetailsStateFactory( onRefresh = intents::onRefreshSwipe, ), balanceBlockState = TangemPayDetailsBalanceBlockState.Loading( - actionButtons = getActionButtonsConfig(isEnabled = true), + actionButtons = getActionButtonsConfig( + isAddFundsEnabled = true, + isWithdrawEnabled = hasWithdrawableBalance, + ), cardsBlockState = null, ), isBalanceHidden = false, @@ -256,23 +264,32 @@ internal class TangemPayDetailsStateFactory( ) } - private fun getActionButtonsConfig(isEnabled: Boolean): ImmutableList { + fun getActionButtonsConfig( + isAddFundsEnabled: Boolean, + isWithdrawEnabled: Boolean, + ): ImmutableList { return persistentListOf( - ActionButtonConfig( - text = resourceReference(id = R.string.tangempay_card_details_add_funds), - iconResId = if (isRedesignEnabled) { - R.drawable.ic_arrow_down_24 - } else { - R.drawable.ic_plus_24 - }, - onClick = intents::onClickAddFunds, - isEnabled = isEnabled, + TangemPayActionButtonUM( + action = TangemPayAction.AddFunds, + config = ActionButtonConfig( + text = resourceReference(id = R.string.tangempay_card_details_add_funds), + iconResId = if (isRedesignEnabled) { + R.drawable.ic_arrow_down_24 + } else { + R.drawable.ic_plus_24 + }, + onClick = intents::onClickAddFunds, + isEnabled = isAddFundsEnabled, + ), ), - ActionButtonConfig( - text = resourceReference(id = R.string.tangempay_card_details_withdraw), - iconResId = R.drawable.ic_arrow_up_24, - onClick = intents::onClickWithdraw, - isEnabled = isEnabled, + TangemPayActionButtonUM( + action = TangemPayAction.Withdraw, + config = ActionButtonConfig( + text = resourceReference(id = R.string.tangempay_card_details_withdraw), + iconResId = R.drawable.ic_arrow_up_24, + onClick = intents::onClickWithdraw, + isEnabled = isWithdrawEnabled, + ), ), ) } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt index 93713fe0a9..541ff3efb8 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsUM.kt @@ -68,26 +68,34 @@ internal sealed interface DisplayNameState { } } +internal enum class TangemPayAction { AddFunds, Withdraw } + +@Immutable +internal data class TangemPayActionButtonUM( + val action: TangemPayAction, + val config: ActionButtonConfig, +) + @Immutable internal sealed class TangemPayDetailsBalanceBlockState { - abstract val actionButtons: ImmutableList + abstract val actionButtons: ImmutableList abstract val cardsBlockState: CardsBlockState? data class Loading( - override val actionButtons: ImmutableList, + override val actionButtons: ImmutableList, override val cardsBlockState: CardsBlockState?, ) : TangemPayDetailsBalanceBlockState() data class Content( - override val actionButtons: ImmutableList, + override val actionButtons: ImmutableList, override val cardsBlockState: CardsBlockState?, val fiatBalance: TextReference, val isBalanceFlickering: Boolean, ) : TangemPayDetailsBalanceBlockState() data class Error( - override val actionButtons: ImmutableList, + override val actionButtons: ImmutableList, override val cardsBlockState: CardsBlockState?, ) : TangemPayDetailsBalanceBlockState() diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt index 888788f83f..270a42f551 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModel.kt @@ -26,6 +26,7 @@ import com.tangem.domain.feedback.models.WalletMetaInfo import com.tangem.domain.models.TokenReceiveConfig import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.pay.TangemPayCardFrozenState import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier import com.tangem.domain.pay.model.TangemPayTopUpData @@ -113,7 +114,9 @@ internal class TangemPayDetailsModel @Inject constructor( val uiState: StateFlow field = MutableStateFlow( when { - params.initialStatus.isDeactivated -> stateFactory.getDeactivatedState() + params.initialStatus.isDeactivated -> stateFactory.getDeactivatedState( + hasWithdrawableBalance = params.initialStatus.balanceOrNull()?.hasWithdrawableAmount == true, + ) else -> stateFactory.getLoadingState() }, ) @@ -134,7 +137,11 @@ internal class TangemPayDetailsModel @Inject constructor( .onEach { state -> when (state) { is PaymentAccountStatusValue.Deactivated -> { - uiState.update { stateFactory.getDeactivatedState() } + uiState.update { + stateFactory.getDeactivatedState( + hasWithdrawableBalance = state.balance.hasWithdrawableAmount, + ) + } uiState.update(DetailsBalanceTransformer(state.balance.fiatBalance)) } is PaymentAccountStatusValue.Loaded -> { @@ -169,7 +176,21 @@ internal class TangemPayDetailsModel @Inject constructor( frozenStateJobHolder.cancel() cardDetailsRepository .cardFrozenState(cardId) - .onEach { uiState.update(TangemPayFreezeUnfreezeStateTransformer(cardFrozenState = it)) } + .onEach { frozenState -> + // Mirror getLoadedState gating so a live freeze update can't re-enable actions on stale data. + val isFresh = currentStatus.value.ifLoadedOrNull { it.isFresh } == true + val isUnfrozen = frozenState == TangemPayCardFrozenState.Unfrozen + val areActionButtonsEnabled = isFresh && isUnfrozen + val hasWithdrawableBalance = currentStatus.value.balanceOrNull()?.hasWithdrawableAmount == true + uiState.update( + TangemPayActionButtonsTransformer( + stateFactory.getActionButtonsConfig( + isAddFundsEnabled = areActionButtonsEnabled, + isWithdrawEnabled = areActionButtonsEnabled && hasWithdrawableBalance, + ), + ), + ) + } .launchIn(modelScope) .saveIn(frozenStateJobHolder) } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayActionButtonsTransformer.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayActionButtonsTransformer.kt new file mode 100644 index 0000000000..bc325204a3 --- /dev/null +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayActionButtonsTransformer.kt @@ -0,0 +1,18 @@ +package com.tangem.features.tangempay.model.transformers + +import com.tangem.features.tangempay.entity.TangemPayActionButtonUM +import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState +import com.tangem.features.tangempay.entity.TangemPayDetailsUM +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.ImmutableList + +internal class TangemPayActionButtonsTransformer( + private val actionButtons: ImmutableList, +) : Transformer { + + override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM { + val balanceBlockState = prevState.balanceBlockState + if (balanceBlockState !is TangemPayDetailsBalanceBlockState.Content) return prevState + return prevState.copy(balanceBlockState = balanceBlockState.copy(actionButtons = actionButtons)) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayFreezeUnfreezeStateTransformer.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayFreezeUnfreezeStateTransformer.kt deleted file mode 100644 index 0146ce7829..0000000000 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayFreezeUnfreezeStateTransformer.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.tangem.features.tangempay.model.transformers - -import com.tangem.domain.models.pay.TangemPayCardFrozenState -import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState -import com.tangem.features.tangempay.entity.TangemPayDetailsUM -import com.tangem.utils.transformer.Transformer -import kotlinx.collections.immutable.toPersistentList - -internal class TangemPayFreezeUnfreezeStateTransformer( - private val cardFrozenState: TangemPayCardFrozenState, -) : Transformer { - - override fun transform(prevState: TangemPayDetailsUM): TangemPayDetailsUM { - val balanceBlockState = if (prevState.balanceBlockState is TangemPayDetailsBalanceBlockState.Content) { - val actionButtons = prevState.balanceBlockState.actionButtons.map { - it.copy(isEnabled = cardFrozenState == TangemPayCardFrozenState.Unfrozen) - } - prevState.balanceBlockState.copy(actionButtons = actionButtons.toPersistentList()) - } else { - prevState.balanceBlockState - } - return prevState.copy(balanceBlockState = balanceBlockState) - } -} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt index 0a0f2dc41d..ea3be706fd 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreen.kt @@ -61,6 +61,7 @@ import com.tangem.features.tangempay.entity.* import com.tangem.features.tokendetails.ExpressTransactionsComponent import com.tangem.utils.StringsSigns.DASH_SIGN import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList private const val DISABLED_ALPHA = 0.5f @@ -283,7 +284,7 @@ private fun TangemPayDetailsBalanceBlock( if (state.actionButtons.isNotEmpty()) { HorizontalActionChips( modifier = Modifier.padding(top = 12.dp), - buttons = state.actionButtons, + buttons = state.actionButtons.map { it.config }.toImmutableList(), containerColor = TangemTheme.colors.background.primary, contentPadding = PaddingValues(horizontal = TangemTheme.dimens.spacing12), ) @@ -468,10 +469,13 @@ internal class TangemPayDetailsUMProvider : CollectionPreviewParameterProvider, + actionButtons: ImmutableList, modifier: Modifier = Modifier, ) { Row( @@ -427,14 +426,15 @@ private fun LazyItemScope.ActionBlock( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, ) { - actionButtons.fastForEach { actionConfig -> + actionButtons.fastForEach { actionButton -> + val config = actionButton.config TangemPayActionButton( modifier = Modifier.testTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON), - iconRes = actionConfig.iconResId, - onClick = actionConfig.onClick, - isEnabled = actionConfig.isEnabled, - isLoading = actionConfig.isInProgress, - title = actionConfig.text, + iconRes = config.iconResId, + onClick = config.onClick, + isEnabled = config.isEnabled, + isLoading = config.isInProgress, + title = config.text, ) } } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/PaymentAccountStatusExt.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/PaymentAccountStatusExt.kt index 56139acdaa..3ac13a5110 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/PaymentAccountStatusExt.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/utils/PaymentAccountStatusExt.kt @@ -42,6 +42,12 @@ internal fun AccountStatus.Payment.balanceOrNull(): PaymentAccountStatusValue.Ba else -> null } +internal val PaymentAccountStatusValue.Balance.hasWithdrawableAmount: Boolean + get() = availableForWithdrawal.signum() > 0 + +internal val PaymentAccountStatusValue.Loaded.isFresh: Boolean + get() = source == StatusSource.ACTUAL && error == null + internal fun AccountStatus.Payment.findCard( initialCardId: String, initialStatus: AccountStatus.Payment, diff --git a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/ActionButtonsTestExt.kt b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/ActionButtonsTestExt.kt new file mode 100644 index 0000000000..aa059ab55c --- /dev/null +++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/ActionButtonsTestExt.kt @@ -0,0 +1,18 @@ +package com.tangem.features.tangempay + +import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig +import com.tangem.features.tangempay.entity.TangemPayAction +import com.tangem.features.tangempay.entity.TangemPayActionButtonUM +import com.tangem.features.tangempay.entity.TangemPayDetailsUM + +internal val List.withdrawButton: ActionButtonConfig + get() = first { it.action == TangemPayAction.Withdraw }.config + +internal val List.addFundsButton: ActionButtonConfig + get() = first { it.action == TangemPayAction.AddFunds }.config + +internal val TangemPayDetailsUM.withdrawButton: ActionButtonConfig + get() = balanceBlockState.actionButtons.withdrawButton + +internal val TangemPayDetailsUM.addFundsButton: ActionButtonConfig + get() = balanceBlockState.actionButtons.addFundsButton \ No newline at end of file diff --git a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/TangemPayTestFixtures.kt b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/TangemPayTestFixtures.kt new file mode 100644 index 0000000000..2a67c729c8 --- /dev/null +++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/TangemPayTestFixtures.kt @@ -0,0 +1,22 @@ +package com.tangem.features.tangempay + +import com.tangem.domain.models.pay.TangemPayCard +import com.tangem.domain.models.pay.TangemPayCardFrozenState +import com.tangem.domain.models.pay.TangemPayCardState + +internal fun tangemPayCard( + id: String = "card_1", + lastDigits: String = "1234", + frozenState: TangemPayCardFrozenState = TangemPayCardFrozenState.Unfrozen, + state: TangemPayCardState = TangemPayCardState.Active, +): TangemPayCard = TangemPayCard( + id = id, + productInstanceId = "product_1", + cardStatus = TangemPayCard.Status.ACTIVE, + hasPinCode = true, + displayName = null, + limit = null, + frozenState = frozenState, + lastDigits = lastDigits, + state = state, +) \ No newline at end of file diff --git a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactoryTest.kt b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactoryTest.kt index c13bd904e7..04609c579d 100644 --- a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactoryTest.kt +++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/entity/TangemPayDetailsStateFactoryTest.kt @@ -6,7 +6,10 @@ import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.pay.TangemPayCard import com.tangem.domain.models.pay.TangemPayCardFrozenState import com.tangem.domain.models.pay.TangemPayCardState +import com.tangem.features.tangempay.addFundsButton +import com.tangem.features.tangempay.tangemPayCard import com.tangem.features.tangempay.utils.TangemPayDetailIntents +import com.tangem.features.tangempay.withdrawButton import io.mockk.clearMocks import io.mockk.every import io.mockk.mockk @@ -14,29 +17,15 @@ import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.MethodSource +import java.math.BigDecimal internal class TangemPayDetailsStateFactoryTest { private val intents: TangemPayDetailIntents = mockk(relaxed = true) - @BeforeEach - fun resetMocks() { - clearMocks(intents) - } + private val activeUnfrozenCard = tangemPayCard() - private val activeUnfrozenCard = TangemPayCard( - id = "card_1", - productInstanceId = "pi_card_1", - cardStatus = TangemPayCard.Status.ACTIVE, - hasPinCode = false, - displayName = null, - frozenState = TangemPayCardFrozenState.Unfrozen, - lastDigits = "1234", - limit = null, - state = TangemPayCardState.Active, - ) - - private fun createFactory() = TangemPayDetailsStateFactory( + private val factory = TangemPayDetailsStateFactory( onBack = {}, onOpenMenu = {}, intents = intents, @@ -45,14 +34,9 @@ internal class TangemPayDetailsStateFactoryTest { isMultipleCardsEnabled = true, ) - private fun loadedStatus( - statusSource: StatusSource, - statusError: PaymentAccountStatusValue.Error?, - statusCards: List = listOf(activeUnfrozenCard), - ): PaymentAccountStatusValue.Loaded = mockk(relaxed = true) { - every { source } returns statusSource - every { error } returns statusError - every { cards } returns statusCards + @BeforeEach + fun resetMocks() { + clearMocks(intents) } @ParameterizedTest @@ -61,14 +45,13 @@ internal class TangemPayDetailsStateFactoryTest { case: ButtonStateCase, ) { // Arrange - val factory = createFactory() val status = loadedStatus(statusSource = case.source, statusError = case.error) // Act val state = factory.getLoadedState(status) // Assert - val actionButtonsEnabled = state.balanceBlockState.actionButtons.map { it.isEnabled } + val actionButtonsEnabled = state.balanceBlockState.actionButtons.map { it.config.isEnabled } assertThat(actionButtonsEnabled).containsExactly(case.expectedEnabled, case.expectedEnabled) assertThat(state.balanceBlockState.cardsBlockState?.isAddCardEnabled).isEqualTo(case.expectedEnabled) // The card tile is intentionally NOT source-gated: it stays clickable on stale data as long as @@ -80,7 +63,6 @@ internal class TangemPayDetailsStateFactoryTest { @Test fun `GIVEN actual status with only frozen card WHEN getLoadedState THEN action buttons disabled`() { // Arrange - val factory = createFactory() val frozenCard = activeUnfrozenCard.copy(frozenState = TangemPayCardFrozenState.Frozen) val status = loadedStatus( statusSource = StatusSource.ACTUAL, @@ -92,14 +74,13 @@ internal class TangemPayDetailsStateFactoryTest { val state = factory.getLoadedState(status) // Assert - assertThat(state.balanceBlockState.actionButtons.map { it.isEnabled }).containsExactly(false, false) + assertThat(state.balanceBlockState.actionButtons.map { it.config.isEnabled }).containsExactly(false, false) assertThat(state.balanceBlockState.cardsBlockState?.isAddCardEnabled).isTrue() } @Test fun `GIVEN actual status with issuing card WHEN getLoadedState THEN add card disabled`() { // Arrange - val factory = createFactory() val issuingCard = activeUnfrozenCard.copy(state = TangemPayCardState.Issuing) val status = loadedStatus( statusSource = StatusSource.ACTUAL, @@ -114,6 +95,87 @@ internal class TangemPayDetailsStateFactoryTest { assertThat(state.balanceBlockState.cardsBlockState?.isAddCardEnabled).isFalse() } + @ParameterizedTest + @MethodSource("provideBalanceCases") + fun `GIVEN fresh status WHEN getLoadedState THEN withdraw gated by balance but add funds enabled`( + case: BalanceCase, + ) { + // Arrange + val status = loadedStatus(availableForWithdrawal = case.availableForWithdrawal) + + // Act + val state = factory.getLoadedState(status) + + // Assert + assertThat(state.addFundsButton.isEnabled).isTrue() + assertThat(state.withdrawButton.isEnabled).isEqualTo(case.expectedWithdrawEnabled) + } + + @Test + fun `GIVEN deactivated with positive balance WHEN getDeactivatedState THEN withdraw enabled`() { + // Act + val state = factory.getDeactivatedState(hasWithdrawableBalance = true) + + // Assert + assertThat(state.addFundsButton.isEnabled).isTrue() + assertThat(state.withdrawButton.isEnabled).isTrue() + } + + @Test + fun `GIVEN deactivated with zero balance WHEN getDeactivatedState THEN withdraw disabled`() { + // Act + val state = factory.getDeactivatedState(hasWithdrawableBalance = false) + + // Assert + assertThat(state.addFundsButton.isEnabled).isTrue() + assertThat(state.withdrawButton.isEnabled).isFalse() + } + + @Test + fun `GIVEN withdraw disabled WHEN getActionButtonsConfig THEN withdraw disabled and add funds enabled`() { + // Act + val buttons = factory.getActionButtonsConfig(isAddFundsEnabled = true, isWithdrawEnabled = false) + + // Assert + assertThat(buttons.addFundsButton.isEnabled).isTrue() + assertThat(buttons.withdrawButton.isEnabled).isFalse() + } + + @Test + fun `GIVEN both enabled WHEN getActionButtonsConfig THEN both buttons enabled`() { + // Act + val buttons = factory.getActionButtonsConfig(isAddFundsEnabled = true, isWithdrawEnabled = true) + + // Assert + assertThat(buttons.addFundsButton.isEnabled).isTrue() + assertThat(buttons.withdrawButton.isEnabled).isTrue() + } + + private fun loadedStatus( + statusSource: StatusSource = StatusSource.ACTUAL, + statusError: PaymentAccountStatusValue.Error? = null, + statusCards: List = listOf(activeUnfrozenCard), + availableForWithdrawal: BigDecimal = BigDecimal.TEN, + ): PaymentAccountStatusValue.Loaded = mockk(relaxed = true) { + every { source } returns statusSource + every { error } returns statusError + every { cards } returns statusCards + every { balance } returns PaymentAccountStatusValue.Balance( + fiatBalance = PaymentAccountStatusValue.FiatBalance( + availableBalance = BigDecimal.ZERO, + currency = "USD", + ), + cryptoBalance = PaymentAccountStatusValue.CryptoBalance( + id = "id", + chainId = 1L, + depositAddress = "address", + tokenContractAddress = "contract", + balance = BigDecimal.ZERO, + ), + availableForWithdrawal = availableForWithdrawal, + ) + } + internal data class ButtonStateCase( val source: StatusSource, val error: PaymentAccountStatusValue.Error?, @@ -121,7 +183,19 @@ internal class TangemPayDetailsStateFactoryTest { val expectedCardEnabled: Boolean, ) + internal data class BalanceCase( + val availableForWithdrawal: BigDecimal, + val expectedWithdrawEnabled: Boolean, + ) + private companion object { + @JvmStatic + fun provideBalanceCases() = listOf( + BalanceCase(availableForWithdrawal = BigDecimal.ZERO, expectedWithdrawEnabled = false), + BalanceCase(availableForWithdrawal = BigDecimal.TEN, expectedWithdrawEnabled = true), + BalanceCase(availableForWithdrawal = BigDecimal("-1"), expectedWithdrawEnabled = false), + ) + @JvmStatic fun provideButtonStateCases() = listOf( // Fresh data from the network -> actions allowed, card tile clickable. diff --git a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModelTest.kt b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModelTest.kt new file mode 100644 index 0000000000..c511c80187 --- /dev/null +++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/TangemPayDetailsModelTest.kt @@ -0,0 +1,171 @@ +package com.tangem.features.tangempay.model + +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.core.decompose.model.MutableParamsContainer +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.account.PaymentAccountStatusValue +import com.tangem.domain.models.pay.TangemPayCardFrozenState +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier +import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository +import com.tangem.features.tangempay.addFundsButton +import com.tangem.features.tangempay.components.TangemPayDetailsContainerComponent +import com.tangem.features.tangempay.tangemPayCard +import com.tangem.features.tangempay.withdrawButton +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import java.math.BigDecimal + +@OptIn(ExperimentalCoroutinesApi::class) +internal class TangemPayDetailsModelTest { + + private val userWalletId = UserWalletId("123") + + private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier = mockk() + private val cardDetailsRepository: TangemPayCardDetailsRepository = mockk(relaxed = true) + + @ParameterizedTest + @MethodSource("provideFreezeCases") + fun `GIVEN frozen state and balance WHEN status loaded THEN action buttons gated accordingly`( + case: FreezeCase, + ) = runTest { + // Arrange + Act + val model = createModel( + testScope = this, + statusSource = case.statusSource, + frozenState = case.frozenState, + availableForWithdrawal = case.availableForWithdrawal, + ) + advanceUntilIdle() + + // Assert + val state = model.uiState.value + assertThat(state.addFundsButton.isEnabled).isEqualTo(case.expectedAddFundsEnabled) + assertThat(state.withdrawButton.isEnabled).isEqualTo(case.expectedWithdrawEnabled) + model.onDestroy() + } + + private fun createModel( + testScope: TestScope, + statusSource: StatusSource, + frozenState: TangemPayCardFrozenState, + availableForWithdrawal: BigDecimal, + ): TangemPayDetailsModel { + val loaded: PaymentAccountStatusValue.Loaded = mockk(relaxed = true) { + every { source } returns statusSource + every { error } returns null + every { cards } returns listOf(tangemPayCard()) + every { balance } returns PaymentAccountStatusValue.Balance( + fiatBalance = PaymentAccountStatusValue.FiatBalance( + availableBalance = BigDecimal.ZERO, + currency = "USD", + ), + cryptoBalance = PaymentAccountStatusValue.CryptoBalance( + id = "id", + chainId = 1L, + depositAddress = "address", + tokenContractAddress = "contract", + balance = BigDecimal.ZERO, + ), + availableForWithdrawal = availableForWithdrawal, + ) + } + val paymentStatus: AccountStatus.Payment = mockk(relaxed = true) { + every { value } returns loaded + every { account } returns mockk(relaxed = true) { + every { userWalletId } returns this@TangemPayDetailsModelTest.userWalletId + } + } + val params = TangemPayDetailsContainerComponent.Params(initialStatus = paymentStatus) + + every { paymentAccountStatusSupplier.invoke(any()) } returns flowOf(paymentStatus) + every { cardDetailsRepository.cardFrozenState(any()) } returns flowOf(frozenState) + coEvery { cardDetailsRepository.isAddToWalletDone(any()) } returns false.right() + + return TangemPayDetailsModel( + paramsContainer = MutableParamsContainer(params), + paymentAccountStatusSupplier = paymentAccountStatusSupplier, + dispatchers = testScope.createTestingCoroutineDispatcherProvider(), + analytics = mockk(relaxed = true), + router = mockk(relaxed = true), + urlOpener = mockk(relaxed = true), + cardDetailsRepository = cardDetailsRepository, + getBalanceHidingSettingsUseCase = mockk(relaxed = true), + uiMessageSender = mockk(relaxed = true), + txHistoryUpdateListener = mockk(relaxed = true), + tangemPayWithdrawRepository = mockk(relaxed = true), + sendFeedbackEmailUseCase = mockk(relaxed = true), + expressTransactionsEventListener = mockk(relaxed = true), + tangemPayFeatureToggles = mockk(relaxed = true), + paymentAccountStatusFetcher = mockk(relaxed = true), + produceTangemPayInitialDataUseCase = mockk(relaxed = true), + onboardingRepository = mockk(relaxed = true), + getCustomerOffers = mockk(relaxed = true), + ) + } + + private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider { + val testDispatcher = StandardTestDispatcher(testScheduler) + return TestingCoroutineDispatcherProvider( + main = testDispatcher, + mainImmediate = testDispatcher, + io = testDispatcher, + default = testDispatcher, + single = testDispatcher, + ) + } + + internal data class FreezeCase( + val statusSource: StatusSource, + val frozenState: TangemPayCardFrozenState, + val availableForWithdrawal: BigDecimal, + val expectedAddFundsEnabled: Boolean, + val expectedWithdrawEnabled: Boolean, + ) + + private companion object { + @JvmStatic + fun provideFreezeCases() = listOf( + FreezeCase( + statusSource = StatusSource.ACTUAL, + frozenState = TangemPayCardFrozenState.Unfrozen, + availableForWithdrawal = BigDecimal.ZERO, + expectedAddFundsEnabled = true, + expectedWithdrawEnabled = false, + ), + FreezeCase( + statusSource = StatusSource.ACTUAL, + frozenState = TangemPayCardFrozenState.Unfrozen, + availableForWithdrawal = BigDecimal.TEN, + expectedAddFundsEnabled = true, + expectedWithdrawEnabled = true, + ), + FreezeCase( + statusSource = StatusSource.ACTUAL, + frozenState = TangemPayCardFrozenState.Frozen, + availableForWithdrawal = BigDecimal.TEN, + expectedAddFundsEnabled = false, + expectedWithdrawEnabled = false, + ), + FreezeCase( + statusSource = StatusSource.CACHE, + frozenState = TangemPayCardFrozenState.Unfrozen, + availableForWithdrawal = BigDecimal.TEN, + expectedAddFundsEnabled = false, + expectedWithdrawEnabled = false, + ), + ) + } +} \ No newline at end of file diff --git a/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayActionButtonsTransformerTest.kt b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayActionButtonsTransformerTest.kt new file mode 100644 index 0000000000..8e4494200a --- /dev/null +++ b/features/tangempay/details/impl/src/test/kotlin/com/tangem/features/tangempay/model/transformers/TangemPayActionButtonsTransformerTest.kt @@ -0,0 +1,79 @@ +package com.tangem.features.tangempay.model.transformers + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig +import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.tangempay.details.impl.R +import com.tangem.features.tangempay.entity.TangemPayAction +import com.tangem.features.tangempay.entity.TangemPayActionButtonUM +import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState +import com.tangem.features.tangempay.entity.TangemPayDetailsTopBarConfig +import com.tangem.features.tangempay.entity.TangemPayDetailsUM +import kotlinx.collections.immutable.persistentListOf +import org.junit.jupiter.api.Test + +internal class TangemPayActionButtonsTransformerTest { + + @Test + fun `GIVEN content balance block WHEN transform THEN action buttons are replaced`() { + // Arrange + val newButtons = persistentListOf(actionButton(enabled = false)) + val transformer = TangemPayActionButtonsTransformer(newButtons) + + // Act + val result = transformer.transform(contentState()) + + // Assert + assertThat(result.balanceBlockState.actionButtons).isEqualTo(newButtons) + } + + @Test + fun `GIVEN non-content balance block WHEN transform THEN state is unchanged`() { + // Arrange + val transformer = TangemPayActionButtonsTransformer(persistentListOf(actionButton())) + val state = contentState().copy( + balanceBlockState = TangemPayDetailsBalanceBlockState.Loading( + actionButtons = persistentListOf(), + cardsBlockState = null, + ), + ) + + // Act + val result = transformer.transform(state) + + // Assert + assertThat(result).isEqualTo(state) + } + + private fun contentState(): TangemPayDetailsUM = TangemPayDetailsUM( + topBarConfig = TangemPayDetailsTopBarConfig( + onBackClick = {}, + onOpenMenu = {}, + items = persistentListOf(), + itemsV2 = persistentListOf(), + ), + pullToRefreshConfig = PullToRefreshConfig(isRefreshing = false, onRefresh = {}), + balanceBlockState = TangemPayDetailsBalanceBlockState.Content( + actionButtons = persistentListOf(actionButton()), + cardsBlockState = null, + fiatBalance = TextReference.EMPTY, + isBalanceFlickering = false, + ), + addToWalletBlockState = null, + isBalanceHidden = false, + errorNotificationConfig = null, + accountDeactivatedNotificationConfig = null, + ) + + private fun actionButton(enabled: Boolean = true): TangemPayActionButtonUM = TangemPayActionButtonUM( + action = TangemPayAction.Withdraw, + config = ActionButtonConfig( + text = resourceReference(R.string.tangempay_card_details_withdraw), + iconResId = R.drawable.ic_arrow_up_24, + onClick = {}, + isEnabled = enabled, + ), + ) +} \ No newline at end of file From de25d4f4dead63e3fe854d99fab7f1f7a9f86549 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 26 Jun 2026 12:29:58 +0300 Subject: [PATCH 046/210] Updated on 2026-08-14 --- .../tests/send/gasless/GaslessSendTest.kt | 14 ++-- .../utils/SdkTransactionTypeConverterTest.kt | 84 +++++++++++++++++++ 2 files changed, 91 insertions(+), 7 deletions(-) create mode 100644 data/wallet-manager/src/test/java/com/tangem/data/walletmanager/utils/SdkTransactionTypeConverterTest.kt diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/gasless/GaslessSendTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/gasless/GaslessSendTest.kt index 2ce8fe21c2..7ad967e1ab 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/send/gasless/GaslessSendTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/gasless/GaslessSendTest.kt @@ -132,9 +132,9 @@ class GaslessSendTest : BaseTestCase() { @DisplayName("Gasless: completed gasless transaction is shown in token transaction history") @Test fun checkGaslessTransactionInHistoryTest() { - val sentAmount = "1.00" + val operationAmount = "1.00" val gaslessFeeAmount = "0.10" - val sentTitle = getResourceString(R.string.common_sent) + val operationTitle = getResourceString(R.string.transaction_history_operation) val gaslessFeeTitle = getResourceString(R.string.gasless_transaction_fee) setupHooks( @@ -166,13 +166,13 @@ class GaslessSendTest : BaseTestCase() { onTxHistoryScreen { transactionItem(gaslessFeeTitle).assertIsDisplayed() } } } - step("Assert '$sentTitle' transaction is displayed") { - onTxHistoryScreen { transactionItem(sentTitle).assertIsDisplayed() } + step("Assert '$operationTitle' transaction is displayed") { + onTxHistoryScreen { transactionItem(operationTitle).assertIsDisplayed() } } - step("Assert '$sentTitle' amount '$sentAmount' is displayed in '$currencySymbol'") { + step("Assert '$operationTitle' amount '$operationAmount' is displayed in '$currencySymbol'") { onTxHistoryScreen { - transactionAmount(sentTitle).assertTextContains(sentAmount, substring = true) - transactionCurrency(sentTitle).assertTextEquals(currencySymbol) + transactionAmount(operationTitle).assertTextContains(operationAmount, substring = true) + transactionCurrency(operationTitle).assertTextEquals(currencySymbol) } } step("Assert gasless '$gaslessFeeTitle' amount '$gaslessFeeAmount' is displayed in '$currencySymbol'") { diff --git a/data/wallet-manager/src/test/java/com/tangem/data/walletmanager/utils/SdkTransactionTypeConverterTest.kt b/data/wallet-manager/src/test/java/com/tangem/data/walletmanager/utils/SdkTransactionTypeConverterTest.kt new file mode 100644 index 0000000000..1186b2a11c --- /dev/null +++ b/data/wallet-manager/src/test/java/com/tangem/data/walletmanager/utils/SdkTransactionTypeConverterTest.kt @@ -0,0 +1,84 @@ +package com.tangem.data.walletmanager.utils + +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.transactionhistory.models.TransactionHistoryItem +import com.tangem.domain.models.network.TxInfo +import com.tangem.domain.walletmanager.model.SmartContractMethod +import com.tangem.test.core.ProvideTestModels +import io.mockk.mockk +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class SdkTransactionTypeConverterTest { + + private val converter = SdkTransactionTypeConverter( + smartContractMethods = mapOf( + GASLESS_METHOD_ID to SmartContractMethod(info = null, source = null, name = "gaslessTransaction"), + ), + yieldSupplyAddresses = emptySet(), + gaslessFeeAddresses = setOf(FEE_RECIPIENT), + ) + + @ParameterizedTest + @ProvideTestModels + fun convert(model: ConvertModel) { + // Act + val actual = converter.convert(gaslessItem(model.destination)) + + // Assert + assertThat(actual).isEqualTo(model.expected) + } + + private fun provideTestModels() = listOf( + ConvertModel( + destination = singleUser(RECIPIENT), + expected = TxInfo.TransactionType.UnknownOperation, + ), + ConvertModel( + destination = singleUser(FEE_RECIPIENT), + expected = TxInfo.TransactionType.GaslessFee, + ), + ConvertModel( + destination = singleUser(FEE_RECIPIENT.uppercase()), + expected = TxInfo.TransactionType.GaslessFee, + ), + ConvertModel( + destination = TransactionHistoryItem.DestinationType.Multiple( + addressTypes = listOf( + TransactionHistoryItem.AddressType.User(RECIPIENT), + TransactionHistoryItem.AddressType.User(FEE_RECIPIENT), + ), + ), + expected = TxInfo.TransactionType.UnknownOperation, + ), + ) + + private fun gaslessItem(destination: TransactionHistoryItem.DestinationType) = TransactionHistoryItem( + txHash = "0xhash", + timestamp = 0L, + isOutgoing = true, + destinationType = destination, + sourceType = TransactionHistoryItem.SourceType.Single(SENDER), + status = TransactionHistoryItem.TransactionStatus.Confirmed, + type = TransactionHistoryItem.TransactionType.ContractMethod(id = GASLESS_METHOD_ID), + amount = mockk(), + fee = mockk(), + ) + + private fun singleUser(address: String) = TransactionHistoryItem.DestinationType.Single( + addressType = TransactionHistoryItem.AddressType.User(address), + ) + + internal data class ConvertModel( + val destination: TransactionHistoryItem.DestinationType, + val expected: TxInfo.TransactionType, + ) + + private companion object { + const val GASLESS_METHOD_ID = "0x6234d42b" + const val SENDER = "0x9ffd974772bda94d288240c1b22f367ce75ccd7f" + const val RECIPIENT = "0x2222222222222222222222222222222222222222" + const val FEE_RECIPIENT = "0x1111111111111111111111111111111111111111" + } +} \ No newline at end of file From d7b52083dc38a149ce84b0d4c5543277dfb447ea Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 26 Jun 2026 15:29:17 +0500 Subject: [PATCH 047/210] Updated on 2026-08-14 --- .../staking/impl/presentation/model/StakingModelValidatorTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelValidatorTest.kt b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelValidatorTest.kt index 8a891e3781..8d3a62afe3 100644 --- a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelValidatorTest.kt +++ b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelValidatorTest.kt @@ -314,6 +314,7 @@ internal class StakingModelValidatorTest : StakingModelTestBase() { advanceUntilIdle() model.onActiveStake(activeStake) + advanceUntilIdle() verify { stateController.update( From 80e33b892a27a9248180877baeb07137fd0d340d Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 26 Jun 2026 15:48:05 +0400 Subject: [PATCH 048/210] Updated on 2026-08-14 --- .../tap/di/domain/TransactionDomainModule.kt | 44 ++ .../configs/feature_toggles_config.json | 4 + .../api/gasless/GaslessTxServiceApiV2.kt | 21 + .../models/GaslessBatchTransactionRequest.kt | 41 ++ .../models/GaslessTransactionRequest.kt | 3 + .../com/tangem/datasource/di/NetworkModule.kt | 17 + data/transaction/build.gradle.kts | 1 + .../DefaultGaslessTransactionRepository.kt | 66 ++- .../MockedGaslessTransactionRepository.kt | 11 + .../Eip7702AuthorizationConverter.kt | 23 + .../GaslessBatchTransactionRequestBuilder.kt | 49 ++ .../GaslessTransactionRequestBuilder.kt | 18 +- .../GaslessTxDataToGaslessRequestConverter.kt | 12 +- .../transaction/di/TransactionDataModule.kt | 10 + ...DefaultGaslessTransactionRepositoryTest.kt | 117 +++++ ...slessBatchTransactionRequestBuilderTest.kt | 193 ++++++++ data/yield-supply/build.gradle.kts | 1 + ...DefaultYieldSupplyTransactionRepository.kt | 32 ++ .../yield/supply/di/YieldSupplyDataModule.kt | 9 + ...ultYieldSupplyTransactionRepositoryTest.kt | 34 ++ .../src/main/assets/contract_methods.json | 10 + .../tangem/domain/ContractMethodsAssetTest.kt | 36 ++ domain/transaction/build.gradle.kts | 6 + .../domain/transaction/error/GetFeeError.kt | 1 + .../GaslessTransactionRepository.kt | 28 ++ .../transaction/GaslessYieldRepository.kt | 33 ++ .../models/GaslessBatchTransactionData.kt | 18 + .../transaction/models/GaslessFeePlan.kt | 39 ++ .../models/GaslessTransactionData.kt | 11 +- .../models/TransactionFeeExtended.kt | 20 + .../CreateAndSendGaslessTransactionUseCase.kt | 193 ++++++-- .../usecase/gasless/Eip712TypedDataBuilder.kt | 197 ++++++-- .../gasless/EstimateFeeForGaslessTxUseCase.kt | 8 +- .../gasless/EstimateFeeForTokenUseCase.kt | 9 + .../gasless/GetAvailableFeeTokensUseCase.kt | 11 +- .../gasless/GetFeeForGaslessUseCase.kt | 125 +++++- .../usecase/gasless/GetFeeForTokenUseCase.kt | 26 +- .../gasless/ResolveGaslessFeePlanUseCase.kt | 97 ++++ .../usecase/gasless/TokenFeeCalculator.kt | 145 +++++- .../models/GaslessBatchTransactionDataTest.kt | 25 ++ .../ComputeSendAmountInFeeTokenTest.kt | 155 +++++++ ...ateAndSendGaslessDestinationAddressTest.kt | 96 ++++ .../CreateAndSendGaslessPayloadTest.kt | 175 ++++++++ .../Eip712TypedDataBuilderBatchTest.kt | 41 ++ .../gasless/Eip712TypedDataBuilderTest.kt | 97 ++++ .../GetAvailableFeeTokensUseCaseTest.kt | 63 +++ .../ResolveGaslessFeePlanUseCaseTest.kt | 425 ++++++++++++++++++ .../usecase/gasless/TokenFeeCalculatorTest.kt | 303 ++++++++++++- .../YieldSupplyTransactionRepository.kt | 12 +- .../express/exchange/ExchangeStatusBlock.kt | 267 ----------- .../ExchangeStatusBottomSheetContent.kt | 42 +- 51 files changed, 2975 insertions(+), 445 deletions(-) create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/gasless/GaslessTxServiceApiV2.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessBatchTransactionRequest.kt create mode 100644 data/transaction/src/main/java/com/tangem/data/transaction/convertes/Eip7702AuthorizationConverter.kt create mode 100644 data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessBatchTransactionRequestBuilder.kt create mode 100644 data/transaction/src/test/kotlin/com/tangem/data/transaction/DefaultGaslessTransactionRepositoryTest.kt create mode 100644 data/transaction/src/test/kotlin/com/tangem/data/transaction/convertes/GaslessBatchTransactionRequestBuilderTest.kt create mode 100644 domain/legacy/src/test/java/com/tangem/domain/ContractMethodsAssetTest.kt create mode 100644 domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessYieldRepository.kt create mode 100644 domain/transaction/src/main/java/com/tangem/domain/transaction/models/GaslessBatchTransactionData.kt create mode 100644 domain/transaction/src/main/java/com/tangem/domain/transaction/models/GaslessFeePlan.kt create mode 100644 domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/ResolveGaslessFeePlanUseCase.kt create mode 100644 domain/transaction/src/test/java/com/tangem/domain/transaction/models/GaslessBatchTransactionDataTest.kt create mode 100644 domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/ComputeSendAmountInFeeTokenTest.kt create mode 100644 domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessDestinationAddressTest.kt create mode 100644 domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessPayloadTest.kt create mode 100644 domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/Eip712TypedDataBuilderBatchTest.kt create mode 100644 domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/Eip712TypedDataBuilderTest.kt create mode 100644 domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCaseTest.kt create mode 100644 domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/ResolveGaslessFeePlanUseCaseTest.kt delete mode 100644 features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/exchange/ExchangeStatusBlock.kt diff --git a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt index 57ab3eb8e5..890e24c01b 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt @@ -6,9 +6,13 @@ import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.card.repository.CardSdkConfigRepository import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.demo.models.DemoConfig +import com.tangem.core.configtoggle.FeatureToggles +import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.domain.dynamicaddresses.DynamicAddressesFeatureToggles import com.tangem.domain.dynamicaddresses.GetDynamicReceiveAddressUseCase import com.tangem.domain.dynamicaddresses.repository.DynamicAddressesRepository +import com.tangem.domain.transaction.GaslessYieldRepository +import com.tangem.domain.transaction.usecase.gasless.ResolveGaslessFeePlanUseCase import com.tangem.domain.networks.single.SingleNetworkStatusFetcher import com.tangem.domain.networks.single.SingleNetworkStatusSupplier import com.tangem.domain.notifications.repository.PushNotificationsRepository @@ -319,30 +323,50 @@ internal object TransactionDomainModule { gaslessTransactionRepository: GaslessTransactionRepository, singleAccountStatusListSupplier: SingleAccountStatusListSupplier, currencyChecksRepository: CurrencyChecksRepository, + featureTogglesManager: FeatureTogglesManager, ): GetAvailableFeeTokensUseCase { return GetAvailableFeeTokensUseCase( singleAccountStatusListSupplier = singleAccountStatusListSupplier, gaslessTransactionRepository = gaslessTransactionRepository, currencyChecksRepository = currencyChecksRepository, + isYieldWithdrawEnabled = featureTogglesManager.isFeatureEnabled( + toggle = FeatureToggles.AND_15632_GASLESS_YIELD_WITHDRAW_ENABLED, + ), ) } + @Provides + @Singleton + fun provideResolveGaslessFeePlanUseCase( + gaslessYieldRepository: GaslessYieldRepository, + ): ResolveGaslessFeePlanUseCase { + return ResolveGaslessFeePlanUseCase(gaslessYieldRepository = gaslessYieldRepository) + } + @Provides @Singleton fun provideGetFeeForGaslessUseCase( walletManagersFacade: WalletManagersFacade, gaslessTransactionRepository: GaslessTransactionRepository, + gaslessYieldRepository: GaslessYieldRepository, getFeeUseCase: GetFeeUseCase, singleAccountStatusListSupplier: SingleAccountStatusListSupplier, currencyChecksRepository: CurrencyChecksRepository, + resolveGaslessFeePlanUseCase: ResolveGaslessFeePlanUseCase, + featureTogglesManager: FeatureTogglesManager, ): GetFeeForGaslessUseCase { return GetFeeForGaslessUseCase( walletManagersFacade = walletManagersFacade, demoConfig = DemoConfig, gaslessTransactionRepository = gaslessTransactionRepository, + gaslessYieldRepository = gaslessYieldRepository, singleAccountStatusListSupplier = singleAccountStatusListSupplier, getFeeUseCase = getFeeUseCase, currencyChecksRepository = currencyChecksRepository, + resolveGaslessFeePlanUseCase = resolveGaslessFeePlanUseCase, + isYieldWithdrawEnabled = featureTogglesManager.isFeatureEnabled( + toggle = FeatureToggles.AND_15632_GASLESS_YIELD_WITHDRAW_ENABLED, + ), ) } @@ -351,15 +375,23 @@ internal object TransactionDomainModule { fun provideGetFeeForTokenUseCase( walletManagersFacade: WalletManagersFacade, gaslessTransactionRepository: GaslessTransactionRepository, + gaslessYieldRepository: GaslessYieldRepository, singleAccountStatusListSupplier: SingleAccountStatusListSupplier, currencyChecksRepository: CurrencyChecksRepository, + resolveGaslessFeePlanUseCase: ResolveGaslessFeePlanUseCase, + featureTogglesManager: FeatureTogglesManager, ): GetFeeForTokenUseCase { return GetFeeForTokenUseCase( gaslessTransactionRepository = gaslessTransactionRepository, + gaslessYieldRepository = gaslessYieldRepository, walletManagersFacade = walletManagersFacade, demoConfig = DemoConfig, singleAccountStatusListSupplier = singleAccountStatusListSupplier, currencyChecksRepository = currencyChecksRepository, + resolveGaslessFeePlanUseCase = resolveGaslessFeePlanUseCase, + isYieldWithdrawEnabled = featureTogglesManager.isFeatureEnabled( + toggle = FeatureToggles.AND_15632_GASLESS_YIELD_WITHDRAW_ENABLED, + ), ) } @@ -379,6 +411,7 @@ internal object TransactionDomainModule { singleAccountListSupplier: SingleAccountListSupplier, cardSdkConfigRepository: CardSdkConfigRepository, tangemHotWalletSignerFactory: TangemHotWalletSigner.Factory, + featureTogglesManager: FeatureTogglesManager, ): CreateAndSendGaslessTransactionUseCase { return CreateAndSendGaslessTransactionUseCase( walletManagersFacade = walletManagersFacade, @@ -386,6 +419,9 @@ internal object TransactionDomainModule { gaslessTransactionRepository = gaslessTransactionRepository, cardSdkConfigRepository = cardSdkConfigRepository, getHotWalletSigner = tangemHotWalletSignerFactory::create, + isGaslessV2Enabled = featureTogglesManager.isFeatureEnabled( + toggle = FeatureToggles.AND_15632_GASLESS_YIELD_WITHDRAW_ENABLED, + ), ) } @@ -394,15 +430,21 @@ internal object TransactionDomainModule { fun provideEstimateFeeForTokenUseCase( walletManagersFacade: WalletManagersFacade, gaslessTransactionRepository: GaslessTransactionRepository, + gaslessYieldRepository: GaslessYieldRepository, singleAccountStatusListSupplier: SingleAccountStatusListSupplier, currencyChecksRepository: CurrencyChecksRepository, + featureTogglesManager: FeatureTogglesManager, ): EstimateFeeForTokenUseCase { return EstimateFeeForTokenUseCase( gaslessTransactionRepository = gaslessTransactionRepository, + gaslessYieldRepository = gaslessYieldRepository, walletManagersFacade = walletManagersFacade, demoConfig = DemoConfig, singleAccountStatusListSupplier = singleAccountStatusListSupplier, currencyChecksRepository = currencyChecksRepository, + isYieldWithdrawEnabled = featureTogglesManager.isFeatureEnabled( + toggle = FeatureToggles.AND_15632_GASLESS_YIELD_WITHDRAW_ENABLED, + ), ) } @@ -411,12 +453,14 @@ internal object TransactionDomainModule { fun provideEstimateFeeForGaslessTxUseCase( walletManagersFacade: WalletManagersFacade, gaslessTransactionRepository: GaslessTransactionRepository, + gaslessYieldRepository: GaslessYieldRepository, singleAccountStatusListSupplier: SingleAccountStatusListSupplier, estimateFeeUseCase: EstimateFeeUseCase, currencyChecksRepository: CurrencyChecksRepository, ): EstimateFeeForGaslessTxUseCase { return EstimateFeeForGaslessTxUseCase( gaslessTransactionRepository = gaslessTransactionRepository, + gaslessYieldRepository = gaslessYieldRepository, walletManagersFacade = walletManagersFacade, demoConfig = DemoConfig, singleAccountStatusListSupplier = singleAccountStatusListSupplier, diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index 343a439161..f0c256441f 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -151,6 +151,10 @@ "name": "TWI_83_ADDRESS_BOOK_ENABLED", "version": "undefined" }, + { + "name": "AND_15632_GASLESS_YIELD_WITHDRAW_ENABLED", + "version": "undefined" + }, { "name": "AND_15489_EXPRESS_SHARE_BUTTON_ENABLED", "version": "6.0" diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/gasless/GaslessTxServiceApiV2.kt b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/GaslessTxServiceApiV2.kt new file mode 100644 index 0000000000..ad24dea6b3 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/GaslessTxServiceApiV2.kt @@ -0,0 +1,21 @@ +package com.tangem.datasource.api.gasless + +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.gasless.models.GaslessBatchTransactionRequest +import com.tangem.datasource.api.gasless.models.GaslessServiceResponse +import com.tangem.datasource.api.gasless.models.GaslessSignedTransactionResultDTO +import com.tangem.datasource.api.gasless.models.GaslessTransactionRequest +import retrofit2.http.Body +import retrofit2.http.POST + +interface GaslessTxServiceApiV2 { + @POST("api/v2/transaction/sign") + suspend fun signGaslessTransaction( + @Body transaction: GaslessTransactionRequest, + ): ApiResponse> + + @POST("api/v2/transaction/batch-sign") + suspend fun signGaslessBatchTransaction( + @Body transaction: GaslessBatchTransactionRequest, + ): ApiResponse> +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessBatchTransactionRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessBatchTransactionRequest.kt new file mode 100644 index 0000000000..350c074a4b --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessBatchTransactionRequest.kt @@ -0,0 +1,41 @@ +package com.tangem.datasource.api.gasless.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * Request body for gasless batch transaction submission (v2 `POST /api/v2/transaction/batch-sign`). + * Represents a batch of transactions with fee delegation metadata. + * + * The top-level payload field is `gaslessTransaction` (shared shape with single sign — see + * gasless-service `BatchSignRequestDto`), carrying `transactions[]`, `fee`, `nonce`. + */ +@JsonClass(generateAdapter = true) +data class GaslessBatchTransactionRequest( + @Json(name = "gaslessTransaction") + val gaslessTransaction: GaslessBatchTransactionDataDTO, + + @Json(name = "signature") + val signature: String, + + @Json(name = "userAddress") + val userAddress: String, + + @Json(name = "chainId") + val chainId: Int, + + @Json(name = "eip7702auth") + val eip7702Auth: Eip7702AuthorizationDTO? = null, +) + +@JsonClass(generateAdapter = true) +data class GaslessBatchTransactionDataDTO( + @Json(name = "transactions") + val transactions: List, + + @Json(name = "fee") + val fee: FeeData, + + @Json(name = "nonce") + val nonce: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessTransactionRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessTransactionRequest.kt index 034bb21097..9154f64e43 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessTransactionRequest.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/gasless/models/GaslessTransactionRequest.kt @@ -45,6 +45,9 @@ data class TransactionData( @Json(name = "value") val value: String, + @Json(name = "gasLimit") + val gasLimit: String? = null, + @Json(name = "data") val data: String, ) diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt index 08c71a4cc5..f937877421 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt @@ -18,6 +18,7 @@ import com.tangem.datasource.api.news.NewsApi import com.tangem.datasource.api.onramp.OnrampApi import com.tangem.datasource.api.ethpool.P2PEthPoolApi import com.tangem.datasource.api.gasless.GaslessTxServiceApi +import com.tangem.datasource.api.gasless.GaslessTxServiceApiV2 import com.tangem.datasource.api.pay.TangemPayApi import com.tangem.datasource.api.pay.TangemPayAuthApi import com.tangem.datasource.api.stakekit.StakeKitApi @@ -252,4 +253,20 @@ internal object NetworkModule { ), ) } + + @Provides + @Singleton + fun provideGaslessTxServiceApiV2(retrofitApiBuilder: RetrofitApiBuilder): GaslessTxServiceApiV2 { + return retrofitApiBuilder.build( + apiConfigId = ApiConfig.ID.GaslessTxService, + applyTimeoutAnnotations = false, + sessionAuth = false, + timeouts = Timeouts( + callTimeoutSeconds = TIMEOUT_60_SECONDS, + connectTimeoutSeconds = TIMEOUT_60_SECONDS, + readTimeoutSeconds = TIMEOUT_60_SECONDS, + writeTimeoutSeconds = TIMEOUT_60_SECONDS, + ), + ) + } } \ No newline at end of file diff --git a/data/transaction/build.gradle.kts b/data/transaction/build.gradle.kts index 4e8ca65edd..e926e9bd32 100644 --- a/data/transaction/build.gradle.kts +++ b/data/transaction/build.gradle.kts @@ -16,6 +16,7 @@ dependencies { implementation(tangemDeps.card.core) /** Core */ + implementation(projects.core.configToggles) implementation(projects.core.datasource) implementation(projects.core.utils) diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt index 2664312cb5..4e2c391429 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/DefaultGaslessTransactionRepository.kt @@ -3,17 +3,23 @@ package com.tangem.data.transaction import com.tangem.blockchain.common.Token import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.data.common.currency.ResponseCryptoCurrenciesFactory +import com.tangem.data.transaction.convertes.GaslessBatchTransactionRequestBuilder import com.tangem.data.transaction.convertes.GaslessSignedTransactionResultConverter import com.tangem.data.transaction.convertes.GaslessTransactionRequestBuilder +import com.tangem.data.transaction.convertes.GaslessTxDataToGaslessRequestConverter import com.tangem.datasource.api.common.response.getOrThrow import com.tangem.datasource.api.gasless.GaslessTxServiceApi +import com.tangem.datasource.api.gasless.GaslessTxServiceApiV2 import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.models.Eip7702Authorization +import com.tangem.domain.transaction.models.GaslessBatchTransactionData import com.tangem.domain.transaction.models.GaslessSignedTransactionResult import com.tangem.domain.transaction.models.GaslessTransactionData import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.coroutines.runSuspendCatching +import com.tangem.utils.logging.TangemLogger import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.sync.Mutex @@ -23,17 +29,19 @@ import java.math.BigInteger class DefaultGaslessTransactionRepository( private val gaslessTxServiceApi: GaslessTxServiceApi, + private val gaslessTxServiceApiV2: GaslessTxServiceApiV2, + private val isGaslessV2Enabled: Boolean, private val coroutineDispatcherProvider: CoroutineDispatcherProvider, private val responseCryptoCurrenciesFactory: ResponseCryptoCurrenciesFactory, ) : GaslessTransactionRepository { private val supportedTokensState = MutableStateFlow>>(hashMapOf()) - private val allFeeRecipientAddress = mutableSetOf() - private val allAddressesMutex = Mutex() private val receiverAddressMutex = Mutex() private var feeReceiverAddress: String? = null - private val gaslessTransactionRequestBuilder = GaslessTransactionRequestBuilder() + private val requestConverter = GaslessTxDataToGaslessRequestConverter(shouldIncludeGasLimit = isGaslessV2Enabled) + private val gaslessTransactionRequestBuilder = GaslessTransactionRequestBuilder(requestConverter) + private val gaslessBatchTransactionRequestBuilder = GaslessBatchTransactionRequestBuilder(requestConverter) private val signedTransactionResultConverter = GaslessSignedTransactionResultConverter() override suspend fun getSupportedTokens(network: Network): Set { @@ -109,7 +117,11 @@ class DefaultGaslessTransactionRepository( eip7702Auth = eip7702Auth, ) - val response = gaslessTxServiceApi.signGaslessTransaction(transactionRequest).getOrThrow() + val response = if (isGaslessV2Enabled) { + gaslessTxServiceApiV2.signGaslessTransaction(transactionRequest) + } else { + gaslessTxServiceApi.signGaslessTransaction(transactionRequest) + }.getOrThrow() if (!response.isSuccess) { error("Gasless service returned unsuccessful response") @@ -119,6 +131,31 @@ class DefaultGaslessTransactionRepository( signedTransactionResultConverter.convert(response.result) } + override suspend fun signGaslessBatchTransaction( + gaslessBatchTransactionData: GaslessBatchTransactionData, + signature: String, + userAddress: String, + network: Network, + eip7702Auth: Eip7702Authorization?, + ): GaslessSignedTransactionResult = withContext(coroutineDispatcherProvider.io) { + val blockchain = network.toBlockchain() + val transactionRequest = gaslessBatchTransactionRequestBuilder.build( + gaslessBatchTransaction = gaslessBatchTransactionData, + signature = signature, + userAddress = userAddress, + chainId = blockchain.getChainId() ?: error("ChainId is null for blockchain: $blockchain"), + eip7702Auth = eip7702Auth, + ) + + val response = gaslessTxServiceApiV2.signGaslessBatchTransaction(transactionRequest).getOrThrow() + + if (!response.isSuccess) { + error("Gasless service returned unsuccessful response") + } + + signedTransactionResultConverter.convert(response.result) + } + override fun getBaseGasForTransaction(): BigInteger { return BASE_GAS_FOR_TRANSACTION } @@ -129,21 +166,20 @@ class DefaultGaslessTransactionRepository( } override suspend fun getGaslessFeeAddresses(): Set { - return allAddressesMutex.withLock { - allFeeRecipientAddress.ifEmpty { - val allFeeAddresses = getAllFeeRecipientAddresses() - allFeeRecipientAddress.addAll(allFeeAddresses) - allFeeRecipientAddress - } - } - } - - private suspend fun getAllFeeRecipientAddresses(): Set { // TODO Replace with other backend call to get all fee recipient addresses when available - return setOf(getTokenFeeReceiverAddress()) + val backendAddress = runSuspendCatching { getTokenFeeReceiverAddress() } + .onFailure { TangemLogger.e("Failed to load gasless fee recipient; serving hardcoded addresses", it) } + .getOrNull() + return KNOWN_FEE_COLLECTION_ADDRESSES + setOfNotNull(backendAddress) } private companion object { val BASE_GAS_FOR_TRANSACTION: BigInteger = BigInteger("60000") + + + val KNOWN_FEE_COLLECTION_ADDRESSES = setOf( + "0xFc719364BcCdc92D055d8C3164eF1ab4f5A9182c", + "0xAf722F46145fbb106379d506ED3a5B96f110c8E5", + ) } } \ No newline at end of file diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt b/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt index 2d6b86ba9a..8c566cd3d9 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/MockedGaslessTransactionRepository.kt @@ -8,6 +8,7 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.models.Eip7702Authorization +import com.tangem.domain.transaction.models.GaslessBatchTransactionData import com.tangem.domain.transaction.models.GaslessSignedTransactionResult import com.tangem.domain.transaction.models.GaslessTransactionData import java.math.BigInteger @@ -53,6 +54,16 @@ class MockedGaslessTransactionRepository( txHash = "0x000", ) + override suspend fun signGaslessBatchTransaction( + gaslessBatchTransactionData: GaslessBatchTransactionData, + signature: String, + userAddress: String, + network: Network, + eip7702Auth: Eip7702Authorization?, + ): GaslessSignedTransactionResult = GaslessSignedTransactionResult( + txHash = "0x000", + ) + override fun getBaseGasForTransaction(): BigInteger { return BASE_GAS_FOR_TRANSACTION } diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/convertes/Eip7702AuthorizationConverter.kt b/data/transaction/src/main/java/com/tangem/data/transaction/convertes/Eip7702AuthorizationConverter.kt new file mode 100644 index 0000000000..648c9ceab0 --- /dev/null +++ b/data/transaction/src/main/java/com/tangem/data/transaction/convertes/Eip7702AuthorizationConverter.kt @@ -0,0 +1,23 @@ +package com.tangem.data.transaction.convertes + +import com.tangem.datasource.api.gasless.models.Eip7702AuthorizationDTO +import com.tangem.domain.transaction.models.Eip7702Authorization +import com.tangem.utils.converter.Converter + +/** + * Converts domain [Eip7702Authorization] to its DTO representation. + * Shared by both single-transaction and batch-transaction request builders. + */ +class Eip7702AuthorizationConverter : Converter { + + override fun convert(value: Eip7702Authorization): Eip7702AuthorizationDTO { + return Eip7702AuthorizationDTO( + chainId = value.chainId, + address = value.address, + nonce = value.nonce.toString(), + yParity = value.yParity, + r = value.r, + s = value.s, + ) + } +} \ No newline at end of file diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessBatchTransactionRequestBuilder.kt b/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessBatchTransactionRequestBuilder.kt new file mode 100644 index 0000000000..7e3a32cc28 --- /dev/null +++ b/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessBatchTransactionRequestBuilder.kt @@ -0,0 +1,49 @@ +package com.tangem.data.transaction.convertes + +import com.tangem.datasource.api.gasless.models.GaslessBatchTransactionDataDTO +import com.tangem.datasource.api.gasless.models.GaslessBatchTransactionRequest +import com.tangem.domain.transaction.models.Eip7702Authorization +import com.tangem.domain.transaction.models.GaslessBatchTransactionData + +/** + * Builder for creating complete [GaslessBatchTransactionRequest] from domain model. + * Combines batch transaction data with signature and user information. + * + * Reuses [GaslessTxDataToGaslessRequestConverter] for transaction and fee conversion + * to avoid duplicating mapping logic. + */ +class GaslessBatchTransactionRequestBuilder( + private val converter: GaslessTxDataToGaslessRequestConverter = GaslessTxDataToGaslessRequestConverter(), + private val eip7702AuthConverter: Eip7702AuthorizationConverter = Eip7702AuthorizationConverter(), +) { + + /** + * Creates complete gasless batch transaction request. + * + * @param gaslessBatchTransaction domain model of batch transaction + * @param signature transaction signature in hex format (with 0x prefix) + * @param userAddress user's Ethereum address + * @param chainId blockchain network chain ID + * @param eip7702Auth optional EIP-7702 authorization for account abstraction + * @return complete request ready for API submission + */ + fun build( + gaslessBatchTransaction: GaslessBatchTransactionData, + signature: String, + userAddress: String, + chainId: Int, + eip7702Auth: Eip7702Authorization? = null, + ): GaslessBatchTransactionRequest { + return GaslessBatchTransactionRequest( + gaslessTransaction = GaslessBatchTransactionDataDTO( + transactions = gaslessBatchTransaction.transactions.map { converter.convertTransaction(it) }, + fee = converter.convertFee(gaslessBatchTransaction.fee), + nonce = gaslessBatchTransaction.nonce.toString(), + ), + signature = signature, + userAddress = userAddress, + chainId = chainId, + eip7702Auth = eip7702Auth?.let(eip7702AuthConverter::convert), + ) + } +} \ No newline at end of file diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessTransactionRequestBuilder.kt b/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessTransactionRequestBuilder.kt index b7caa4b13a..4f0417dbad 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessTransactionRequestBuilder.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessTransactionRequestBuilder.kt @@ -3,7 +3,6 @@ package com.tangem.data.transaction.convertes import com.tangem.datasource.api.gasless.models.GaslessTransactionRequest import com.tangem.domain.transaction.models.Eip7702Authorization import com.tangem.domain.transaction.models.GaslessTransactionData -import com.tangem.datasource.api.gasless.models.Eip7702AuthorizationDTO /** * Builder for creating complete GaslessTransactionRequest from domain model. @@ -11,6 +10,7 @@ import com.tangem.datasource.api.gasless.models.Eip7702AuthorizationDTO */ class GaslessTransactionRequestBuilder( private val converter: GaslessTxDataToGaslessRequestConverter = GaslessTxDataToGaslessRequestConverter(), + private val eip7702AuthConverter: Eip7702AuthorizationConverter = Eip7702AuthorizationConverter(), ) { /** @@ -35,21 +35,7 @@ class GaslessTransactionRequestBuilder( signature = signature, userAddress = userAddress, chainId = chainId, - eip7702Auth = eip7702Auth?.toDTO(), - ) - } - - /** - * Converts domain Eip7702Authorization to DTO. - */ - private fun Eip7702Authorization.toDTO(): Eip7702AuthorizationDTO { - return Eip7702AuthorizationDTO( - chainId = chainId, - address = address, - nonce = nonce.toString(), - yParity = yParity, - r = r, - s = s, + eip7702Auth = eip7702Auth?.let(eip7702AuthConverter::convert), ) } } \ No newline at end of file diff --git a/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessTxDataToGaslessRequestConverter.kt b/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessTxDataToGaslessRequestConverter.kt index 925d10460a..502f87216c 100644 --- a/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessTxDataToGaslessRequestConverter.kt +++ b/data/transaction/src/main/java/com/tangem/data/transaction/convertes/GaslessTxDataToGaslessRequestConverter.kt @@ -13,8 +13,13 @@ import com.tangem.datasource.api.gasless.models.GaslessTransactionData as Gasles * Note: This converter only handles the transaction data conversion. * Additional fields (signature, userAddress, chainId) must be added separately * to create complete GaslessTransactionRequest. + * + * @param shouldIncludeGasLimit when true (v2), serializes the per-call `gasLimit`; when false (v1), omits it so the + * request matches the legacy v1 service. Must stay in sync with the EIP-712 message that was signed. */ -class GaslessTxDataToGaslessRequestConverter : Converter { +class GaslessTxDataToGaslessRequestConverter( + private val shouldIncludeGasLimit: Boolean = true, +) : Converter { override fun convert(value: GaslessTransactionData): GaslessTransactionDataDTO { return GaslessTransactionDataDTO( @@ -24,15 +29,16 @@ class GaslessTxDataToGaslessRequestConverter : Converter> + coEvery { gaslessTxServiceApi.getFeeRecipient() } returns error + } + + @Test + fun `GIVEN backend returns recipient WHEN getGaslessFeeAddresses THEN hardcoded plus backend address`() = runTest { + // Arrange + stubFeeRecipientSuccess(BACKEND_ADDRESS) + val repository = createRepository() + + // Act + val actual = repository.getGaslessFeeAddresses() + + // Assert + assertThat(actual).containsExactly(HARDCODED_ADDRESS_1, HARDCODED_ADDRESS_2, BACKEND_ADDRESS) + } + + @Test + fun `GIVEN backend fails WHEN getGaslessFeeAddresses THEN hardcoded addresses only`() = runTest { + // Arrange + stubFeeRecipientFailure() + val repository = createRepository() + + // Act + val actual = repository.getGaslessFeeAddresses() + + // Assert + assertThat(actual).containsExactly(HARDCODED_ADDRESS_1, HARDCODED_ADDRESS_2) + } + + @Test + fun `GIVEN backend fails then recovers WHEN called twice THEN second call includes backend address`() = runTest { + // Arrange + stubFeeRecipientFailure() + val repository = createRepository() + val firstResult = repository.getGaslessFeeAddresses() + stubFeeRecipientSuccess(BACKEND_ADDRESS) + + // Act + val secondResult = repository.getGaslessFeeAddresses() + + // Assert + assertThat(firstResult).containsExactly(HARDCODED_ADDRESS_1, HARDCODED_ADDRESS_2) + assertThat(secondResult).containsExactly(HARDCODED_ADDRESS_1, HARDCODED_ADDRESS_2, BACKEND_ADDRESS) + } + + @Test + fun `GIVEN backend succeeds WHEN called twice THEN fee recipient requested once`() = runTest { + // Arrange + stubFeeRecipientSuccess(BACKEND_ADDRESS) + val repository = createRepository() + + // Act + repository.getGaslessFeeAddresses() + repository.getGaslessFeeAddresses() + + // Assert + coVerify(exactly = 1) { gaslessTxServiceApi.getFeeRecipient() } + } + + private companion object { + const val HARDCODED_ADDRESS_1 = "0xFc719364BcCdc92D055d8C3164eF1ab4f5A9182c" + const val HARDCODED_ADDRESS_2 = "0xAf722F46145fbb106379d506ED3a5B96f110c8E5" + const val BACKEND_ADDRESS = "0x1111111111111111111111111111111111111111" + } +} \ No newline at end of file diff --git a/data/transaction/src/test/kotlin/com/tangem/data/transaction/convertes/GaslessBatchTransactionRequestBuilderTest.kt b/data/transaction/src/test/kotlin/com/tangem/data/transaction/convertes/GaslessBatchTransactionRequestBuilderTest.kt new file mode 100644 index 0000000000..b1e1412951 --- /dev/null +++ b/data/transaction/src/test/kotlin/com/tangem/data/transaction/convertes/GaslessBatchTransactionRequestBuilderTest.kt @@ -0,0 +1,193 @@ +package com.tangem.data.transaction.convertes + +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.transaction.models.Eip7702Authorization +import com.tangem.domain.transaction.models.GaslessBatchTransactionData +import com.tangem.domain.transaction.models.GaslessTransactionData +import org.junit.jupiter.api.Test +import java.math.BigInteger + +class GaslessBatchTransactionRequestBuilderTest { + + private val builder = GaslessBatchTransactionRequestBuilder() + + // byteArrayOf(0x12, 0x34).toHexString() == "1234" (uppercase), .formatHex() prepends "0x" → "0x1234" + private val tx1Data = byteArrayOf(0x12, 0x34) + private val tx1DataHex = "0x1234" + + // byteArrayOf(0xAB.toByte(), 0xCD.toByte()).toHexString() == "ABCD", .formatHex() → "0xABCD" + private val tx2Data = byteArrayOf(0xAB.toByte(), 0xCD.toByte()) + private val tx2DataHex = "0xABCD" + + private val tx1 = GaslessTransactionData.Transaction( + to = "0xContractA", + value = BigInteger("100"), + gasLimit = BigInteger("120000"), + data = tx1Data, + ) + private val tx2 = GaslessTransactionData.Transaction( + to = "0xContractB", + value = BigInteger("0"), + gasLimit = BigInteger("150000"), + data = tx2Data, + ) + private val fee = GaslessTransactionData.Fee( + feeToken = "0xFeeToken", + maxTokenFee = BigInteger("500"), + coinPriceInToken = BigInteger("200"), + feeTransferGasLimit = BigInteger("21000"), + baseGas = BigInteger("60000"), + feeReceiver = "0xFeeReceiver", + ) + private val nonce = BigInteger("42") + + private val batchData = GaslessBatchTransactionData( + transactions = listOf(tx1, tx2), + fee = fee, + nonce = nonce, + ) + + @Test + fun `build - transactions list has correct size and order`() { + val result = builder.build( + gaslessBatchTransaction = batchData, + signature = "0xSig", + userAddress = "0xUser", + chainId = 1, + ) + + assertThat(result.gaslessTransaction.transactions).hasSize(2) + assertThat(result.gaslessTransaction.transactions[0].to).isEqualTo("0xContractA") + assertThat(result.gaslessTransaction.transactions[1].to).isEqualTo("0xContractB") + } + + @Test + fun `build - transaction data fields are encoded correctly`() { + val result = builder.build( + gaslessBatchTransaction = batchData, + signature = "0xSig", + userAddress = "0xUser", + chainId = 1, + ) + + val txDtoList = result.gaslessTransaction.transactions + // data bytes are hex-encoded with 0x prefix (uppercase) + assertThat(txDtoList[0].data).isEqualTo(tx1DataHex) + assertThat(txDtoList[1].data).isEqualTo(tx2DataHex) + // value is BigInteger.toString() + assertThat(txDtoList[0].value).isEqualTo("100") + assertThat(txDtoList[1].value).isEqualTo("0") + // v2: per-call gasLimit is BigInteger.toString() + assertThat(txDtoList[0].gasLimit).isEqualTo("120000") + assertThat(txDtoList[1].gasLimit).isEqualTo("150000") + } + + @Test + fun `build - v1 converter omits per-call gasLimit`() { + // Arrange: a builder whose converter is in v1 mode (shouldIncludeGasLimit = false) + val v1Builder = GaslessBatchTransactionRequestBuilder( + converter = GaslessTxDataToGaslessRequestConverter(shouldIncludeGasLimit = false), + ) + + // Act + val result = v1Builder.build( + gaslessBatchTransaction = batchData, + signature = "0xSig", + userAddress = "0xUser", + chainId = 1, + ) + + // Assert: gasLimit is null so Moshi omits it, restoring the legacy v1 {to, value, data} shape + val txDtoList = result.gaslessTransaction.transactions + assertThat(txDtoList[0].gasLimit).isNull() + assertThat(txDtoList[1].gasLimit).isNull() + // other fields are unaffected + assertThat(txDtoList[0].value).isEqualTo("100") + assertThat(txDtoList[0].data).isEqualTo(tx1DataHex) + } + + @Test + fun `build - fee fields are all toString of BigInteger inputs`() { + val result = builder.build( + gaslessBatchTransaction = batchData, + signature = "0xSig", + userAddress = "0xUser", + chainId = 1, + ) + + val feeDto = result.gaslessTransaction.fee + assertThat(feeDto.feeToken).isEqualTo("0xFeeToken") + assertThat(feeDto.maxTokenFee).isEqualTo("500") + assertThat(feeDto.coinPriceInToken).isEqualTo("200") + assertThat(feeDto.feeTransferGasLimit).isEqualTo("21000") + assertThat(feeDto.baseGas).isEqualTo("60000") + assertThat(feeDto.feeReceiver).isEqualTo("0xFeeReceiver") + } + + @Test + fun `build - nonce is toString of BigInteger input`() { + val result = builder.build( + gaslessBatchTransaction = batchData, + signature = "0xSig", + userAddress = "0xUser", + chainId = 1, + ) + + assertThat(result.gaslessTransaction.nonce).isEqualTo("42") + } + + @Test + fun `build - top-level signature, userAddress, chainId pass through`() { + val result = builder.build( + gaslessBatchTransaction = batchData, + signature = "0xDeadBeef", + userAddress = "0xAlice", + chainId = 137, + ) + + assertThat(result.signature).isEqualTo("0xDeadBeef") + assertThat(result.userAddress).isEqualTo("0xAlice") + assertThat(result.chainId).isEqualTo(137) + } + + @Test + fun `build - eip7702Auth is null when not provided`() { + val result = builder.build( + gaslessBatchTransaction = batchData, + signature = "0xSig", + userAddress = "0xUser", + chainId = 1, + ) + + assertThat(result.eip7702Auth).isNull() + } + + @Test + fun `build - eip7702Auth maps correctly when provided`() { + val auth = Eip7702Authorization( + chainId = 1, + address = "0xEntryPoint", + nonce = BigInteger("7"), + yParity = 0, + r = "0xRValue", + s = "0xSValue", + ) + + val result = builder.build( + gaslessBatchTransaction = batchData, + signature = "0xSig", + userAddress = "0xUser", + chainId = 1, + eip7702Auth = auth, + ) + + val authDto = result.eip7702Auth + assertThat(authDto).isNotNull() + assertThat(authDto!!.chainId).isEqualTo(1) + assertThat(authDto.address).isEqualTo("0xEntryPoint") + assertThat(authDto.nonce).isEqualTo("7") + assertThat(authDto.yParity).isEqualTo(0) + assertThat(authDto.r).isEqualTo("0xRValue") + assertThat(authDto.s).isEqualTo("0xSValue") + } +} \ No newline at end of file diff --git a/data/yield-supply/build.gradle.kts b/data/yield-supply/build.gradle.kts index fc1e8aade5..b4a5685da1 100644 --- a/data/yield-supply/build.gradle.kts +++ b/data/yield-supply/build.gradle.kts @@ -24,6 +24,7 @@ dependencies { implementation(projects.core.analytics) /** Domain */ + implementation(projects.domain.transaction) implementation(projects.domain.yieldSupply) implementation(projects.domain.yieldSupply.models) implementation(projects.domain.walletManager) diff --git a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepository.kt b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepository.kt index ac1c2517a6..7357341b16 100644 --- a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepository.kt +++ b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepository.kt @@ -8,6 +8,7 @@ import com.tangem.blockchain.common.* import com.tangem.blockchain.common.smartcontract.SmartContractCallData import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.yieldsupply.YieldSupplyContractCallDataProviderFactory +import com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionStatus import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus @@ -238,6 +239,37 @@ internal class DefaultYieldSupplyTransactionRepository( YieldSupplyContractCallDataProviderFactory.wrapWithUpgradeIfNeeded(versionStatus, callData) } + override suspend fun getYieldModuleVersionStatus( + userWalletId: UserWalletId, + network: Network, + ): YieldModuleVersionStatus = withContext(dispatchers.io) { + val walletManager = walletManagersFacade.getOrCreateWalletManager( + userWalletId = userWalletId, + blockchain = network.toBlockchain(), + derivationPath = network.derivationPath.value, + ) ?: error("Wallet manager not found for $network") + walletManager.checkModuleVersionStatus() + } + + override suspend fun createPartialWithdrawCallData( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, + amount: Amount, + ): SmartContractCallData = withContext(dispatchers.io) { + require(cryptoCurrency is CryptoCurrency.Token) + val walletManager = walletManagersFacade.getOrCreateWalletManager( + userWalletId = userWalletId, + blockchain = cryptoCurrency.network.toBlockchain(), + derivationPath = cryptoCurrency.network.derivationPath.value, + ) ?: error("Wallet manager not found") + val withdrawCallData = YieldSupplyContractCallDataProviderFactory.getWithdrawCallData( + tokenContractAddress = cryptoCurrency.contractAddress, + amount = amount, + ) + val versionStatus = walletManager.checkModuleVersionStatus() + YieldSupplyContractCallDataProviderFactory.wrapWithUpgradeIfNeeded(versionStatus, withdrawCallData) + } + private suspend fun getYieldTokenStatus( walletManager: WalletManager, cryptoCurrency: CryptoCurrency.Token, diff --git a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/di/YieldSupplyDataModule.kt b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/di/YieldSupplyDataModule.kt index 38ff5bba43..faf4e7bce3 100644 --- a/data/yield-supply/src/main/java/com/tangem/data/yield/supply/di/YieldSupplyDataModule.kt +++ b/data/yield-supply/src/main/java/com/tangem/data/yield/supply/di/YieldSupplyDataModule.kt @@ -12,6 +12,7 @@ import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.yieldsupply.YieldMarketsStore import com.tangem.datasource.local.yieldsupply.promo.YieldBoostPromoStore import com.tangem.datasource.local.yieldsupply.promo.YieldBoostStatusStore +import com.tangem.domain.transaction.GaslessYieldRepository import com.tangem.domain.walletmanager.WalletManagersFacade import com.tangem.domain.yield.supply.YieldModuleAddressProvider import com.tangem.domain.yield.supply.YieldSupplyRepository @@ -41,6 +42,14 @@ internal object YieldSupplyDataModule { ) } + @Provides + @Singleton + fun provideGaslessYieldRepository( + yieldSupplyTransactionRepository: YieldSupplyTransactionRepository, + ): GaslessYieldRepository { + return yieldSupplyTransactionRepository + } + @Provides @Singleton fun provideYieldSupplyMarketRepository( diff --git a/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepositoryTest.kt b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepositoryTest.kt index 310c568308..de155d8ad7 100644 --- a/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepositoryTest.kt +++ b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyTransactionRepositoryTest.kt @@ -3,11 +3,14 @@ package com.tangem.data.yield.supply import com.google.common.truth.Truth import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras import com.tangem.blockchain.blockchains.ethereum.EthereumUtils +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.AmountType import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.Token import com.tangem.blockchain.common.WalletManager import com.tangem.blockchain.common.smartcontract.SmartContractCallDataProviderFactory import com.tangem.blockchain.yieldsupply.YieldSupplyContractCallDataProviderFactory +import com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionStatus import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network @@ -306,4 +309,35 @@ class DefaultYieldSupplyTransactionRepositoryTest { Truth.assertThat(result.extras).isInstanceOf(EthereumTransactionExtras::class.java) Truth.assertThat((result.extras as EthereumTransactionExtras).callData?.data).isEqualTo(expectedCallData.data) } + + @Test + fun `createPartialWithdrawCallData returns withdraw call data when module is up to date`() = runTest { + coEvery { walletManager.checkModuleVersionStatus() } returns YieldModuleVersionStatus.UpToDate + + val token = mockk(relaxed = true) { + every { contractAddress } returns mockedContractAddress + every { decimals } returns 6 + } + val amount = Amount( + currencySymbol = "USDC", + value = BigDecimal("1.5"), + decimals = 6, + type = AmountType.Token( + token = Token( + symbol = "USDC", + contractAddress = mockedContractAddress, + decimals = 6, + ), + ), + ) + + val result = repository.createPartialWithdrawCallData( + userWalletId = userWalletId, + cryptoCurrency = token, + amount = amount, + ) + + Truth.assertThat(result).isNotNull() + Truth.assertThat(result.methodId).isEqualTo("0xf3fef3a3") + } } \ No newline at end of file diff --git a/domain/legacy/src/main/assets/contract_methods.json b/domain/legacy/src/main/assets/contract_methods.json index 626e7657e1..fa8f6fe75c 100644 --- a/domain/legacy/src/main/assets/contract_methods.json +++ b/domain/legacy/src/main/assets/contract_methods.json @@ -251,5 +251,15 @@ "info": "GaslessTransactions", "source": "https://github.com/tangem-developments/tangem-gasless-service", "name": "gaslessTransaction" + }, + "0x4b072692": { + "info": "GaslessTransactions", + "source": "https://github.com/tangem-developments/tangem-gasless-service", + "name": "gaslessTransaction" + }, + "0xf9b181bf": { + "info": "GaslessTransactions", + "source": "https://github.com/tangem-developments/tangem-gasless-service", + "name": "gaslessTransaction" } } diff --git a/domain/legacy/src/test/java/com/tangem/domain/ContractMethodsAssetTest.kt b/domain/legacy/src/test/java/com/tangem/domain/ContractMethodsAssetTest.kt new file mode 100644 index 0000000000..af9319dca6 --- /dev/null +++ b/domain/legacy/src/test/java/com/tangem/domain/ContractMethodsAssetTest.kt @@ -0,0 +1,36 @@ +package com.tangem.domain + +import com.google.common.truth.Truth.assertThat +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import java.io.File + +/** + * Guards the `contract_methods.json` asset consumed by `SdkTransactionTypeConverter` (via + * `DefaultWalletManagersFacade.readSmartContractMethods`). History marking of gasless fee transfers + * relies on every gasless entry-point selector being mapped to the `gaslessTransaction` method name. + */ +internal class ContractMethodsAssetTest { + + private val methods: Map> by lazy { + val json = File("src/main/assets/contract_methods.json").readText() + val type = Types.newParameterizedType( + Map::class.java, + String::class.java, + Types.newParameterizedType(Map::class.java, String::class.java, String::class.java), + ) + requireNotNull(Moshi.Builder().build().adapter>>(type).fromJson(json)) + } + + + @ParameterizedTest + @ValueSource(strings = ["0x6234d42b", "0x4b072692", "0xf9b181bf"]) + fun `GIVEN gasless selector WHEN asset parsed THEN maps to gaslessTransaction`(selector: String) { + val entry = methods[selector] + + assertThat(entry).isNotNull() + assertThat(entry?.get("name")).isEqualTo("gaslessTransaction") + } +} \ No newline at end of file diff --git a/domain/transaction/build.gradle.kts b/domain/transaction/build.gradle.kts index 7d7aadb53f..02aa7efb9e 100644 --- a/domain/transaction/build.gradle.kts +++ b/domain/transaction/build.gradle.kts @@ -8,6 +8,10 @@ android { namespace = "com.tangem.domain.transaction" } +tasks.withType().configureEach { + useJUnitPlatform() +} + dependencies { implementation(deps.kotlin.coroutines) implementation(deps.arrow.core) @@ -42,6 +46,8 @@ dependencies { implementation(projects.domain.notifications) api(projects.domain.networks) + testRuntimeOnly(deps.test.junit5.engine) + testRuntimeOnly(deps.test.junit5.vintage.engine) testImplementation(projects.common.test) testImplementation(projects.test.core) testImplementation(projects.test.mock) diff --git a/domain/transaction/models/src/main/kotlin/com/tangem/domain/transaction/error/GetFeeError.kt b/domain/transaction/models/src/main/kotlin/com/tangem/domain/transaction/error/GetFeeError.kt index db3123dee5..9d30d7e501 100644 --- a/domain/transaction/models/src/main/kotlin/com/tangem/domain/transaction/error/GetFeeError.kt +++ b/domain/transaction/models/src/main/kotlin/com/tangem/domain/transaction/error/GetFeeError.kt @@ -18,6 +18,7 @@ sealed class GetFeeError { data object NetworkIsNotSupported : GaslessError() data object NoSupportedTokensFound : GaslessError() data object NotEnoughFunds : GaslessError() + data object ModuleUpdateUnavailable : GaslessError() data class DataError(val cause: Throwable?) : GaslessError() } diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt index 2f7b061892..09da65bb7c 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessTransactionRepository.kt @@ -3,6 +3,7 @@ package com.tangem.domain.transaction import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.transaction.models.Eip7702Authorization +import com.tangem.domain.transaction.models.GaslessBatchTransactionData import com.tangem.domain.transaction.models.GaslessSignedTransactionResult import com.tangem.domain.transaction.models.GaslessTransactionData import java.math.BigInteger @@ -57,6 +58,33 @@ interface GaslessTransactionRepository { eip7702Auth: Eip7702Authorization? = null, ): GaslessSignedTransactionResult + /** + * Sends a gasless BATCH transaction to the gasless service for signing and returns the signed result. + * + * Mirrors [signGaslessTransaction] but accepts multiple transactions executed in array order. + * Index 0 is the user's main transaction; subsequent entries are appended operations + * (e.g. a yield `withdraw` to cover the fee from staked balance). + * + * @param gaslessBatchTransactionData domain model containing: + * - transactions: ordered list of calls (to, value, data) + * - fee: token payment configuration + * - nonce: user's contract nonce to prevent replay attacks + * @param signature user's ECDSA signature of the batch transaction in hex format (0x...) + * @param userAddress user's Ethereum address (EOA or contract wallet) + * @param network blockchain network used to determine chainId for the request + * @param eip7702Auth optional EIP-7702 authorization for EOA delegation to smart contract + * @return [GaslessSignedTransactionResult] containing the fully signed transaction ready to broadcast + * @throws IllegalStateException if network is not supported or chainId cannot be determined + * @throws Exception if service returns error or network request fails + */ + suspend fun signGaslessBatchTransaction( + gaslessBatchTransactionData: GaslessBatchTransactionData, + signature: String, + userAddress: String, + network: Network, + eip7702Auth: Eip7702Authorization? = null, + ): GaslessSignedTransactionResult + /** * Hardcoded value as baseGas */ diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessYieldRepository.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessYieldRepository.kt new file mode 100644 index 0000000000..bd7ce32ced --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/GaslessYieldRepository.kt @@ -0,0 +1,33 @@ +package com.tangem.domain.transaction + +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.smartcontract.SmartContractCallData +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import java.math.BigDecimal + +/** + * Narrow repository interface used by [com.tangem.domain.transaction.usecase.gasless.ResolveGaslessFeePlanUseCase] + * to query yield-module state without introducing a circular module dependency. + * + * [com.tangem.domain.yield.supply.YieldSupplyTransactionRepository] extends this interface. + */ +interface GaslessYieldRepository { + + /** Returns the effective (liquid) protocol balance for [cryptoCurrency], or null if unavailable. */ + suspend fun getEffectiveProtocolBalance(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): BigDecimal? + + /** Returns the yield-module contract address for [cryptoCurrency], or null if unavailable. */ + suspend fun getYieldContractAddress(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): String? + + /** + * Builds an upgrade-wrapped `withdraw(yieldToken, amount)` call data for the user's yield module. + * @throws com.tangem.blockchain.yieldsupply.providers.YieldModuleUpgradeUnavailableException + * @throws com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionIndeterminateException + */ + suspend fun createPartialWithdrawCallData( + userWalletId: UserWalletId, + cryptoCurrency: CryptoCurrency, + amount: Amount, + ): SmartContractCallData +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/models/GaslessBatchTransactionData.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/models/GaslessBatchTransactionData.kt new file mode 100644 index 0000000000..444405f5ca --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/models/GaslessBatchTransactionData.kt @@ -0,0 +1,18 @@ +package com.tangem.domain.transaction.models + +import java.math.BigInteger + +/** + * Domain model for a gasless BATCH transaction (EIP-712 primaryType `GaslessBatchTransaction`). + * Reuses [GaslessTransactionData.Transaction] and [GaslessTransactionData.Fee]. + * + * @property transactions ordered list — index 0 is the user's main transaction, subsequent entries + * are appended operations (e.g. the yield `withdraw`). Executed in array order. + * @property fee fee payment configuration. + * @property nonce nonce from the user's contract. + */ +data class GaslessBatchTransactionData( + val transactions: List, + val fee: GaslessTransactionData.Fee, + val nonce: BigInteger, +) \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/models/GaslessFeePlan.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/models/GaslessFeePlan.kt new file mode 100644 index 0000000000..080c5789e2 --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/models/GaslessFeePlan.kt @@ -0,0 +1,39 @@ +package com.tangem.domain.transaction.models + +import com.tangem.blockchain.common.smartcontract.SmartContractCallData +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.domain.models.currency.CryptoCurrency +import java.math.BigInteger + +/** + * Resolved strategy for paying a gasless transaction fee. Produced by ResolveGaslessFeePlanUseCase, + * consumed by CreateAndSendGaslessTransactionUseCase. + */ +sealed interface GaslessFeePlan { + + /** Pay in the native coin (enough native balance) — falls back to the standard fee. */ + data class NativePay(val fee: Fee) : GaslessFeePlan + + /** Pay the fee from the token's plain balance. */ + data class TokenPay( + val feeToken: CryptoCurrency.Token, + val fee: Fee.Ethereum.TokenCurrency, + ) : GaslessFeePlan + + /** + * Pay the fee by first withdrawing the token from the user's yield module (appended as a second + * batch transaction). [withdrawCallData] is already upgrade-wrapped when the module needs an upgrade. + * + * Note: the executed on-chain withdraw amount is the (floor-rounded) value encoded inside + * [withdrawCallData]. [withdrawAmount] is a CEILING-rounded copy intended for DISPLAY (e.g. a future + * "X withdrawn from Yield" notification); it intentionally may exceed the executed amount by ≤1 base + * unit. Do NOT use [withdrawAmount] to build the on-chain call data. + */ + data class TokenPayWithYieldWithdraw( + val feeToken: CryptoCurrency.Token, + val fee: Fee.Ethereum.TokenCurrency, + val withdrawAmount: BigInteger, + val withdrawCallData: SmartContractCallData, + val yieldModuleAddress: String, + ) : GaslessFeePlan +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/models/GaslessTransactionData.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/models/GaslessTransactionData.kt index c7ee49e692..0850791f86 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/models/GaslessTransactionData.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/models/GaslessTransactionData.kt @@ -15,16 +15,11 @@ data class GaslessTransactionData( val nonce: BigInteger, ) { - /** - * Core transaction data. - * - * @property to destination address - * @property value transaction value in wei (currently always 0 for gasless) - * @property data encoded transaction data (contract call) - */ + data class Transaction( val to: String, val value: BigInteger, + val gasLimit: BigInteger, val data: ByteArray, ) { override fun equals(other: Any?): Boolean { @@ -35,6 +30,7 @@ data class GaslessTransactionData( if (to != other.to) return false if (value != other.value) return false + if (gasLimit != other.gasLimit) return false if (!data.contentEquals(other.data)) return false return true @@ -43,6 +39,7 @@ data class GaslessTransactionData( override fun hashCode(): Int { var result = to.hashCode() result = 31 * result + value.hashCode() + result = 31 * result + gasLimit.hashCode() result = 31 * result + data.contentHashCode() return result } diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/models/TransactionFeeExtended.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/models/TransactionFeeExtended.kt index 581292f419..31284d0b64 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/models/TransactionFeeExtended.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/models/TransactionFeeExtended.kt @@ -2,8 +2,28 @@ package com.tangem.domain.transaction.models import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.domain.models.currency.CryptoCurrency +import java.math.BigInteger data class TransactionFeeExtended( val transactionFee: TransactionFee, val feeTokenId: CryptoCurrency.ID, + /** + * Resolved gasless fee strategy. Non-null only for token-paid gasless fees; null for native fee. + * A null value is semantically equivalent to [GaslessFeePlan.NativePay] — consumers MUST treat them + * the same. [GaslessFeePlan.NativePay] is produced only by ResolveGaslessFeePlanUseCase. + * When it is [GaslessFeePlan.TokenPayWithYieldWithdraw], the send step builds a batch transaction. + */ + val gaslessFeePlan: GaslessFeePlan? = null, + /** + * Per-call gas limit for the user's main transaction, bound into the v2 EIP-712 hash + * ([GaslessTransactionData.Transaction.gasLimit]). Non-null only on the token-fee (gasless) path, + * where it equals the estimated execution gas of the user's transaction. + */ + val mainTransactionGasLimit: BigInteger? = null, + /** + * Per-call gas limit for the appended yield-withdraw sub-call in a batch. Non-null only when the + * fee is paid via [GaslessFeePlan.TokenPayWithYieldWithdraw]; used as the withdraw transaction's + * [GaslessTransactionData.Transaction.gasLimit]. + */ + val withdrawGasLimit: BigInteger? = null, ) \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt index d307e498ab..cb448b6085 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt @@ -27,6 +27,8 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.transaction.GaslessTransactionRepository import com.tangem.domain.transaction.error.SendTransactionError import com.tangem.domain.transaction.models.Eip7702Authorization +import com.tangem.domain.transaction.models.GaslessBatchTransactionData +import com.tangem.domain.transaction.models.GaslessFeePlan import com.tangem.domain.transaction.models.GaslessTransactionData import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.walletmanager.WalletManagersFacade @@ -38,6 +40,7 @@ class CreateAndSendGaslessTransactionUseCase( private val gaslessTransactionRepository: GaslessTransactionRepository, private val cardSdkConfigRepository: CardSdkConfigRepository, private val getHotWalletSigner: (UserWallet.Hot) -> TransactionSigner, + private val isGaslessV2Enabled: Boolean, ) { suspend operator fun invoke( @@ -69,6 +72,12 @@ class CreateAndSendGaslessTransactionUseCase( /** * Prepares all necessary context for gasless transaction. * Includes: wallet manager, gasless provider, token status, nonce, transaction data. + * + * When the resolved fee plan is [GaslessFeePlan.TokenPayWithYieldWithdraw], the payload is a + * [GaslessPayload.Batch] with the user's main tx at index 0 and the yield-withdraw tx at index 1. + * [GaslessFeePlan.TokenPay] and a null plan produce a [GaslessPayload.Single] with the same + * single-transaction behavior as before. [GaslessFeePlan.NativePay] must never reach this use + * case — it is guarded in [assembleGaslessPayload]. */ private suspend fun prepareGaslessContext( userWallet: UserWallet, @@ -91,11 +100,17 @@ class CreateAndSendGaslessTransactionUseCase( val gaslessContractNonce = getContractNonce(gaslessDataProvider, transactionData.sourceAddress) - val gaslessTransactionData = createGaslessTransactionData( - transactionData = transactionData, - txFee = fee, - currency = currency, + val mainTxGasLimit = fee.mainTransactionGasLimit + ?: error("Main transaction gas limit is required for a gasless (token-fee) transaction") + val mainTx = buildTransaction(transactionData, mainTxGasLimit) + val feeObj = buildFee(fee, currency) + + val payload = assembleGaslessPayload( + mainTx = mainTx, + feeObj = feeObj, nonce = gaslessContractNonce, + plan = fee.gaslessFeePlan, + withdrawGasLimit = fee.withdrawGasLimit, ) val chainId = gaslessTransactionRepository.getChainIdForNetwork(currency.network) @@ -104,7 +119,7 @@ class CreateAndSendGaslessTransactionUseCase( walletManager = walletManager, gaslessDataProvider = gaslessDataProvider, currency = currency, - gaslessTransactionData = gaslessTransactionData, + payload = payload, chainId = chainId, ) } @@ -125,17 +140,30 @@ class CreateAndSendGaslessTransactionUseCase( /** * Signs gasless transaction and EIP-7702 authorization. * Returns prepared signatures and authorization data. + * + * EIP-712 typed data is constructed from the payload: + * - [GaslessPayload.Single] → [Eip712TypedDataBuilder.build] (single-transaction schema) + * - [GaslessPayload.Batch] → [Eip712TypedDataBuilder.buildBatch] (batch schema) */ private suspend fun signGaslessTransactionByUser( userWallet: UserWallet, context: GaslessContext, transactionData: TransactionData.Uncompiled, ): SignedGaslessData { - val eip712Data = Eip712TypedDataBuilder.build( - gaslessTransaction = context.gaslessTransactionData, - chainId = context.chainId, - verifyingContract = transactionData.sourceAddress, - ) + val eip712Data = when (val payload = context.payload) { + is GaslessPayload.Single -> Eip712TypedDataBuilder.build( + gaslessTransaction = payload.data, + chainId = context.chainId, + verifyingContract = transactionData.sourceAddress, + includeGasLimit = isGaslessV2Enabled, + ) + is GaslessPayload.Batch -> Eip712TypedDataBuilder.buildBatch( + gaslessBatch = payload.data, + chainId = context.chainId, + verifyingContract = transactionData.sourceAddress, + includeGasLimit = isGaslessV2Enabled, + ) + } val eip712HashToSign = EthereumUtils.makeTypedDataHash(eip712Data) val eip7702Data = getEIP7702DataForGasless(context.gaslessDataProvider) @@ -182,19 +210,34 @@ class CreateAndSendGaslessTransactionUseCase( /** * Sends gasless transaction to the service. + * + * Routes to the appropriate repository call based on payload type: + * - [GaslessPayload.Single] → [GaslessTransactionRepository.signGaslessTransaction] + * - [GaslessPayload.Batch] → [GaslessTransactionRepository.signGaslessBatchTransaction] + * + * Pending-transaction tracking is always keyed on the main (user's) transaction only. */ private suspend fun signAndSendTransactionOnBackend( context: GaslessContext, signedData: SignedGaslessData, transactionData: TransactionData.Uncompiled, ): String { - val txHash = gaslessTransactionRepository.signGaslessTransaction( - network = context.currency.network, - gaslessTransactionData = context.gaslessTransactionData, - signature = signedData.eip712Signature, - userAddress = transactionData.sourceAddress, - eip7702Auth = signedData.eip7702Auth, - ).txHash + val txHash = when (val payload = context.payload) { + is GaslessPayload.Single -> gaslessTransactionRepository.signGaslessTransaction( + network = context.currency.network, + gaslessTransactionData = payload.data, + signature = signedData.eip712Signature, + userAddress = transactionData.sourceAddress, + eip7702Auth = signedData.eip7702Auth, + ).txHash + is GaslessPayload.Batch -> gaslessTransactionRepository.signGaslessBatchTransaction( + network = context.currency.network, + gaslessBatchTransactionData = payload.data, + signature = signedData.eip712Signature, + userAddress = transactionData.sourceAddress, + eip7702Auth = signedData.eip7702Auth, + ).txHash + } (context.walletManager as? PendingTransactionHandler)?.addPendingGaslessTransaction( transactionData = transactionData, @@ -241,23 +284,10 @@ class CreateAndSendGaslessTransactionUseCase( } } - private suspend fun createGaslessTransactionData( + private fun buildTransaction( transactionData: TransactionData.Uncompiled, - txFee: TransactionFeeExtended, - currency: CryptoCurrency, - nonce: BigInteger, - ): GaslessTransactionData { - val transaction = buildTransaction(transactionData) - val fee = buildFee(txFee, currency) - - return GaslessTransactionData( - transaction = transaction, - fee = fee, - nonce = nonce, - ) - } - - private fun buildTransaction(transactionData: TransactionData.Uncompiled): GaslessTransactionData.Transaction { + gasLimit: BigInteger, + ): GaslessTransactionData.Transaction { val callData = (transactionData.extras as? EthereumTransactionExtras)?.callData ?: error("Ethereum call data is required") @@ -268,6 +298,7 @@ class CreateAndSendGaslessTransactionUseCase( return GaslessTransactionData.Transaction( to = getDestinationAddress(transactionData), value = nativeAmount, + gasLimit = gasLimit, data = callData.data, ) } @@ -295,20 +326,28 @@ class CreateAndSendGaslessTransactionUseCase( private suspend fun getEIP7702DataForGasless( gaslessDataProvider: EthereumGaslessDataProvider, ): EIP7702AuthorizationData { - return when (val dataResult = gaslessDataProvider.prepareEIP7702AuthorizationData(isV2 = false)) { + return when (val dataResult = gaslessDataProvider.prepareEIP7702AuthorizationData(isV2 = isGaslessV2Enabled)) { is Result.Failure -> throw dataResult.error is Result.Success -> dataResult.data } } - private fun getDestinationAddress(txData: TransactionData.Uncompiled): String { - val ethereumCallData = (txData.extras as? EthereumTransactionExtras)?.callData - val contractAddress = txData.contractAddress - return if (ethereumCallData is EthereumYieldSupplySendCallData) { - ethereumCallData.destinationAddress - } else { - contractAddress ?: error("supports only Token transaction with contract address") - } + /** + * Discriminated union of the gasless transaction payload to sign and send. + * + * [Single] carries a single-transaction payload (the pre-existing path). + * [Batch] carries a batch payload where the yield-withdraw call is appended as the second + * transaction so that staked tokens are unlocked before the fee is settled. + */ + internal sealed interface GaslessPayload { + /** Single-transaction path — behavior is identical to the original implementation. */ + data class Single(val data: GaslessTransactionData) : GaslessPayload + + /** + * Batch path — used when [GaslessFeePlan.TokenPayWithYieldWithdraw] is resolved. + * [data.transactions] has the user's main tx at index 0 and the withdraw tx at index 1. + */ + data class Batch(val data: GaslessBatchTransactionData) : GaslessPayload } /** @@ -318,7 +357,7 @@ class CreateAndSendGaslessTransactionUseCase( val walletManager: WalletManager, val gaslessDataProvider: EthereumGaslessDataProvider, val currency: CryptoCurrency, - val gaslessTransactionData: GaslessTransactionData, + val payload: GaslessPayload, val chainId: Int, ) @@ -353,9 +392,75 @@ class CreateAndSendGaslessTransactionUseCase( } } - private companion object { + internal companion object { + + /** + * Assembles the [GaslessPayload] from already-built domain objects and the resolved fee plan. + * + * Dispatch rules: + * - [GaslessFeePlan.TokenPayWithYieldWithdraw] → [GaslessPayload.Batch]: the yield-withdraw + * call is appended as the second transaction so that the fee token balance is topped up + * before the gasless service processes the fee. + * - [GaslessFeePlan.TokenPay] or `null` → [GaslessPayload.Single]: single-transaction path, + * identical to the original implementation. `null` is a legitimate value meaning the plan + * was not explicitly resolved. + * - [GaslessFeePlan.NativePay] → error: native-pay fees must never reach this use case + * (they are handled by the standard send path). + */ + internal fun assembleGaslessPayload( + mainTx: GaslessTransactionData.Transaction, + feeObj: GaslessTransactionData.Fee, + nonce: BigInteger, + plan: GaslessFeePlan?, + withdrawGasLimit: BigInteger?, + ): GaslessPayload = when (plan) { + is GaslessFeePlan.TokenPayWithYieldWithdraw -> GaslessPayload.Batch( + GaslessBatchTransactionData( + transactions = listOf( + mainTx, + GaslessTransactionData.Transaction( + to = plan.yieldModuleAddress, + value = BigInteger.ZERO, + gasLimit = withdrawGasLimit + ?: error("Withdraw gas limit is required for a yield-withdraw batch"), + data = plan.withdrawCallData.data, + ), + ), + fee = feeObj, + nonce = nonce, + ), + ) + is GaslessFeePlan.TokenPay, null -> GaslessPayload.Single( + GaslessTransactionData(transaction = mainTx, fee = feeObj, nonce = nonce), + ) + is GaslessFeePlan.NativePay -> error("NativePay must not reach the gasless send path") + } + fun BigInteger.toFormattedHex(bytes: Int): String { return toByteArray().normalizeByteArray(bytes).toHexString().formatHex() } + + /** + * Resolves the on-chain `to` for the user's main gasless sub-call. + * + * - Yield-supply send (`EthereumYieldSupplySendCallData`, selector 0x0779afe6): `send(token, dest, + * amount)` is a method ON the user's yield module — the executor must CALL the module (it holds the + * staked funds and routes the transfer); the recipient is already encoded inside the call data. + * [TransactionData.Uncompiled.destinationAddress] is patched to the module address in + * `DefaultTransactionRepository.createTransaction`, mirroring the non-gasless send path (and the + * withdraw sub-call's `to`). Reading `ethereumCallData.destinationAddress` (the recipient) instead + * makes the executor call a plain address with the module's calldata, reverting the whole batch with + * GAS_ESTIMATION_FAILED / require(false). + * - Otherwise (e.g. ERC-20 transfer): `to` is the contract the calldata runs against + * ([TransactionData.Uncompiled.contractAddress], the token contract). + */ + internal fun getDestinationAddress(txData: TransactionData.Uncompiled): String { + val ethereumCallData = (txData.extras as? EthereumTransactionExtras)?.callData + return if (ethereumCallData is EthereumYieldSupplySendCallData) { + txData.destinationAddress + } else { + txData.contractAddress ?: error("supports only Token transaction with contract address") + } + } } } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/Eip712TypedDataBuilder.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/Eip712TypedDataBuilder.kt index 0c605613fe..471f1f980e 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/Eip712TypedDataBuilder.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/Eip712TypedDataBuilder.kt @@ -1,6 +1,7 @@ package com.tangem.domain.transaction.usecase.gasless import com.tangem.common.extensions.toHexString +import com.tangem.domain.transaction.models.GaslessBatchTransactionData import com.tangem.domain.transaction.models.GaslessTransactionData import org.json.JSONArray import org.json.JSONObject @@ -26,6 +27,7 @@ object Eip712TypedDataBuilder { private const val DOMAIN_NAME = "Tangem7702GaslessExecutor" private const val DOMAIN_VERSION = "1" private const val PRIMARY_TYPE = "GaslessTransaction" + private const val PRIMARY_TYPE_BATCH = "GaslessBatchTransaction" /** * Builds EIP-712 typed data JSON for gasless transaction. @@ -35,47 +37,106 @@ object Eip712TypedDataBuilder { * @param verifyingContract address of the deployed gasless executor contract * @return JSON string ready for EIP-712 signing */ - fun build(gaslessTransaction: GaslessTransactionData, chainId: Int, verifyingContract: String): String { + fun build( + gaslessTransaction: GaslessTransactionData, + chainId: Int, + verifyingContract: String, + includeGasLimit: Boolean = true, + ): String { val typedData = JSONObject().apply { - put("types", buildTypes()) + put("types", buildTypes(includeGasLimit)) put("primaryType", PRIMARY_TYPE) put("domain", buildDomain(chainId, verifyingContract)) - put("message", buildMessage(gaslessTransaction)) + put("message", buildMessage(gaslessTransaction, includeGasLimit)) } return typedData.toString() } + /** + * Builds EIP-712 typed data JSON for gasless batch transaction. + * + * @param gaslessBatch domain model with ordered list of transactions and fee data + * @param chainId blockchain network chain ID + * @param verifyingContract address of the deployed gasless executor contract + * @return JSON string ready for EIP-712 signing + */ + fun buildBatch( + gaslessBatch: GaslessBatchTransactionData, + chainId: Int, + verifyingContract: String, + includeGasLimit: Boolean = true, + ): String { + require( + gaslessBatch.transactions.isNotEmpty(), + ) { "GaslessBatchTransaction must contain at least one transaction" } + val typedData = JSONObject().apply { + put("types", buildBatchTypes(includeGasLimit)) + put("primaryType", PRIMARY_TYPE_BATCH) + put("domain", buildDomain(chainId, verifyingContract)) + put("message", buildBatchMessage(gaslessBatch, includeGasLimit)) + } + return typedData.toString() + } + + /** + * Builds the type definitions for all structures in the batch variant. + * Uses `Transaction[]` for the ordered transactions array. + */ + private fun buildBatchTypes(includeGasLimit: Boolean): JSONObject { + return JSONObject().apply { + put("EIP712Domain", buildEip712DomainTypeProperties()) + put("Transaction", buildTransactionTypeProperties(includeGasLimit)) + put("Fee", buildFeeTypeProperties()) + put("GaslessBatchTransaction", buildGaslessBatchTransactionTypeProperties()) + } + } + + private fun buildGaslessBatchTransactionTypeProperties(): JSONArray { + return JSONArray().apply { + put(typeProperty("transactions", "Transaction[]")) + put(typeProperty("fee", "Fee")) + put(typeProperty("nonce", "uint256")) + } + } + + /** + * Builds the message data from gasless batch transaction. + */ + private fun buildBatchMessage(gaslessBatch: GaslessBatchTransactionData, includeGasLimit: Boolean): JSONObject { + return JSONObject().apply { + put("transactions", buildTransactionsArray(gaslessBatch.transactions, includeGasLimit)) + put("fee", buildFeeMessage(gaslessBatch.fee)) + put("nonce", gaslessBatch.nonce.toString()) + } + } + + private fun buildTransactionsArray( + transactions: List, + includeGasLimit: Boolean, + ): JSONArray { + return JSONArray().apply { + transactions.forEach { tx -> put(buildTransactionMessage(tx, includeGasLimit)) } + } + } + /** * Builds the type definitions for all structures. * This schema is fixed and defines the structure of the data being signed. */ - @Suppress("NestedScopeFunctions") - private fun buildTypes(): JSONObject { + private fun buildTypes(includeGasLimit: Boolean): JSONObject { return JSONObject().apply { - put("EIP712Domain", JSONArray().apply { - put(typeProperty("name", "string")) - put(typeProperty("version", "string")) - put(typeProperty("chainId", "uint256")) - put(typeProperty("verifyingContract", "address")) - }) - put("Transaction", JSONArray().apply { - put(typeProperty("to", "address")) - put(typeProperty("value", "uint256")) - put(typeProperty("data", "bytes")) - }) - put("Fee", JSONArray().apply { - put(typeProperty("feeToken", "address")) - put(typeProperty("maxTokenFee", "uint256")) - put(typeProperty("coinPriceInToken", "uint256")) - put(typeProperty("feeTransferGasLimit", "uint256")) - put(typeProperty("baseGas", "uint256")) - put(typeProperty("feeReceiver", "address")) - }) - put("GaslessTransaction", JSONArray().apply { - put(typeProperty("transaction", "Transaction")) - put(typeProperty("fee", "Fee")) - put(typeProperty("nonce", "uint256")) - }) + put("EIP712Domain", buildEip712DomainTypeProperties()) + put("Transaction", buildTransactionTypeProperties(includeGasLimit)) + put("Fee", buildFeeTypeProperties()) + put("GaslessTransaction", buildGaslessTransactionTypeProperties()) + } + } + + private fun buildGaslessTransactionTypeProperties(): JSONArray { + return JSONArray().apply { + put(typeProperty("transaction", "Transaction")) + put(typeProperty("fee", "Fee")) + put(typeProperty("nonce", "uint256")) } } @@ -104,23 +165,71 @@ object Eip712TypedDataBuilder { /** * Builds the message data from gasless transaction. */ - @Suppress("NestedScopeFunctions") - private fun buildMessage(gaslessTransaction: GaslessTransactionData): JSONObject { + private fun buildMessage(gaslessTransaction: GaslessTransactionData, includeGasLimit: Boolean): JSONObject { return JSONObject().apply { - put("transaction", JSONObject().apply { - put("to", gaslessTransaction.transaction.to) - put("value", gaslessTransaction.transaction.value.toString()) - put("data", gaslessTransaction.transaction.data.toHexString()) - }) - put("fee", JSONObject().apply { - put("feeToken", gaslessTransaction.fee.feeToken) - put("maxTokenFee", gaslessTransaction.fee.maxTokenFee.toString()) - put("coinPriceInToken", gaslessTransaction.fee.coinPriceInToken.toString()) - put("feeTransferGasLimit", gaslessTransaction.fee.feeTransferGasLimit.toString()) - put("baseGas", gaslessTransaction.fee.baseGas.toString()) - put("feeReceiver", gaslessTransaction.fee.feeReceiver) - }) + put("transaction", buildTransactionMessage(gaslessTransaction.transaction, includeGasLimit)) + put("fee", buildFeeMessage(gaslessTransaction.fee)) put("nonce", gaslessTransaction.nonce.toString()) } } + + private fun buildTransactionMessage( + transaction: GaslessTransactionData.Transaction, + includeGasLimit: Boolean, + ): JSONObject { + return JSONObject().apply { + put("to", transaction.to) + put("value", transaction.value.toString()) + if (includeGasLimit) put("gasLimit", transaction.gasLimit.toString()) + put("data", transaction.data.toHexString()) + } + } + + // region Shared type schema helpers + + private fun buildEip712DomainTypeProperties(): JSONArray { + return JSONArray().apply { + put(typeProperty("name", "string")) + put(typeProperty("version", "string")) + put(typeProperty("chainId", "uint256")) + put(typeProperty("verifyingContract", "address")) + } + } + + private fun buildTransactionTypeProperties(includeGasLimit: Boolean): JSONArray { + return JSONArray().apply { + put(typeProperty("to", "address")) + put(typeProperty("value", "uint256")) + if (includeGasLimit) put(typeProperty("gasLimit", "uint256")) + put(typeProperty("data", "bytes")) + } + } + + private fun buildFeeTypeProperties(): JSONArray { + return JSONArray().apply { + put(typeProperty("feeToken", "address")) + put(typeProperty("maxTokenFee", "uint256")) + put(typeProperty("coinPriceInToken", "uint256")) + put(typeProperty("feeTransferGasLimit", "uint256")) + put(typeProperty("baseGas", "uint256")) + put(typeProperty("feeReceiver", "address")) + } + } + + // endregion + + // region Shared message helpers + + private fun buildFeeMessage(fee: GaslessTransactionData.Fee): JSONObject { + return JSONObject().apply { + put("feeToken", fee.feeToken) + put("maxTokenFee", fee.maxTokenFee.toString()) + put("coinPriceInToken", fee.coinPriceInToken.toString()) + put("feeTransferGasLimit", fee.feeTransferGasLimit.toString()) + put("baseGas", fee.baseGas.toString()) + put("feeReceiver", fee.feeReceiver) + } + } + + // endregion } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForGaslessTxUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForGaslessTxUseCase.kt index 260a0dd6f1..7f11d3b31b 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForGaslessTxUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForGaslessTxUseCase.kt @@ -18,12 +18,14 @@ import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.transaction.GaslessTransactionRepository +import com.tangem.domain.transaction.GaslessYieldRepository import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.error.GetFeeError.GaslessError import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.raiseIllegalStateError import com.tangem.domain.transaction.usecase.EstimateFeeUseCase import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.utils.extensions.isZero import java.math.BigDecimal @Suppress("LongParameterList") @@ -31,6 +33,7 @@ class EstimateFeeForGaslessTxUseCase( private val walletManagersFacade: WalletManagersFacade, private val demoConfig: DemoConfig, private val gaslessTransactionRepository: GaslessTransactionRepository, + private val gaslessYieldRepository: GaslessYieldRepository, private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, private val estimateFeeUseCase: EstimateFeeUseCase, private val currencyChecksRepository: CurrencyChecksRepository, @@ -40,6 +43,7 @@ class EstimateFeeForGaslessTxUseCase( walletManagersFacade = walletManagersFacade, gaslessTransactionRepository = gaslessTransactionRepository, demoConfig = demoConfig, + gaslessYieldRepository = gaslessYieldRepository, ) suspend operator fun invoke( @@ -153,11 +157,11 @@ class EstimateFeeForGaslessTxUseCase( val supportedGaslessTokens = gaslessTransactionRepository.getSupportedTokens( network = nativeCurrencyStatus.currency.network, ).mapNotNull { currency -> - (currency as? CryptoCurrency.Token)?.contractAddress + (currency as? CryptoCurrency.Token)?.contractAddress?.lowercase() }.toSet() val supportedGaslessTokensStatusesSortedByBalanceDesc = networkCurrenciesStatuses - .filterNot { it.value.amount == BigDecimal.ZERO || it.currency !is CryptoCurrency.Token } + .filterNot { it.value.amount?.isZero() == true || it.currency !is CryptoCurrency.Token } .sortedByDescending { it.value.amount } .filter { status -> val token = status.currency as? CryptoCurrency.Token ?: return@filter false diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForTokenUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForTokenUseCase.kt index c311cce1aa..c8d955b475 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForTokenUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/EstimateFeeForTokenUseCase.kt @@ -15,6 +15,7 @@ import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.transaction.GaslessTransactionRepository +import com.tangem.domain.transaction.GaslessYieldRepository import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.error.GetFeeError.GaslessError import com.tangem.domain.transaction.models.TransactionFeeExtended @@ -22,18 +23,22 @@ import com.tangem.domain.transaction.raiseIllegalStateError import com.tangem.domain.walletmanager.WalletManagersFacade import java.math.BigDecimal +@Suppress("LongParameterList") class EstimateFeeForTokenUseCase( private val gaslessTransactionRepository: GaslessTransactionRepository, + private val gaslessYieldRepository: GaslessYieldRepository, private val walletManagersFacade: WalletManagersFacade, private val demoConfig: DemoConfig, private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, private val currencyChecksRepository: CurrencyChecksRepository, + private val isYieldWithdrawEnabled: Boolean, ) { private val tokenFeeCalculator = TokenFeeCalculator( walletManagersFacade = walletManagersFacade, gaslessTransactionRepository = gaslessTransactionRepository, demoConfig = demoConfig, + gaslessYieldRepository = gaslessYieldRepository, ) suspend operator fun invoke( @@ -70,11 +75,15 @@ class EstimateFeeForTokenUseCase( val walletManager = prepareWalletManager(userWallet, token.network) + val isYieldActive = isYieldWithdrawEnabled && + feeTokenCurrencyStatus.value.yieldSupplyStatus?.isActive == true + tokenFeeCalculator.calculateTokenFee( walletManager = walletManager, tokenForPayFeeStatus = feeTokenCurrencyStatus, nativeCurrencyStatus = nativeCurrencyStatus, initialFee = initialFeeEth, + isYieldActive = isYieldActive, ).bind() }, catch = { diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt index ecac79ad59..2e8b9b51cb 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCase.kt @@ -19,6 +19,7 @@ class GetAvailableFeeTokensUseCase( private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, private val gaslessTransactionRepository: GaslessTransactionRepository, private val currencyChecksRepository: CurrencyChecksRepository, + private val isYieldWithdrawEnabled: Boolean, ) { /** @@ -69,7 +70,7 @@ class GetAvailableFeeTokensUseCase( }.toSet() return userCurrenciesStatuses .asSequence() - .filter { it.value.yieldSupplyStatus == null } + .filter { isEligibleFeeToken(it, isYieldWithdrawEnabled) } .filter { it.currency.network.id == network.id } .filter { currencyStatus -> val token = currencyStatus.currency @@ -77,4 +78,12 @@ class GetAvailableFeeTokensUseCase( } .toList() } + + internal companion object { + + internal fun isEligibleFeeToken(status: CryptoCurrencyStatus, isYieldWithdrawEnabled: Boolean): Boolean { + val yieldSupplyStatus = status.value.yieldSupplyStatus ?: return true + return isYieldWithdrawEnabled && yieldSupplyStatus.isActive + } + } } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt index c6bf7fc229..981230a637 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForGaslessUseCase.kt @@ -6,6 +6,7 @@ import arrow.core.raise.Raise import arrow.core.raise.catch import arrow.core.raise.either import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager +import com.tangem.blockchain.common.AmountType import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee @@ -19,6 +20,7 @@ import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.transaction.GaslessTransactionRepository +import com.tangem.domain.transaction.GaslessYieldRepository import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.error.GetFeeError.GaslessError import com.tangem.domain.transaction.models.TransactionFeeExtended @@ -32,15 +34,19 @@ class GetFeeForGaslessUseCase( private val walletManagersFacade: WalletManagersFacade, private val demoConfig: DemoConfig, private val gaslessTransactionRepository: GaslessTransactionRepository, + private val gaslessYieldRepository: GaslessYieldRepository, private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, private val getFeeUseCase: GetFeeUseCase, private val currencyChecksRepository: CurrencyChecksRepository, + private val resolveGaslessFeePlanUseCase: ResolveGaslessFeePlanUseCase, + private val isYieldWithdrawEnabled: Boolean, ) { private val tokenFeeCalculator = TokenFeeCalculator( walletManagersFacade = walletManagersFacade, gaslessTransactionRepository = gaslessTransactionRepository, demoConfig = demoConfig, + gaslessYieldRepository = gaslessYieldRepository, ) suspend operator fun invoke( @@ -80,11 +86,13 @@ class GetFeeForGaslessUseCase( ).bind() selectFeePaymentStrategy( + userWallet = userWallet, accountStatusList = accountStatusList, walletManager = walletManager, nativeCurrencyStatus = nativeCurrencyStatus, network = network, initialFee = initialFee, + transactionData = transactionData, ) }, catch = { @@ -108,12 +116,15 @@ class GetFeeForGaslessUseCase( return ethereumWalletManager } + @Suppress("LongParameterList") private suspend fun Raise.selectFeePaymentStrategy( + userWallet: UserWallet, accountStatusList: AccountStatusList, walletManager: EthereumWalletManager, nativeCurrencyStatus: CryptoCurrencyStatus, network: Network, initialFee: TransactionFee, + transactionData: TransactionData, ): TransactionFeeExtended { val feeValue = initialFee.normal.amount.value ?: raise(GetFeeError.UnknownError) @@ -128,10 +139,12 @@ class GetFeeForGaslessUseCase( nativeCoinSelectedResult } else { findTokensToPayFee( + userWallet = userWallet, walletManager = walletManager, initialTxFee = initialFee, nativeCurrencyStatus = nativeCurrencyStatus, networkCurrenciesStatuses = networkCurrenciesStatuses, + transactionData = transactionData, ).getOrElse { error -> when (error) { GaslessError.NotEnoughFunds -> nativeCoinSelectedResult @@ -141,12 +154,14 @@ class GetFeeForGaslessUseCase( } } - @Suppress("NullableToStringCall") + @Suppress("NullableToStringCall", "LongParameterList") private suspend fun findTokensToPayFee( + userWallet: UserWallet, walletManager: EthereumWalletManager, initialTxFee: TransactionFee, nativeCurrencyStatus: CryptoCurrencyStatus, networkCurrenciesStatuses: List, + transactionData: TransactionData, ): Either = either { val initialFee = initialTxFee.normal as? Fee.Ethereum ?: raiseIllegalStateError( @@ -156,29 +171,109 @@ class GetFeeForGaslessUseCase( val supportedGaslessTokens = gaslessTransactionRepository.getSupportedTokens( network = nativeCurrencyStatus.currency.network, ).mapNotNull { currency -> - (currency as? CryptoCurrency.Token)?.contractAddress + (currency as? CryptoCurrency.Token)?.contractAddress?.lowercase() }.toSet() - val supportedGaslessTokensStatusesSortedByBalanceDesc = networkCurrenciesStatuses - .filterNot { it.value.amount == BigDecimal.ZERO || it.currency !is CryptoCurrency.Token } - .sortedByDescending { it.value.amount } - .filter { status -> - val token = status.currency as? CryptoCurrency.Token ?: return@filter false - token.contractAddress.lowercase() in supportedGaslessTokens - } - /** - * Selects token with highest balance to maximize chances of successful fee payment. - * Returns null if no suitable tokens found. + * Yield-aware candidate selection: + * a token is eligible if it is a supported gasless token AND + * (total balance > 0 OR has an active yield position). + * Sorted by total balance descending to maximise chances of covering the fee. For a yield token + * value.amount is already effectiveBalance (liquid EOA + effectiveProtocolBalance), so it must NOT + * be summed with effectiveProtocolBalance again — that would double-count the module portion. */ - val tokenForPayFeeStatus = supportedGaslessTokensStatusesSortedByBalanceDesc.firstOrNull() - ?: raise(GaslessError.NoSupportedTokensFound) + val candidates = networkCurrenciesStatuses + .asSequence() + .filter { it.currency is CryptoCurrency.Token } + .filter { (it.currency as CryptoCurrency.Token).contractAddress.lowercase() in supportedGaslessTokens } + .filter { status -> + val total = status.value.amount ?: BigDecimal.ZERO + total > BigDecimal.ZERO || isYieldWithdrawEnabled && status.value.yieldSupplyStatus?.isActive == true + } + .sortedByDescending { status -> status.value.amount ?: BigDecimal.ZERO } - return tokenFeeCalculator.calculateTokenFee( + val tokenForPayFeeStatus = candidates.firstOrNull() ?: raise(GaslessError.NoSupportedTokensFound) + + val isYieldActive = isYieldWithdrawEnabled && tokenForPayFeeStatus.value.yieldSupplyStatus?.isActive == true + val tokenFeeExtended = tokenFeeCalculator.calculateTokenFee( walletManager = walletManager, tokenForPayFeeStatus = tokenForPayFeeStatus, nativeCurrencyStatus = nativeCurrencyStatus, initialFee = initialFee, + isYieldActive = isYieldActive, + userWallet = userWallet, + ).bind() + + attachGaslessFeePlan( + resolveGaslessFeePlanUseCase = resolveGaslessFeePlanUseCase, + userWallet = userWallet, + tokenStatus = tokenForPayFeeStatus, + tokenFeeExtended = tokenFeeExtended, + transactionData = transactionData, + isYieldActive = isYieldActive, ) } +} + +/** + * Resolves the [com.tangem.domain.transaction.models.GaslessFeePlan] for [tokenStatus] paying the gasless + * fee and attaches it to [tokenFeeExtended]. Shared by the auto path ([GetFeeForGaslessUseCase]) and the + * manual fee-token selection path ([GetFeeForTokenUseCase]) so both produce identical plans. + */ +@Suppress("LongParameterList") +internal suspend fun Raise.attachGaslessFeePlan( + resolveGaslessFeePlanUseCase: ResolveGaslessFeePlanUseCase, + userWallet: UserWallet, + tokenStatus: CryptoCurrencyStatus, + tokenFeeExtended: TransactionFeeExtended, + transactionData: TransactionData, + isYieldActive: Boolean, +): TransactionFeeExtended { + val feeInTokenCurrency = tokenFeeExtended.transactionFee.normal as? Fee.Ethereum.TokenCurrency + ?: raiseIllegalStateError("gasless token fee must be Fee.Ethereum.TokenCurrency") + val feeTokenContract = (tokenStatus.currency as? CryptoCurrency.Token)?.contractAddress + ?: raiseIllegalStateError("gasless fee currency must be a token") + + val plan = resolveGaslessFeePlanUseCase( + userWallet = userWallet, + tokenStatus = tokenStatus, + tokenFee = feeInTokenCurrency, + isYieldActive = isYieldActive, + sendAmountInFeeToken = computeSendAmountInFeeToken(transactionData, feeTokenContract), + ).bind() + + return tokenFeeExtended.copy(gaslessFeePlan = plan) +} + +/** + * Computes how much of the fee token is also being spent in the main transaction body. + * + * Gasless token-fee transactions MUST supply uncompiled data (the resolver needs the raw amount to + * account for it in the required-balance check). A compiled tx or a null sent amount on the + * matching-token path are both programmer errors, so they raise loudly instead of silently + * under-accounting as ZERO. + * + * @param transactionData the raw transaction data passed into [GetFeeForGaslessUseCase]. + * @param feeTokenContract the contract address of the token selected to pay the gasless fee. + * @return the sent amount when [feeTokenContract] matches the sent-token contract, + * or [BigDecimal.ZERO] when a different token is being sent. + */ +internal fun Raise.computeSendAmountInFeeToken( + transactionData: TransactionData, + feeTokenContract: String, +): BigDecimal { + // Gasless token-fee requires uncompiled tx data (mirrors CreateAndSendGaslessTransactionUseCase). + val uncompiled = transactionData as? TransactionData.Uncompiled + ?: raiseIllegalStateError("gasless token fee requires uncompiled transaction data") + val sentTokenContract = when (val type = uncompiled.amount.type) { + is AmountType.Token -> type.token.contractAddress + is AmountType.TokenYieldSupply -> type.token.contractAddress + else -> null + } + return if (sentTokenContract != null && sentTokenContract.equals(feeTokenContract, ignoreCase = true)) { + uncompiled.amount.value + ?: raiseIllegalStateError("sent amount is null while paying the gasless fee in the sent token") + } else { + BigDecimal.ZERO + } } \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForTokenUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForTokenUseCase.kt index 1e96c27a19..60678ed6af 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForTokenUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/GetFeeForTokenUseCase.kt @@ -17,24 +17,30 @@ import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.tokens.repository.CurrencyChecksRepository import com.tangem.domain.transaction.GaslessTransactionRepository +import com.tangem.domain.transaction.GaslessYieldRepository import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.error.GetFeeError.GaslessError import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.domain.transaction.raiseIllegalStateError import com.tangem.domain.walletmanager.WalletManagersFacade +@Suppress("LongParameterList") class GetFeeForTokenUseCase( private val gaslessTransactionRepository: GaslessTransactionRepository, + private val gaslessYieldRepository: GaslessYieldRepository, private val walletManagersFacade: WalletManagersFacade, private val demoConfig: DemoConfig, private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, private val currencyChecksRepository: CurrencyChecksRepository, + private val resolveGaslessFeePlanUseCase: ResolveGaslessFeePlanUseCase, + private val isYieldWithdrawEnabled: Boolean, ) { private val tokenFeeCalculator = TokenFeeCalculator( walletManagersFacade = walletManagersFacade, gaslessTransactionRepository = gaslessTransactionRepository, demoConfig = demoConfig, + gaslessYieldRepository = gaslessYieldRepository, ) suspend operator fun invoke( @@ -74,12 +80,30 @@ class GetFeeForTokenUseCase( raiseIllegalStateError("Token currency not found for network ${token.network.id}") } - tokenFeeCalculator.calculateTokenFee( + val isYieldActive = isYieldWithdrawEnabled && + tokenCurrencyStatus.value.yieldSupplyStatus?.isActive == true + + val tokenFeeExtended = tokenFeeCalculator.calculateTokenFee( walletManager = walletManager, tokenForPayFeeStatus = tokenCurrencyStatus, nativeCurrencyStatus = nativeCurrencyStatus, initialFee = initialFeeEth, + isYieldActive = isYieldActive, + userWallet = userWallet, ).bind() + + if (isYieldActive) { + attachGaslessFeePlan( + resolveGaslessFeePlanUseCase = resolveGaslessFeePlanUseCase, + userWallet = userWallet, + tokenStatus = tokenCurrencyStatus, + tokenFeeExtended = tokenFeeExtended, + transactionData = transactionData, + isYieldActive = true, + ) + } else { + tokenFeeExtended + } }, catch = { raise(GaslessError.DataError(it)) diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/ResolveGaslessFeePlanUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/ResolveGaslessFeePlanUseCase.kt new file mode 100644 index 0000000000..7409bbda93 --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/ResolveGaslessFeePlanUseCase.kt @@ -0,0 +1,97 @@ +package com.tangem.domain.transaction.usecase.gasless + +import arrow.core.Either +import arrow.core.raise.catch +import arrow.core.raise.either +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.Token +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.yieldsupply.providers.YieldModuleUpgradeUnavailableException +import com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionIndeterminateException +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.transaction.GaslessYieldRepository +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.error.GetFeeError.GaslessError +import com.tangem.domain.transaction.models.GaslessFeePlan +import java.math.BigDecimal +import java.math.RoundingMode + +class ResolveGaslessFeePlanUseCase( + private val gaslessYieldRepository: GaslessYieldRepository, +) { + + suspend operator fun invoke( + userWallet: UserWallet, + tokenStatus: CryptoCurrencyStatus, + tokenFee: Fee.Ethereum.TokenCurrency, + isYieldActive: Boolean, + sendAmountInFeeToken: BigDecimal, + ): Either = either { + val token = tokenStatus.currency as? CryptoCurrency.Token + ?: raise(GaslessError.DataError(IllegalStateException("fee currency must be a token"))) + + val feeAmount = tokenFee.amount.value + ?: raise(GaslessError.DataError(IllegalStateException("token fee amount is null"))) + val totalBalance = tokenStatus.value.amount ?: BigDecimal.ZERO + val required = feeAmount + sendAmountInFeeToken + if (!isYieldActive) { + return@either if (totalBalance >= required) { + GaslessFeePlan.TokenPay(feeToken = token, fee = tokenFee) + } else { + raise(GaslessError.NotEnoughFunds) + } + } + + val moduleBalance = gaslessYieldRepository + .getEffectiveProtocolBalance(userWallet.walletId, token) ?: BigDecimal.ZERO + + // Liquid balance already on the EOA = total - what is held inside the yield module. + val liquidBalance = (totalBalance - moduleBalance).coerceAtLeast(BigDecimal.ZERO) + if (liquidBalance >= required) { + return@either GaslessFeePlan.TokenPay(feeToken = token, fee = tokenFee) + } + + if (totalBalance < required) raise(GaslessError.NotEnoughFunds) + + val liquidLeftForFee = (liquidBalance - sendAmountInFeeToken).coerceAtLeast(BigDecimal.ZERO) + val withdrawAmountDecimal = (feeAmount - liquidLeftForFee).coerceAtLeast(BigDecimal.ZERO) + + val withdrawCallData = catch( + block = { + gaslessYieldRepository.createPartialWithdrawCallData( + userWalletId = userWallet.walletId, + cryptoCurrency = token, + amount = Amount( + token = Token(token.symbol, token.contractAddress, token.decimals), + value = withdrawAmountDecimal, + ), + ) + }, + catch = { error -> + when (error) { + is YieldModuleUpgradeUnavailableException, + is YieldModuleVersionIndeterminateException, + -> raise(GaslessError.ModuleUpdateUnavailable) + else -> raise(GaslessError.DataError(error)) + } + }, + ) + + val yieldModuleAddress = gaslessYieldRepository + .getYieldContractAddress(userWallet.walletId, token) + ?: raise(GaslessError.DataError(IllegalStateException("yield module address is null"))) + + GaslessFeePlan.TokenPayWithYieldWithdraw( + feeToken = token, + fee = tokenFee, + withdrawAmount = withdrawAmountDecimal + .movePointRight(token.decimals) + .setScale(0, RoundingMode.CEILING) + .toBigInteger(), + withdrawCallData = withdrawCallData, + yieldModuleAddress = yieldModuleAddress, + ) + } +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculator.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculator.kt index 451be7a94b..006ca05071 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculator.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculator.kt @@ -6,12 +6,15 @@ import arrow.core.raise.either import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager import com.tangem.blockchain.blockchains.ethereum.tokenmethods.TransferERC20TokenCallData import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.AmountType import com.tangem.blockchain.common.BlockchainSdkError import com.tangem.blockchain.common.Token import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.blockchain.extensions.Result +import com.tangem.blockchain.yieldsupply.providers.YieldModuleUpgradeUnavailableException +import com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionIndeterminateException import com.tangem.domain.demo.DemoTransactionSender import com.tangem.domain.demo.models.DemoConfig import com.tangem.domain.models.currency.CryptoCurrency @@ -19,6 +22,7 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.transaction.GaslessTransactionRepository +import com.tangem.domain.transaction.GaslessYieldRepository import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.error.GetFeeError.GaslessError import com.tangem.domain.transaction.models.TransactionFeeExtended @@ -34,6 +38,7 @@ internal class TokenFeeCalculator( private val walletManagersFacade: WalletManagersFacade, private val gaslessTransactionRepository: GaslessTransactionRepository, private val demoConfig: DemoConfig, + private val gaslessYieldRepository: GaslessYieldRepository, ) { suspend fun calculateInitialFee( @@ -90,16 +95,19 @@ internal class TokenFeeCalculator( } } - @Suppress("LongMethod", "CyclomaticComplexMethod") + @Suppress("LongMethod", "CyclomaticComplexity") suspend fun calculateTokenFee( walletManager: EthereumWalletManager, tokenForPayFeeStatus: CryptoCurrencyStatus, nativeCurrencyStatus: CryptoCurrencyStatus, initialFee: Fee.Ethereum, + isYieldActive: Boolean = false, + userWallet: UserWallet? = null, ): Either { return either { - // fast finish to skip calculations if no funds in token - if (tokenForPayFeeStatus.value.amount?.isZero() == true) { + // fast finish to skip calculations if no funds in token. + // Skipped on the yield path: a zero plain balance is expected — it will be topped up from yield. + if (!isYieldActive && tokenForPayFeeStatus.value.amount?.isZero() == true) { raise(GaslessError.NotEnoughFunds) } @@ -120,23 +128,16 @@ internal class TokenFeeCalculator( ), ) - val feeTransferGasLimit = when (feeTransferGasLimitResult) { - is Result.Success -> feeTransferGasLimitResult.data - is Result.Failure -> { - // If there is a dust on the balance, the gas limit estimation will fail with code - if (feeTransferGasLimitResult.error is BlockchainSdkError.WrappedThrowable) { - val cause = feeTransferGasLimitResult.error.cause - if (cause is BlockchainSdkError.Ethereum.InsufficientFundsForOperation) { - raise(GaslessError.NotEnoughFunds) - } - } - raise(GaslessError.DataError(feeTransferGasLimitResult.error)) - } - }.increaseByPercent(PERCENT_TO_INCREASE_TRANSFER_GASLIMIT) + val feeTransferGasLimit = resolveFeeTransferGasLimit(feeTransferGasLimitResult, isYieldActive) val baseGas = gaslessTransactionRepository.getBaseGasForTransaction() - val maxTokenFeeGas = initialFee.gasLimit + feeTransferGasLimit + baseGas + val withdrawGas = if (isYieldActive) { + estimateWithdrawGasLimit(userWallet, walletManager, tokenForPayFee) + } else { + BigInteger.ZERO + } + val maxTokenFeeGas = initialFee.gasLimit + feeTransferGasLimit + baseGas + withdrawGas val maxFeePerGas = when (initialFee) { is Fee.Ethereum.EIP1559 -> initialFee.maxFeePerGas @@ -170,7 +171,8 @@ internal class TokenFeeCalculator( ) val tokenBalance = tokenForPayFeeStatus.value.amount ?: BigDecimal.ZERO - if (tokenBalance < feeInTokenCurrency) { + // Skipped on the yield path: ResolveGaslessFeePlanUseCase decides plain-vs-yield coverage. + if (!isYieldActive && tokenBalance < feeInTokenCurrency) { raise(GaslessError.NotEnoughFunds) } @@ -186,10 +188,97 @@ internal class TokenFeeCalculator( TransactionFeeExtended( transactionFee = TransactionFee.Single(normal = fee), feeTokenId = tokenForPayFee.id, + // Per-call gas limits for the v2 gasless meta-tx (bound into the EIP-712 hash). + // Main = the user's transaction execution gas; withdraw = the appended yield-withdraw + // sub-call gas, present only on the yield path where a batch is built. + mainTransactionGasLimit = initialFee.gasLimit, + withdrawGasLimit = withdrawGas.takeIf { isYieldActive }, ) } } + /** + * Resolves the fee-transfer gas limit from the on-chain estimation result. + * + * On the yield path ([isYieldActive] = true), when the estimation reverts with + * [BlockchainSdkError.Ethereum.InsufficientFundsForOperation] (expected for a zero plain balance), + * falls back to [FALLBACK_FEE_TRANSFER_GAS_LIMIT] instead of raising [GaslessError.NotEnoughFunds]. + * All other failures propagate as [GaslessError.DataError] on both paths. + */ + private fun Raise.resolveFeeTransferGasLimit( + feeTransferGasLimitResult: Result, + isYieldActive: Boolean, + ): BigInteger { + val rawFeeTransferGasLimit: BigInteger = when (feeTransferGasLimitResult) { + is Result.Success -> feeTransferGasLimitResult.data + is Result.Failure -> { + // If there is a dust on the balance, the gas limit estimation will fail with code + if (feeTransferGasLimitResult.error is BlockchainSdkError.WrappedThrowable) { + val cause = feeTransferGasLimitResult.error.cause + if (cause is BlockchainSdkError.Ethereum.InsufficientFundsForOperation) { + if (isYieldActive) { + FALLBACK_FEE_TRANSFER_GAS_LIMIT + } else { + raise(GaslessError.NotEnoughFunds) + } + } else { + raise(GaslessError.DataError(feeTransferGasLimitResult.error)) + } + } else { + raise(GaslessError.DataError(feeTransferGasLimitResult.error)) + } + } + } + return rawFeeTransferGasLimit.increaseByPercent(PERCENT_TO_INCREASE_TRANSFER_GASLIMIT) + } + + @Suppress("SwallowedException") + private suspend fun estimateWithdrawGasLimit( + userWallet: UserWallet?, + walletManager: EthereumWalletManager, + token: CryptoCurrency.Token, + ): BigInteger { + if (userWallet == null) return WITHDRAW_GAS_LIMIT + + val moduleAddress = gaslessYieldRepository.getYieldContractAddress(userWallet.walletId, token) + ?: return WITHDRAW_GAS_LIMIT + + // The withdraw amount is encoded into the call data: a small fixed probe whose exact value does not + // affect the gas cost. It is a token amount because the call data needs the token's contract/decimals. + val withdrawAmount = createTokenAmount( + token = token, + value = BigDecimal(PROBE_WITHDRAW_AMOUNT_MINIMAL_UNITS).movePointLeft(token.decimals), + ) + + val probeCallData = try { + gaslessYieldRepository.createPartialWithdrawCallData( + userWalletId = userWallet.walletId, + cryptoCurrency = token, + amount = withdrawAmount, + ) + } catch (e: YieldModuleUpgradeUnavailableException) { + return WITHDRAW_GAS_LIMIT + } catch (e: YieldModuleVersionIndeterminateException) { + return WITHDRAW_GAS_LIMIT + } + + // Mirrors the real batch sub-call (see CreateAndSendGaslessTransactionUseCase.assembleGaslessPayload): + // `to = moduleAddress`, zero native value, withdraw call data. A zero-value Coin amount is required so + // that EthereumWalletManager.getGasLimit keeps `to` = moduleAddress — a Token amount would override it + // with the token contract address and estimate the wrong call. + val estimationAmount = Amount( + currencySymbol = token.symbol, + value = BigDecimal.ZERO, + decimals = token.decimals, + type = AmountType.Coin, + ) + + return when (val result = walletManager.getGasLimit(estimationAmount, moduleAddress, probeCallData)) { + is Result.Success -> result.data + is Result.Failure -> WITHDRAW_GAS_LIMIT + } + } + private fun createTokenAmount(token: CryptoCurrency.Token, value: BigDecimal): Amount = Amount( token = Token( symbol = token.symbol, @@ -217,6 +306,26 @@ internal class TokenFeeCalculator( const val PERCENT_TO_INCREASE_TOKEN_PRICE = 1 const val PERCENT_TO_INCREASE_TRANSFER_GASLIMIT = 10 + /** + * Fallback gas for the batch yield-withdraw operation (withdraw + possible module upgrade), used when + * the on-chain probe estimation in [estimateWithdrawGasLimit] is unavailable or reverts. Overestimate-safe + * because it only inflates maxTokenFee (a cap) and the signed per-call gas limit. + */ + val WITHDRAW_GAS_LIMIT: BigInteger = BigInteger("150000") + + /** + * Probe amount (in the fee token's minimal units) for the `withdraw` gas estimation. Per spec it is a + * small fixed value: large enough to simulate a real withdraw, small enough not to exceed the yield + * balance. The withdraw gas cost is effectively independent of the amount. + */ + const val PROBE_WITHDRAW_AMOUNT_MINIMAL_UNITS = 10_000L + + /** + * Fallback fee-transfer gas limit used when on-chain estimation reverts due to a zero plain balance on the + * yield path. TODO: tune against testnet if needs. + */ + val FALLBACK_FEE_TRANSFER_GAS_LIMIT: BigInteger = BigInteger("100000") + /** * Increases BigDecimal value by specified percentage. * diff --git a/domain/transaction/src/test/java/com/tangem/domain/transaction/models/GaslessBatchTransactionDataTest.kt b/domain/transaction/src/test/java/com/tangem/domain/transaction/models/GaslessBatchTransactionDataTest.kt new file mode 100644 index 0000000000..e3f05ba19f --- /dev/null +++ b/domain/transaction/src/test/java/com/tangem/domain/transaction/models/GaslessBatchTransactionDataTest.kt @@ -0,0 +1,25 @@ +package com.tangem.domain.transaction.models + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import java.math.BigInteger + +internal class GaslessBatchTransactionDataTest { + @Test + fun `holds transactions fee and nonce`() { + val tx = GaslessTransactionData.Transaction( + to = "0xabc", value = BigInteger.ZERO, gasLimit = BigInteger.valueOf(120_000), data = byteArrayOf(1), + ) + val withdraw = GaslessTransactionData.Transaction( + to = "0xdef", value = BigInteger.ZERO, gasLimit = BigInteger.valueOf(150_000), data = byteArrayOf(2), + ) + val fee = GaslessTransactionData.Fee( + feeToken = "0xtoken", maxTokenFee = BigInteger.TEN, coinPriceInToken = BigInteger.ONE, + feeTransferGasLimit = BigInteger.valueOf(100), baseGas = BigInteger.valueOf(60000), feeReceiver = "0xrecv", + ) + val batch = GaslessBatchTransactionData(transactions = listOf(tx, withdraw), fee = fee, nonce = BigInteger.ZERO) + + assertThat(batch.transactions).hasSize(2) + assertThat(batch.transactions[1]).isEqualTo(withdraw) + } +} \ No newline at end of file diff --git a/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/ComputeSendAmountInFeeTokenTest.kt b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/ComputeSendAmountInFeeTokenTest.kt new file mode 100644 index 0000000000..6bbb8a6b9c --- /dev/null +++ b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/ComputeSendAmountInFeeTokenTest.kt @@ -0,0 +1,155 @@ +package com.tangem.domain.transaction.usecase.gasless + +import arrow.core.raise.either +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.AmountType +import com.tangem.blockchain.common.Token +import com.tangem.blockchain.common.TransactionData +import com.tangem.domain.transaction.error.GetFeeError +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +/** + * Unit tests for [computeSendAmountInFeeToken]. + * + * Cases: + * (a) Different token → ZERO (fee token ≠ sent token). + * (b) Same token via AmountType.Token → the actual sent amount. + * (c) Same token via AmountType.TokenYieldSupply → the actual sent amount. + * (d) Same token but amount.value == null → raises (loud error, never silent ZERO). + * (e) Compiled tx → raises (gasless token-fee requires uncompiled data). + */ +class ComputeSendAmountInFeeTokenTest { + + private val feeContract = "0xUSDC" + private val otherContract = "0xDAI" + private val sentAmount = BigDecimal("50.0") + + private fun makeToken(contract: String) = Token( + name = "TestToken", + symbol = "TST", + contractAddress = contract, + decimals = 6, + ) + + private fun uncompiledWith(type: AmountType, value: BigDecimal?) = TransactionData.Uncompiled( + amount = Amount( + currencySymbol = "TST", + value = value, + maxValue = null, + decimals = 6, + type = type, + ), + sourceAddress = "0xSrc", + destinationAddress = "0xDst", + fee = null, + ) + + // (a) Sent token is different from fee token → ZERO + @Test + fun `returns ZERO when sent token differs from fee token`() { + val tx = uncompiledWith( + type = AmountType.Token(makeToken(otherContract)), + value = sentAmount, + ) + + val result = either { + computeSendAmountInFeeToken(tx, feeContract) + } + + assertTrue(result.isRight()) + assertEquals(BigDecimal.ZERO, result.getOrNull()) + } + + // (b) AmountType.Token — same contract as fee token → returns the sent amount + @Test + fun `returns sent amount when AmountType Token matches fee token contract`() { + val tx = uncompiledWith( + type = AmountType.Token(makeToken(feeContract)), + value = sentAmount, + ) + + val result = either { + computeSendAmountInFeeToken(tx, feeContract) + } + + assertTrue(result.isRight()) + assertEquals(sentAmount, result.getOrNull()) + } + + // (b) Case-insensitive contract address match + @Test + fun `contract address comparison is case-insensitive`() { + val tx = uncompiledWith( + type = AmountType.Token(makeToken(feeContract.uppercase())), + value = sentAmount, + ) + + val result = either { + computeSendAmountInFeeToken(tx, feeContract.lowercase()) + } + + assertTrue(result.isRight()) + assertEquals(sentAmount, result.getOrNull()) + } + + // (c) AmountType.TokenYieldSupply — same contract as fee token → returns the sent amount + @Test + fun `returns sent amount when AmountType TokenYieldSupply matches fee token contract`() { + val tx = uncompiledWith( + type = AmountType.TokenYieldSupply( + token = makeToken(feeContract), + isActive = true, + isInitialized = true, + isAllowedToSpend = true, + ), + value = sentAmount, + ) + + val result = either { + computeSendAmountInFeeToken(tx, feeContract) + } + + assertTrue(result.isRight()) + assertEquals(sentAmount, result.getOrNull()) + } + + // (d) Same token but amount.value == null → raises (never silently under-accounts as ZERO) + @Test + fun `raises when same token is sent but amount value is null`() { + val tx = uncompiledWith( + type = AmountType.Token(makeToken(feeContract)), + value = null, + ) + + val result = either { + computeSendAmountInFeeToken(tx, feeContract) + } + + assertTrue(result.isLeft(), "Expected Left (error) when sent amount is null") + assertTrue( + result.leftOrNull() is GetFeeError.DataError, + "Expected GetFeeError.DataError wrapping IllegalStateException", + ) + } + + // (e) Compiled tx → raises (gasless token-fee requires uncompiled data) + @Test + fun `raises when transactionData is Compiled`() { + val compiled = TransactionData.Compiled( + value = TransactionData.Compiled.Data.Bytes(byteArrayOf(0x01, 0x02)), + ) + + val result = either { + computeSendAmountInFeeToken(compiled, feeContract) + } + + assertTrue(result.isLeft(), "Expected Left (error) for compiled tx") + assertTrue( + result.leftOrNull() is GetFeeError.DataError, + "Expected GetFeeError.DataError wrapping IllegalStateException", + ) + } +} \ No newline at end of file diff --git a/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessDestinationAddressTest.kt b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessDestinationAddressTest.kt new file mode 100644 index 0000000000..2ed5a47562 --- /dev/null +++ b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessDestinationAddressTest.kt @@ -0,0 +1,96 @@ +package com.tangem.domain.transaction.usecase.gasless + +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.blockchains.ethereum.EthereumTransactionExtras +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.smartcontract.SmartContractCallData +import com.tangem.blockchain.yieldsupply.providers.ethereum.yield.EthereumYieldSupplySendCallData +import io.mockk.mockk +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +/** + * Unit tests for [CreateAndSendGaslessTransactionUseCase.getDestinationAddress] — resolves the on-chain + * `to` of the user's main gasless sub-call. + * + * Regression guard: a yield-supply send must target the user's yield MODULE (the contract that + * runs `send(token, dest, amount)`), not the transfer recipient. Targeting the recipient reverts the whole + * batch with GAS_ESTIMATION_FAILED / require(false). + */ +internal class CreateAndSendGaslessDestinationAddressTest { + + private val module = "0xmodule" + private val recipient = "0xrecipient" + private val tokenContract = "0xtokencontract" + + private fun uncompiled( + destinationAddress: String, + extras: EthereumTransactionExtras?, + contractAddress: String?, + ) = TransactionData.Uncompiled( + amount = mockk(relaxed = true), + fee = null, + sourceAddress = "0xsource", + destinationAddress = destinationAddress, + extras = extras, + contractAddress = contractAddress, + ) + + @Test + fun `GIVEN yield-supply send WHEN getDestinationAddress THEN returns module not recipient`() { + // Arrange — destinationAddress is patched to the yield module; the recipient lives inside the callData + val yieldCallData = EthereumYieldSupplySendCallData( + tokenContractAddress = tokenContract, + destinationAddress = recipient, + amount = mockk(relaxed = true), + ) + val txData = uncompiled( + destinationAddress = module, + extras = EthereumTransactionExtras(callData = yieldCallData), + contractAddress = tokenContract, + ) + + // Act + val to = CreateAndSendGaslessTransactionUseCase.getDestinationAddress(txData) + + // Assert + assertThat(to).isEqualTo(module) + } + + @Test + fun `GIVEN ERC20 transfer WHEN getDestinationAddress THEN returns token contract`() { + // Arrange — a non-yield callData; `to` must be the token contract, not the recipient + val erc20CallData = object : SmartContractCallData { + override val methodId = "0xa9059cbb" + override val data = byteArrayOf(0x01) + override fun validate(blockchain: Blockchain) = true + } + val txData = uncompiled( + destinationAddress = recipient, + extras = EthereumTransactionExtras(callData = erc20CallData), + contractAddress = tokenContract, + ) + + // Act + val to = CreateAndSendGaslessTransactionUseCase.getDestinationAddress(txData) + + // Assert + assertThat(to).isEqualTo(tokenContract) + } + + @Test + fun `GIVEN non-yield tx without contract address WHEN getDestinationAddress THEN throws`() { + // Arrange + val txData = uncompiled( + destinationAddress = recipient, + extras = null, + contractAddress = null, + ) + + // Act & Assert + assertThrows { + CreateAndSendGaslessTransactionUseCase.getDestinationAddress(txData) + } + } +} \ No newline at end of file diff --git a/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessPayloadTest.kt b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessPayloadTest.kt new file mode 100644 index 0000000000..5f6767206d --- /dev/null +++ b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessPayloadTest.kt @@ -0,0 +1,175 @@ +package com.tangem.domain.transaction.usecase.gasless + +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.smartcontract.SmartContractCallData +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.transaction.models.GaslessBatchTransactionData +import com.tangem.domain.transaction.models.GaslessFeePlan +import com.tangem.domain.transaction.models.GaslessTransactionData +import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase.GaslessPayload +import io.mockk.mockk +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.math.BigInteger + +/** + * Unit tests for [CreateAndSendGaslessTransactionUseCase.assembleGaslessPayload]. + * Pure function — no coroutines or SDK side-effects. + */ +internal class CreateAndSendGaslessPayloadTest { + + // ─── Common fixtures ───────────────────────────────────────────────────────── + + private val mainTx = GaslessTransactionData.Transaction( + to = "0xmain", + value = BigInteger.ZERO, + gasLimit = BigInteger.valueOf(120_000), + data = byteArrayOf(0x01, 0x02), + ) + + private val withdrawGasLimit = BigInteger.valueOf(150_000) + + private val feeObj = GaslessTransactionData.Fee( + feeToken = "0xtoken", + maxTokenFee = BigInteger.TEN, + coinPriceInToken = BigInteger.ONE, + feeTransferGasLimit = BigInteger.valueOf(60_000), + baseGas = BigInteger.valueOf(21_000), + feeReceiver = "0xrecv", + ) + + private val nonce = BigInteger.valueOf(42) + + // Minimal SmartContractCallData fake — only `data` is consumed by the SUT. + private val fakeWithdrawCallData = object : SmartContractCallData { + override val methodId: String = "0xfakeid" + override val data: ByteArray = byteArrayOf(0x12, 0x34) + override fun validate(blockchain: com.tangem.blockchain.common.Blockchain) = true + } + + private val fakeToken: CryptoCurrency.Token = mockk(relaxed = true) + private val fakeTokenFee: Fee.Ethereum.TokenCurrency = mockk(relaxed = true) + private val fakeNativeFee: Fee = mockk(relaxed = true) + + // ─── Case 1: TokenPayWithYieldWithdraw → GaslessPayload.Batch ──────────────── + + @Test + fun `TokenPayWithYieldWithdraw plan returns Batch with correct structure`() { + val plan = GaslessFeePlan.TokenPayWithYieldWithdraw( + feeToken = fakeToken, + fee = fakeTokenFee, + withdrawAmount = BigInteger.valueOf(7_000_001), + withdrawCallData = fakeWithdrawCallData, + yieldModuleAddress = "0xmodule", + ) + + val result = CreateAndSendGaslessTransactionUseCase.assembleGaslessPayload( + mainTx = mainTx, + feeObj = feeObj, + nonce = nonce, + plan = plan, + withdrawGasLimit = withdrawGasLimit, + ) + + assertThat(result).isInstanceOf(GaslessPayload.Batch::class.java) + val batch = (result as GaslessPayload.Batch).data + + // transactions list has exactly 2 entries + assertThat(batch.transactions).hasSize(2) + + // index 0 is the unchanged main transaction + assertThat(batch.transactions[0]).isEqualTo(mainTx) + + // index 1 is the yield-withdraw transaction + val withdrawTx = batch.transactions[1] + assertThat(withdrawTx.to).isEqualTo(plan.yieldModuleAddress) + assertThat(withdrawTx.value).isEqualTo(BigInteger.ZERO) + assertThat(withdrawTx.gasLimit).isEqualTo(withdrawGasLimit) + assertThat(withdrawTx.data).isEqualTo(fakeWithdrawCallData.data) + + // fee and nonce are carried through + assertThat(batch.fee).isEqualTo(feeObj) + assertThat(batch.nonce).isEqualTo(nonce) + } + + // ─── Case 2: TokenPay → GaslessPayload.Single ──────────────────────────────── + + @Test + fun `TokenPay plan returns Single wrapping mainTx feeObj and nonce`() { + val plan = GaslessFeePlan.TokenPay(feeToken = fakeToken, fee = fakeTokenFee) + + val result = CreateAndSendGaslessTransactionUseCase.assembleGaslessPayload( + mainTx = mainTx, + feeObj = feeObj, + nonce = nonce, + plan = plan, + withdrawGasLimit = null, + ) + + assertThat(result).isInstanceOf(GaslessPayload.Single::class.java) + val single = (result as GaslessPayload.Single).data + assertThat(single.transaction).isEqualTo(mainTx) + assertThat(single.fee).isEqualTo(feeObj) + assertThat(single.nonce).isEqualTo(nonce) + } + + // ─── Case 3: null plan → GaslessPayload.Single (same as TokenPay) ─────────── + + @Test + fun `null plan returns Single wrapping mainTx feeObj and nonce`() { + val result = CreateAndSendGaslessTransactionUseCase.assembleGaslessPayload( + mainTx = mainTx, + feeObj = feeObj, + nonce = nonce, + plan = null, + withdrawGasLimit = null, + ) + + assertThat(result).isInstanceOf(GaslessPayload.Single::class.java) + val single = (result as GaslessPayload.Single).data + assertThat(single.transaction).isEqualTo(mainTx) + assertThat(single.fee).isEqualTo(feeObj) + assertThat(single.nonce).isEqualTo(nonce) + } + + // ─── Case 4: NativePay → throws IllegalStateException ─────────────────────── + + @Test + fun `NativePay plan throws IllegalStateException`() { + val plan = GaslessFeePlan.NativePay(fee = fakeNativeFee) + + assertThrows { + CreateAndSendGaslessTransactionUseCase.assembleGaslessPayload( + mainTx = mainTx, + feeObj = feeObj, + nonce = nonce, + plan = plan, + withdrawGasLimit = null, + ) + } + } + + // ─── Case 5: yield-withdraw plan without a withdraw gas limit → throws ──────── + + @Test + fun `TokenPayWithYieldWithdraw plan without withdrawGasLimit throws IllegalStateException`() { + val plan = GaslessFeePlan.TokenPayWithYieldWithdraw( + feeToken = fakeToken, + fee = fakeTokenFee, + withdrawAmount = BigInteger.valueOf(7_000_001), + withdrawCallData = fakeWithdrawCallData, + yieldModuleAddress = "0xmodule", + ) + + assertThrows { + CreateAndSendGaslessTransactionUseCase.assembleGaslessPayload( + mainTx = mainTx, + feeObj = feeObj, + nonce = nonce, + plan = plan, + withdrawGasLimit = null, + ) + } + } +} \ No newline at end of file diff --git a/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/Eip712TypedDataBuilderBatchTest.kt b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/Eip712TypedDataBuilderBatchTest.kt new file mode 100644 index 0000000000..01dba6099e --- /dev/null +++ b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/Eip712TypedDataBuilderBatchTest.kt @@ -0,0 +1,41 @@ +package com.tangem.domain.transaction.usecase.gasless + +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.transaction.models.GaslessBatchTransactionData +import com.tangem.domain.transaction.models.GaslessTransactionData +import org.json.JSONObject +import org.junit.jupiter.api.Test +import java.math.BigInteger + +internal class Eip712TypedDataBuilderBatchTest { + + @Test + fun `buildBatch emits GaslessBatchTransaction primary type with transactions array`() { + val tx = GaslessTransactionData.Transaction( + to = "0xaaa", value = BigInteger.ZERO, gasLimit = BigInteger.valueOf(120_000), data = byteArrayOf(0x12), + ) + val withdraw = GaslessTransactionData.Transaction( + to = "0xbbb", value = BigInteger.ZERO, gasLimit = BigInteger.valueOf(150_000), data = byteArrayOf(0x34), + ) + val fee = GaslessTransactionData.Fee( + feeToken = "0xtoken", maxTokenFee = BigInteger.TEN, coinPriceInToken = BigInteger.ONE, + feeTransferGasLimit = BigInteger.valueOf(100), baseGas = BigInteger.valueOf(60000), feeReceiver = "0xrecv", + ) + val batch = GaslessBatchTransactionData(listOf(tx, withdraw), fee, BigInteger.ZERO) + + val json = JSONObject(Eip712TypedDataBuilder.buildBatch(batch, chainId = 1, verifyingContract = "0xuser")) + + assertThat(json.getString("primaryType")).isEqualTo("GaslessBatchTransaction") + val message = json.getJSONObject("message") + assertThat(message.getJSONArray("transactions").length()).isEqualTo(2) + assertThat(message.getJSONArray("transactions").getJSONObject(1).getString("to")).isEqualTo("0xbbb") + // v2: each sub-call carries its per-call gasLimit in the message + assertThat(message.getJSONArray("transactions").getJSONObject(1).getString("gasLimit")).isEqualTo("150000") + val types = json.getJSONObject("types").getJSONArray("GaslessBatchTransaction") + assertThat(types.getJSONObject(0).getString("type")).isEqualTo("Transaction[]") + // v2: the Transaction struct adds gasLimit between value and data + val txType = json.getJSONObject("types").getJSONArray("Transaction") + val txTypeFields = (0 until txType.length()).map { txType.getJSONObject(it).getString("name") } + assertThat(txTypeFields).containsExactly("to", "value", "gasLimit", "data").inOrder() + } +} \ No newline at end of file diff --git a/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/Eip712TypedDataBuilderTest.kt b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/Eip712TypedDataBuilderTest.kt new file mode 100644 index 0000000000..eeae86937b --- /dev/null +++ b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/Eip712TypedDataBuilderTest.kt @@ -0,0 +1,97 @@ +package com.tangem.domain.transaction.usecase.gasless + +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.transaction.models.GaslessTransactionData +import org.json.JSONObject +import org.junit.jupiter.api.Test +import java.math.BigInteger + +internal class Eip712TypedDataBuilderTest { + + @Test + fun `build emits GaslessTransaction primary type with per-call gasLimit in type and message`() { + // Arrange + val gaslessTransaction = GaslessTransactionData( + transaction = GaslessTransactionData.Transaction( + to = "0xaaa", + value = BigInteger.ZERO, + gasLimit = BigInteger.valueOf(120_000), + data = byteArrayOf(0x12, 0x34), + ), + fee = GaslessTransactionData.Fee( + feeToken = "0xtoken", + maxTokenFee = BigInteger.TEN, + coinPriceInToken = BigInteger.ONE, + feeTransferGasLimit = BigInteger.valueOf(60_000), + baseGas = BigInteger.valueOf(60_000), + feeReceiver = "0xrecv", + ), + nonce = BigInteger.ZERO, + ) + + // Act + val json = JSONObject( + Eip712TypedDataBuilder.build(gaslessTransaction, chainId = 137, verifyingContract = "0xuser"), + ) + + // Assert + assertThat(json.getString("primaryType")).isEqualTo("GaslessTransaction") + + // v2: the single transaction carries its per-call gasLimit in the message + val txMessage = json.getJSONObject("message").getJSONObject("transaction") + assertThat(txMessage.getString("gasLimit")).isEqualTo("120000") + + // v2: the Transaction struct adds gasLimit between value and data (order defines the EIP-712 typehash) + val txType = json.getJSONObject("types").getJSONArray("Transaction") + val txTypeFields = (0 until txType.length()).map { txType.getJSONObject(it).getString("name") } + assertThat(txTypeFields).containsExactly("to", "value", "gasLimit", "data").inOrder() + + // Domain is unchanged between v1/v2; verifyingContract is the user's EOA address + val domain = json.getJSONObject("domain") + assertThat(domain.getString("name")).isEqualTo("Tangem7702GaslessExecutor") + assertThat(domain.getString("version")).isEqualTo("1") + assertThat(domain.getString("verifyingContract")).isEqualTo("0xuser") + } + + @Test + fun `build with includeGasLimit false omits gasLimit reproducing the v1 typehash`() { + // Arrange + val gaslessTransaction = GaslessTransactionData( + transaction = GaslessTransactionData.Transaction( + to = "0xaaa", + value = BigInteger.ZERO, + gasLimit = BigInteger.valueOf(120_000), + data = byteArrayOf(0x12, 0x34), + ), + fee = GaslessTransactionData.Fee( + feeToken = "0xtoken", + maxTokenFee = BigInteger.TEN, + coinPriceInToken = BigInteger.ONE, + feeTransferGasLimit = BigInteger.valueOf(60_000), + baseGas = BigInteger.valueOf(60_000), + feeReceiver = "0xrecv", + ), + nonce = BigInteger.ZERO, + ) + + // Act — v1 mode (feature flag off) + val json = JSONObject( + Eip712TypedDataBuilder.build( + gaslessTransaction = gaslessTransaction, + chainId = 137, + verifyingContract = "0xuser", + includeGasLimit = false, + ), + ) + + // Assert: the Transaction struct is the legacy {to, value, data} — gasLimit drives the typehash, so its + // absence reproduces exactly the v1 hash the current develop signs. + val txType = json.getJSONObject("types").getJSONArray("Transaction") + val txTypeFields = (0 until txType.length()).map { txType.getJSONObject(it).getString("name") } + assertThat(txTypeFields).containsExactly("to", "value", "data").inOrder() + + // and the message carries no gasLimit + val txMessage = json.getJSONObject("message").getJSONObject("transaction") + assertThat(txMessage.has("gasLimit")).isFalse() + } +} \ No newline at end of file diff --git a/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCaseTest.kt b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCaseTest.kt new file mode 100644 index 0000000000..966f906af4 --- /dev/null +++ b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/GetAvailableFeeTokensUseCaseTest.kt @@ -0,0 +1,63 @@ +package com.tangem.domain.transaction.usecase.gasless + +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.yield.supply.YieldSupplyStatus +import com.tangem.domain.transaction.usecase.gasless.GetAvailableFeeTokensUseCase.Companion.isEligibleFeeToken +import com.tangem.test.core.ProvideTestModels +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import java.math.BigDecimal + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class GetAvailableFeeTokensUseCaseTest { + + @ParameterizedTest + @ProvideTestModels + fun isEligible(model: EligibilityModel) { + // Arrange + val status = createStatus(model.yieldSupplyStatus) + + // Act + val actual = isEligibleFeeToken(status, isYieldWithdrawEnabled = model.isYieldWithdrawEnabled) + + // Assert + assertThat(actual).isEqualTo(model.expected) + } + + private fun provideTestModels() = listOf( + // Plain token (no yield status) is always eligible, regardless of the toggle. + EligibilityModel(yieldSupplyStatus = null, isYieldWithdrawEnabled = false, expected = true), + EligibilityModel(yieldSupplyStatus = null, isYieldWithdrawEnabled = true, expected = true), + // Active yield: eligible only when gasless v2 (yield withdraw) is enabled. + EligibilityModel(yieldSupplyStatus = ACTIVE_YIELD, isYieldWithdrawEnabled = true, expected = true), + EligibilityModel(yieldSupplyStatus = ACTIVE_YIELD, isYieldWithdrawEnabled = false, expected = false), + // Inactive yield status: excluded either way (no module to withdraw from). + EligibilityModel(yieldSupplyStatus = INACTIVE_YIELD, isYieldWithdrawEnabled = true, expected = false), + EligibilityModel(yieldSupplyStatus = INACTIVE_YIELD, isYieldWithdrawEnabled = false, expected = false), + ) + + internal data class EligibilityModel( + val yieldSupplyStatus: YieldSupplyStatus?, + val isYieldWithdrawEnabled: Boolean, + val expected: Boolean, + ) + + private fun createStatus(yieldSupplyStatus: YieldSupplyStatus?): CryptoCurrencyStatus { + val status = mockk() + every { status.value.yieldSupplyStatus } returns yieldSupplyStatus + return status + } + + private companion object { + val ACTIVE_YIELD = YieldSupplyStatus( + isActive = true, + isInitialized = true, + isAllowedToSpend = true, + effectiveProtocolBalance = BigDecimal("100"), + ) + val INACTIVE_YIELD = ACTIVE_YIELD.copy(isActive = false) + } +} \ No newline at end of file diff --git a/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/ResolveGaslessFeePlanUseCaseTest.kt b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/ResolveGaslessFeePlanUseCaseTest.kt new file mode 100644 index 0000000000..8ecbed7d23 --- /dev/null +++ b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/ResolveGaslessFeePlanUseCaseTest.kt @@ -0,0 +1,425 @@ +package com.tangem.domain.transaction.usecase.gasless + +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.Token +import com.tangem.blockchain.common.smartcontract.SmartContractCallData +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.yieldsupply.providers.YieldModuleUpgradeUnavailableException +import com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionIndeterminateException +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.transaction.GaslessYieldRepository +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.models.GaslessFeePlan +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.math.BigDecimal +import java.math.BigInteger +import java.math.RoundingMode + +/** + * Unit tests for [ResolveGaslessFeePlanUseCase]. + * Covers every branch of the gasless fee decision tree. + */ +internal class ResolveGaslessFeePlanUseCaseTest { + + private lateinit var gaslessYieldRepository: GaslessYieldRepository + private lateinit var useCase: ResolveGaslessFeePlanUseCase + + private val mockUserWalletId: UserWalletId = mockk(relaxed = true) + private val mockUserWallet: UserWallet = mockk().also { + every { it.walletId } returns mockUserWalletId + } + + @BeforeEach + fun setup() { + gaslessYieldRepository = mockk() + useCase = ResolveGaslessFeePlanUseCase(gaslessYieldRepository) + } + + // ─── Case 1: plain balance >= required → TokenPay ────────────────────────── + + @Test + fun `plain balance covers fee returns TokenPay`() = runTest { + val tokenStatus = tokenStatus(plainBalance = BigDecimal("10"), decimals = 6) + val tokenFee = tokenFee(feeAmount = BigDecimal("5"), decimals = 6) + + val result = useCase( + userWallet = mockUserWallet, + tokenStatus = tokenStatus, + tokenFee = tokenFee, + isYieldActive = false, + sendAmountInFeeToken = BigDecimal.ZERO, + ) + + assertThat(result.isRight()).isTrue() + val plan = result.getOrNull() + assertThat(plan).isInstanceOf(GaslessFeePlan.TokenPay::class.java) + assertThat((plan as GaslessFeePlan.TokenPay).fee).isEqualTo(tokenFee) + } + + @Test + fun `plain balance equals required returns TokenPay`() = runTest { + val amount = BigDecimal("5") + val tokenStatus = tokenStatus(plainBalance = amount, decimals = 6) + val tokenFee = tokenFee(feeAmount = amount, decimals = 6) + + val result = useCase( + userWallet = mockUserWallet, + tokenStatus = tokenStatus, + tokenFee = tokenFee, + isYieldActive = false, + sendAmountInFeeToken = BigDecimal.ZERO, + ) + + assertThat(result.isRight()).isTrue() + assertThat(result.getOrNull()).isInstanceOf(GaslessFeePlan.TokenPay::class.java) + } + + // ─── Case 2: yield-active with no liquid → the whole fee is withdrawn from the module ── + + @Test + fun `yield active with no liquid withdraws the whole fee`() = runTest { + val decimals = 6 + // value.amount is effectiveBalance = liquid(EOA) + effectiveProtocolBalance. Here total == module + // balance (20), so liquid is 0 and the entire fee must be withdrawn from the module — the plan must + // not short-circuit to TokenPay. + // withdraw == feeAmount, CEILING-rounded: 10000000.5 → 10000001 (floor would give 10000000). + val feeAmount = BigDecimal("10.0000005") + val moduleBalance = BigDecimal("20") + val expectedWithdrawAmount = feeAmount + .movePointRight(decimals) + .setScale(0, RoundingMode.CEILING) + .toBigInteger() + val floorAmount = feeAmount.movePointRight(decimals).toBigInteger() // 10000000 + assertThat(expectedWithdrawAmount).isGreaterThan(floorAmount) + + // value.amount == module balance → liquid is 0, so the fee cannot be paid from the EOA (no TokenPay). + val tokenStatus = tokenStatus(plainBalance = moduleBalance, decimals = decimals) + val tokenFee = tokenFee(feeAmount = feeAmount, decimals = decimals) + val mockCallData = mockk(relaxed = true) + + coEvery { + gaslessYieldRepository.getEffectiveProtocolBalance(mockUserWalletId, any()) + } returns moduleBalance + + coEvery { + gaslessYieldRepository.createPartialWithdrawCallData( + userWalletId = mockUserWalletId, + cryptoCurrency = any(), + amount = any(), + ) + } returns mockCallData + + coEvery { + gaslessYieldRepository.getYieldContractAddress(mockUserWalletId, any()) + } returns "0xmodule" + + val result = useCase( + userWallet = mockUserWallet, + tokenStatus = tokenStatus, + tokenFee = tokenFee, + isYieldActive = true, + sendAmountInFeeToken = BigDecimal.ZERO, + ) + + assertThat(result.isRight()).isTrue() + val plan = result.getOrNull() as? GaslessFeePlan.TokenPayWithYieldWithdraw + assertThat(plan).isNotNull() + // Must be 10000001 (CEILING of the fee), not the module balance and not floor. + assertThat(plan!!.withdrawAmount).isEqualTo(expectedWithdrawAmount) + assertThat(plan.withdrawAmount).isEqualTo(BigInteger.valueOf(10_000_001)) + assertThat(plan.yieldModuleAddress).isEqualTo("0xmodule") + assertThat(plan.withdrawCallData).isEqualTo(mockCallData) + } + + // ─── Case 2b: send amount counts toward sufficiency but NOT toward the withdraw ──────────── + + @Test + fun `yield active withdraw covers only the fee not the send amount`() = runTest { + val decimals = 6 + // The main module.send tx moves the send amount from the module itself, so the fee-withdraw must + // cover ONLY the fee. Including the send amount would withdraw it twice and overdraw the module. + val feeAmount = BigDecimal("3.0") + val sendAmountInFeeToken = BigDecimal("1.5") + val moduleBalance = BigDecimal("5.0") // covers required = fee(3.0) + send(1.5) = 4.5 ✓ + val expectedWithdrawAmount = feeAmount + .movePointRight(decimals) + .setScale(0, RoundingMode.CEILING) + .toBigInteger() // 3000000 — the FEE only, NOT 4.5 + + val tokenStatus = tokenStatus(plainBalance = moduleBalance, decimals = decimals) + val tokenFee = tokenFee(feeAmount = feeAmount, decimals = decimals) + val mockCallData = mockk(relaxed = true) + + coEvery { + gaslessYieldRepository.getEffectiveProtocolBalance(mockUserWalletId, any()) + } returns moduleBalance + + coEvery { + gaslessYieldRepository.createPartialWithdrawCallData( + userWalletId = mockUserWalletId, + cryptoCurrency = any(), + amount = any(), + ) + } returns mockCallData + + coEvery { + gaslessYieldRepository.getYieldContractAddress(mockUserWalletId, any()) + } returns "0xmodule" + + val result = useCase( + userWallet = mockUserWallet, + tokenStatus = tokenStatus, + tokenFee = tokenFee, + isYieldActive = true, + sendAmountInFeeToken = sendAmountInFeeToken, + ) + + assertThat(result.isRight()).isTrue() + val plan = result.getOrNull() as? GaslessFeePlan.TokenPayWithYieldWithdraw + assertThat(plan).isNotNull() + assertThat(plan!!.withdrawAmount).isEqualTo(expectedWithdrawAmount) + assertThat(plan.withdrawAmount).isEqualTo(BigInteger.valueOf(3_000_000)) + assertThat(plan.yieldModuleAddress).isEqualTo("0xmodule") + assertThat(plan.withdrawCallData).isEqualTo(mockCallData) + } + + // ─── Case 2c: module cannot cover send + fee → NotEnoughFunds ────────────── + + @Test + fun `yield active module cannot cover send plus fee returns NotEnoughFunds`() = runTest { + val tokenStatus = tokenStatus(plainBalance = BigDecimal("4"), decimals = 6) + val tokenFee = tokenFee(feeAmount = BigDecimal("3"), decimals = 6) + + // required = fee(3) + send(1.5) = 4.5, but the module holds only 4.0 + coEvery { + gaslessYieldRepository.getEffectiveProtocolBalance(mockUserWalletId, any()) + } returns BigDecimal("4.0") + + val result = useCase( + userWallet = mockUserWallet, + tokenStatus = tokenStatus, + tokenFee = tokenFee, + isYieldActive = true, + sendAmountInFeeToken = BigDecimal("1.5"), + ) + + assertThat(result.isLeft()).isTrue() + assertThat(result.leftOrNull()).isInstanceOf(GetFeeError.GaslessError.NotEnoughFunds::class.java) + } + + // ─── Case 3: plain insufficient, isYieldActive=false → NotEnoughFunds ────── + + @Test + fun `plain insufficient yield inactive returns NotEnoughFunds`() = runTest { + val tokenStatus = tokenStatus(plainBalance = BigDecimal("1"), decimals = 6) + val tokenFee = tokenFee(feeAmount = BigDecimal("5"), decimals = 6) + + val result = useCase( + userWallet = mockUserWallet, + tokenStatus = tokenStatus, + tokenFee = tokenFee, + isYieldActive = false, + sendAmountInFeeToken = BigDecimal.ZERO, + ) + + assertThat(result.isLeft()).isTrue() + assertThat(result.leftOrNull()).isInstanceOf(GetFeeError.GaslessError.NotEnoughFunds::class.java) + } + + // ─── Case 4: YieldModuleUpgradeUnavailableException → ModuleUpdateUnavailable + + @Test + fun `createPartialWithdrawCallData throws UpgradeUnavailableException returns ModuleUpdateUnavailable`() = runTest { + // total(10) covers the fee(5) and liquid(0) does not, so the flow reaches the module withdraw. + val tokenStatus = tokenStatus(plainBalance = BigDecimal("10"), decimals = 6) + val tokenFee = tokenFee(feeAmount = BigDecimal("5"), decimals = 6) + + coEvery { + gaslessYieldRepository.getEffectiveProtocolBalance(mockUserWalletId, any()) + } returns BigDecimal("10") + + coEvery { + gaslessYieldRepository.createPartialWithdrawCallData(any(), any(), any()) + } throws YieldModuleUpgradeUnavailableException("0xold") + + val result = useCase( + userWallet = mockUserWallet, + tokenStatus = tokenStatus, + tokenFee = tokenFee, + isYieldActive = true, + sendAmountInFeeToken = BigDecimal.ZERO, + ) + + assertThat(result.isLeft()).isTrue() + assertThat(result.leftOrNull()).isInstanceOf(GetFeeError.GaslessError.ModuleUpdateUnavailable::class.java) + } + + // ─── Case 5: plain + yield < required → NotEnoughFunds ───────────────────── + + @Test + fun `plain plus yield insufficient returns NotEnoughFunds`() = runTest { + // total(6) = liquid(1) + module(5) < fee(10) → not enough funds anywhere. + val tokenStatus = tokenStatus(plainBalance = BigDecimal("6"), decimals = 6) + val tokenFee = tokenFee(feeAmount = BigDecimal("10"), decimals = 6) + + coEvery { + gaslessYieldRepository.getEffectiveProtocolBalance(mockUserWalletId, any()) + } returns BigDecimal("5") // liquid 1 + module 5 = 6 < 10 + + val result = useCase( + userWallet = mockUserWallet, + tokenStatus = tokenStatus, + tokenFee = tokenFee, + isYieldActive = true, + sendAmountInFeeToken = BigDecimal.ZERO, + ) + + assertThat(result.isLeft()).isTrue() + assertThat(result.leftOrNull()).isInstanceOf(GetFeeError.GaslessError.NotEnoughFunds::class.java) + } + + // ─── Case 6: YieldModuleVersionIndeterminateException → ModuleUpdateUnavailable + + @Test + fun `createPartialWithdrawCallData throws VersionIndeterminateException returns ModuleUpdateUnavailable`() = runTest { + // total(10) covers the fee(5) and liquid(0) does not, so the flow reaches the module withdraw. + val tokenStatus = tokenStatus(plainBalance = BigDecimal("10"), decimals = 6) + val tokenFee = tokenFee(feeAmount = BigDecimal("5"), decimals = 6) + + coEvery { + gaslessYieldRepository.getEffectiveProtocolBalance(mockUserWalletId, any()) + } returns BigDecimal("10") + + coEvery { + gaslessYieldRepository.createPartialWithdrawCallData(any(), any(), any()) + } throws YieldModuleVersionIndeterminateException("rpc error") + + val result = useCase( + userWallet = mockUserWallet, + tokenStatus = tokenStatus, + tokenFee = tokenFee, + isYieldActive = true, + sendAmountInFeeToken = BigDecimal.ZERO, + ) + + assertThat(result.isLeft()).isTrue() + assertThat(result.leftOrNull()).isInstanceOf(GetFeeError.GaslessError.ModuleUpdateUnavailable::class.java) + } + + // ─── Case 7: liquid EOA balance covers most of send+fee, protocol alone does not ────────────── + + @Test + fun `GIVEN liquid covers send but protocol alone does not WHEN yield active THEN TokenPayWithYieldWithdraw`() = + runTest { + // value.amount is effectiveBalance (liquid EOA + effectiveProtocolBalance). The user sends 3.00 of + // 3.585624 total. The yield module (effectiveProtocolBalance) holds only 0.6, the rest (2.985624) + // is liquid on the EOA. required = send(3.00) + fee(0.05) = 3.05 < total(3.585624), so funds ARE + // sufficient. The old check compared the module balance (0.6) against required and wrongly raised + // NotEnoughFunds. + val decimals = 6 + val totalBalance = BigDecimal("3.585624") + val moduleBalance = BigDecimal("0.6") + val feeAmount = BigDecimal("0.05") + val sendAmount = BigDecimal("3.00") + // module.send consumes EOA liquid first, leaving 0 for the fee, so the whole fee must be withdrawn. + val expectedWithdrawAmount = feeAmount + .movePointRight(decimals) + .setScale(0, RoundingMode.CEILING) + .toBigInteger() + + val tokenStatus = tokenStatus(plainBalance = totalBalance, decimals = decimals) + val tokenFee = tokenFee(feeAmount = feeAmount, decimals = decimals) + val mockCallData = mockk(relaxed = true) + + coEvery { + gaslessYieldRepository.getEffectiveProtocolBalance(mockUserWalletId, any()) + } returns moduleBalance + coEvery { + gaslessYieldRepository.createPartialWithdrawCallData(mockUserWalletId, any(), any()) + } returns mockCallData + coEvery { + gaslessYieldRepository.getYieldContractAddress(mockUserWalletId, any()) + } returns "0xmodule" + + // Act + val result = useCase( + userWallet = mockUserWallet, + tokenStatus = tokenStatus, + tokenFee = tokenFee, + isYieldActive = true, + sendAmountInFeeToken = sendAmount, + ) + + // Assert + assertThat(result.isRight()).isTrue() + val plan = result.getOrNull() as? GaslessFeePlan.TokenPayWithYieldWithdraw + assertThat(plan).isNotNull() + assertThat(plan!!.withdrawAmount).isEqualTo(expectedWithdrawAmount) + } + + // ─── Case 8: liquid EOA balance alone covers send + fee → no withdraw needed ─────────────────── + + @Test + fun `GIVEN liquid covers send plus fee WHEN yield active THEN TokenPay without withdraw`() = runTest { + // Arrange — liquid = total(10) - module(2) = 8, which already covers required = send(3) + fee(1) = 4. + // The EOA holds enough after the main send to settle the fee, so no yield withdraw is needed. + val tokenStatus = tokenStatus(plainBalance = BigDecimal("10"), decimals = 6) + val tokenFee = tokenFee(feeAmount = BigDecimal("1"), decimals = 6) + + coEvery { + gaslessYieldRepository.getEffectiveProtocolBalance(mockUserWalletId, any()) + } returns BigDecimal("2") + + // Act + val result = useCase( + userWallet = mockUserWallet, + tokenStatus = tokenStatus, + tokenFee = tokenFee, + isYieldActive = true, + sendAmountInFeeToken = BigDecimal("3"), + ) + + // Assert + assertThat(result.isRight()).isTrue() + assertThat(result.getOrNull()).isInstanceOf(GaslessFeePlan.TokenPay::class.java) + } + + // ─── Helpers ──────────────────────────────────────────────────────────────── + + private fun tokenStatus( + plainBalance: BigDecimal = BigDecimal("100"), + decimals: Int = 6, + ): CryptoCurrencyStatus { + val token = mockk(relaxed = true) + every { token.symbol } returns "USDC" + every { token.contractAddress } returns "0xUSDC" + every { token.decimals } returns decimals + + val status = mockk() + every { status.currency } returns token + every { status.value.amount } returns plainBalance + + return status + } + + private fun tokenFee(feeAmount: BigDecimal, decimals: Int = 6): Fee.Ethereum.TokenCurrency { + val blockchainToken = Token(symbol = "USDC", contractAddress = "0xUSDC", decimals = decimals) + val amount = Amount(token = blockchainToken, value = feeAmount) + return Fee.Ethereum.TokenCurrency( + amount = amount, + gasLimit = BigInteger("100000"), + coinPriceInToken = BigInteger("2000000000"), + feeTransferGasLimit = BigInteger("60000"), + baseGas = BigInteger("21000"), + ) + } +} \ No newline at end of file diff --git a/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculatorTest.kt b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculatorTest.kt index 8dffa62c8b..40fe32b035 100644 --- a/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculatorTest.kt +++ b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/gasless/TokenFeeCalculatorTest.kt @@ -14,7 +14,10 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.yield.supply.YieldSupplyStatus +import com.tangem.blockchain.common.smartcontract.SmartContractCallData import com.tangem.domain.transaction.GaslessTransactionRepository +import com.tangem.domain.transaction.GaslessYieldRepository import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.walletmanager.WalletManagersFacade import io.mockk.coEvery @@ -36,6 +39,7 @@ class TokenFeeCalculatorTest { private lateinit var walletManagersFacade: WalletManagersFacade private lateinit var gaslessTransactionRepository: GaslessTransactionRepository + private lateinit var gaslessYieldRepository: GaslessYieldRepository private lateinit var demoConfig: DemoConfig private lateinit var tokenFeeCalculator: TokenFeeCalculator @@ -49,12 +53,14 @@ class TokenFeeCalculatorTest { fun setup() { walletManagersFacade = mockk() gaslessTransactionRepository = mockk() + gaslessYieldRepository = mockk() demoConfig = mockk() tokenFeeCalculator = TokenFeeCalculator( walletManagersFacade = walletManagersFacade, gaslessTransactionRepository = gaslessTransactionRepository, demoConfig = demoConfig, + gaslessYieldRepository = gaslessYieldRepository, ) mockWalletManager = mockk() @@ -215,6 +221,9 @@ class TokenFeeCalculatorTest { assertNotNull(feeExtended) assertEquals(tokenStatus.currency.id, feeExtended.feeTokenId) assertTrue(feeExtended.transactionFee is TransactionFee.Single) + // main-tx per-call gas = initialFee.gasLimit; no withdraw on the non-yield path + assertEquals(BigInteger("100000"), feeExtended.mainTransactionGasLimit) + assertNull(feeExtended.withdrawGasLimit) } } @@ -413,6 +422,283 @@ class TokenFeeCalculatorTest { } } + // ===== Yield-path Tests ===== + + /** + * With active yield, a token whose plain balance is small (not enough to pay the fee on its own) must NOT + * raise NotEnoughFunds — the resolver decides coverage. The gas limit must include the extra withdraw gas. + * + * Here `userWallet` is not passed (null), so the withdraw gas estimation is skipped and the + * deterministic fallback [WITHDRAW_GAS_LIMIT] is used. + * + * Expected gasLimit breakdown (matching companion constants): + * initialFee.gasLimit = 100_000 + * feeTransferGasLimit = 60_000 * 1.10 = 66_000 + * baseGas = 21_000 + * WITHDRAW_GAS_LIMIT = 150_000 + * total = 337_000 + */ + @Test + fun `calculateTokenFee with active yield but no wallet falls back to WITHDRAW_GAS_LIMIT`() = runTest { + // Given + val activeYieldStatus = YieldSupplyStatus( + isActive = true, + isInitialized = true, + isAllowedToSpend = true, + effectiveProtocolBalance = BigDecimal("100"), // yield covers the rest + ) + val tokenStatus = createMockTokenStatus( + balance = BigDecimal("0.001"), // tiny plain balance — insufficient on its own + fiatRate = BigDecimal("1"), + ).withYieldSupplyStatus(activeYieldStatus) + + val nativeStatus = createMockNativeCurrencyStatus(fiatRate = BigDecimal("2000")) + val initialFee = createMockEIP1559Fee() // gasLimit = 100_000 + + coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Success(BigInteger("60000")) + coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver" + every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000") + + // When + val result = tokenFeeCalculator.calculateTokenFee( + walletManager = mockWalletManager, + tokenForPayFeeStatus = tokenStatus, + nativeCurrencyStatus = nativeStatus, + initialFee = initialFee, + isYieldActive = true, + ) + + // Then + assertTrue(result.isRight(), "Expected success on yield path with small plain balance") + result.onRight { feeExtended -> + val fee = feeExtended.transactionFee.normal as Fee.Ethereum.TokenCurrency + // gasLimit = 100_000 + 66_000 + 21_000 + 150_000 = 337_000 + assertEquals(BigInteger("337000"), fee.gasLimit, "gasLimit must include WITHDRAW_GAS_LIMIT (150000)") + // feeTransferGasLimit stored in the fee object = 66_000 + assertEquals(BigInteger("66000"), fee.feeTransferGasLimit, "feeTransferGasLimit = 60000 * 1.10") + // v2 per-call gas limits: main = initialFee.gasLimit, withdraw = WITHDRAW_GAS_LIMIT + assertEquals(BigInteger("100000"), feeExtended.mainTransactionGasLimit) + assertEquals(BigInteger("150000"), feeExtended.withdrawGasLimit) + } + } + + /** + * With active yield, when getGasLimit reverts due to zero plain balance + * (BlockchainSdkError.Ethereum.InsufficientFundsForOperation wrapped in WrappedThrowable), + * calculateTokenFee must use the deterministic FALLBACK_FEE_TRANSFER_GAS_LIMIT (100_000) instead of raising. + * + * Expected breakdown: + * initialFee.gasLimit = 100_000 + * feeTransferGasLimit = 100_000 * 1.10 = 110_000 (FALLBACK_FEE_TRANSFER_GAS_LIMIT * 1.10) + * baseGas = 21_000 + * WITHDRAW_GAS_LIMIT = 150_000 + * total gasLimit = 381_000 + */ + @Test + fun `calculateTokenFee with active yield uses fallback gas when transfer estimation reverts with insufficient funds`() = + runTest { + // Given + val activeYieldStatus = YieldSupplyStatus( + isActive = true, + isInitialized = true, + isAllowedToSpend = true, + effectiveProtocolBalance = BigDecimal("100"), + ) + // Zero plain balance — exactly the condition that causes estimation revert + val tokenStatus = createMockTokenStatus( + balance = BigDecimal("0"), + fiatRate = BigDecimal("1"), + ).withYieldSupplyStatus(activeYieldStatus) + + val nativeStatus = createMockNativeCurrencyStatus(fiatRate = BigDecimal("2000")) + val initialFee = createMockEIP1559Fee() // gasLimit = 100_000 + + // Simulate on-chain estimation reverting with InsufficientFundsForOperation + val insufficientFundsException = + BlockchainSdkError.Ethereum.InsufficientFundsForOperation("insufficient funds for gas") + val wrappedError = BlockchainSdkError.WrappedThrowable(insufficientFundsException) + coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Failure(wrappedError) + coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver" + every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000") + + // When + val result = tokenFeeCalculator.calculateTokenFee( + walletManager = mockWalletManager, + tokenForPayFeeStatus = tokenStatus, + nativeCurrencyStatus = nativeStatus, + initialFee = initialFee, + isYieldActive = true, + ) + + // Then + assertTrue(result.isRight(), "Expected success with fallback gas on yield path") + result.onRight { feeExtended -> + val fee = feeExtended.transactionFee.normal as Fee.Ethereum.TokenCurrency + // feeTransferGasLimit = FALLBACK_FEE_TRANSFER_GAS_LIMIT (100_000) * 1.10 = 110_000 + assertEquals( + BigInteger("110000"), + fee.feeTransferGasLimit, + "feeTransferGasLimit must use fallback (100000 * 1.10 = 110000)", + ) + // gasLimit = 100_000 + 110_000 + 21_000 + 150_000 = 381_000 + assertEquals( + BigInteger("381000"), + fee.gasLimit, + "gasLimit must include WITHDRAW_GAS_LIMIT (150000)", + ) + } + } + + /** + * Confirms that the non-yield path (isYieldActive = false, default) is unchanged: + * a token with insufficient plain balance still raises NotEnoughFunds. + */ + @Test + fun `calculateTokenFee without yield still raises NotEnoughFunds on insufficient balance`() = runTest { + // Given + val tokenStatus = createMockTokenStatus( + balance = BigDecimal("0.001"), // very small — insufficient + fiatRate = BigDecimal("1"), + ) + val nativeStatus = createMockNativeCurrencyStatus(fiatRate = BigDecimal("2000")) + val initialFee = createMockEIP1559Fee() + + coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Success(BigInteger("60000")) + coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver" + every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000") + + // When — default isYieldActive = false + val result = tokenFeeCalculator.calculateTokenFee( + walletManager = mockWalletManager, + tokenForPayFeeStatus = tokenStatus, + nativeCurrencyStatus = nativeStatus, + initialFee = initialFee, + ) + + // Then + assertTrue(result.isLeft(), "Non-yield path must still raise NotEnoughFunds for insufficient balance") + result.onLeft { error -> + assertTrue(error is GetFeeError.GaslessError.NotEnoughFunds) + } + } + + /** + * With active yield AND a wallet, the withdraw gas limit is estimated on-chain via a probe + * `withdraw(yieldToken, 10000)` against the yield module. The estimated value (here 200_000) flows into + * BOTH the maxTokenFee cap and the signed per-call withdraw gas limit — not the hardcoded fallback. + * + * Expected gasLimit breakdown: + * initialFee.gasLimit = 100_000 + * feeTransferGasLimit = 60_000 * 1.10 = 66_000 + * baseGas = 21_000 + * estimated withdraw = 200_000 + * total = 387_000 + */ + @Test + fun `calculateTokenFee with active yield and wallet estimates withdraw gas on-chain`() = runTest { + // Given + val activeYieldStatus = YieldSupplyStatus( + isActive = true, + isInitialized = true, + isAllowedToSpend = true, + effectiveProtocolBalance = BigDecimal("100"), + ) + val tokenStatus = createMockTokenStatus( + balance = BigDecimal("0.001"), + fiatRate = BigDecimal("1"), + ).withYieldSupplyStatus(activeYieldStatus) + + val nativeStatus = createMockNativeCurrencyStatus(fiatRate = BigDecimal("2000")) + val initialFee = createMockEIP1559Fee() // gasLimit = 100_000 + + coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver" + every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000") + // fee-transfer estimation (to the fee receiver) vs. withdraw estimation (to the yield module) + coEvery { + mockWalletManager.getGasLimit(any(), "0xFeeReceiver", any()) + } returns Result.Success(BigInteger("60000")) + coEvery { + mockWalletManager.getGasLimit(any(), "0xModule", any()) + } returns Result.Success(BigInteger("200000")) + coEvery { + gaslessYieldRepository.getYieldContractAddress(mockUserWalletId, any()) + } returns "0xModule" + coEvery { + gaslessYieldRepository.createPartialWithdrawCallData(mockUserWalletId, any(), any()) + } returns mockk(relaxed = true) + + // When + val result = tokenFeeCalculator.calculateTokenFee( + walletManager = mockWalletManager, + tokenForPayFeeStatus = tokenStatus, + nativeCurrencyStatus = nativeStatus, + initialFee = initialFee, + isYieldActive = true, + userWallet = mockUserWallet, + ) + + // Then + assertTrue(result.isRight(), "Expected success on yield path with on-chain withdraw estimation") + result.onRight { feeExtended -> + val fee = feeExtended.transactionFee.normal as Fee.Ethereum.TokenCurrency + // gasLimit = 100_000 + 66_000 + 21_000 + 200_000 = 387_000 + assertEquals(BigInteger("387000"), fee.gasLimit, "gasLimit must include the estimated withdraw gas") + // v2 per-call gas limits: main = initialFee.gasLimit, withdraw = estimated 200_000 + assertEquals(BigInteger("100000"), feeExtended.mainTransactionGasLimit) + assertEquals(BigInteger("200000"), feeExtended.withdrawGasLimit) + } + coVerify { gaslessYieldRepository.getYieldContractAddress(mockUserWalletId, any()) } + coVerify { mockWalletManager.getGasLimit(any(), "0xModule", any()) } + } + + /** + * When the yield module address is unavailable (e.g. module not yet deployed), the on-chain estimation + * is skipped and the calculator falls back to [WITHDRAW_GAS_LIMIT] — even though a wallet is provided. + */ + @Test + fun `calculateTokenFee with active yield falls back when yield module address is unavailable`() = runTest { + // Given + val activeYieldStatus = YieldSupplyStatus( + isActive = true, + isInitialized = true, + isAllowedToSpend = true, + effectiveProtocolBalance = BigDecimal("100"), + ) + val tokenStatus = createMockTokenStatus( + balance = BigDecimal("0.001"), + fiatRate = BigDecimal("1"), + ).withYieldSupplyStatus(activeYieldStatus) + + val nativeStatus = createMockNativeCurrencyStatus(fiatRate = BigDecimal("2000")) + val initialFee = createMockEIP1559Fee() + + coEvery { mockWalletManager.getGasLimit(any(), any(), any()) } returns Result.Success(BigInteger("60000")) + coEvery { gaslessTransactionRepository.getTokenFeeReceiverAddress() } returns "0xFeeReceiver" + every { gaslessTransactionRepository.getBaseGasForTransaction() } returns BigInteger("21000") + coEvery { gaslessYieldRepository.getYieldContractAddress(mockUserWalletId, any()) } returns null + + // When + val result = tokenFeeCalculator.calculateTokenFee( + walletManager = mockWalletManager, + tokenForPayFeeStatus = tokenStatus, + nativeCurrencyStatus = nativeStatus, + initialFee = initialFee, + isYieldActive = true, + userWallet = mockUserWallet, + ) + + // Then + assertTrue(result.isRight()) + result.onRight { feeExtended -> + // gasLimit = 100_000 + 66_000 + 21_000 + 150_000 (fallback) = 337_000 + val fee = feeExtended.transactionFee.normal as Fee.Ethereum.TokenCurrency + assertEquals(BigInteger("337000"), fee.gasLimit) + assertEquals(BigInteger("150000"), feeExtended.withdrawGasLimit) + } + // withdraw estimation must NOT be attempted without a module address + coVerify(exactly = 0) { gaslessYieldRepository.createPartialWithdrawCallData(any(), any(), any()) } + } + // ===== Helper Methods ===== private fun createMockTransactionFee(): TransactionFee { @@ -455,6 +741,21 @@ class TokenFeeCalculatorTest { return status } + /** + * Returns a copy of this [CryptoCurrencyStatus] mock with [yieldSupplyStatus] overridden. + * Since [CryptoCurrencyStatus] is a mockk, we create a new mock that delegates everything and + * overrides only [yieldSupplyStatus]. + */ + private fun CryptoCurrencyStatus.withYieldSupplyStatus(yieldSupplyStatus: YieldSupplyStatus?): CryptoCurrencyStatus { + val original = this + val newStatus = mockk() + every { newStatus.currency } returns original.currency + every { newStatus.value.amount } returns original.value.amount + every { newStatus.value.fiatRate } returns original.value.fiatRate + every { newStatus.value.yieldSupplyStatus } returns yieldSupplyStatus + return newStatus + } + private fun createMockNativeCurrencyStatus( fiatRate: BigDecimal? = BigDecimal("2000"), decimals: Int = 18, @@ -471,4 +772,4 @@ class TokenFeeCalculatorTest { return status } -} +} \ No newline at end of file diff --git a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyTransactionRepository.kt b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyTransactionRepository.kt index cfb76be389..1cc70f6c46 100644 --- a/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyTransactionRepository.kt +++ b/domain/yield-supply/src/main/java/com/tangem/domain/yield/supply/YieldSupplyTransactionRepository.kt @@ -3,13 +3,14 @@ package com.tangem.domain.yield.supply import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.smartcontract.SmartContractCallData import com.tangem.blockchain.common.transaction.Fee -import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionStatus import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.transaction.GaslessYieldRepository import java.math.BigDecimal -interface YieldSupplyTransactionRepository { +interface YieldSupplyTransactionRepository : GaslessYieldRepository { suspend fun createEnterTransactions( userWalletId: UserWalletId, @@ -23,10 +24,6 @@ interface YieldSupplyTransactionRepository { fee: Fee?, ): TransactionData.Uncompiled - suspend fun getYieldContractAddress(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): String? - - suspend fun getEffectiveProtocolBalance(userWalletId: UserWalletId, cryptoCurrency: CryptoCurrency): BigDecimal? - /** * Checks the version status of the user's yield-module contract and wraps [callData] with an * upgrade transaction if the deployed version is out of date. @@ -36,4 +33,7 @@ interface YieldSupplyTransactionRepository { network: Network, callData: SmartContractCallData, ): SmartContractCallData + + /** Returns the on-chain version status of the user's yield module for [network]. */ + suspend fun getYieldModuleVersionStatus(userWalletId: UserWalletId, network: Network): YieldModuleVersionStatus } \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/exchange/ExchangeStatusBlock.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/exchange/ExchangeStatusBlock.kt deleted file mode 100644 index eb95cd174f..0000000000 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/exchange/ExchangeStatusBlock.kt +++ /dev/null @@ -1,267 +0,0 @@ -package com.tangem.feature.tokendetails.presentation.tokendetails.ui.components.express.exchange - -import androidx.annotation.DrawableRes -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -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.graphics.Color -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.tooling.preview.Preview -import com.tangem.core.ui.components.SpacerWMax -import com.tangem.core.ui.extensions.resolveReference -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.stringResourceSafe -import com.tangem.core.ui.res.TangemTheme -import com.tangem.core.ui.res.TangemThemePreview -import com.tangem.feature.swap.domain.models.domain.ExchangeStatus -import com.tangem.feature.swap.domain.models.domain.ExchangeStatus.Companion.isFailed -import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeStatusState -import com.tangem.features.tokendetails.impl.R -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toImmutableList - -@Deprecated("Use ExpressStatusBlock from common") -@Composable -internal fun ExchangeStatusBlock( - statuses: ImmutableList, - showLink: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - Column( - modifier = modifier - .clip(TangemTheme.shapes.roundedCornersXMedium) - .background(TangemTheme.colors.background.action) - .padding( - vertical = TangemTheme.dimens.spacing14, - horizontal = TangemTheme.dimens.spacing12, - ), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .padding(bottom = TangemTheme.dimens.spacing16), - ) { - Text( - text = stringResourceSafe(id = R.string.express_exchange_status_title), - style = TangemTheme.typography.subtitle2, - color = TangemTheme.colors.text.tertiary, - ) - SpacerWMax() - AnimatedVisibility(visible = showLink) { - Row( - modifier = Modifier.clickable { onClick() }, - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - painter = painterResource(id = R.drawable.ic_arrow_top_right_24), - contentDescription = null, - tint = TangemTheme.colors.icon.informative, - modifier = Modifier - .size(TangemTheme.dimens.spacing16) - .padding(end = TangemTheme.dimens.spacing2), - ) - Text( - text = stringResourceSafe(id = R.string.common_go_to_provider), - style = TangemTheme.typography.body2, - color = TangemTheme.colors.text.tertiary, - ) - } - } - } - - AnimatedContent(targetState = statuses.lastIndex, label = "Exchange Status List Change") { - Column { - statuses.forEachIndexed { index, item -> - ExchangeStatusStep( - stepStatus = item, - isLast = index == it, - ) - } - } - } - } -} - -@Composable -private fun ExchangeStatusStep( - stepStatus: ExchangeStatusState, - modifier: Modifier = Modifier, - isLast: Boolean = false, -) { - Row(modifier = modifier) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - ) { - AnimatedContent( - targetState = stepStatus, - label = "Exchange Step Change Success", - modifier = Modifier - .size(TangemTheme.dimens.size20), - ) { state -> - when { - state.status == ExchangeStatus.Cancelled -> { - ExchangeStep( - iconRes = R.drawable.ic_close_24, - color = TangemTheme.colors.icon.warning, - isDone = false, - ) - } - state.status.isFailed() || - state.status == ExchangeStatus.Refunded || - state.status == ExchangeStatus.Paused - -> { - ExchangeStep( - iconRes = R.drawable.ic_close_24, - color = TangemTheme.colors.icon.warning, - isDone = state.isDone, - ) - } - state.status == ExchangeStatus.Verifying -> ExchangeStep( - iconRes = R.drawable.ic_exclamation_24, - color = TangemTheme.colors.icon.attention, - isDone = state.isDone, - ) - state.isDone -> ExchangeStep( - iconRes = R.drawable.ic_check_24, - color = TangemTheme.colors.icon.primary1, - isDone = true, - ) - state.isActive -> ExchangeStepInProgress() - else -> ExchangeStepDefault() - } - } - if (!isLast) { - ExchangeStepSeparator() - } - } - ExchangeStatusStepText(stepStatus) - } -} - -@Composable -private fun ExchangeStatusStepText(stepStatus: ExchangeStatusState) { - val status = stepStatus.status - - val textColor = when { - status == ExchangeStatus.Cancelled || status == ExchangeStatus.Refunded || status == ExchangeStatus.Paused -> { - TangemTheme.colors.icon.warning - } - status.isFailed() && !stepStatus.isDone -> TangemTheme.colors.icon.warning - status == ExchangeStatus.Verifying && !stepStatus.isDone -> TangemTheme.colors.icon.attention - stepStatus.isDone -> TangemTheme.colors.text.primary1 - !stepStatus.isActive -> TangemTheme.colors.text.disabled - else -> TangemTheme.colors.text.primary1 - } - - Text( - text = stepStatus.text.resolveReference(), - style = TangemTheme.typography.body2, - color = textColor, - modifier = Modifier - .padding(start = TangemTheme.dimens.spacing12), - ) -} - -@Composable -private fun ExchangeStepDefault() { - Box( - modifier = Modifier - .border( - width = TangemTheme.dimens.size1_5, - color = TangemTheme.colors.field.focused, - shape = CircleShape, - ) - .padding(TangemTheme.dimens.spacing2), - ) -} - -@Composable -private fun ExchangeStep(color: Color, @DrawableRes iconRes: Int, isDone: Boolean) { - val (iconColor, borderColor) = if (isDone) { - TangemTheme.colors.icon.primary1 to TangemTheme.colors.field.focused - } else { - color to color - } - Icon( - painter = painterResource(id = iconRes), - contentDescription = null, - tint = iconColor, - modifier = Modifier - .border( - width = TangemTheme.dimens.size1_5, - color = borderColor, - shape = CircleShape, - ) - .padding(TangemTheme.dimens.spacing2), - ) -} - -@Composable -private fun ExchangeStepInProgress() { - CircularProgressIndicator( - color = TangemTheme.colors.icon.primary1, - strokeWidth = TangemTheme.dimens.size2, - modifier = Modifier - .padding(TangemTheme.dimens.spacing2) - .size(TangemTheme.dimens.size14), - ) -} - -@Composable -private fun ExchangeStepSeparator() { - Box( - modifier = Modifier - .padding(vertical = TangemTheme.dimens.spacing2) - .size( - width = TangemTheme.dimens.size1_5, - height = TangemTheme.dimens.size10, - ) - .background( - color = TangemTheme.colors.field.focused, - shape = CircleShape, - ), - ) -} - -@Preview -@Composable -private fun Preview_ExchangeStatusBlock() { - val base = ExchangeStatusState( - status = ExchangeStatus.Failed, - text = resourceReference(id = R.string.express_exchange_status_failed), - isActive = true, - isDone = false, - ) - - TangemThemePreview { - ExchangeStatusBlock( - statuses = listOf( - base, - base.copy(isActive = false, isDone = false), - base.copy(isActive = true, isDone = false), - base.copy(isActive = true, isDone = true), - ExchangeStatusState( - status = ExchangeStatus.Paused, - text = resourceReference(id = R.string.express_exchange_status_paused), - isActive = true, - isDone = false, - ), - ) - .toImmutableList(), - showLink = false, - onClick = {}, - ) - } -} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/exchange/ExchangeStatusBottomSheetContent.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/exchange/ExchangeStatusBottomSheetContent.kt index 3f4557962e..d28fe50927 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/exchange/ExchangeStatusBottomSheetContent.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/express/exchange/ExchangeStatusBottomSheetContent.kt @@ -14,7 +14,13 @@ import androidx.compose.ui.unit.dp import com.tangem.common.ui.expressStatus.ExpressEstimate import com.tangem.common.ui.expressStatus.ExpressHideButton import com.tangem.common.ui.expressStatus.ExpressProvider +import com.tangem.common.ui.expressStatus.ExpressStatusBlock +import com.tangem.common.ui.expressStatus.state.ExpressLinkUM +import com.tangem.common.ui.expressStatus.state.ExpressStatusItemState +import com.tangem.common.ui.expressStatus.state.ExpressStatusItemUM +import com.tangem.common.ui.expressStatus.state.ExpressStatusUM import com.tangem.core.ui.R +import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.SpacerH10 import com.tangem.core.ui.components.SpacerH12 @@ -27,7 +33,9 @@ import com.tangem.core.ui.res.TangemTheme import com.tangem.feature.swap.domain.models.domain.ExchangeStatus import com.tangem.feature.swap.domain.models.domain.ExchangeStatus.Companion.isFailed import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.ExchangeStatusNotification +import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeStatusState import com.tangem.feature.tokendetails.presentation.tokendetails.state.express.ExchangeUM +import kotlinx.collections.immutable.toImmutableList @Composable internal fun ExchangeStatusBottomSheetContent( @@ -80,11 +88,7 @@ internal fun ExchangeStatusBottomSheetContent( extraContent() SpacerH12() } - ExchangeStatusBlock( - statuses = state.statuses, - showLink = state.showProviderLink, - onClick = { state.info.onGoToProviderClick(state.info.txExternalUrl.orEmpty()) }, - ) + ExpressStatusBlock(state = state.toExpressStatusUM()) if (state.notification != null) { Notification(state = state.notification, activeStatus = state.activeStatus) } @@ -101,6 +105,34 @@ internal fun ExchangeStatusBottomSheetContent( } } +private fun ExchangeUM.toExpressStatusUM(): ExpressStatusUM = ExpressStatusUM( + title = resourceReference(R.string.express_exchange_status_title), + link = if (showProviderLink) { + ExpressLinkUM.Content( + icon = R.drawable.ic_arrow_top_right_24, + text = resourceReference(R.string.common_go_to_provider), + onClick = { info.onGoToProviderClick(info.txExternalUrl.orEmpty()) }, + ) + } else { + ExpressLinkUM.Empty + }, + statuses = statuses.map { it.toExpressStatusItemUM() }.toImmutableList(), +) + +private fun ExchangeStatusState.toExpressStatusItemUM(): ExpressStatusItemUM = ExpressStatusItemUM( + text = text, + state = when { + status == ExchangeStatus.Cancelled -> ExpressStatusItemState.Error + status.isFailed() || status == ExchangeStatus.Refunded || status == ExchangeStatus.Paused -> { + if (isDone) ExpressStatusItemState.Done else ExpressStatusItemState.Error + } + status == ExchangeStatus.Verifying -> ExpressStatusItemState.Warning + isDone -> ExpressStatusItemState.Done + isActive -> ExpressStatusItemState.Active + else -> ExpressStatusItemState.Default + }, +) + @Composable private fun Notification(state: ExchangeStatusNotification, activeStatus: ExchangeStatus?) { AnimatedContent( From 3adf9bd4814856a67e6231a3b50e1da16c5acdc7 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 26 Jun 2026 15:48:48 +0300 Subject: [PATCH 049/210] Updated on 2026-08-14 --- .../com/tangem/screens/DialogPageObject.kt | 7 + .../tangem/screens/TokenDetailsPageObject.kt | 6 + .../tests/send/reasonBlock/ReasonBlockTest.kt | 123 ++++++++++++++++++ 3 files changed, 136 insertions(+) create mode 100644 app/src/androidTest/kotlin/com/tangem/tests/send/reasonBlock/ReasonBlockTest.kt diff --git a/app/src/androidTest/kotlin/com/tangem/screens/DialogPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/DialogPageObject.kt index b3efafdd81..5feacc5839 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/DialogPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/DialogPageObject.kt @@ -10,6 +10,7 @@ import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onCompose import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.kakao.common.utilities.getResourceString import androidx.compose.ui.test.hasTestTag as withTestTag +import androidx.compose.ui.test.hasText as withText class DialogPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : ComposeScreen(semanticsProvider = semanticsProvider) { @@ -18,6 +19,12 @@ class DialogPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : hasTestTag(BaseDialogTestTags.CONTAINER) } + fun containerWithText(text: String): KNode = child { + hasTestTag(BaseDialogTestTags.CONTAINER) + hasAnyDescendant(withText(text = text, substring = true)) + useUnmergedTree = true + } + val title: KNode = child { hasTestTag(BaseDialogTestTags.TITLE) } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt index 149aaf1d89..8a7f71afee 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt @@ -114,6 +114,12 @@ class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvide useUnmergedTree = true } + fun tokenTitle(name: String): KNode = child { + hasTestTag(TokenDetailsScreenTestTags.TOKEN_TITLE) + hasAnyDescendant(withText(text = name, substring = true)) + useUnmergedTree = true + } + fun networkFeeNotificationMessage( currencyName: String, networkName: String, diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/reasonBlock/ReasonBlockTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/reasonBlock/ReasonBlockTest.kt new file mode 100644 index 0000000000..1042164541 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/reasonBlock/ReasonBlockTest.kt @@ -0,0 +1,123 @@ +package com.tangem.tests.send.reasonBlock + +import com.tangem.common.BaseTestCase +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.common.utils.setWireMockScenarioState +import com.tangem.core.ui.R +import com.tangem.scenarios.openMainScreen +import com.tangem.scenarios.synchronizeAddresses +import com.tangem.screens.onDialog +import com.tangem.screens.onMainScreen +import com.tangem.screens.onTokenDetailsScreen +import com.tangem.screens.onTransferBottomSheet +import dagger.hilt.android.testing.HiltAndroidTest +import io.github.kakaocup.kakao.common.utilities.getResourceString +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +@HiltAndroidTest +class ReasonBlockTest : BaseTestCase() { + + @AllureId("3616") + @DisplayName("Reason block: Send is unavailable if user has pending transaction") + @Test + fun reasonBlockSendUnavailableWithPendingTransactionTest() { + val txHistoryScenarioName = "dogecoin_tx_history" + val txHistoryState = "EmptyWithPendingTransaction" + val walletsScenarioName = "user_tokens_api" + val walletsState = "Dogecoin" + val token = "Dogecoin" + val reasonText = getResourceString(R.string.token_button_unavailability_reason_pending_transaction_send) + .substringBefore("%s") + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(txHistoryScenarioName) + resetWireMockScenarioState(walletsScenarioName) + } + ).run { + step("Set Wiremock scenario: $txHistoryScenarioName to state $txHistoryState") { + setWireMockScenarioState(scenarioName = txHistoryScenarioName, state = txHistoryState) + } + step("Set Wiremock scenario: $walletsScenarioName to state $walletsState") { + setWireMockScenarioState(scenarioName = walletsScenarioName, state = walletsState) + } + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Click on token with name $token") { + onMainScreen { tokenWithTitleAndAddress(token).clickWithAssertion() } + } + step("Click on 'Transfer' button") { + onTokenDetailsScreen { transferButton.clickWithAssertion() } + } + step("Verify 'Send' button is disabled") { + onTransferBottomSheet { sendButton.assertIsNotEnabled() } + } + step("Click on 'Send' button") { + onTransferBottomSheet { sendButton.clickWithAssertion() } + } + step("Assert pending-transaction reason dialog is displayed") { + onDialog { containerWithText(reasonText).assertIsDisplayed() } + } + } + } + + @AllureId("3615") + @DisplayName("Reason block: Token withdrawal is unavailable if there are no fee coverage") + @Test + fun reasonBlockTokenWithdrawalUnavailableWithoutFeeCoverage() { + val userWalletsScenarioName = "user_tokens_api" + val userWalletsState = "SolanaUSDC" + val solBalanceScenarioName = "GetAccountInfoSol" + val solBalanceState = "ZeroBalance" + val token = "USDC" + val feeCurrencyName = "Solana" + val feeCurrencySymbol = "SOL" + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(userWalletsScenarioName) + resetWireMockScenarioState(solBalanceScenarioName) + } + ).run { + step("Set Wiremock scenario: $userWalletsScenarioName to state $userWalletsState") { + setWireMockScenarioState(scenarioName = userWalletsScenarioName, state = userWalletsState) + } + step("Set Wiremock scenario: $solBalanceScenarioName to state $solBalanceState") { + setWireMockScenarioState(scenarioName = solBalanceScenarioName, state = solBalanceState) + } + step("Open 'Main Screen'") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Click on token with name $token") { + onMainScreen { tokenWithTitleAndAddress(token).clickWithAssertion() } + } + step("Assert 'Insufficient $feeCurrencySymbol for fee' notification is displayed") { + onTokenDetailsScreen { + networkFeeNotificationTitle(feeCurrencyName).assertIsDisplayed() + networkFeeNotificationMessage( + currencyName = token, + networkName = feeCurrencyName, + feeCurrencyName = feeCurrencyName, + feeCurrencySymbol = feeCurrencySymbol, + ).assertIsDisplayed() + } + } + step("Click on 'Go to $feeCurrencySymbol' button") { + onTokenDetailsScreen { goToBuyCurrencyButton(feeCurrencySymbol).clickWithAssertion() } + } + step("Assert $feeCurrencyName token screen is opened") { + onTokenDetailsScreen { tokenTitle(feeCurrencyName).assertIsDisplayed() } + } + } + } +} \ No newline at end of file From a50cb152288675231fe076e324d52a18d2839f6c Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 26 Jun 2026 16:52:36 +0400 Subject: [PATCH 050/210] Updated on 2026-08-14 --- .../txhistory/model/TxHistoryModel.kt | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt index 6193084ec0..c5515de93e 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt @@ -23,6 +23,9 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.TxInfo import com.tangem.domain.txhistory.model.ExpressTx import com.tangem.domain.txhistory.model.OnChainTx +import com.tangem.domain.txhistory.TxHistoryFeatureToggles +import com.tangem.domain.txhistory.fetcher.AppTxHistoryFetcher +import com.tangem.domain.txhistory.fetcher.TxHistoryFetchTrigger import com.tangem.domain.txhistory.model.TxHistoryInfo import com.tangem.domain.txhistory.model.explorerHash import com.tangem.domain.txhistory.models.TxHistoryStateError @@ -30,7 +33,6 @@ import com.tangem.domain.txhistory.repository.TxHistoryRepositoryV2 import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase import com.tangem.domain.wallets.usecase.GetWalletIconUseCase -import com.tangem.domain.txhistory.TxHistoryFeatureToggles import com.tangem.features.txhistory.component.TxHistoryComponent import com.tangem.features.txhistory.converter.ExpressTxToTransactionItemUMConverter import com.tangem.features.txhistory.converter.TxHistoryInfoToTransactionItemUMConverter @@ -54,7 +56,7 @@ import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import javax.inject.Inject -@Suppress("LongParameterList") +@Suppress("LongParameterList", "LargeClass") @Stable @ModelScoped internal class TxHistoryModel @Inject constructor( @@ -71,6 +73,7 @@ internal class TxHistoryModel @Inject constructor( private val designFeatureToggles: DesignFeatureToggles, private val txHistoryFeatureToggle: TxHistoryFeatureToggles, private val historyTxListManagerFactory: HistoryTxListManager.Factory, + private val appTxHistoryFetcher: AppTxHistoryFetcher, repository: TxHistoryRepositoryV2, paramsContainer: ParamsContainer, multiAccountStatusListSupplier: MultiAccountStatusListSupplier, @@ -254,6 +257,13 @@ internal class TxHistoryModel @Inject constructor( historyTxListManager?.startLoading() } } + if (txHistoryFeatureToggle.isNewTxHistoryEnabled) { + val trigger = TxHistoryFetchTrigger.TokenDetailsOpen( + walletId = params.userWalletId, + currency = params.currency, + ) + modelScope.launch { appTxHistoryFetcher.invoke(trigger) } + } } fun reload() { @@ -267,6 +277,13 @@ internal class TxHistoryModel @Inject constructor( txHistoryListManager?.reload() historyTxListManager?.reload() } + if (txHistoryFeatureToggle.isNewTxHistoryEnabled) { + val trigger = TxHistoryFetchTrigger.TokenDetailsPTR( + walletId = params.userWalletId, + currency = params.currency, + ) + modelScope.launch { appTxHistoryFetcher.invoke(trigger) } + } } } From e2cd6813a0a1595cc150c882901036bee82bb828 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 26 Jun 2026 15:55:43 +0300 Subject: [PATCH 051/210] Updated on 2026-08-14 --- .../DefaultYieldModuleAddressProviderTest.kt | 194 +++++ .../DefaultYieldSupplyErrorResolverTest.kt | 33 + .../DefaultYieldSupplyRepositoryTest.kt | 470 ++++++++++++ .../YieldMarketTokenConverterTest.kt | 64 ++ .../YieldTokenChartConverterTest.kt | 59 ++ .../promo/DefaultYieldPromoRepositoryTest.kt | 245 +++++++ .../YieldSupplyGetMaxFeeUseCaseTest.kt | 406 ++++++++++ ...ldSupplyActiveFeeContentTransformerTest.kt | 198 +++++ ...eldSupplyActiveMinAmountTransformerTest.kt | 325 ++++++++ .../chart/model/YieldSupplyChartModelTest.kt | 172 +++++ .../entry/model/YieldSupplyEntryModelTest.kt | 310 ++++++++ .../impl/main/model/YieldSupplyModelTest.kt | 691 ++++++++++++++++++ ...SupplyTokenStatusSuccessTransformerTest.kt | 122 ++++ .../YieldSupplyActionModelTestBase.kt | 188 +++++ .../model/YieldSupplyApproveModelTest.kt | 244 +++++++ .../model/YieldSupplyStartEarningModelTest.kt | 278 +++++++ ...lyStartEarningFeeContentTransformerTest.kt | 192 +++++ .../model/YieldSupplyStopEarningModelTest.kt | 247 +++++++ ...plyStopEarningFeeContentTransformerTest.kt | 161 ++++ 19 files changed, 4599 insertions(+) create mode 100644 data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldModuleAddressProviderTest.kt create mode 100644 data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyErrorResolverTest.kt create mode 100644 data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyRepositoryTest.kt create mode 100644 data/yield-supply/src/test/java/com/tangem/data/yield/supply/converters/YieldMarketTokenConverterTest.kt create mode 100644 data/yield-supply/src/test/java/com/tangem/data/yield/supply/converters/YieldTokenChartConverterTest.kt create mode 100644 data/yield-supply/src/test/java/com/tangem/data/yield/supply/promo/DefaultYieldPromoRepositoryTest.kt create mode 100644 domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetMaxFeeUseCaseTest.kt create mode 100644 features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/active/model/transformers/YieldSupplyActiveFeeContentTransformerTest.kt create mode 100644 features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/active/model/transformers/YieldSupplyActiveMinAmountTransformerTest.kt create mode 100644 features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/chart/model/YieldSupplyChartModelTest.kt create mode 100644 features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/entry/model/YieldSupplyEntryModelTest.kt create mode 100644 features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModelTest.kt create mode 100644 features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/main/model/transformers/YieldSupplyTokenStatusSuccessTransformerTest.kt create mode 100644 features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/YieldSupplyActionModelTestBase.kt create mode 100644 features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/approve/model/YieldSupplyApproveModelTest.kt create mode 100644 features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModelTest.kt create mode 100644 features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/transformers/YieldSupplyStartEarningFeeContentTransformerTest.kt create mode 100644 features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModelTest.kt create mode 100644 features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/transformer/YieldSupplyStopEarningFeeContentTransformerTest.kt diff --git a/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldModuleAddressProviderTest.kt b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldModuleAddressProviderTest.kt new file mode 100644 index 0000000000..0196128ec2 --- /dev/null +++ b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldModuleAddressProviderTest.kt @@ -0,0 +1,194 @@ +package com.tangem.data.yield.supply + +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.blockchains.ethereum.EthereumUtils +import com.tangem.blockchain.common.WalletManager +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +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) +internal class DefaultYieldModuleAddressProviderTest { + + private val walletManager: WalletManager = mockk() + private val walletManagersFacade: WalletManagersFacade = mockk() + + private val provider = DefaultYieldModuleAddressProvider( + walletManagersFacade = walletManagersFacade, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + private val userWalletId = UserWalletId("abcdef012345") + private val otherWalletId = UserWalletId("fedcba543210") + private val network = network() + + @BeforeEach + fun setUp() { + clearMocks(walletManager, walletManagersFacade) + provider.invalidate(null) + coEvery { + walletManagersFacade.getOrCreateWalletManager(any(), any(), any()) + } returns walletManager + } + + @Test + fun `GIVEN non-zero address WHEN getOrFetch THEN returns and caches it`() = runTest { + // Arrange + coEvery { walletManager.getYieldModuleAddress() } returns ADDRESS + + // Act + val first = provider.getOrFetch(userWalletId, network) + val second = provider.getOrFetch(userWalletId, network) + + // Assert + assertThat(first).isEqualTo(ADDRESS) + assertThat(second).isEqualTo(ADDRESS) + coVerify(exactly = 1) { walletManagersFacade.getOrCreateWalletManager(any(), any(), any()) } + } + + @Test + fun `GIVEN zero address WHEN getOrFetch THEN returns null and does not cache`() = runTest { + // Arrange + coEvery { walletManager.getYieldModuleAddress() } returns EthereumUtils.ZERO_ADDRESS + + // Act + val first = provider.getOrFetch(userWalletId, network) + val second = provider.getOrFetch(userWalletId, network) + + // Assert — null result is never cached, so the manager is queried again + assertThat(first).isNull() + assertThat(second).isNull() + coVerify(exactly = 2) { walletManager.getYieldModuleAddress() } + } + + @Test + fun `GIVEN missing wallet manager WHEN getOrFetch THEN throws`() = runTest { + // Arrange + coEvery { walletManagersFacade.getOrCreateWalletManager(any(), any(), any()) } returns null + + // Act + val error = runCatching { provider.getOrFetch(userWalletId, network) }.exceptionOrNull() + + // Assert + assertThat(error).isInstanceOf(IllegalStateException::class.java) + } + + @Test + fun `GIVEN cached address WHEN invalidate for that wallet THEN it is refetched`() = runTest { + // Arrange + coEvery { walletManager.getYieldModuleAddress() } returns ADDRESS + provider.getOrFetch(userWalletId, network) + + // Act + provider.invalidate(userWalletId) + provider.getOrFetch(userWalletId, network) + + // Assert + coVerify(exactly = 2) { walletManagersFacade.getOrCreateWalletManager(any(), any(), any()) } + } + + @Test + fun `GIVEN two cached wallets WHEN invalidate one THEN only that one is refetched`() = runTest { + // Arrange + coEvery { walletManager.getYieldModuleAddress() } returns ADDRESS + provider.getOrFetch(userWalletId, network) + provider.getOrFetch(otherWalletId, network) + + // Act + provider.invalidate(userWalletId) + provider.getOrFetch(userWalletId, network) // refetched + provider.getOrFetch(otherWalletId, network) // still cached + + // Assert — 2 initial fetches + 1 refetch for the invalidated wallet only + coVerify(exactly = 3) { walletManagersFacade.getOrCreateWalletManager(any(), any(), any()) } + } + + @Test + fun `GIVEN cached addresses WHEN invalidate all THEN every wallet is refetched`() = runTest { + // Arrange + coEvery { walletManager.getYieldModuleAddress() } returns ADDRESS + provider.getOrFetch(userWalletId, network) + provider.getOrFetch(otherWalletId, network) + + // Act + provider.invalidate(null) + provider.getOrFetch(userWalletId, network) + provider.getOrFetch(otherWalletId, network) + + // Assert — 2 initial + 2 after a full invalidation + coVerify(exactly = 4) { walletManagersFacade.getOrCreateWalletManager(any(), any(), any()) } + } + + @Test + fun `GIVEN two concurrent fetches for the same key WHEN one is in flight THEN manager is created once`() = runTest { + // Arrange — io dispatcher we control so both callers reach the mutex before the cache is populated + val testDispatcher = StandardTestDispatcher(testScheduler) + val concurrentProvider = DefaultYieldModuleAddressProvider( + walletManagersFacade = walletManagersFacade, + dispatchers = TestingCoroutineDispatcherProvider( + main = testDispatcher, + mainImmediate = testDispatcher, + io = testDispatcher, + default = testDispatcher, + single = testDispatcher, + ), + ) + val proceed = CompletableDeferred() + coEvery { walletManager.getYieldModuleAddress() } coAnswers { + proceed.await() + ADDRESS + } + + // Act — both pass the lock-free pre-check; one holds the lock and fetches, the other waits on it + val first = launch { concurrentProvider.getOrFetch(userWalletId, network) } + val second = launch { concurrentProvider.getOrFetch(userWalletId, network) } + runCurrent() + // At the barrier both callers have passed the lock-free pre-check (cache still empty): one holds the mutex and + // awaits the gate, the other is blocked on the lock. Asserting neither completed proves the second did NOT + // short-circuit on the outer pre-check, so it must hit the in-lock double-check once released. + assertThat(first.isCompleted).isFalse() + assertThat(second.isCompleted).isFalse() + proceed.complete(Unit) + advanceUntilIdle() + first.join() + second.join() + + // Assert — the second caller is served from cache via the in-lock double-check + coVerify(exactly = 1) { walletManagersFacade.getOrCreateWalletManager(any(), any(), any()) } + coVerify(exactly = 1) { walletManager.getYieldModuleAddress() } + } + + private fun network(): Network { + val derivationPath = Network.DerivationPath.None + return Network( + id = Network.ID(value = "ethereum", derivationPath = derivationPath), + name = "Ethereum", + currencySymbol = "ETH", + derivationPath = derivationPath, + isTestnet = false, + standardType = Network.StandardType.Unspecified("UNSPECIFIED"), + hasFiatFeeRate = true, + canHandleTokens = true, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ) + } + + private companion object { + const val ADDRESS = "0x1234567890abcdef1234567890abcdef12345678" + } +} \ No newline at end of file diff --git a/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyErrorResolverTest.kt b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyErrorResolverTest.kt new file mode 100644 index 0000000000..32c6b3be6e --- /dev/null +++ b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyErrorResolverTest.kt @@ -0,0 +1,33 @@ +package com.tangem.data.yield.supply + +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.yield.supply.YieldSupplyError +import org.junit.jupiter.api.Test +import java.io.IOException + +internal class DefaultYieldSupplyErrorResolverTest { + + @Test + fun `GIVEN a YieldSupplyError WHEN resolve THEN returns the same instance`() { + // Arrange + val error = YieldSupplyError.DataError(IOException("boom")) + + // Act + val result = DefaultYieldSupplyErrorResolver.resolve(error) + + // Assert + assertThat(result).isSameInstanceAs(error) + } + + @Test + fun `GIVEN a generic throwable WHEN resolve THEN wraps it into DataError`() { + // Arrange + val throwable = IllegalStateException("unexpected") + + // Act + val result = DefaultYieldSupplyErrorResolver.resolve(throwable) + + // Assert + assertThat(result).isEqualTo(YieldSupplyError.DataError(throwable)) + } +} \ No newline at end of file diff --git a/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyRepositoryTest.kt b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyRepositoryTest.kt new file mode 100644 index 0000000000..07a94446cc --- /dev/null +++ b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/DefaultYieldSupplyRepositoryTest.kt @@ -0,0 +1,470 @@ +package com.tangem.data.yield.supply + +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.TransactionStatus +import com.tangem.blockchain.common.WalletManager +import com.tangem.core.analytics.api.AnalyticsExceptionHandler +import com.tangem.data.yield.supply.converters.YieldMarketTokenConverter +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.tangemTech.YieldSupplyApi +import com.tangem.datasource.api.tangemTech.models.YieldMarketsResponse +import com.tangem.datasource.api.tangemTech.models.YieldModuleStatusResponse +import com.tangem.datasource.api.tangemTech.models.YieldSupplyMarketTokenDto +import com.tangem.datasource.api.tangemTech.models.YieldTokenChartResponse +import com.tangem.datasource.local.preferences.AppPreferencesStore +import com.tangem.datasource.local.preferences.PreferencesKeys +import com.tangem.datasource.local.preferences.utils.get +import com.tangem.datasource.local.preferences.utils.store +import com.tangem.datasource.local.yieldsupply.YieldMarketsStore +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.walletmanager.WalletManagersFacade +import com.tangem.domain.yield.supply.models.YieldSupplyPendingStatus +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import io.mockk.verify +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 +import java.io.IOException +import java.math.BigDecimal + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class DefaultYieldSupplyRepositoryTest { + + private val yieldSupplyApi: YieldSupplyApi = mockk() + private val store: YieldMarketsStore = mockk(relaxed = true) + private val walletManagersFacade: WalletManagersFacade = mockk() + private val analyticsExceptionHandler: AnalyticsExceptionHandler = mockk(relaxed = true) + private val appPreferencesStore: AppPreferencesStore = mockk() + + private val repository = DefaultYieldSupplyRepository( + yieldSupplyApi = yieldSupplyApi, + store = store, + walletManagersFacade = walletManagersFacade, + dispatchers = TestingCoroutineDispatcherProvider(), + analyticsExceptionHandler = analyticsExceptionHandler, + appPreferencesStore = appPreferencesStore, + ) + + private val userWalletId = UserWalletId("abcdef012345") + private val token = token() + + @BeforeEach + fun setUp() { + clearMocks(yieldSupplyApi, store, walletManagersFacade, analyticsExceptionHandler) + } + + // region markets + @Test + fun `GIVEN cached dtos WHEN getCachedMarkets THEN returns enriched domain`() = runTest { + // Arrange + coEvery { store.getSyncOrNull() } returns listOf(marketDto(chainId = 1)) + + // Act + val result = repository.getCachedMarkets() + + // Assert — chainId 1 is enriched to its network id + assertThat(result).hasSize(1) + assertThat(result.first().backendId).isEqualTo("ethereum") + } + + @Test + fun `GIVEN empty cache WHEN getCachedMarkets THEN returns empty list`() = runTest { + // Arrange + coEvery { store.getSyncOrNull() } returns null + + // Act + val result = repository.getCachedMarkets() + + // Assert + assertThat(result).isEmpty() + } + + @Test + fun `GIVEN cached dto with unmapped chain id WHEN getCachedMarkets THEN backend id is null`() = runTest { + // Arrange — chainId -1 (the converter's default for a DTO without a chainId) maps to no network + coEvery { store.getSyncOrNull() } returns listOf(marketDto(chainId = -1)) + + // Act + val result = repository.getCachedMarkets() + + // Assert + assertThat(result).hasSize(1) + assertThat(result.first().backendId).isNull() + } + + @Test + fun `GIVEN api returns markets WHEN updateMarkets THEN stores dtos and returns domain`() = runTest { + // Arrange + val dto = marketDto(chainId = 1) + coEvery { yieldSupplyApi.getYieldMarkets(any()) } returns ApiResponse.Success( + YieldMarketsResponse(marketDtos = listOf(dto), lastUpdated = "now"), + ) + + // Act + val result = repository.updateMarkets() + + // Assert + assertThat(result).containsExactly(YieldMarketTokenConverter.convert(dto)) + coVerify(exactly = 1) { store.store(listOf(dto)) } + } + + @Test + fun `GIVEN store flow WHEN getMarketsFlow THEN emits enriched domain`() = runTest { + // Arrange + every { store.get() } returns flowOf(listOf(marketDto(chainId = 1))) + + // Act + val result = repository.getMarketsFlow().first() + + // Assert + assertThat(result.first().backendId).isEqualTo("ethereum") + } + // endregion + + // region token status / chart + @Test + fun `GIVEN evm token WHEN getTokenStatus THEN returns converted market token`() = runTest { + // Arrange + val dto = marketDto(chainId = 1) + coEvery { yieldSupplyApi.getYieldTokenStatus(1, token.contractAddress) } returns ApiResponse.Success(dto) + + // Act + val result = repository.getTokenStatus(token) + + // Assert + assertThat(result).isEqualTo(YieldMarketTokenConverter.convert(dto)) + } + + @Test + fun `GIVEN non-evm token WHEN getTokenStatus THEN throws`() = runTest { + // Arrange + val nonEvm = token(rawId = "unknown-network-xyz") + + // Act + val error = runCatching { repository.getTokenStatus(nonEvm) }.exceptionOrNull() + + // Assert + assertThat(error).isInstanceOf(IllegalStateException::class.java) + } + + @Test + fun `GIVEN evm token WHEN getTokenChart THEN returns converted chart`() = runTest { + // Arrange + coEvery { yieldSupplyApi.getYieldTokenChart(1, token.contractAddress) } returns ApiResponse.Success( + chartResponse(), + ) + + // Act + val result = repository.getTokenChart(token) + + // Assert + assertThat(result.avr).isEqualTo(4.25) + assertThat(result.y).containsExactly(3.5).inOrder() + } + + @Test + fun `GIVEN non-evm token WHEN getTokenChart THEN throws`() = runTest { + // Arrange + val nonEvm = token(rawId = "unknown-network-xyz") + + // Act + val error = runCatching { repository.getTokenChart(nonEvm) }.exceptionOrNull() + + // Assert + assertThat(error).isInstanceOf(IllegalStateException::class.java) + } + // endregion + + // region isYieldSupplySupported + @Test + fun `GIVEN supported yield provider WHEN isYieldSupplySupported THEN returns true`() = runTest { + // Arrange — WalletManager itself implements YieldSupplyProvider + val walletManager = mockk { every { isSupported() } returns true } + coEvery { walletManagersFacade.getOrCreateWalletManager(any(), any(), any()) } returns walletManager + + // Act + val result = repository.isYieldSupplySupported(userWalletId, token) + + // Assert + assertThat(result).isTrue() + } + + @Test + fun `GIVEN unsupported yield provider WHEN isYieldSupplySupported THEN returns false`() = runTest { + // Arrange + val walletManager = mockk { every { isSupported() } returns false } + coEvery { walletManagersFacade.getOrCreateWalletManager(any(), any(), any()) } returns walletManager + + // Act + val result = repository.isYieldSupplySupported(userWalletId, token) + + // Assert + assertThat(result).isFalse() + } + + @Test + fun `GIVEN no wallet manager WHEN isYieldSupplySupported THEN sends analytics and returns false`() = runTest { + // Arrange + coEvery { walletManagersFacade.getOrCreateWalletManager(any(), any(), any()) } returns null + + // Act + val result = repository.isYieldSupplySupported(userWalletId, token) + + // Assert + assertThat(result).isFalse() + verify { analyticsExceptionHandler.sendException(any()) } + } + // endregion + + // region activate / deactivate + @Test + fun `GIVEN api returns active WHEN activateProtocol THEN returns true`() = runTest { + // Arrange + coEvery { + yieldSupplyApi.activateYieldModule(body = any(), userWalletId = any()) + } returns ApiResponse.Success(statusResponse(isActive = true)) + + // Act + val result = repository.activateProtocol(userWalletId, token, ADDRESS) + + // Assert + assertThat(result).isTrue() + } + + @Test + fun `GIVEN non-evm token WHEN activateProtocol THEN throws`() = runTest { + // Arrange + val nonEvm = token(rawId = "unknown-network-xyz") + + // Act + val error = runCatching { repository.activateProtocol(userWalletId, nonEvm, ADDRESS) }.exceptionOrNull() + + // Assert + assertThat(error).isInstanceOf(IllegalStateException::class.java) + } + + @Test + fun `GIVEN api returns inactive WHEN deactivateProtocol THEN returns false`() = runTest { + // Arrange + coEvery { yieldSupplyApi.deactivateYieldModule(any()) } returns ApiResponse.Success( + statusResponse(isActive = false), + ) + + // Act + val result = repository.deactivateProtocol(token, ADDRESS) + + // Assert + assertThat(result).isFalse() + } + + @Test + fun `GIVEN non-evm token WHEN deactivateProtocol THEN throws`() = runTest { + // Arrange + val nonEvm = token(rawId = "unknown-network-xyz") + + // Act + val error = runCatching { repository.deactivateProtocol(nonEvm, ADDRESS) }.exceptionOrNull() + + // Assert + assertThat(error).isInstanceOf(IllegalStateException::class.java) + } + // endregion + + // region pending status (in-memory) + @Test + fun `GIVEN saved pending status WHEN getTokenProtocolPendingStatus THEN returns it`() = runTest { + // Arrange + val status = YieldSupplyPendingStatus.Enter(txIds = listOf("0x1"), createdAt = 1L) + repository.saveTokenProtocolPendingStatus(userWalletId, token, status) + + // Act + val result = repository.getTokenProtocolPendingStatus(userWalletId, token) + + // Assert + assertThat(result).isEqualTo(status) + } + + @Test + fun `GIVEN saved then cleared WHEN getTokenProtocolPendingStatus THEN returns null`() = runTest { + // Arrange + repository.saveTokenProtocolPendingStatus( + userWalletId, + token, + YieldSupplyPendingStatus.Enter(txIds = listOf("0x1"), createdAt = 1L), + ) + + // Act + repository.saveTokenProtocolPendingStatus(userWalletId, token, null) + val result = repository.getTokenProtocolPendingStatus(userWalletId, token) + + // Assert + assertThat(result).isNull() + } + + @Test + fun `GIVEN saved status WHEN flow collected THEN emits the status`() = runTest { + // Arrange + val status = YieldSupplyPendingStatus.Exit(txIds = listOf("0x9"), createdAt = 1L) + repository.saveTokenProtocolPendingStatus(userWalletId, token, status) + + // Act + val emitted = repository.getTokenProtocolPendingStatusFlow(userWalletId, token).first() + + // Assert + assertThat(emitted).isEqualTo(status) + } + // endregion + + // region pending tx hashes + @Test + fun `GIVEN unconfirmed and confirmed txs WHEN getPendingTxHashes THEN returns only unconfirmed hashes`() = runTest { + // Arrange + val walletManager = mockk { + every { wallet.recentTransactions } returns mutableListOf( + tx(TransactionStatus.Unconfirmed, "0xUnconfirmed"), + tx(TransactionStatus.Confirmed, "0xConfirmed"), + ) + } + coEvery { walletManagersFacade.getOrCreateWalletManager(any(), any()) } returns walletManager + + // Act + val result = repository.getPendingTxHashes(userWalletId, token) + + // Assert + assertThat(result).containsExactly("0xUnconfirmed") + } + + @Test + fun `GIVEN no wallet manager WHEN getPendingTxHashes THEN returns empty`() = runTest { + // Arrange + coEvery { walletManagersFacade.getOrCreateWalletManager(any(), any()) } returns null + + // Act + val result = repository.getPendingTxHashes(userWalletId, token) + + // Assert + assertThat(result).isEmpty() + } + // endregion + + // region promo banner preference + @Test + fun `GIVEN stored flag WHEN getShouldShowYieldPromoBanner THEN emits it`() = runTest { + // Arrange + mockkStatic("com.tangem.datasource.local.preferences.utils.PreferencesDataStoreExtKt") + try { + every { + appPreferencesStore.get(PreferencesKeys.YIELD_SUPPLY_SHOULD_SHOW_MAIN_PROMO_KEY, true) + } returns flowOf(false) + + // Act + val result = repository.getShouldShowYieldPromoBanner().first() + + // Assert + assertThat(result).isFalse() + } finally { + unmockkStatic("com.tangem.datasource.local.preferences.utils.PreferencesDataStoreExtKt") + } + } + + @Test + fun `WHEN setShouldShowYieldPromoBanner THEN stores the value`() = runTest { + // Arrange + mockkStatic("com.tangem.datasource.local.preferences.utils.PreferencesDataStoreExtKt") + try { + coEvery { + appPreferencesStore.store(PreferencesKeys.YIELD_SUPPLY_SHOULD_SHOW_MAIN_PROMO_KEY, false) + } returns Unit + + // Act + repository.setShouldShowYieldPromoBanner(false) + + // Assert + coVerify { appPreferencesStore.store(PreferencesKeys.YIELD_SUPPLY_SHOULD_SHOW_MAIN_PROMO_KEY, false) } + } finally { + unmockkStatic("com.tangem.datasource.local.preferences.utils.PreferencesDataStoreExtKt") + } + } + // endregion + + private fun tx(status: TransactionStatus, hash: String): TransactionData.Uncompiled = mockk { + every { this@mockk.status } returns status + every { this@mockk.hash } returns hash + } + + private fun marketDto(chainId: Int) = YieldSupplyMarketTokenDto( + tokenAddress = "0xToken", + tokenSymbol = "USDT", + tokenName = "Tether", + apy = BigDecimal("5.5"), + decimals = 6, + isActive = true, + chainId = chainId, + maxFeeNative = BigDecimal("0.005"), + maxFeeUSD = BigDecimal("12.34"), + ) + + private fun chartResponse() = YieldTokenChartResponse( + underlying = "USDT", + market = "aave", + bucketSizeDays = 1, + period = "30d", + data = listOf(YieldTokenChartResponse.DataPoint(bucketIndex = 0, avgApy = BigDecimal("3.5"))), + averageApy = BigDecimal("4.25"), + ) + + private fun statusResponse(isActive: Boolean) = YieldModuleStatusResponse( + tokenAddress = "0xToken", + chainId = 1, + isActive = isActive, + activatedAt = null, + deactivatedAt = null, + ) + + private fun token(rawId: String = "ethereum", contractAddress: String = "0xToken"): CryptoCurrency.Token { + val derivationPath = Network.DerivationPath.None + val network = Network( + id = Network.ID(value = rawId, derivationPath = derivationPath), + name = "Net", + currencySymbol = "ETH", + derivationPath = derivationPath, + isTestnet = false, + standardType = Network.StandardType.Unspecified("UNSPECIFIED"), + hasFiatFeeRate = true, + canHandleTokens = true, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ) + return CryptoCurrency.Token( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId(rawId), + suffix = CryptoCurrency.ID.Suffix.RawID(rawId), + ), + network = network, + name = "TEST_TOKEN", + symbol = "TTK", + decimals = 6, + iconUrl = null, + isCustom = false, + contractAddress = contractAddress, + ) + } + + private companion object { + const val ADDRESS = "0x1111111111111111111111111111111111111111" + } +} \ No newline at end of file diff --git a/data/yield-supply/src/test/java/com/tangem/data/yield/supply/converters/YieldMarketTokenConverterTest.kt b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/converters/YieldMarketTokenConverterTest.kt new file mode 100644 index 0000000000..8e68d3e36f --- /dev/null +++ b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/converters/YieldMarketTokenConverterTest.kt @@ -0,0 +1,64 @@ +package com.tangem.data.yield.supply.converters + +import com.google.common.truth.Truth.assertThat +import com.tangem.datasource.api.tangemTech.models.YieldSupplyMarketTokenDto +import com.tangem.domain.yield.supply.models.YieldMarketToken +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +internal class YieldMarketTokenConverterTest { + + @Test + fun `GIVEN fully populated dto WHEN convert THEN maps every field`() { + // Arrange + val dto = YieldSupplyMarketTokenDto( + tokenAddress = "0xToken", + tokenSymbol = "USDT", + tokenName = "Tether", + apy = BigDecimal("5.5"), + decimals = 6, + isActive = true, + chainId = 1, + maxFeeNative = BigDecimal("0.005"), + maxFeeUSD = BigDecimal("12.34"), + ) + + // Act + val result = YieldMarketTokenConverter.convert(dto) + + // Assert + assertThat(result).isEqualTo( + YieldMarketToken( + tokenAddress = "0xToken", + chainId = 1, + apy = BigDecimal("5.5"), + isActive = true, + maxFeeNative = BigDecimal("0.005"), + maxFeeUSD = BigDecimal("12.34"), + backendId = null, + ), + ) + } + + @Test + fun `GIVEN dto with null fields WHEN convert THEN applies defaults`() { + // Arrange + val dto = YieldSupplyMarketTokenDto() + + // Act + val result = YieldMarketTokenConverter.convert(dto) + + // Assert + assertThat(result).isEqualTo( + YieldMarketToken( + tokenAddress = "", + chainId = -1, + apy = BigDecimal.ZERO, + isActive = false, + maxFeeNative = BigDecimal.ZERO, + maxFeeUSD = BigDecimal.ZERO, + backendId = null, + ), + ) + } +} \ No newline at end of file diff --git a/data/yield-supply/src/test/java/com/tangem/data/yield/supply/converters/YieldTokenChartConverterTest.kt b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/converters/YieldTokenChartConverterTest.kt new file mode 100644 index 0000000000..7025885c1f --- /dev/null +++ b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/converters/YieldTokenChartConverterTest.kt @@ -0,0 +1,59 @@ +package com.tangem.data.yield.supply.converters + +import com.google.common.truth.Truth.assertThat +import com.tangem.datasource.api.tangemTech.models.YieldTokenChartResponse +import com.tangem.domain.yield.supply.models.YieldSupplyMarketChartData +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +internal class YieldTokenChartConverterTest { + + @Test + fun `GIVEN response with data points WHEN convert THEN splits avgApy into y and bucketIndex into x preserving order`() { + // Arrange + val response = response( + averageApy = BigDecimal("4.25"), + points = listOf( + YieldTokenChartResponse.DataPoint(bucketIndex = 0, avgApy = BigDecimal("3.5")), + YieldTokenChartResponse.DataPoint(bucketIndex = 1, avgApy = BigDecimal("4.0")), + YieldTokenChartResponse.DataPoint(bucketIndex = 2, avgApy = BigDecimal("5.0")), + ), + ) + + // Act + val result = YieldTokenChartConverter.convert(response) + + // Assert + assertThat(result).isEqualTo( + YieldSupplyMarketChartData( + y = listOf(3.5, 4.0, 5.0), + x = listOf(0.0, 1.0, 2.0), + avr = 4.25, + ), + ) + } + + @Test + fun `GIVEN response with empty data WHEN convert THEN returns empty y and x with average`() { + // Arrange + val response = response(averageApy = BigDecimal("1.0"), points = emptyList()) + + // Act + val result = YieldTokenChartConverter.convert(response) + + // Assert + assertThat(result).isEqualTo( + YieldSupplyMarketChartData(y = emptyList(), x = emptyList(), avr = 1.0), + ) + } + + private fun response(averageApy: BigDecimal, points: List) = + YieldTokenChartResponse( + underlying = "USDT", + market = "aave", + bucketSizeDays = 1, + period = "30d", + data = points, + averageApy = averageApy, + ) +} \ No newline at end of file diff --git a/data/yield-supply/src/test/java/com/tangem/data/yield/supply/promo/DefaultYieldPromoRepositoryTest.kt b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/promo/DefaultYieldPromoRepositoryTest.kt new file mode 100644 index 0000000000..c8d8cbf95d --- /dev/null +++ b/data/yield-supply/src/test/java/com/tangem/data/yield/supply/promo/DefaultYieldPromoRepositoryTest.kt @@ -0,0 +1,245 @@ +package com.tangem.data.yield.supply.promo + +import com.google.common.truth.Truth.assertThat +import com.tangem.data.yield.supply.promo.converter.YieldBoostPromoConverter +import com.tangem.data.yield.supply.promo.converter.YieldBoostStatusConverter +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.promotion.models.PromotionsResponse +import com.tangem.datasource.api.promotion.models.YieldBoostStatusResponse +import com.tangem.datasource.api.tangemTech.TangemTechApi +import com.tangem.datasource.local.yieldsupply.promo.YieldBoostPromoStore +import com.tangem.datasource.local.yieldsupply.promo.YieldBoostStatusStore +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.yield.supply.models.YieldBoostPromo +import com.tangem.domain.yield.supply.models.YieldBoostStatus +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +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 +import java.io.IOException + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class DefaultYieldPromoRepositoryTest { + + private val tangemApi: TangemTechApi = mockk() + private val promoStore: YieldBoostPromoStore = mockk(relaxed = true) + private val statusStore: YieldBoostStatusStore = mockk(relaxed = true) + + private val repository = DefaultYieldPromoRepository( + tangemApi = tangemApi, + promoStore = promoStore, + statusStore = statusStore, + dispatchers = TestingCoroutineDispatcherProvider(), + ) + + private val userWalletId = UserWalletId("abcdef012345") + + @BeforeEach + fun setUp() { + clearMocks(tangemApi, promoStore, statusStore) + } + + // region getYieldBoostPromo + @Test + fun `GIVEN cached promo and no refresh WHEN getYieldBoostPromo THEN returns cache without api`() = runTest { + // Arrange + val cached = YieldBoostPromo.None + coEvery { promoStore.getSyncOrNull(userWalletId) } returns cached + + // Act + val result = repository.getYieldBoostPromo(userWalletId, forceRefresh = false) + + // Assert + assertThat(result).isEqualTo(cached) + coVerify(exactly = 0) { tangemApi.getPromotions(any(), any()) } + } + + @Test + fun `GIVEN no cache WHEN getYieldBoostPromo THEN fetches stores and returns converted`() = runTest { + // Arrange + val dto = matchingPromoDto() + coEvery { promoStore.getSyncOrNull(userWalletId) } returns null + coEvery { tangemApi.getPromotions(any(), any()) } returns ApiResponse.Success( + PromotionsResponse(promotions = listOf(dto)), + ) + val expected = YieldBoostPromoConverter.convert(dto) + + // Act + val result = repository.getYieldBoostPromo(userWalletId, forceRefresh = false) + + // Assert + assertThat(result).isEqualTo(expected) + coVerify(exactly = 1) { promoStore.store(userWalletId, expected) } + } + + @Test + fun `GIVEN cached promo and force refresh WHEN getYieldBoostPromo THEN fetches anyway`() = runTest { + // Arrange + coEvery { promoStore.getSyncOrNull(userWalletId) } returns YieldBoostPromo.None + coEvery { tangemApi.getPromotions(any(), any()) } returns ApiResponse.Success( + PromotionsResponse(promotions = listOf(matchingPromoDto())), + ) + + // Act + repository.getYieldBoostPromo(userWalletId, forceRefresh = true) + + // Assert + coVerify(exactly = 1) { tangemApi.getPromotions(any(), any()) } + } + + @Test + fun `GIVEN no matching promo name WHEN getYieldBoostPromo THEN returns None`() = runTest { + // Arrange + coEvery { promoStore.getSyncOrNull(userWalletId) } returns null + coEvery { tangemApi.getPromotions(any(), any()) } returns ApiResponse.Success( + PromotionsResponse(promotions = listOf(PromotionsResponse.PromotionDto(name = "other", all = null))), + ) + + // Act + val result = repository.getYieldBoostPromo(userWalletId, forceRefresh = false) + + // Assert + assertThat(result).isEqualTo(YieldBoostPromo.None) + coVerify(exactly = 1) { promoStore.store(userWalletId, YieldBoostPromo.None) } + } + + @Test + fun `GIVEN fetch fails and cache present WHEN getYieldBoostPromo THEN falls back to cache`() = runTest { + // Arrange — force refresh so the initial cache check is skipped and the fetch is attempted + val cached = YieldBoostPromo.None + coEvery { tangemApi.getPromotions(any(), any()) } throws IOException("network") + coEvery { promoStore.getSyncOrNull(userWalletId) } returns cached + + // Act + val result = repository.getYieldBoostPromo(userWalletId, forceRefresh = true) + + // Assert + assertThat(result).isEqualTo(cached) + coVerify(exactly = 0) { promoStore.store(any(), any()) } + } + + @Test + fun `GIVEN fetch fails and no cache WHEN getYieldBoostPromo THEN rethrows`() = runTest { + // Arrange + coEvery { tangemApi.getPromotions(any(), any()) } throws IOException("network") + coEvery { promoStore.getSyncOrNull(userWalletId) } returns null + + // Act + val error = runCatching { repository.getYieldBoostPromo(userWalletId, forceRefresh = true) } + .exceptionOrNull() + + // Assert + assertThat(error).isInstanceOf(IOException::class.java) + } + // endregion + + // region getYieldBoostStatus + @Test + fun `GIVEN cached status and no refresh WHEN getYieldBoostStatus THEN returns cache without api`() = runTest { + // Arrange + val cached = YieldBoostStatus.NotStarted + coEvery { statusStore.getSyncOrNull(userWalletId) } returns cached + + // Act + val result = repository.getYieldBoostStatus(userWalletId, forceRefresh = false) + + // Assert + assertThat(result).isEqualTo(cached) + coVerify(exactly = 0) { tangemApi.getYieldBoostStatus(any()) } + } + + @Test + fun `GIVEN no cache WHEN getYieldBoostStatus THEN fetches stores and returns converted`() = runTest { + // Arrange + val response = statusResponse() + coEvery { statusStore.getSyncOrNull(userWalletId) } returns null + coEvery { tangemApi.getYieldBoostStatus(any()) } returns ApiResponse.Success(response) + val expected = YieldBoostStatusConverter.convert(response) + + // Act + val result = repository.getYieldBoostStatus(userWalletId, forceRefresh = false) + + // Assert + assertThat(result).isEqualTo(expected) + coVerify(exactly = 1) { statusStore.store(userWalletId, expected) } + } + + @Test + fun `GIVEN cached status and force refresh WHEN getYieldBoostStatus THEN fetches anyway`() = runTest { + // Arrange + coEvery { statusStore.getSyncOrNull(userWalletId) } returns YieldBoostStatus.NotStarted + coEvery { tangemApi.getYieldBoostStatus(any()) } returns ApiResponse.Success(statusResponse()) + + // Act + repository.getYieldBoostStatus(userWalletId, forceRefresh = true) + + // Assert + coVerify(exactly = 1) { tangemApi.getYieldBoostStatus(any()) } + } + + @Test + fun `GIVEN fetch fails and cache present WHEN getYieldBoostStatus THEN falls back to cache`() = runTest { + // Arrange — force refresh so the initial cache check is skipped and the fetch is attempted + val cached = YieldBoostStatus.NotStarted + coEvery { tangemApi.getYieldBoostStatus(any()) } throws IOException("network") + coEvery { statusStore.getSyncOrNull(userWalletId) } returns cached + + // Act + val result = repository.getYieldBoostStatus(userWalletId, forceRefresh = true) + + // Assert + assertThat(result).isEqualTo(cached) + coVerify(exactly = 0) { statusStore.store(any(), any()) } + } + + @Test + fun `GIVEN fetch fails and no cache WHEN getYieldBoostStatus THEN rethrows`() = runTest { + // Arrange + coEvery { tangemApi.getYieldBoostStatus(any()) } throws IOException("network") + coEvery { statusStore.getSyncOrNull(userWalletId) } returns null + + // Act + val error = runCatching { repository.getYieldBoostStatus(userWalletId, forceRefresh = true) } + .exceptionOrNull() + + // Assert + assertThat(error).isInstanceOf(IOException::class.java) + } + // endregion + + private fun matchingPromoDto() = PromotionsResponse.PromotionDto( + name = "yield-apr-boost", + all = PromotionsResponse.PromotionDto.All( + timeline = PromotionsResponse.PromotionDto.Timeline( + start = "2026-06-15T00:00:00.000Z", + end = "2027-06-15T22:00:00.000Z", + ), + tokens = listOf( + PromotionsResponse.PromotionDto.PromoToken( + tokenAddress = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + tokenSymbol = "USDC", + tokenName = "USD Coin", + networkId = "ethereum", + ), + ), + status = "active", + link = "https://example.com/terms", + ), + ) + + private fun statusResponse() = YieldBoostStatusResponse( + tokenName = "USD Coin", + networkId = "ethereum", + moduleAddress = "0xModule", + userAddress = "0xUser", + contractAddress = "0xContract", + promoEnrollmentStatus = "NOT_STARTED", + qualificationEndDate = null, + disqualificationReason = null, + ) +} \ No newline at end of file diff --git a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetMaxFeeUseCaseTest.kt b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetMaxFeeUseCaseTest.kt new file mode 100644 index 0000000000..229eb20309 --- /dev/null +++ b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/usecase/YieldSupplyGetMaxFeeUseCaseTest.kt @@ -0,0 +1,406 @@ +package com.tangem.domain.yield.supply.usecase + +import arrow.core.Either +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.account.models.AccountList +import com.tangem.domain.account.supplier.SingleAccountListSupplier +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.network.NetworkAddress +import com.tangem.domain.models.quote.QuoteStatus +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.quotes.QuotesRepository +import com.tangem.domain.yield.supply.YieldSupplyRepository +import com.tangem.domain.yield.supply.models.YieldMarketToken +import com.tangem.domain.yield.supply.models.YieldSupplyMaxFee +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.math.BigDecimal +import java.math.RoundingMode + +@OptIn(ExperimentalCoroutinesApi::class) +internal class YieldSupplyGetMaxFeeUseCaseTest { + + private val yieldSupplyRepository: YieldSupplyRepository = mockk() + private val quotesRepository: QuotesRepository = mockk() + private val singleAccountListSupplier: SingleAccountListSupplier = mockk() + + private val useCase = YieldSupplyGetMaxFeeUseCase( + yieldSupplyRepository = yieldSupplyRepository, + quotesRepository = quotesRepository, + singleAccountListSupplier = singleAccountListSupplier, + ) + + private val userWalletId = UserWalletId("abcdef012345") + + @BeforeEach + fun setUp() { + clearMocks(yieldSupplyRepository, quotesRepository, singleAccountListSupplier) + } + + @Test + fun `GIVEN cached market token WHEN invoke THEN converts and HALF_UP-rounds the fee to token and fiat`() = + runTest { + // Arrange — values chosen to pin the formula AND the rounding mode with literal expectations: + // fiatMaxFee = maxFeeNative(0.0002) * nativeFiatRate(1000) = 0.2 + // tokenMaxFee = 0.2 / tokenFiatRate(3) = 0.066666… → 0.066667 at 6 decimals (HALF_UP; HALF_DOWN = 0.066666) + val token = createToken(rawNetworkId = NETWORK_ID, decimals = 6) + val nativeCoin = createCoin(rawNetworkId = NETWORK_ID, decimals = 18) + val cryptoStatus = createTokenStatus(token = token, fiatRate = BigDecimal("3")) + + stubAccountList(token, nativeCoin) + stubNativeQuote(nativeCoin, fiatRate = BigDecimal("1000")) + coEvery { yieldSupplyRepository.getCachedMarkets() } returns listOf( + createMarketToken(token = token, maxFeeNative = BigDecimal("0.0002")), + ) + + // Act + val result = useCase(userWalletId, cryptoStatus) + + // Assert — literal expectations, not a mirror of the production expression + assertThat(result).isEqualTo( + Either.Right( + YieldSupplyMaxFee( + nativeMaxFee = BigDecimal("0.0002"), + tokenMaxFee = BigDecimal("0.066667"), + fiatMaxFee = BigDecimal("0.2"), + ), + ), + ) + coVerify(exactly = 0) { yieldSupplyRepository.getTokenStatus(any()) } + } + + @Test + fun `GIVEN no matching cached token WHEN invoke THEN falls back to fetching token status`() = runTest { + // Arrange + val token = createToken(rawNetworkId = NETWORK_ID, decimals = 6) + val nativeCoin = createCoin(rawNetworkId = NETWORK_ID, decimals = 18) + val cryptoStatus = createTokenStatus(token = token, fiatRate = BigDecimal("1.00")) + val nativeFiatRate = BigDecimal("2000.00") + val maxFeeNative = BigDecimal("0.005") + + stubAccountList(token, nativeCoin) + stubNativeQuote(nativeCoin, nativeFiatRate) + coEvery { yieldSupplyRepository.getCachedMarkets() } returns emptyList() + coEvery { yieldSupplyRepository.getTokenStatus(token) } returns createMarketToken( + token = token, + maxFeeNative = maxFeeNative, + ) + + val fiatMaxFee = maxFeeNative.multiply(nativeFiatRate) + val expected = YieldSupplyMaxFee( + nativeMaxFee = maxFeeNative, + tokenMaxFee = fiatMaxFee.divide(cryptoStatus.value.fiatRate, token.decimals, RoundingMode.HALF_UP), + fiatMaxFee = fiatMaxFee.stripTrailingZeros(), + ) + + // Act + val result = useCase(userWalletId, cryptoStatus) + + // Assert + assertThat(result).isEqualTo(Either.Right(expected)) + coVerify(exactly = 1) { yieldSupplyRepository.getTokenStatus(token) } + } + + @Test + fun `GIVEN null cached markets WHEN invoke THEN falls back to fetching token status`() = runTest { + // Arrange + val token = createToken(rawNetworkId = NETWORK_ID, decimals = 6) + val nativeCoin = createCoin(rawNetworkId = NETWORK_ID, decimals = 18) + val cryptoStatus = createTokenStatus(token = token, fiatRate = BigDecimal("1.00")) + val nativeFiatRate = BigDecimal("2000.00") + val maxFeeNative = BigDecimal("0.005") + + stubAccountList(token, nativeCoin) + stubNativeQuote(nativeCoin, nativeFiatRate) + coEvery { yieldSupplyRepository.getCachedMarkets() } returns null + coEvery { yieldSupplyRepository.getTokenStatus(token) } returns createMarketToken( + token = token, + maxFeeNative = maxFeeNative, + ) + + val fiatMaxFee = maxFeeNative.multiply(nativeFiatRate) + val expected = YieldSupplyMaxFee( + nativeMaxFee = maxFeeNative, + tokenMaxFee = fiatMaxFee.divide(cryptoStatus.value.fiatRate, token.decimals, RoundingMode.HALF_UP), + fiatMaxFee = fiatMaxFee.stripTrailingZeros(), + ) + + // Act + val result = useCase(userWalletId, cryptoStatus) + + // Assert + assertThat(result).isEqualTo(Either.Right(expected)) + coVerify(exactly = 1) { yieldSupplyRepository.getTokenStatus(token) } + } + + @Test + fun `GIVEN currency is not a token WHEN invoke THEN returns error`() = runTest { + // Arrange + val coinStatus = createCoinStatus(createCoin(rawNetworkId = NETWORK_ID, decimals = 18)) + + // Act + val result = useCase(userWalletId, coinStatus) + + // Assert + assertLeftWithMessage(result, "CryptoCurrency must be token for max fee calculation") + } + + @Test + fun `GIVEN token fiat rate missing WHEN invoke THEN returns error`() = runTest { + // Arrange + val token = createToken(rawNetworkId = NETWORK_ID, decimals = 6) + val cryptoStatus = createTokenStatus(token = token, fiatRate = null) + + // Act + val result = useCase(userWalletId, cryptoStatus) + + // Assert + assertLeftWithMessage(result, "Fiat rate is missing") + } + + @Test + fun `GIVEN token fiat rate non-positive WHEN invoke THEN returns error`() = runTest { + // Arrange + val token = createToken(rawNetworkId = NETWORK_ID, decimals = 6) + val cryptoStatus = createTokenStatus(token = token, fiatRate = BigDecimal.ZERO) + + // Act + val result = useCase(userWalletId, cryptoStatus) + + // Assert + assertLeftWithMessage(result, "Fiat rate for token must be > 0") + } + + @Test + fun `GIVEN account status list missing WHEN invoke THEN returns error`() = runTest { + // Arrange + val token = createToken(rawNetworkId = NETWORK_ID, decimals = 6) + val cryptoStatus = createTokenStatus(token = token, fiatRate = BigDecimal("1.00")) + coEvery { singleAccountListSupplier.getSyncOrNull(userWalletId = userWalletId) } returns null + + // Act + val result = useCase(userWalletId, cryptoStatus) + + // Assert + assertLeftStartingWith(result, "Account status list is missing") + } + + @Test + fun `GIVEN native coin not found in account list WHEN invoke THEN returns error`() = runTest { + // Arrange + val token = createToken(rawNetworkId = NETWORK_ID, decimals = 6) + val cryptoStatus = createTokenStatus(token = token, fiatRate = BigDecimal("1.00")) + coEvery { + singleAccountListSupplier.getSyncOrNull(userWalletId = userWalletId) + } returns AccountList.empty(userWalletId = userWalletId, cryptoCurrencies = listOf(token)) + + // Act + val result = useCase(userWalletId, cryptoStatus) + + // Assert + assertLeftStartingWith(result, "Unable to find coin for network ID") + } + + @Test + fun `GIVEN native quotes unavailable WHEN invoke THEN returns error`() = runTest { + // Arrange + val token = createToken(rawNetworkId = NETWORK_ID, decimals = 6) + val nativeCoin = createCoin(rawNetworkId = NETWORK_ID, decimals = 18) + val cryptoStatus = createTokenStatus(token = token, fiatRate = BigDecimal("1.00")) + stubAccountList(token, nativeCoin) + coEvery { + quotesRepository.getMultiQuoteSyncOrNull(setOf(nativeCoin.id.rawCurrencyId!!)) + } returns null + + // Act + val result = useCase(userWalletId, cryptoStatus) + + // Assert + assertLeftWithMessage(result, "Quotes for native coin are unavailable") + } + + @Test + fun `GIVEN empty native quotes list WHEN invoke THEN returns error`() = runTest { + // Arrange + val token = createToken(rawNetworkId = NETWORK_ID, decimals = 6) + val nativeCoin = createCoin(rawNetworkId = NETWORK_ID, decimals = 18) + val cryptoStatus = createTokenStatus(token = token, fiatRate = BigDecimal("1.00")) + stubAccountList(token, nativeCoin) + coEvery { + quotesRepository.getMultiQuoteSyncOrNull(setOf(nativeCoin.id.rawCurrencyId!!)) + } returns emptySet() + + // Act + val result = useCase(userWalletId, cryptoStatus) + + // Assert + assertLeftWithMessage(result, "Empty quotes list for native coin") + } + + @Test + fun `GIVEN native quote has no fiat rate WHEN invoke THEN returns error`() = runTest { + // Arrange + val token = createToken(rawNetworkId = NETWORK_ID, decimals = 6) + val nativeCoin = createCoin(rawNetworkId = NETWORK_ID, decimals = 18) + val cryptoStatus = createTokenStatus(token = token, fiatRate = BigDecimal("1.00")) + stubAccountList(token, nativeCoin) + coEvery { + quotesRepository.getMultiQuoteSyncOrNull(setOf(nativeCoin.id.rawCurrencyId!!)) + } returns setOf(QuoteStatus(rawCurrencyId = nativeCoin.id.rawCurrencyId!!)) + + // Act + val result = useCase(userWalletId, cryptoStatus) + + // Assert + assertLeftWithMessage(result, "Native fiat rate is missing") + } + + @Test + fun `GIVEN native fiat rate non-positive WHEN invoke THEN returns error`() = runTest { + // Arrange + val token = createToken(rawNetworkId = NETWORK_ID, decimals = 6) + val nativeCoin = createCoin(rawNetworkId = NETWORK_ID, decimals = 18) + val cryptoStatus = createTokenStatus(token = token, fiatRate = BigDecimal("1.00")) + stubAccountList(token, nativeCoin) + stubNativeQuote(nativeCoin, fiatRate = BigDecimal.ZERO) + + // Act + val result = useCase(userWalletId, cryptoStatus) + + // Assert + assertLeftWithMessage(result, "Native fiat rate must be > 0") + } + + // region Helpers + + private fun stubAccountList(token: CryptoCurrency.Token, nativeCoin: CryptoCurrency.Coin) { + coEvery { + singleAccountListSupplier.getSyncOrNull(userWalletId = userWalletId) + } returns AccountList.empty(userWalletId = userWalletId, cryptoCurrencies = listOf(nativeCoin, token)) + } + + private fun stubNativeQuote(nativeCoin: CryptoCurrency.Coin, fiatRate: BigDecimal) { + coEvery { + quotesRepository.getMultiQuoteSyncOrNull(setOf(nativeCoin.id.rawCurrencyId!!)) + } returns setOf( + QuoteStatus( + rawCurrencyId = nativeCoin.id.rawCurrencyId!!, + value = QuoteStatus.Data( + source = StatusSource.ACTUAL, + fiatRate = fiatRate, + fiatRateUSD = fiatRate, + priceChange = BigDecimal.ZERO, + ), + ), + ) + } + + private fun assertLeftWithMessage(result: Either, message: String) { + assertThat(result.isLeft()).isTrue() + assertThat((result as Either.Left).value.message).isEqualTo(message) + } + + private fun assertLeftStartingWith(result: Either, prefix: String) { + assertThat(result.isLeft()).isTrue() + assertThat((result as Either.Left).value.message).startsWith(prefix) + } + + private fun createMarketToken(token: CryptoCurrency.Token, maxFeeNative: BigDecimal): YieldMarketToken = + YieldMarketToken( + tokenAddress = token.contractAddress, + chainId = 1, + apy = BigDecimal.ZERO, + isActive = true, + maxFeeNative = maxFeeNative, + maxFeeUSD = BigDecimal.ZERO, + backendId = token.network.rawId, + ) + + private fun createToken(rawNetworkId: String, decimals: Int): CryptoCurrency.Token { + return CryptoCurrency.Token( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId), + suffix = CryptoCurrency.ID.Suffix.RawID(rawNetworkId), + ), + network = createNetwork(rawNetworkId), + name = "TEST_TOKEN", + symbol = "TTK", + decimals = decimals, + iconUrl = null, + isCustom = false, + contractAddress = "0xToken", + ) + } + + private fun createCoin(rawNetworkId: String, decimals: Int): CryptoCurrency.Coin { + return CryptoCurrency.Coin( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId(rawNetworkId), + suffix = CryptoCurrency.ID.Suffix.RawID(rawNetworkId), + ), + network = createNetwork(rawNetworkId), + name = "TEST_COIN", + symbol = "TCN", + decimals = decimals, + iconUrl = null, + isCustom = false, + ) + } + + private fun createNetwork(rawNetworkId: String): Network { + val derivationPath = Network.DerivationPath.None + return Network( + id = Network.ID(value = rawNetworkId, derivationPath = derivationPath), + name = rawNetworkId, + currencySymbol = rawNetworkId.take(3).uppercase(), + derivationPath = derivationPath, + isTestnet = false, + standardType = Network.StandardType.Unspecified("UNSPECIFIED"), + hasFiatFeeRate = true, + canHandleTokens = true, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ) + } + + private fun createTokenStatus(token: CryptoCurrency.Token, fiatRate: BigDecimal?): CryptoCurrencyStatus = + CryptoCurrencyStatus(currency = token, value = customValue(fiatRate)) + + private fun createCoinStatus(coin: CryptoCurrency.Coin): CryptoCurrencyStatus = + CryptoCurrencyStatus(currency = coin, value = customValue(BigDecimal.ONE)) + + private fun customValue(fiatRate: BigDecimal?): CryptoCurrencyStatus.Custom = CryptoCurrencyStatus.Custom( + amount = BigDecimal.ZERO, + fiatAmount = BigDecimal.ZERO, + fiatRate = fiatRate, + priceChange = BigDecimal.ZERO, + stakingBalance = null, + yieldSupplyStatus = null, + hasCurrentNetworkTransactions = false, + pendingTransactions = emptySet(), + networkAddress = NetworkAddress.Single( + defaultAddress = NetworkAddress.Address( + value = "0x0000000000000000000000000000000000000000", + type = NetworkAddress.Address.Type.Primary, + ), + ), + sources = CryptoCurrencyStatus.Sources(), + ) + + // endregion + + private companion object { + const val NETWORK_ID = "ethereum" + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/active/model/transformers/YieldSupplyActiveFeeContentTransformerTest.kt b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/active/model/transformers/YieldSupplyActiveFeeContentTransformerTest.kt new file mode 100644 index 0000000000..8139340549 --- /dev/null +++ b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/active/model/transformers/YieldSupplyActiveFeeContentTransformerTest.kt @@ -0,0 +1,198 @@ +package com.tangem.features.yield.supply.impl.active.model.transformers + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.network.NetworkAddress +import com.tangem.domain.yield.supply.models.YieldSupplyMaxFee +import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics +import com.tangem.features.yield.supply.impl.R +import com.tangem.features.yield.supply.impl.active.entity.YieldSupplyActiveContentUM +import io.mockk.clearMocks +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +internal class YieldSupplyActiveFeeContentTransformerTest { + + private val analyticsHandler: AnalyticsEventHandler = mockk(relaxed = true) + private val token = createToken() + private val appCurrency = AppCurrency.Default + + @BeforeEach + fun setUp() { + clearMocks(analyticsHandler) + } + + @Test + fun `GIVEN fee below max WHEN transform THEN not high fee and computed fee texts`() { + // Arrange — fee 1, maxToken 2, maxFiat 4, fiatRate 1 + val transformer = createTransformer(feeValue = BigDecimal("1"), tokenMaxFee = BigDecimal("2")) + + // Act + val result = transformer.transform(emptyContent()) + + // Assert — currentFee is the token fiat fee (feeValue * fiatRate); feeDescription holds the 4 args in order + val expectedFiatFee = fiatText(BigDecimal("1").multiply(BigDecimal("1"))) + assertThat(result.isHighFee).isFalse() + assertThat(result.currentFee).isEqualTo(stringReference(expectedFiatFee)) + assertThat(result.feeDescription).isEqualTo( + resourceReference( + id = R.string.yield_module_fee_policy_sheet_fee_note, + formatArgs = wrappedList( + stringReference(expectedFiatFee), + stringReference(cryptoText(BigDecimal("1"))), + stringReference(fiatText(BigDecimal("4"))), + stringReference(cryptoText(BigDecimal("2"))), + ), + ), + ) + verify(exactly = 0) { analyticsHandler.send(any()) } + } + + @Test + fun `GIVEN fee above max WHEN transform THEN high fee and analytics carries token and blockchain`() { + // Arrange + val transformer = createTransformer(feeValue = BigDecimal("3"), tokenMaxFee = BigDecimal("2")) + val eventSlot = slot() + + // Act + val result = transformer.transform(emptyContent()) + + // Assert + assertThat(result.isHighFee).isTrue() + verify(exactly = 1) { analyticsHandler.send(capture(eventSlot)) } + val event = eventSlot.captured as YieldSupplyAnalytics.NoticeHighNetworkFee + assertThat(event.token).isEqualTo("TTK") + assertThat(event.blockchain).isEqualTo("Ethereum") + } + + @Test + fun `GIVEN fee equal to max WHEN transform THEN not high fee`() { + // Arrange — boundary: comparison is strictly greater-than + val transformer = createTransformer(feeValue = BigDecimal("2"), tokenMaxFee = BigDecimal("2")) + + // Act + val result = transformer.transform(emptyContent()) + + // Assert + assertThat(result.isHighFee).isFalse() + verify(exactly = 0) { analyticsHandler.send(any()) } + } + + @Test + fun `GIVEN missing fiat rate WHEN transform THEN current fee is the placeholder and high fee resolved by crypto`() { + // Arrange — null fiat rate: fiat fee text falls back to the placeholder, high-fee logic unaffected + val transformer = createTransformer( + feeValue = BigDecimal("3"), + tokenMaxFee = BigDecimal("2"), + fiatRate = null, + ) + + // Act + val result = transformer.transform(emptyContent()) + + // Assert — placeholder differs from a populated fiat value, proving the null branch was taken + assertThat(result.currentFee).isEqualTo(stringReference(fiatText(null))) + assertThat(result.isHighFee).isTrue() + verify(exactly = 1) { analyticsHandler.send(any()) } + } + + private fun cryptoText(value: BigDecimal): String = value.format { crypto(token) } + + private fun fiatText(value: BigDecimal?): String = value.format { fiat(appCurrency.code, appCurrency.symbol) } + + private fun createTransformer( + feeValue: BigDecimal, + tokenMaxFee: BigDecimal, + fiatRate: BigDecimal? = BigDecimal("1"), + ): YieldSupplyActiveFeeContentTransformer = YieldSupplyActiveFeeContentTransformer( + cryptoCurrencyStatus = status(fiatRate = fiatRate), + appCurrency = appCurrency, + feeValue = feeValue, + maxNetworkFee = YieldSupplyMaxFee( + nativeMaxFee = BigDecimal("0.01"), + tokenMaxFee = tokenMaxFee, + fiatMaxFee = BigDecimal("4"), + ), + analyticsHandler = analyticsHandler, + ) + + private fun status(fiatRate: BigDecimal?): CryptoCurrencyStatus = CryptoCurrencyStatus( + currency = token, + value = CryptoCurrencyStatus.Custom( + amount = BigDecimal.ZERO, + fiatAmount = BigDecimal.ZERO, + fiatRate = fiatRate, + priceChange = BigDecimal.ZERO, + stakingBalance = null, + yieldSupplyStatus = null, + hasCurrentNetworkTransactions = false, + pendingTransactions = emptySet(), + networkAddress = NetworkAddress.Single( + defaultAddress = NetworkAddress.Address( + value = "0x0000000000000000000000000000000000000000", + type = NetworkAddress.Address.Type.Primary, + ), + ), + sources = CryptoCurrencyStatus.Sources(), + ), + ) + + private fun emptyContent(): YieldSupplyActiveContentUM = YieldSupplyActiveContentUM( + totalEarnings = stringReference(""), + availableBalance = null, + providerTitle = stringReference(""), + subtitle = stringReference(""), + subtitleLink = stringReference(""), + notifications = persistentListOf(), + minAmount = null, + currentFee = null, + feeDescription = null, + minFeeDescription = null, + ) + + private fun createToken(): CryptoCurrency.Token { + val derivationPath = Network.DerivationPath.None + val network = Network( + id = Network.ID(value = "ethereum", derivationPath = derivationPath), + name = "Ethereum", + currencySymbol = "ETH", + derivationPath = derivationPath, + isTestnet = false, + standardType = Network.StandardType.Unspecified("UNSPECIFIED"), + hasFiatFeeRate = true, + canHandleTokens = true, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ) + return CryptoCurrency.Token( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId("ethereum"), + suffix = CryptoCurrency.ID.Suffix.RawID("ethereum"), + ), + network = network, + name = "TEST_TOKEN", + symbol = "TTK", + decimals = 6, + iconUrl = null, + isCustom = false, + contractAddress = "0xToken", + ) + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/active/model/transformers/YieldSupplyActiveMinAmountTransformerTest.kt b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/active/model/transformers/YieldSupplyActiveMinAmountTransformerTest.kt new file mode 100644 index 0000000000..16fd1476b0 --- /dev/null +++ b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/active/model/transformers/YieldSupplyActiveMinAmountTransformerTest.kt @@ -0,0 +1,325 @@ +package com.tangem.features.yield.supply.impl.active.model.transformers + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.ui.components.notifications.NotificationConfig +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.common.ui.notifications.NotificationUM +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.network.NetworkAddress +import com.tangem.domain.models.yield.supply.YieldSupplyStatus +import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics +import com.tangem.features.yield.supply.impl.R +import com.tangem.features.yield.supply.impl.active.entity.YieldSupplyActiveContentUM +import io.mockk.clearMocks +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +internal class YieldSupplyActiveMinAmountTransformerTest { + + private val analyticsHandler: AnalyticsEventHandler = mockk(relaxed = true) + private val token = createToken() + private val appCurrency = AppCurrency.Default + private var approveClicked = false + + @BeforeEach + fun setUp() { + clearMocks(analyticsHandler) + approveClicked = false + } + + @Test + fun `GIVEN spending not allowed and nothing un-supplied WHEN transform THEN approval notification and min amount texts`() { + // Arrange + val status = status(amount = BigDecimal("5"), isAllowedToSpend = false, effectiveProtocolBalance = BigDecimal("5")) + val transformer = createTransformer(status = status, dustMinAmount = BigDecimal("1")) + + // Act + val result = transformer.transform(emptyContent()) + + // Assert — minAmount uses the fiat value (minAmount * fiatRate); minFeeDescription carries [fiat, crypto] in order + val expectedMinFiat = fiatText(MIN_AMOUNT.multiply(BigDecimal("1"))) + val expectedMinCrypto = cryptoText(MIN_AMOUNT) + assertThat(result.notifications).hasSize(1) + assertThat(result.notifications.first()).isInstanceOf(NotificationUM.Error::class.java) + assertThat(result.minAmount).isEqualTo(stringReference(expectedMinFiat)) + assertThat(result.minFeeDescription).isEqualTo( + resourceReference( + id = R.string.yield_module_fee_policy_sheet_min_amount_note, + formatArgs = wrappedList(expectedMinFiat, expectedMinCrypto), + ), + ) + verify(exactly = 0) { analyticsHandler.send(any()) } + } + + @Test + fun `GIVEN spending allowed and un-supplied above dust WHEN transform THEN not-supplied notification with amount and analytics`() { + // Arrange — un-supplied = amount(10) - protocolBalance(1) = 9 + val status = status( + amount = BigDecimal("10"), + isAllowedToSpend = true, + effectiveProtocolBalance = BigDecimal("1"), + fiatRate = BigDecimal("1"), + ) + val transformer = createTransformer(status = status, dustMinAmount = BigDecimal("1")) + val eventSlot = slot() + + // Act + val result = transformer.transform(emptyContent()) + + // Assert + assertThat(result.notifications).hasSize(1) + val notification = result.notifications.first() as NotificationUM.Info.YieldSupplyNotAllAmountSupplied + assertThat(notification.symbol).isEqualTo(TOKEN_SYMBOL) + assertThat(notification.formattedAmount).isEqualTo(notSuppliedText(BigDecimal("9"))) + verify(exactly = 1) { analyticsHandler.send(capture(eventSlot)) } + val event = eventSlot.captured as YieldSupplyAnalytics.NoticeAmountNotDeposited + assertThat(event.token).isEqualTo(TOKEN_SYMBOL) + assertThat(event.blockchain).isEqualTo("Ethereum") + } + + @Test + fun `GIVEN spending allowed and fully supplied WHEN transform THEN no notifications`() { + // Arrange + val status = status(amount = BigDecimal("5"), isAllowedToSpend = true, effectiveProtocolBalance = BigDecimal("5")) + val transformer = createTransformer(status = status, dustMinAmount = BigDecimal("1")) + + // Act + val result = transformer.transform(emptyContent()) + + // Assert + assertThat(result.notifications).isEmpty() + verify(exactly = 0) { analyticsHandler.send(any()) } + } + + @Test + fun `GIVEN un-supplied amount below dust threshold WHEN transform THEN no not-supplied notification`() { + // Arrange — un-supplied = 1 (fiat), dust threshold = 5 → below threshold + val status = status( + amount = BigDecimal("10"), + isAllowedToSpend = true, + effectiveProtocolBalance = BigDecimal("9"), + fiatRate = BigDecimal("1"), + ) + val transformer = createTransformer(status = status, dustMinAmount = BigDecimal("5")) + + // Act + val result = transformer.transform(emptyContent()) + + // Assert + assertThat(result.notifications).isEmpty() + verify(exactly = 0) { analyticsHandler.send(any()) } + } + + @Test + fun `GIVEN un-supplied fiat equals dust threshold WHEN transform THEN not-supplied notification shown`() { + // Arrange — boundary: shouldShowNotSuppliedNotification uses >=, so equality must show the notification + val status = status( + amount = BigDecimal("10"), + isAllowedToSpend = true, + effectiveProtocolBalance = BigDecimal("5"), + fiatRate = BigDecimal("1"), + ) + val transformer = createTransformer(status = status, dustMinAmount = BigDecimal("5")) + + // Act + val result = transformer.transform(emptyContent()) + + // Assert — un-supplied fiat = (10-5)*1 = 5 == dust 5 + assertThat(result.notifications).hasSize(1) + assertThat(result.notifications.first()) + .isInstanceOf(NotificationUM.Info.YieldSupplyNotAllAmountSupplied::class.java) + verify(exactly = 1) { analyticsHandler.send(any()) } + } + + @Test + fun `GIVEN supply inactive WHEN transform THEN no not-supplied notification even if balance differs`() { + // Arrange — isActive=false short-circuits notSupplied calculation + val status = status( + amount = BigDecimal("10"), + isAllowedToSpend = true, + isActive = false, + effectiveProtocolBalance = BigDecimal("1"), + fiatRate = BigDecimal("1"), + ) + val transformer = createTransformer(status = status, dustMinAmount = BigDecimal("1")) + + // Act + val result = transformer.transform(emptyContent()) + + // Assert + assertThat(result.notifications).isEmpty() + verify(exactly = 0) { analyticsHandler.send(any()) } + } + + @Test + fun `GIVEN missing fiat rate WHEN transform THEN min amount is the placeholder and no not-supplied notification`() { + // Arrange — null fiat rate: fiat min amount cannot be computed, not-supplied calc is skipped + val status = status( + amount = BigDecimal("10"), + isAllowedToSpend = true, + isActive = false, + effectiveProtocolBalance = BigDecimal("1"), + fiatRate = null, + ) + val transformer = createTransformer(status = status, dustMinAmount = BigDecimal("1")) + + // Act + val result = transformer.transform(emptyContent()) + + // Assert — minAmount falls back to the null-rate placeholder + assertThat(result.minAmount).isEqualTo(stringReference(fiatText(null))) + assertThat(result.notifications).isEmpty() + verify(exactly = 0) { analyticsHandler.send(any()) } + } + + @Test + fun `GIVEN approval needed and un-supplied above dust WHEN transform THEN both notifications in order`() { + // Arrange + val status = status( + amount = BigDecimal("10"), + isAllowedToSpend = false, + effectiveProtocolBalance = BigDecimal("1"), + fiatRate = BigDecimal("1"), + ) + val transformer = createTransformer(status = status, dustMinAmount = BigDecimal("1")) + + // Act + val result = transformer.transform(emptyContent()) + + // Assert — approval first, then not-supplied (listOfNotNull order) + assertThat(result.notifications).hasSize(2) + assertThat(result.notifications[0]).isInstanceOf(NotificationUM.Error::class.java) + assertThat(result.notifications[1]) + .isInstanceOf(NotificationUM.Info.YieldSupplyNotAllAmountSupplied::class.java) + verify(exactly = 1) { analyticsHandler.send(any()) } + } + + @Test + fun `GIVEN approval notification WHEN its button clicked THEN onApprove fires`() { + // Arrange + val status = status(amount = BigDecimal("5"), isAllowedToSpend = false, effectiveProtocolBalance = BigDecimal("5")) + val transformer = createTransformer(status = status, dustMinAmount = BigDecimal("1")) + + // Act + val result = transformer.transform(emptyContent()) + val button = (result.notifications.first() as NotificationUM.Error) + .config.buttonsState as NotificationConfig.ButtonsState.PrimaryButtonConfig + button.onClick() + + // Assert + assertThat(approveClicked).isTrue() + } + + private fun cryptoText(value: BigDecimal): String = value.format { crypto(token) } + + private fun fiatText(value: BigDecimal?): String = value.format { fiat(appCurrency.code, appCurrency.symbol) } + + private fun notSuppliedText(value: BigDecimal): String = value.format { crypto(symbol = "", decimals = token.decimals) } + + private fun createTransformer( + status: CryptoCurrencyStatus, + dustMinAmount: BigDecimal, + ): YieldSupplyActiveMinAmountTransformer = YieldSupplyActiveMinAmountTransformer( + cryptoCurrencyStatus = status, + appCurrency = appCurrency, + minAmount = MIN_AMOUNT, + dustMinAmount = dustMinAmount, + analyticsHandler = analyticsHandler, + onApprove = { approveClicked = true }, + ) + + private fun status( + amount: BigDecimal, + isAllowedToSpend: Boolean, + isActive: Boolean = true, + effectiveProtocolBalance: BigDecimal? = null, + fiatRate: BigDecimal? = BigDecimal("1"), + ): CryptoCurrencyStatus = CryptoCurrencyStatus( + currency = token, + value = CryptoCurrencyStatus.Custom( + amount = amount, + fiatAmount = BigDecimal.ZERO, + fiatRate = fiatRate, + priceChange = BigDecimal.ZERO, + stakingBalance = null, + yieldSupplyStatus = YieldSupplyStatus( + isActive = isActive, + isInitialized = true, + isAllowedToSpend = isAllowedToSpend, + effectiveProtocolBalance = effectiveProtocolBalance, + ), + hasCurrentNetworkTransactions = false, + pendingTransactions = emptySet(), + networkAddress = NetworkAddress.Single( + defaultAddress = NetworkAddress.Address( + value = "0x0000000000000000000000000000000000000000", + type = NetworkAddress.Address.Type.Primary, + ), + ), + sources = CryptoCurrencyStatus.Sources(), + ), + ) + + private fun emptyContent(): YieldSupplyActiveContentUM = YieldSupplyActiveContentUM( + totalEarnings = stringReference(""), + availableBalance = null, + providerTitle = stringReference(""), + subtitle = stringReference(""), + subtitleLink = stringReference(""), + notifications = persistentListOf(), + minAmount = null, + currentFee = null, + feeDescription = null, + minFeeDescription = null, + ) + + private fun createToken(): CryptoCurrency.Token { + val derivationPath = Network.DerivationPath.None + val network = Network( + id = Network.ID(value = "ethereum", derivationPath = derivationPath), + name = "Ethereum", + currencySymbol = "ETH", + derivationPath = derivationPath, + isTestnet = false, + standardType = Network.StandardType.Unspecified("UNSPECIFIED"), + hasFiatFeeRate = true, + canHandleTokens = true, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ) + return CryptoCurrency.Token( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId("ethereum"), + suffix = CryptoCurrency.ID.Suffix.RawID("ethereum"), + ), + network = network, + name = "TEST_TOKEN", + symbol = TOKEN_SYMBOL, + decimals = 6, + iconUrl = null, + isCustom = false, + contractAddress = "0xToken", + ) + } + + private companion object { + const val TOKEN_SYMBOL = "TTK" + val MIN_AMOUNT: BigDecimal = BigDecimal("2") + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/chart/model/YieldSupplyChartModelTest.kt b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/chart/model/YieldSupplyChartModelTest.kt new file mode 100644 index 0000000000..65447d0488 --- /dev/null +++ b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/chart/model/YieldSupplyChartModelTest.kt @@ -0,0 +1,172 @@ +package com.tangem.features.yield.supply.impl.chart.model + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.core.decompose.model.MutableParamsContainer +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.yield.supply.models.YieldSupplyMarketChartData +import com.tangem.domain.yield.supply.usecase.YieldSupplyGetChartUseCase +import com.tangem.features.yield.supply.impl.chart.DefaultYieldSupplyChartComponent +import com.tangem.features.yield.supply.impl.chart.entity.YieldSupplyChartUM +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +internal class YieldSupplyChartModelTest { + + private val getChartUseCase: YieldSupplyGetChartUseCase = mockk() + private val callback: DefaultYieldSupplyChartComponent.ModelCallback = mockk(relaxed = true) + + @BeforeEach + fun setUp() { + clearMocks(getChartUseCase, callback) + } + + @Test + fun `GIVEN chart data with values above one WHEN model created THEN Data state with integer percent format`() = + runTest { + // Arrange + coEvery { getChartUseCase(any()) } returns chartData(y = listOf(2.0, 5.0, 10.0)).right() + + // Act + val model = createModel() + + // Assert + val state = model.uiState.value + assertThat(state).isInstanceOf(YieldSupplyChartUM.Data::class.java) + val data = state as YieldSupplyChartUM.Data + assertThat(data.chartData.percentFormat).isEqualTo("%.0f") + assertThat(data.monthLables).hasSize(MONTH_LABELS_COUNT) + verify(exactly = 1) { callback.onStartLoading() } + verify(exactly = 1) { callback.onSuccessLoad() } + verify(exactly = 0) { callback.onLoadFail() } + } + + @Test + fun `GIVEN chart data with values below one WHEN model created THEN Data state with one-decimal percent format`() = + runTest { + // Arrange + coEvery { getChartUseCase(any()) } returns chartData(y = listOf(0.2, 0.5, 0.9)).right() + + // Act + val model = createModel() + + // Assert + val data = model.uiState.value as YieldSupplyChartUM.Data + assertThat(data.chartData.percentFormat).isEqualTo("%.1f") + } + + @Test + fun `GIVEN empty chart data WHEN model created THEN Error state and load fail callback`() = runTest { + // Arrange + coEvery { getChartUseCase(any()) } returns chartData(y = emptyList()).right() + + // Act + val model = createModel() + + // Assert + assertThat(model.uiState.value).isInstanceOf(YieldSupplyChartUM.Error::class.java) + verify(exactly = 1) { callback.onStartLoading() } + verify(exactly = 1) { callback.onLoadFail() } + verify(exactly = 0) { callback.onSuccessLoad() } + } + + @Test + fun `GIVEN use case fails WHEN model created THEN Error state and load fail callback`() = runTest { + // Arrange + coEvery { getChartUseCase(any()) } returns IllegalStateException("boom").left() + + // Act + val model = createModel() + + // Assert + assertThat(model.uiState.value).isInstanceOf(YieldSupplyChartUM.Error::class.java) + verify(exactly = 1) { callback.onLoadFail() } + verify(exactly = 0) { callback.onSuccessLoad() } + } + + @Test + fun `GIVEN error state WHEN retry invoked AND data available THEN recovers to Data state`() = runTest { + // Arrange — first call fails, retry succeeds + coEvery { getChartUseCase(any()) } returnsMany listOf( + IllegalStateException("boom").left(), + chartData(y = listOf(2.0, 5.0)).right(), + ) + val model = createModel() + val error = model.uiState.value as YieldSupplyChartUM.Error + + // Act + error.onRetry() + + // Assert + assertThat(model.uiState.value).isInstanceOf(YieldSupplyChartUM.Data::class.java) + } + + @Test + fun `GIVEN no callback WHEN model created with data THEN Data state without crash`() = runTest { + // Arrange — Params.callback is optional; model must tolerate its absence + coEvery { getChartUseCase(any()) } returns chartData(y = listOf(2.0, 5.0)).right() + + // Act + val model = createModel(callback = null) + + // Assert + assertThat(model.uiState.value).isInstanceOf(YieldSupplyChartUM.Data::class.java) + } + + private fun createModel( + callback: DefaultYieldSupplyChartComponent.ModelCallback? = this.callback, + ): YieldSupplyChartModel = YieldSupplyChartModel( + paramsContainer = MutableParamsContainer( + DefaultYieldSupplyChartComponent.Params(cryptoCurrency = createToken(), callback = callback), + ), + dispatchers = TestingCoroutineDispatcherProvider(), + yieldSupplyGetChartUseCase = getChartUseCase, + ) + + private fun chartData(y: List): YieldSupplyMarketChartData = + YieldSupplyMarketChartData(y = y, x = y.indices.map { it.toDouble() }, avr = 1.0) + + private fun createToken(): CryptoCurrency.Token { + val derivationPath = Network.DerivationPath.None + val network = Network( + id = Network.ID(value = "ethereum", derivationPath = derivationPath), + name = "Ethereum", + currencySymbol = "ETH", + derivationPath = derivationPath, + isTestnet = false, + standardType = Network.StandardType.Unspecified("UNSPECIFIED"), + hasFiatFeeRate = true, + canHandleTokens = true, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ) + return CryptoCurrency.Token( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId("ethereum"), + suffix = CryptoCurrency.ID.Suffix.RawID("ethereum"), + ), + network = network, + name = "TEST_TOKEN", + symbol = "TTK", + decimals = 6, + iconUrl = null, + isCustom = false, + contractAddress = "0xToken", + ) + } + + private companion object { + const val MONTH_LABELS_COUNT = 5 + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/entry/model/YieldSupplyEntryModelTest.kt b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/entry/model/YieldSupplyEntryModelTest.kt new file mode 100644 index 0000000000..560732b75f --- /dev/null +++ b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/entry/model/YieldSupplyEntryModelTest.kt @@ -0,0 +1,310 @@ +package com.tangem.features.yield.supply.impl.entry.model + +import arrow.core.left +import arrow.core.none +import arrow.core.right +import arrow.core.some +import com.google.common.truth.Truth.assertThat +import com.tangem.common.routing.AppRoute +import com.tangem.core.decompose.model.MutableParamsContainer +import com.tangem.core.decompose.navigation.Route +import com.tangem.core.decompose.navigation.Router +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.network.NetworkAddress +import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.yield.supply.YieldSupplyStatus +import com.tangem.domain.tokens.model.details.NavigationAction +import com.tangem.domain.yield.supply.models.YieldSupplyPendingStatus +import com.tangem.domain.yield.supply.promo.usecase.IsYieldBoostPromoEnabledForTokenUseCase +import com.tangem.domain.yield.supply.usecase.YieldSupplyEnterStatusUseCase +import com.tangem.features.yield.supply.api.YieldSupplyEntryComponent +import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles +import com.tangem.features.yield.supply.api.entry.YieldSupplyEntryRoute +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.slot +import io.mockk.unmockkObject +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +@OptIn(ExperimentalCoroutinesApi::class) +internal class YieldSupplyEntryModelTest { + + private val router: Router = mockk(relaxed = true) + private val enterStatusUseCase: YieldSupplyEnterStatusUseCase = mockk() + private val accountStatusListSupplier: SingleAccountStatusListSupplier = mockk() + private val isPromoEnabledUseCase: IsYieldBoostPromoEnabledForTokenUseCase = mockk() + private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles = mockk() + + private val accountStatusList: AccountStatusList = mockk() + + @BeforeEach + fun setUp() { + clearMocks( + router, enterStatusUseCase, accountStatusListSupplier, + isPromoEnabledUseCase, yieldSupplyFeatureToggles, + ) + mockkObject(CryptoCurrencyStatusOperations) + coEvery { accountStatusListSupplier.getSyncOrNull(USER_WALLET_ID) } returns accountStatusList + every { yieldSupplyFeatureToggles.isYieldPromoEnabled } returns true + } + + @AfterEach + fun tearDown() { + unmockkObject(CryptoCurrencyStatusOperations) + } + + @Test + fun `GIVEN currency status not found WHEN created THEN pops without navigating`() = runTest { + // Arrange + stubStatusLookup(none()) + + // Act + createModel(currency = token()) + + // Assert + verify(exactly = 1) { router.pop(any()) } + verify(exactly = 0) { router.replaceCurrent(any(), any()) } + } + + @Test + fun `GIVEN currency is not a token WHEN created THEN pops without navigating`() = runTest { + // Arrange + stubStatusLookup(status(isActive = false).some()) + + // Act + createModel(currency = coin()) + + // Assert + verify(exactly = 1) { router.pop(any()) } + verify(exactly = 0) { router.replaceCurrent(any(), any()) } + } + + @Test + fun `GIVEN pending enter status and active yield WHEN created THEN navigates to currency details active`() = + runTest { + // Arrange + stubStatusLookup(status(isActive = true).some()) + coEvery { enterStatusUseCase(USER_WALLET_ID, any()) } returns pendingEnter().right() + + // Act + createModel(currency = token()) + + // Assert + val route = captureReplacedRoute() + assertThat(route).isInstanceOf(AppRoute.CurrencyDetails::class.java) + assertThat((route as AppRoute.CurrencyDetails).navigationAction) + .isEqualTo(NavigationAction.YieldSupply(isActive = true)) + assertThat(route.userWalletId).isEqualTo(USER_WALLET_ID) + assertThat(route.currency).isEqualTo(token()) + } + + @Test + fun `GIVEN pending enter status and inactive yield WHEN created THEN currency details with inactive flag`() = + runTest { + // Arrange + stubStatusLookup(status(isActive = false).some()) + coEvery { enterStatusUseCase(USER_WALLET_ID, any()) } returns pendingEnter().right() + + // Act + createModel(currency = token()) + + // Assert + val route = captureReplacedRoute() + assertThat((route as AppRoute.CurrencyDetails).navigationAction) + .isEqualTo(NavigationAction.YieldSupply(isActive = false)) + } + + @Test + fun `GIVEN no pending status and active yield WHEN created THEN navigates to Active route`() = runTest { + // Arrange + stubStatusLookup(status(isActive = true).some()) + coEvery { enterStatusUseCase(USER_WALLET_ID, any()) } returns null.right() + + // Act + createModel(currency = token()) + + // Assert + val route = captureReplacedRoute() + assertThat(route).isInstanceOf(YieldSupplyEntryRoute.Active::class.java) + assertThat((route as YieldSupplyEntryRoute.Active).cryptoCurrency).isEqualTo(token()) + } + + @Test + fun `GIVEN enter status use case fails WHEN created THEN coerced to no pending and routes to Active`() = runTest { + // Arrange — a Left is coerced to null by getOrNull, so it must NOT route to CurrencyDetails + stubStatusLookup(status(isActive = true).some()) + coEvery { enterStatusUseCase(USER_WALLET_ID, any()) } returns Throwable("boom").left() + + // Act + createModel(currency = token()) + + // Assert + assertThat(captureReplacedRoute()).isInstanceOf(YieldSupplyEntryRoute.Active::class.java) + } + + @Test + fun `GIVEN no pending status and inactive yield with promo enabled WHEN created THEN Promo route promo-enabled`() = + runTest { + // Arrange + stubStatusLookup(status(isActive = false).some()) + coEvery { enterStatusUseCase(USER_WALLET_ID, any()) } returns null.right() + coEvery { isPromoEnabledUseCase(USER_WALLET_ID, any()) } returns true.right() + + // Act + createModel(currency = token()) + + // Assert + val route = captureReplacedRoute() + assertThat(route).isInstanceOf(YieldSupplyEntryRoute.Promo::class.java) + assertThat((route as YieldSupplyEntryRoute.Promo).isPromoEnabled).isTrue() + assertThat(route.apy).isEqualTo("5.0") + assertThat(route.cryptoCurrency).isEqualTo(token()) + } + + @Test + fun `GIVEN promo toggle disabled WHEN created THEN Promo route with promo disabled`() = runTest { + // Arrange + every { yieldSupplyFeatureToggles.isYieldPromoEnabled } returns false + stubStatusLookup(status(isActive = false).some()) + coEvery { enterStatusUseCase(USER_WALLET_ID, any()) } returns null.right() + + // Act + createModel(currency = token()) + + // Assert + val route = captureReplacedRoute() + assertThat((route as YieldSupplyEntryRoute.Promo).isPromoEnabled).isFalse() + } + + @Test + fun `GIVEN promo use case returns false WHEN created THEN Promo route with promo disabled`() = runTest { + // Arrange + stubStatusLookup(status(isActive = false).some()) + coEvery { enterStatusUseCase(USER_WALLET_ID, any()) } returns null.right() + coEvery { isPromoEnabledUseCase(USER_WALLET_ID, any()) } returns false.right() + + // Act + createModel(currency = token()) + + // Assert + assertThat((captureReplacedRoute() as YieldSupplyEntryRoute.Promo).isPromoEnabled).isFalse() + } + + private fun captureReplacedRoute(): Route { + val slot = slot() + verify { router.replaceCurrent(capture(slot), any()) } + return slot.captured + } + + private fun stubStatusLookup(result: arrow.core.Option) { + every { + with(CryptoCurrencyStatusOperations) { + accountStatusList.getCryptoCurrencyStatus(any()) + } + } returns result + } + + private fun createModel(currency: CryptoCurrency): YieldSupplyEntryModel = YieldSupplyEntryModel( + paramsContainer = MutableParamsContainer( + YieldSupplyEntryComponent.Params(userWalletId = USER_WALLET_ID, cryptoCurrency = currency, apy = "5.0"), + ), + dispatchers = TestingCoroutineDispatcherProvider(), + router = router, + yieldSupplyEnterStatusUseCase = enterStatusUseCase, + singleAccountStatusListSupplier = accountStatusListSupplier, + isYieldBoostPromoEnabledForTokenUseCase = isPromoEnabledUseCase, + yieldSupplyFeatureToggles = yieldSupplyFeatureToggles, + ) + + private fun pendingEnter(): YieldSupplyPendingStatus = YieldSupplyPendingStatus.Enter(txIds = listOf("0xTx")) + + private fun status(isActive: Boolean): CryptoCurrencyStatus = CryptoCurrencyStatus( + currency = token(), + value = CryptoCurrencyStatus.Custom( + amount = BigDecimal.ZERO, + fiatAmount = BigDecimal.ZERO, + fiatRate = BigDecimal.ONE, + priceChange = BigDecimal.ZERO, + stakingBalance = null, + yieldSupplyStatus = YieldSupplyStatus( + isActive = isActive, + isInitialized = true, + isAllowedToSpend = true, + effectiveProtocolBalance = null, + ), + hasCurrentNetworkTransactions = false, + pendingTransactions = emptySet(), + networkAddress = NetworkAddress.Single( + defaultAddress = NetworkAddress.Address( + value = "0x0000000000000000000000000000000000000000", + type = NetworkAddress.Address.Type.Primary, + ), + ), + sources = CryptoCurrencyStatus.Sources(), + ), + ) + + private fun token(): CryptoCurrency.Token = CryptoCurrency.Token( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId("ethereum"), + suffix = CryptoCurrency.ID.Suffix.RawID("ethereum"), + ), + network = network(), + name = "TEST_TOKEN", + symbol = "TTK", + decimals = 6, + iconUrl = null, + isCustom = false, + contractAddress = "0xToken", + ) + + private fun coin(): CryptoCurrency.Coin = CryptoCurrency.Coin( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId("ethereum"), + suffix = CryptoCurrency.ID.Suffix.RawID("ethereum"), + ), + network = network(), + name = "TEST_COIN", + symbol = "ETH", + decimals = 18, + iconUrl = null, + isCustom = false, + ) + + private fun network(): Network { + val derivationPath = Network.DerivationPath.None + return Network( + id = Network.ID(value = "ethereum", derivationPath = derivationPath), + name = "Ethereum", + currencySymbol = "ETH", + derivationPath = derivationPath, + isTestnet = false, + standardType = Network.StandardType.Unspecified("UNSPECIFIED"), + hasFiatFeeRate = true, + canHandleTokens = true, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ) + } + + private companion object { + val USER_WALLET_ID = UserWalletId("abcdef012345") + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModelTest.kt b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModelTest.kt new file mode 100644 index 0000000000..359d3fd1aa --- /dev/null +++ b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModelTest.kt @@ -0,0 +1,691 @@ +package com.tangem.features.yield.supply.impl.main.model + +import arrow.core.Option +import arrow.core.left +import arrow.core.none +import arrow.core.right +import arrow.core.some +import com.google.common.truth.Truth.assertThat +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter +import com.tangem.common.ui.earn.EarnBlockUM +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.decompose.model.MutableParamsContainer +import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.error.SelectedAppCurrencyError +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.StatusSource +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.network.NetworkAddress +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.yield.supply.YieldSupplyStatus +import com.tangem.domain.networks.single.SingleNetworkStatusFetcher +import com.tangem.domain.stories.models.StoryContentIds +import com.tangem.domain.wallets.models.errors.GetUserWalletError +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.domain.yield.supply.models.YieldMarketToken +import com.tangem.domain.yield.supply.models.YieldSupplyPendingStatus +import com.tangem.domain.yield.supply.promo.usecase.GetBoostedApyUseCase +import com.tangem.domain.yield.supply.promo.usecase.IsYieldBoostPromoEnabledForTokenUseCase +import com.tangem.domain.yield.supply.usecase.YieldSupplyActivateUseCase +import com.tangem.domain.yield.supply.usecase.YieldSupplyDeactivateUseCase +import com.tangem.domain.yield.supply.usecase.YieldSupplyEnterStatusFlowUseCase +import com.tangem.domain.yield.supply.usecase.YieldSupplyEnterStatusUseCase +import com.tangem.domain.yield.supply.usecase.YieldSupplyGetDustMinAmountUseCase +import com.tangem.domain.yield.supply.usecase.YieldSupplyGetTokenStatusUseCase +import com.tangem.domain.yield.supply.usecase.YieldSupplyIsAvailableUseCase +import com.tangem.domain.yield.supply.usecase.YieldSupplyMinAmountUseCase +import com.tangem.features.yield.supply.api.YieldSupplyComponent +import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles +import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics +import com.tangem.features.yield.supply.impl.YieldBoostStoryPreloader +import com.tangem.features.yield.supply.impl.main.entity.YieldSupplyUM +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.slot +import io.mockk.unmockkObject +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +@OptIn(ExperimentalCoroutinesApi::class) +internal class YieldSupplyModelTest { + + private val analytics: AnalyticsEventHandler = mockk(relaxed = true) + private val appRouter: AppRouter = mockk(relaxed = true) + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk() + private val getUserWalletUseCase: GetUserWalletUseCase = mockk() + private val accountStatusListSupplier: SingleAccountStatusListSupplier = mockk() + private val singleNetworkStatusFetcher: SingleNetworkStatusFetcher = mockk() + private val getTokenStatusUseCase: YieldSupplyGetTokenStatusUseCase = mockk() + private val isAvailableUseCase: YieldSupplyIsAvailableUseCase = mockk() + private val activateUseCase: YieldSupplyActivateUseCase = mockk() + private val deactivateUseCase: YieldSupplyDeactivateUseCase = mockk() + private val enterStatusUseCase: YieldSupplyEnterStatusUseCase = mockk() + private val enterStatusFlowUseCase: YieldSupplyEnterStatusFlowUseCase = mockk() + private val minAmountUseCase: YieldSupplyMinAmountUseCase = mockk() + private val getDustMinAmountUseCase: YieldSupplyGetDustMinAmountUseCase = mockk() + private val isBoostPromoEnabledUseCase: IsYieldBoostPromoEnabledForTokenUseCase = mockk() + private val getBoostedApyUseCase = GetBoostedApyUseCase() + private val featureToggles: YieldSupplyFeatureToggles = mockk() + private val boostStoryPreloader: YieldBoostStoryPreloader = mockk(relaxed = true) + + private val userWalletId = UserWalletId("abcdef012345") + private val userWallet: UserWallet = mockk(relaxed = true) { every { walletId } returns userWalletId } + private val token: CryptoCurrency.Token = token() + private val coin: CryptoCurrency.Coin = coin() + private val accountStatusList: AccountStatusList = mockk() + + @BeforeEach + fun setUp() { + mockkObject(CryptoCurrencyStatusOperations) + coEvery { getSelectedAppCurrencyUseCase.invokeSync() } returns AppCurrency.Default.right() + coEvery { isAvailableUseCase(any(), any()) } returns true + every { getUserWalletUseCase(userWalletId) } returns userWallet.right() + every { accountStatusListSupplier(userWalletId) } returns flowOf(accountStatusList) + every { enterStatusFlowUseCase(any(), any()) } returns flowOf(null) + coEvery { enterStatusUseCase(any(), any()) } returns null.right() + coEvery { singleNetworkStatusFetcher(any()) } returns Unit.right() + coEvery { getTokenStatusUseCase(any()) } returns marketToken(isActive = true).right() + coEvery { isBoostPromoEnabledUseCase(any(), any()) } returns false.right() + every { featureToggles.isYieldPromoEnabled } returns false + coEvery { activateUseCase(any(), any(), any()) } returns true.right() + coEvery { deactivateUseCase(any(), any()) } returns true.right() + coEvery { minAmountUseCase(any(), any()) } returns BigDecimal("5").right() + every { getDustMinAmountUseCase(any(), any(), any()) } returns BigDecimal("0.1") + stubStatus(status(isActive = false).some()) + } + + @AfterEach + fun tearDown() { + unmockkObject(CryptoCurrencyStatusOperations) + } + + @Test + fun `GIVEN yield supply unavailable WHEN model created THEN stays initial and skips wallet load`() = runTest { + // Arrange + coEvery { isAvailableUseCase(any(), any()) } returns false + + // Act + val model = createModel() + advanceUntilIdle() + + // Assert + assertThat(model.uiStateLegacy.value).isEqualTo(YieldSupplyUM.Initial) + assertThat(model.uiState.value).isNull() + verify(exactly = 0) { getUserWalletUseCase(any()) } + coVerify(exactly = 0) { singleNetworkStatusFetcher(any()) } + } + + @Test + fun `GIVEN wallet load fails WHEN model created THEN stays initial and skips status subscription`() = runTest { + // Arrange + every { getUserWalletUseCase(userWalletId) } returns mockk(relaxed = true).left() + + // Act + val model = createModel() + advanceUntilIdle() + + // Assert + assertThat(model.uiStateLegacy.value).isEqualTo(YieldSupplyUM.Initial) + verify(exactly = 0) { accountStatusListSupplier(any()) } + coVerify(exactly = 1) { singleNetworkStatusFetcher(any()) } + } + + @Test + fun `GIVEN inactive token with active market WHEN status emitted THEN available state without boost`() = runTest { + // Act + val model = createModel() + advanceUntilIdle() + + // Assert + val legacy = model.uiStateLegacy.value + assertThat(legacy).isInstanceOf(YieldSupplyUM.Available::class.java) + assertThat((legacy as YieldSupplyUM.Available).isBoostAvailable).isFalse() + assertThat(legacy.apy).isEqualTo("5") + + val block = model.uiState.value + assertThat(block).isInstanceOf(EarnBlockUM.Content::class.java) + assertThat((block as EarnBlockUM.Content).backgroundUM).isEqualTo(EarnBlockUM.BackgroundUM.AccentSoft) + } + + @Test + fun `GIVEN promo enabled for token WHEN status emitted THEN boosted available promo`() = runTest { + // Arrange + every { featureToggles.isYieldPromoEnabled } returns true + coEvery { isBoostPromoEnabledUseCase(any(), any()) } returns true.right() + + // Act + val model = createModel() + advanceUntilIdle() + + // Assert + val legacy = model.uiStateLegacy.value + assertThat(legacy).isInstanceOf(YieldSupplyUM.Available::class.java) + assertThat((legacy as YieldSupplyUM.Available).isBoostAvailable).isTrue() + assertThat(model.uiState.value).isInstanceOf(EarnBlockUM.Promo::class.java) + } + + @Test + fun `GIVEN app currency unavailable WHEN status emitted THEN falls back to default and still loads`() = runTest { + // Arrange + coEvery { getSelectedAppCurrencyUseCase.invokeSync() } returns SelectedAppCurrencyError.NoAppCurrencySelected.left() + + // Act + val model = createModel() + advanceUntilIdle() + + // Assert + assertThat(model.uiStateLegacy.value).isInstanceOf(YieldSupplyUM.Available::class.java) + } + + @Test + fun `GIVEN inactive token with inactive market WHEN status emitted THEN unavailable and no block`() = runTest { + // Arrange + coEvery { getTokenStatusUseCase(any()) } returns marketToken(isActive = false).right() + + // Act + val model = createModel() + advanceUntilIdle() + + // Assert + assertThat(model.uiStateLegacy.value).isEqualTo(YieldSupplyUM.Unavailable) + assertThat(model.uiState.value).isNull() + } + + @Test + fun `GIVEN inactive token and token status fails WHEN status emitted THEN resets to initial`() = runTest { + // Arrange + coEvery { getTokenStatusUseCase(any()) } returns Throwable("boom").left() + + // Act + val model = createModel() + advanceUntilIdle() + + // Assert + assertThat(model.uiStateLegacy.value).isEqualTo(YieldSupplyUM.Initial) + } + + @Test + fun `GIVEN active token allowed to spend WHEN status emitted THEN content without warning icon`() = runTest { + // Arrange — supplied fully so the info-icon branch stays off + stubStatus(status(isActive = true, effectiveProtocolBalance = BigDecimal.TEN).some()) + + // Act + val model = createModel() + advanceUntilIdle() + + // Assert + val legacy = model.uiStateLegacy.value + assertThat(legacy).isInstanceOf(YieldSupplyUM.Content::class.java) + assertThat((legacy as YieldSupplyUM.Content).shouldShowWarningIcon).isFalse() + assertThat(legacy.shouldShowInfoIcon).isFalse() + verify(exactly = 0) { analytics.send(any()) } + } + + @Test + fun `GIVEN active token not allowed to spend WHEN status emitted THEN warning icon and analytics sent`() = runTest { + // Arrange + stubStatus(status(isActive = true, isAllowedToSpend = false, effectiveProtocolBalance = BigDecimal.TEN).some()) + + // Act + val model = createModel() + advanceUntilIdle() + + // Assert + val legacy = model.uiStateLegacy.value as YieldSupplyUM.Content + assertThat(legacy.shouldShowWarningIcon).isTrue() + val events = mutableListOf() + verify { analytics.send(capture(events)) } + val approveEvent = events.filterIsInstance().single() + assertThat(approveEvent.token).isEqualTo("TTK") + assertThat(approveEvent.blockchain).isEqualTo("Ethereum") + + val block = model.uiState.value as EarnBlockUM.Content + assertThat(block.titleUM.iconUM?.tone).isEqualTo(EarnBlockUM.TitleUM.IconTone.Warning) + } + + @Test + fun `GIVEN active token and token status fails WHEN status emitted THEN content with empty apy`() = runTest { + // Arrange + stubStatus(status(isActive = true, effectiveProtocolBalance = BigDecimal.TEN).some()) + coEvery { getTokenStatusUseCase(any()) } returns Throwable("boom").left() + + // Act + val model = createModel() + advanceUntilIdle() + + // Assert + val legacy = model.uiStateLegacy.value as YieldSupplyUM.Content + assertThat(legacy.apy).isEmpty() + } + + @Test + fun `GIVEN active token with not supplied amount WHEN status emitted THEN info icon shown`() = runTest { + // Arrange — amount(10) > protocolBalance(1) so there is a not-supplied remainder above the dust limit + stubStatus(status(isActive = true, effectiveProtocolBalance = BigDecimal.ONE).some()) + + // Act + val model = createModel() + advanceUntilIdle() + + // Assert + val legacy = model.uiStateLegacy.value as YieldSupplyUM.Content + assertThat(legacy.shouldShowInfoIcon).isTrue() + assertThat(legacy.shouldShowWarningIcon).isFalse() + val block = model.uiState.value as EarnBlockUM.Content + assertThat(block.titleUM.iconUM?.tone).isEqualTo(EarnBlockUM.TitleUM.IconTone.Info) + } + + @Test + fun `GIVEN not supplied amount below dust WHEN status emitted THEN info icon hidden`() = runTest { + // Arrange — dust threshold far above the not-supplied fiat value + stubStatus(status(isActive = true, effectiveProtocolBalance = BigDecimal.ONE).some()) + every { getDustMinAmountUseCase(any(), any(), any()) } returns BigDecimal("1000") + + // Act + val model = createModel() + advanceUntilIdle() + + // Assert + assertThat((model.uiStateLegacy.value as YieldSupplyUM.Content).shouldShowInfoIcon).isFalse() + } + + @Test + fun `GIVEN not supplied amount but min amount unavailable WHEN status emitted THEN info icon hidden`() = runTest { + // Arrange — not-supplied remainder exists, but the min-amount lookup fails + stubStatus(status(isActive = true, effectiveProtocolBalance = BigDecimal.ONE).some()) + coEvery { minAmountUseCase(any(), any()) } returns Throwable("no min").left() + + // Act + val model = createModel() + advanceUntilIdle() + + // Assert + assertThat((model.uiStateLegacy.value as YieldSupplyUM.Content).shouldShowInfoIcon).isFalse() + verify(exactly = 0) { getDustMinAmountUseCase(any(), any(), any()) } + } + + @Test + fun `GIVEN pending enter status WHEN status emitted THEN processing enter`() = runTest { + // Arrange + coEvery { enterStatusUseCase(any(), any()) } returns YieldSupplyPendingStatus.Enter(txIds = listOf("0x1")).right() + + // Act + val model = createModel() + advanceUntilIdle() + + // Assert + assertThat(model.uiStateLegacy.value).isEqualTo(YieldSupplyUM.Processing.Enter) + assertThat(model.uiState.value).isInstanceOf(EarnBlockUM.Content::class.java) + } + + @Test + fun `GIVEN pending exit status WHEN status emitted THEN processing exit`() = runTest { + // Arrange + coEvery { enterStatusUseCase(any(), any()) } returns YieldSupplyPendingStatus.Exit(txIds = listOf("0x1")).right() + + // Act + val model = createModel() + advanceUntilIdle() + + // Assert + assertThat(model.uiStateLegacy.value).isEqualTo(YieldSupplyUM.Processing.Exit) + } + + @Test + fun `GIVEN processing state WHEN cached status emitted THEN keeps processing`() = runTest { + // Arrange — first emission sets Processing.Enter, second (from cache) must be ignored + val firstList: AccountStatusList = mockk() + val secondList: AccountStatusList = mockk() + val supplierFlow = MutableStateFlow(firstList) + every { accountStatusListSupplier(userWalletId) } returns supplierFlow + stubStatus(status(isActive = false, amount = BigDecimal.TEN).some(), firstList) + stubStatus( + option = status(isActive = false, amount = BigDecimal.ONE, networkSource = StatusSource.CACHE).some(), + list = secondList, + ) + coEvery { enterStatusUseCase(any(), any()) } returns + YieldSupplyPendingStatus.Enter(txIds = listOf("0x1")).right() + + // Act + val model = createModel() + advanceUntilIdle() + supplierFlow.value = secondList + advanceUntilIdle() + + // Assert + assertThat(model.uiStateLegacy.value).isEqualTo(YieldSupplyUM.Processing.Enter) + coVerify(exactly = 1) { enterStatusUseCase(any(), any()) } + } + + @Test + fun `GIVEN identical statuses emitted twice WHEN model created THEN downstream runs once`() = runTest { + // Arrange — distinctUntilChanged must collapse equal emissions + val firstList: AccountStatusList = mockk() + val secondList: AccountStatusList = mockk() + val sameStatus = status(isActive = false) + every { accountStatusListSupplier(userWalletId) } returns flowOf(firstList, secondList) + stubStatus(sameStatus.some(), firstList) + stubStatus(sameStatus.some(), secondList) + + // Act + createModel() + advanceUntilIdle() + + // Assert + coVerify(exactly = 1) { enterStatusUseCase(any(), any()) } + } + + @Test + fun `GIVEN two distinct emissions WHEN model created THEN protocol status sent only on the first`() = runTest { + // Arrange — first emission active, second inactive; the once-only compareAndSet must fire sendInfo on the first + // only. If the guard were removed, the second (inactive) emission would call deactivate. + val firstList: AccountStatusList = mockk() + val secondList: AccountStatusList = mockk() + every { accountStatusListSupplier(userWalletId) } returns flowOf(firstList, secondList) + stubStatus( + status(isActive = true, amount = BigDecimal.TEN, effectiveProtocolBalance = BigDecimal.TEN).some(), + firstList, + ) + stubStatus( + status(isActive = false, amount = BigDecimal.ONE).some(), + secondList, + ) + + // Act + createModel() + advanceUntilIdle() + + // Assert — activate fired once (first emission); the guard suppressed the second, so deactivate never ran + coVerify(exactly = 1) { activateUseCase(userWalletId, token, SOURCE_ADDRESS) } + coVerify(exactly = 0) { deactivateUseCase(any(), any()) } + } + + @Test + fun `GIVEN cached status while not processing WHEN status emitted THEN state still advances`() = runTest { + // Arrange — the cache guard must short-circuit ONLY while Processing + stubStatus(status(isActive = false, networkSource = StatusSource.CACHE).some()) + + // Act + val model = createModel() + advanceUntilIdle() + + // Assert + assertThat(model.uiStateLegacy.value).isInstanceOf(YieldSupplyUM.Available::class.java) + } + + @Test + fun `GIVEN coin currency WHEN status emitted THEN token-only logic is skipped`() = runTest { + // Arrange — every token-specific step guards on CryptoCurrency.Token + stubStatus(status(currency = coin, isActive = false).some()) + + // Act + val model = createModel(currency = coin) + advanceUntilIdle() + + // Assert + assertThat(model.uiStateLegacy.value).isEqualTo(YieldSupplyUM.Initial) + coVerify(exactly = 0) { getTokenStatusUseCase(any()) } + coVerify(exactly = 0) { activateUseCase(any(), any(), any()) } + coVerify(exactly = 0) { deactivateUseCase(any(), any()) } + } + + @Test + fun `GIVEN active status on first emission WHEN model created THEN activates protocol`() = runTest { + // Arrange + stubStatus(status(isActive = true, effectiveProtocolBalance = BigDecimal.TEN).some()) + + // Act + createModel() + advanceUntilIdle() + + // Assert + coVerify { activateUseCase(userWalletId, token, SOURCE_ADDRESS) } + coVerify(exactly = 0) { deactivateUseCase(any(), any()) } + } + + @Test + fun `GIVEN inactive status on first emission WHEN model created THEN deactivates protocol`() = runTest { + // Act + createModel() + advanceUntilIdle() + + // Assert + coVerify { deactivateUseCase(token, SOURCE_ADDRESS) } + coVerify(exactly = 0) { activateUseCase(any(), any(), any()) } + } + + @Test + fun `GIVEN missing network address WHEN status emitted THEN protocol status not sent`() = runTest { + // Arrange — a Loading value carries no network address, so the side-effect must short-circuit + stubStatus(CryptoCurrencyStatus(currency = token, value = CryptoCurrencyStatus.Loading).some()) + + // Act + createModel() + advanceUntilIdle() + + // Assert + coVerify(exactly = 0) { activateUseCase(any(), any(), any()) } + coVerify(exactly = 0) { deactivateUseCase(any(), any()) } + } + + @Test + fun `GIVEN latest status loaded WHEN onStartEarningClick THEN pushes yield entry route`() = runTest { + // Arrange + val model = createModel() + advanceUntilIdle() + val routeSlot = slot() + + // Act + model.onStartEarningClick() + + // Assert + verify { appRouter.push(capture(routeSlot), any()) } + val route = routeSlot.captured as AppRoute.YieldSupplyEntry + assertThat(route.userWalletId).isEqualTo(userWalletId) + assertThat(route.cryptoCurrency).isEqualTo(token) + assertThat(route.apy).isEqualTo("5") + } + + @Test + fun `GIVEN processing state WHEN onStartEarningClick THEN pushes route with empty apy`() = runTest { + // Arrange — Processing state has no apy field, so the route apy collapses to empty + coEvery { enterStatusUseCase(any(), any()) } returns YieldSupplyPendingStatus.Enter(txIds = listOf("0x1")).right() + val model = createModel() + advanceUntilIdle() + val routeSlot = slot() + + // Act + model.onStartEarningClick() + + // Assert + verify { appRouter.push(capture(routeSlot), any()) } + assertThat((routeSlot.captured as AppRoute.YieldSupplyEntry).apy).isEmpty() + } + + @Test + fun `GIVEN no latest status WHEN onActiveClick THEN does not navigate`() = runTest { + // Arrange — currency status never resolves, so latestCryptoCurrencyStatus stays null + stubStatus(none()) + val model = createModel() + advanceUntilIdle() + + // Act + model.onActiveClick() + + // Assert + verify(exactly = 0) { appRouter.push(any(), any()) } + } + + @Test + fun `GIVEN latest status loaded WHEN onLearnMoreClick THEN pushes stories route`() = runTest { + // Arrange + val model = createModel() + advanceUntilIdle() + val routeSlot = slot() + + // Act + model.onLearnMoreClick() + + // Assert + verify { appRouter.push(capture(routeSlot), any()) } + val route = routeSlot.captured as AppRoute.Stories + assertThat(route.storyId).isEqualTo(StoryContentIds.STORY_FIRST_TIME_YIELD_PROMO.id) + assertThat(route.screenSource).isEqualTo("TokenDetails") + assertThat(route.nextScreen).isInstanceOf(AppRoute.YieldSupplyEntry::class.java) + } + + private fun stubStatus(option: Option, list: AccountStatusList = accountStatusList) { + every { + with(CryptoCurrencyStatusOperations) { list.getCryptoCurrencyStatus(any()) } + } returns option + } + + private fun TestScope.createModel(currency: CryptoCurrency = token): YieldSupplyModel = YieldSupplyModel( + paramsContainer = MutableParamsContainer( + YieldSupplyComponent.Params(userWalletId = userWalletId, cryptoCurrency = currency), + ), + dispatchers = createDispatchers(), + analyticsEventsHandler = analytics, + appRouter = appRouter, + getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase, + getUserWalletUseCase = getUserWalletUseCase, + singleAccountStatusListSupplier = accountStatusListSupplier, + singleNetworkStatusFetcher = singleNetworkStatusFetcher, + yieldSupplyGetTokenStatusUseCase = getTokenStatusUseCase, + yieldSupplyIsAvailableUseCase = isAvailableUseCase, + yieldSupplyActivateUseCase = activateUseCase, + yieldSupplyDeactivateUseCase = deactivateUseCase, + yieldSupplyEnterStatusUseCase = enterStatusUseCase, + yieldSupplyEnterStatusFlowUseCase = enterStatusFlowUseCase, + yieldSupplyMinAmountUseCase = minAmountUseCase, + yieldSupplyGetDustMinAmountUseCase = getDustMinAmountUseCase, + isYieldBoostPromoEnabledForTokenUseCase = isBoostPromoEnabledUseCase, + getBoostedApyUseCase = getBoostedApyUseCase, + yieldSupplyFeatureToggles = featureToggles, + boostStoryPreloader = boostStoryPreloader, + ) + + private fun TestScope.createDispatchers(): TestingCoroutineDispatcherProvider { + val dispatcher = StandardTestDispatcher(testScheduler) + return TestingCoroutineDispatcherProvider( + main = dispatcher, + mainImmediate = dispatcher, + io = dispatcher, + default = dispatcher, + single = dispatcher, + ) + } + + private fun status( + currency: CryptoCurrency = token, + isActive: Boolean = false, + isAllowedToSpend: Boolean = true, + amount: BigDecimal = BigDecimal.TEN, + effectiveProtocolBalance: BigDecimal? = BigDecimal.ONE, + fiatRate: BigDecimal? = BigDecimal.ONE, + networkSource: StatusSource = StatusSource.ACTUAL, + address: String = SOURCE_ADDRESS, + ): CryptoCurrencyStatus = CryptoCurrencyStatus( + currency = currency, + value = CryptoCurrencyStatus.Custom( + amount = amount, + fiatAmount = amount, + fiatRate = fiatRate, + priceChange = BigDecimal.ZERO, + stakingBalance = null, + yieldSupplyStatus = YieldSupplyStatus( + isActive = isActive, + isInitialized = true, + isAllowedToSpend = isAllowedToSpend, + effectiveProtocolBalance = effectiveProtocolBalance, + ), + hasCurrentNetworkTransactions = false, + pendingTransactions = emptySet(), + networkAddress = NetworkAddress.Single( + defaultAddress = NetworkAddress.Address(value = address, type = NetworkAddress.Address.Type.Primary), + ), + sources = CryptoCurrencyStatus.Sources(networkSource = networkSource), + ), + ) + + private fun marketToken(isActive: Boolean): YieldMarketToken = YieldMarketToken( + tokenAddress = "0xToken", + chainId = 1, + apy = BigDecimal("5"), + isActive = isActive, + maxFeeNative = BigDecimal.ZERO, + maxFeeUSD = BigDecimal.ZERO, + backendId = "ethereum", + ) + + private fun token(): CryptoCurrency.Token = CryptoCurrency.Token( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId("ethereum"), + suffix = CryptoCurrency.ID.Suffix.RawID("ethereum"), + ), + network = network(), + name = "TEST_TOKEN", + symbol = "TTK", + decimals = 6, + iconUrl = null, + isCustom = false, + contractAddress = "0xToken", + ) + + private fun coin(): CryptoCurrency.Coin = CryptoCurrency.Coin( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId("ethereum"), + suffix = CryptoCurrency.ID.Suffix.RawID("ethereum"), + ), + network = network(), + name = "TEST_COIN", + symbol = "ETH", + decimals = 18, + iconUrl = null, + isCustom = false, + ) + + private fun network(): Network { + val derivationPath = Network.DerivationPath.None + return Network( + id = Network.ID(value = "ethereum", derivationPath = derivationPath), + name = "Ethereum", + currencySymbol = "ETH", + derivationPath = derivationPath, + isTestnet = false, + standardType = Network.StandardType.Unspecified("UNSPECIFIED"), + hasFiatFeeRate = true, + canHandleTokens = true, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ) + } + + private companion object { + const val SOURCE_ADDRESS = "0x1111111111111111111111111111111111111111" + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/main/model/transformers/YieldSupplyTokenStatusSuccessTransformerTest.kt b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/main/model/transformers/YieldSupplyTokenStatusSuccessTransformerTest.kt new file mode 100644 index 0000000000..ef0905610f --- /dev/null +++ b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/main/model/transformers/YieldSupplyTokenStatusSuccessTransformerTest.kt @@ -0,0 +1,122 @@ +package com.tangem.features.yield.supply.impl.main.model.transformers + +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle +import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.extensions.annotatedReference +import com.tangem.core.ui.extensions.combinedReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.yield.supply.models.YieldMarketToken +import com.tangem.features.yield.supply.impl.R +import com.tangem.features.yield.supply.impl.main.entity.YieldSupplyUM +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +internal class YieldSupplyTokenStatusSuccessTransformerTest { + + private var startEarningClicked = false + private var learnMoreClicked = false + + @Test + fun `GIVEN inactive token WHEN transform THEN Unavailable`() { + // Arrange + val transformer = createTransformer(tokenStatus = marketToken(isActive = false)) + + // Act + val result = transformer.transform(YieldSupplyUM.Initial) + + // Assert + assertThat(result).isEqualTo(YieldSupplyUM.Unavailable) + } + + @Test + fun `GIVEN active token without boost WHEN transform THEN Available with plain apy text`() { + // Arrange + val transformer = createTransformer(tokenStatus = marketToken(isActive = true, apy = BigDecimal("5.5"))) + + // Act + val result = transformer.transform(YieldSupplyUM.Initial) + + // Assert + assertThat(result).isInstanceOf(YieldSupplyUM.Available::class.java) + val available = result as YieldSupplyUM.Available + assertThat(available.isBoostAvailable).isFalse() + assertThat(available.apy).isEqualTo("5.5") + assertThat(available.title).isEqualTo( + resourceReference(R.string.yield_module_token_details_earn_notification_earning_on_your_balance_title), + ) + assertThat(available.apyText).isEqualTo( + combinedReference( + resourceReference(R.string.yield_module_token_details_earn_notification_apy), + stringReference(" 5.5%"), + ), + ) + } + + @Test + fun `GIVEN active token with boost WHEN transform THEN Available with boosted apy text and title`() { + // Arrange + val transformer = createTransformer( + tokenStatus = marketToken(isActive = true, apy = BigDecimal("5.5")), + boostedApy = BigDecimal("16.5"), + ) + + // Act + val result = transformer.transform(YieldSupplyUM.Initial) + + // Assert + assertThat(result).isInstanceOf(YieldSupplyUM.Available::class.java) + val available = result as YieldSupplyUM.Available + assertThat(available.isBoostAvailable).isTrue() + assertThat(available.title).isEqualTo(resourceReference(R.string.yield_apy_boost_banner_title)) + assertThat(available.apyText).isEqualTo( + annotatedReference( + buildAnnotatedString { + append("APY ") + withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) { + append("5.5%") + } + append(" x3 → 16.5%") + }, + ), + ) + } + + @Test + fun `GIVEN active token WHEN clicks delegated THEN original callbacks fire`() { + // Arrange + val transformer = createTransformer(tokenStatus = marketToken(isActive = true)) + + // Act + val available = transformer.transform(YieldSupplyUM.Initial) as YieldSupplyUM.Available + available.onClick() + available.onLearnMoreClick() + + // Assert + assertThat(startEarningClicked).isTrue() + assertThat(learnMoreClicked).isTrue() + } + + private fun createTransformer( + tokenStatus: YieldMarketToken, + boostedApy: BigDecimal? = null, + ): YieldSupplyTokenStatusSuccessTransformer = YieldSupplyTokenStatusSuccessTransformer( + tokenStatus = tokenStatus, + onStartEarningClick = { startEarningClicked = true }, + onLearnMoreClick = { learnMoreClicked = true }, + boostedApy = boostedApy, + ) + + private fun marketToken(isActive: Boolean, apy: BigDecimal = BigDecimal("5.5")): YieldMarketToken = + YieldMarketToken( + tokenAddress = "0xToken", + chainId = 1, + apy = apy, + isActive = isActive, + maxFeeNative = BigDecimal.ZERO, + maxFeeUSD = BigDecimal.ZERO, + ) +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/YieldSupplyActionModelTestBase.kt b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/YieldSupplyActionModelTestBase.kt new file mode 100644 index 0000000000..2f73937b15 --- /dev/null +++ b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/YieldSupplyActionModelTestBase.kt @@ -0,0 +1,188 @@ +package com.tangem.features.yield.supply.impl.subcomponents + +import arrow.core.right +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.AmountType +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.navigation.url.UrlOpener +import com.tangem.datasource.local.appsflyer.AppsFlyerStore +import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.network.NetworkAddress +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.models.yield.supply.YieldSupplyStatus +import com.tangem.domain.transaction.usecase.GetFeeUseCase +import com.tangem.domain.transaction.usecase.SendTransactionUseCase +import com.tangem.domain.yield.supply.YieldSupplyRepository +import com.tangem.domain.yield.supply.usecase.YieldSupplyPendingTracker +import com.tangem.features.yield.supply.impl.common.YieldSupplyAlertFactory +import com.tangem.features.yield.supply.impl.subcomponents.notifications.YieldSupplyNotificationsUpdateTrigger +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import org.junit.jupiter.api.BeforeEach +import java.math.BigDecimal +import java.math.BigInteger + +/** + * Shared fixtures, mocks and builders for the Yield Supply transactional model tests + * (Approve / StopEarning / StartEarning). Subclasses declare their own unique mocks and build + * the concrete model via the base mocks; tests read [uiState] synchronously thanks to the + * Unconfined [TestingCoroutineDispatcherProvider]. + */ +@OptIn(ExperimentalCoroutinesApi::class) +internal abstract class YieldSupplyActionModelTestBase { + + protected val analytics: AnalyticsEventHandler = mockk(relaxed = true) + protected val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk() + protected val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase = mockk() + protected val sendTransactionUseCase: SendTransactionUseCase = mockk() + protected val getFeeUseCase: GetFeeUseCase = mockk() + protected val urlOpener: UrlOpener = mockk(relaxed = true) + protected val notificationsUpdateTrigger: YieldSupplyNotificationsUpdateTrigger = mockk(relaxed = true) + protected val alertFactory: YieldSupplyAlertFactory = mockk(relaxed = true) + protected val pendingTracker: YieldSupplyPendingTracker = mockk(relaxed = true) + protected val yieldSupplyRepository: YieldSupplyRepository = mockk(relaxed = true) + protected val appsFlyerStore: AppsFlyerStore = mockk(relaxed = true) + + protected val userWalletId = UserWalletId("abcdef012345") + protected val userWallet: UserWallet = mockk(relaxed = true) { + every { walletId } returns userWalletId + } + + protected val token: CryptoCurrency.Token = token() + protected val coin: CryptoCurrency.Coin = coin() + protected val cryptoCurrencyStatus: CryptoCurrencyStatus = statusOf(token) + protected val cryptoCurrencyStatusFlow = MutableStateFlow(cryptoCurrencyStatus) + + @BeforeEach + fun baseSetUp() { + coEvery { getSelectedAppCurrencyUseCase.invokeSync() } returns AppCurrency.Default.right() + every { notificationsUpdateTrigger.hasErrorFlow } returns MutableStateFlow(false) + coEvery { getFeePaidCryptoCurrencyStatusSyncUseCase(any(), any()) } returns cryptoCurrencyStatus.right() + } + + /** A [StandardTestDispatcher] for every role so `advanceUntilIdle()` drives the model's coroutines. */ + protected fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider { + val testDispatcher = StandardTestDispatcher(testScheduler) + return TestingCoroutineDispatcherProvider( + main = testDispatcher, + mainImmediate = testDispatcher, + io = testDispatcher, + default = testDispatcher, + single = testDispatcher, + ) + } + + /** Network fee is paid in the native coin (token amounts are rejected by `increaseGasLimitBy`). */ + protected fun coinAmount(value: BigDecimal): Amount = + Amount(currencySymbol = "ETH", value = value, decimals = 18, type = AmountType.Coin) + + protected fun ethFee(value: BigDecimal = BigDecimal("0.001")): Fee.Ethereum.EIP1559 = Fee.Ethereum.EIP1559( + maxFeePerGas = BigInteger.valueOf(1_000_000_000L), + priorityFee = BigInteger.ONE, + gasLimit = BigInteger.valueOf(21_000), + amount = coinAmount(value), + ) + + protected fun transactionFee(value: BigDecimal = BigDecimal("0.001")): TransactionFee.Single = + TransactionFee.Single(normal = ethFee(value)) + + protected fun uncompiledTx(fee: Fee = ethFee()): TransactionData.Uncompiled = TransactionData.Uncompiled( + fee = fee, + amount = coinAmount(BigDecimal.ONE), + contractAddress = null, + sourceAddress = SOURCE_ADDRESS, + destinationAddress = DESTINATION_ADDRESS, + extras = null, + ) + + protected fun statusOf(currency: CryptoCurrency): CryptoCurrencyStatus = CryptoCurrencyStatus( + currency = currency, + value = CryptoCurrencyStatus.Custom( + amount = BigDecimal.TEN, + fiatAmount = BigDecimal.TEN, + fiatRate = BigDecimal.ONE, + priceChange = BigDecimal.ZERO, + stakingBalance = null, + yieldSupplyStatus = YieldSupplyStatus( + isActive = true, + isInitialized = true, + isAllowedToSpend = true, + effectiveProtocolBalance = BigDecimal.ONE, + ), + hasCurrentNetworkTransactions = false, + pendingTransactions = emptySet(), + networkAddress = NetworkAddress.Single( + defaultAddress = NetworkAddress.Address( + value = SOURCE_ADDRESS, + type = NetworkAddress.Address.Type.Primary, + ), + ), + sources = CryptoCurrencyStatus.Sources(), + ), + ) + + protected fun token(): CryptoCurrency.Token = CryptoCurrency.Token( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId("ethereum"), + suffix = CryptoCurrency.ID.Suffix.RawID("ethereum"), + ), + network = network(), + name = "TEST_TOKEN", + symbol = "TTK", + decimals = 6, + iconUrl = null, + isCustom = false, + contractAddress = "0xToken", + ) + + protected fun coin(): CryptoCurrency.Coin = CryptoCurrency.Coin( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.COIN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId("ethereum"), + suffix = CryptoCurrency.ID.Suffix.RawID("ethereum"), + ), + network = network(), + name = "TEST_COIN", + symbol = "ETH", + decimals = 18, + iconUrl = null, + isCustom = false, + ) + + protected fun network(): Network { + val derivationPath = Network.DerivationPath.None + return Network( + id = Network.ID(value = "ethereum", derivationPath = derivationPath), + name = "Ethereum", + currencySymbol = "ETH", + derivationPath = derivationPath, + isTestnet = false, + standardType = Network.StandardType.Unspecified("UNSPECIFIED"), + hasFiatFeeRate = true, + canHandleTokens = true, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ) + } + + protected companion object { + const val SOURCE_ADDRESS = "0x1111111111111111111111111111111111111111" + const val DESTINATION_ADDRESS = "0x2222222222222222222222222222222222222222" + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/approve/model/YieldSupplyApproveModelTest.kt b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/approve/model/YieldSupplyApproveModelTest.kt new file mode 100644 index 0000000000..8988b4b388 --- /dev/null +++ b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/approve/model/YieldSupplyApproveModelTest.kt @@ -0,0 +1,244 @@ +package com.tangem.features.yield.supply.impl.subcomponents.approve.model + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.common.TangemBlogUrlBuilder +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.analytics.models.Basic +import com.tangem.core.decompose.model.MutableParamsContainer +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.error.SendTransactionError +import com.tangem.domain.transaction.usecase.CreateApprovalTransactionUseCase +import com.tangem.domain.yield.supply.usecase.YieldSupplyGetContractAddressUseCase +import com.tangem.features.yield.supply.impl.common.entity.YieldSupplyFeeUM +import com.tangem.features.yield.supply.impl.subcomponents.YieldSupplyActionModelTestBase +import com.tangem.features.yield.supply.impl.subcomponents.approve.YieldSupplyApproveComponent +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +internal class YieldSupplyApproveModelTest : YieldSupplyActionModelTestBase() { + + private val createApprovalTransactionUseCase: CreateApprovalTransactionUseCase = mockk() + private val getContractAddressUseCase: YieldSupplyGetContractAddressUseCase = mockk() + private val callback: YieldSupplyApproveComponent.ModelCallback = mockk(relaxed = true) + + @BeforeEach + fun setUp() { + coEvery { getContractAddressUseCase(any(), any()) } returns "0xSpender".right() + coEvery { + createApprovalTransactionUseCase(any(), any(), any(), any(), any()) + } returns uncompiledTx().right() + coEvery { getFeeUseCase(any(), any(), any()) } returns transactionFee().right() + coEvery { sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) } returns "0xhash".right() + } + + @Test + fun `GIVEN successful fee load WHEN model created THEN fee content and button enabled`() = runTest { + // Act + val model = createModel() + advanceUntilIdle() + + // Assert + assertThat(model.uiState.value.yieldSupplyFeeUM).isInstanceOf(YieldSupplyFeeUM.Content::class.java) + assertThat(model.uiState.value.isPrimaryButtonEnabled).isTrue() + coVerify { notificationsUpdateTrigger.triggerUpdate(any()) } + } + + @Test + fun `GIVEN get fee fails WHEN model created THEN fee error state`() = runTest { + // Arrange + coEvery { getFeeUseCase(any(), any(), any()) } returns GetFeeError.UnknownError.left() + + // Act + val model = createModel() + advanceUntilIdle() + + // Assert + assertThat(model.uiState.value.yieldSupplyFeeUM).isEqualTo(YieldSupplyFeeUM.Error) + } + + @Test + fun `GIVEN non-token currency WHEN model created THEN fee not loaded`() = runTest { + // Act + val model = createModel(statusFlow = MutableStateFlow(statusOf(coin))) + advanceUntilIdle() + + // Assert + assertThat(model.uiState.value.yieldSupplyFeeUM).isEqualTo(YieldSupplyFeeUM.Loading) + coVerify(exactly = 0) { getFeeUseCase(any(), any(), any()) } + } + + @Test + fun `GIVEN contract address missing WHEN model created THEN fee not loaded`() = runTest { + // Arrange + coEvery { getContractAddressUseCase(any(), any()) } returns (null as String?).right() + + // Act + val model = createModel() + advanceUntilIdle() + + // Assert + assertThat(model.uiState.value.yieldSupplyFeeUM).isEqualTo(YieldSupplyFeeUM.Loading) + coVerify(exactly = 0) { getFeeUseCase(any(), any(), any()) } + } + + @Test + fun `GIVEN content loaded WHEN onClick THEN sends transaction tracks pending and notifies sent`() = runTest { + // Arrange + val model = createModel() + advanceUntilIdle() + + // Act + model.onClick() + advanceUntilIdle() + + // Assert + verify { callback.onTransactionProgress(true) } + coVerify { pendingTracker.addPending(userWalletId, any(), any()) } + verify { callback.onTransactionSent() } + + // Token fee asset (default fee currency is the token itself) + val events = mutableListOf() + verify { analytics.send(capture(events)) } + val sent = events.filterIsInstance().single() + assertThat(sent.params["Fee Token"]).isEqualTo("TTK") + assertThat(sent.params["Fee Asset Type"]).isEqualTo(AnalyticsParam.FeeAssetType.Token.value) + } + + @Test + fun `GIVEN coin fee currency WHEN onClick succeeds THEN transaction sent analytics carries coin fee asset`() = runTest { + // Arrange — network fee paid in the native coin, not the token + coEvery { getFeePaidCryptoCurrencyStatusSyncUseCase(any(), any()) } returns statusOf(coin).right() + val model = createModel() + advanceUntilIdle() + + // Act + model.onClick() + advanceUntilIdle() + + // Assert + val events = mutableListOf() + verify { analytics.send(capture(events)) } + val sent = events.filterIsInstance().single() + assertThat(sent.params["Fee Token"]).isEqualTo("ETH") + assertThat(sent.params["Fee Asset Type"]).isEqualTo(AnalyticsParam.FeeAssetType.Coin.value) + } + + @Test + fun `GIVEN fee not loaded WHEN onClick THEN does not send transaction`() = runTest { + // Arrange — fee load fails so the fee state is Error; onClick reports progress then early-returns + coEvery { getFeeUseCase(any(), any(), any()) } returns GetFeeError.UnknownError.left() + val model = createModel() + advanceUntilIdle() + + // Act + model.onClick() + advanceUntilIdle() + + // Assert + verify { callback.onTransactionProgress(true) } + coVerify(exactly = 0) { sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) } + } + + @Test + fun `GIVEN notifications report an error WHEN flag emitted THEN primary button disabled`() = runTest { + // Arrange + val hasErrorFlow = MutableStateFlow(false) + every { notificationsUpdateTrigger.hasErrorFlow } returns hasErrorFlow + val model = createModel() + advanceUntilIdle() + assertThat(model.uiState.value.isPrimaryButtonEnabled).isTrue() + + // Act + hasErrorFlow.value = true + advanceUntilIdle() + + // Assert + assertThat(model.uiState.value.isPrimaryButtonEnabled).isFalse() + } + + @Test + fun `GIVEN content loaded WHEN onClick and send fails THEN shows error and stops progress`() = runTest { + // Arrange + coEvery { + sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) + } returns SendTransactionError.UnknownError().left() + val model = createModel() + advanceUntilIdle() + + // Act + model.onClick() + advanceUntilIdle() + + // Assert + assertThat(model.uiState.value.isTransactionSending).isFalse() + verify { alertFactory.getSendTransactionErrorState(any(), any(), any()) } + verify { callback.onTransactionProgress(false) } + verify(exactly = 0) { callback.onTransactionSent() } + } + + @Test + fun `WHEN onReadMoreClick THEN opens url`() = runTest { + // Arrange — TangemBlogUrlBuilder.build is a real suspend object; stub it to isolate the model's intent + mockkObject(TangemBlogUrlBuilder) + try { + coEvery { TangemBlogUrlBuilder.build(any()) } returns BLOG_URL + val model = createModel() + advanceUntilIdle() + + // Act + model.onReadMoreClick() + advanceUntilIdle() + + // Assert + verify { urlOpener.openUrl(BLOG_URL) } + } finally { + unmockkObject(TangemBlogUrlBuilder) + } + } + + private fun TestScope.createModel( + statusFlow: StateFlow = cryptoCurrencyStatusFlow, + ): YieldSupplyApproveModel = YieldSupplyApproveModel( + dispatchers = createTestingCoroutineDispatcherProvider(), + paramsContainer = MutableParamsContainer( + YieldSupplyApproveComponent.Params( + userWallet = userWallet, + cryptoCurrencyStatusFlow = statusFlow, + callback = callback, + ), + ), + analyticsEventHandler = analytics, + urlOpener = urlOpener, + yieldSupplyNotificationsUpdateTrigger = notificationsUpdateTrigger, + createApprovalTransactionUseCase = createApprovalTransactionUseCase, + getFeeUseCase = getFeeUseCase, + sendTransactionUseCase = sendTransactionUseCase, + getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase, + getFeePaidCryptoCurrencyStatusSyncUseCase = getFeePaidCryptoCurrencyStatusSyncUseCase, + yieldSupplyGetContractAddressUseCase = getContractAddressUseCase, + yieldSupplyPendingTracker = pendingTracker, + yieldSupplyAlertFactory = alertFactory, + ) + + private companion object { + const val BLOG_URL = "https://tangem.com/blog" + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModelTest.kt b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModelTest.kt new file mode 100644 index 0000000000..7418b9df04 --- /dev/null +++ b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/YieldSupplyStartEarningModelTest.kt @@ -0,0 +1,278 @@ +package com.tangem.features.yield.supply.impl.subcomponents.startearning.model + +import arrow.core.left +import arrow.core.none +import arrow.core.right +import arrow.core.some +import com.google.common.truth.Truth.assertThat +import com.tangem.core.decompose.model.MutableParamsContainer +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.account.models.AccountStatusList +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.error.SendTransactionError +import com.tangem.domain.wallets.models.errors.GetUserWalletError +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.domain.yield.supply.YieldSupplyError +import com.tangem.domain.yield.supply.models.YieldSupplyFee +import com.tangem.domain.yield.supply.models.YieldSupplyMaxFee +import com.tangem.domain.yield.supply.usecase.YieldSupplyActivateUseCase +import com.tangem.domain.yield.supply.usecase.YieldSupplyEstimateEnterFeeUseCase +import com.tangem.domain.yield.supply.usecase.YieldSupplyGetCurrentFeeUseCase +import com.tangem.domain.yield.supply.usecase.YieldSupplyGetMaxFeeUseCase +import com.tangem.domain.yield.supply.usecase.YieldSupplyMinAmountUseCase +import com.tangem.domain.yield.supply.usecase.YieldSupplyStartEarningUseCase +import com.tangem.features.yield.supply.impl.common.entity.YieldSupplyActionUM +import com.tangem.features.yield.supply.impl.common.entity.YieldSupplyFeeUM +import com.tangem.features.yield.supply.impl.subcomponents.YieldSupplyActionModelTestBase +import com.tangem.features.yield.supply.impl.subcomponents.startearning.YieldSupplyStartEarningComponent +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +@OptIn(ExperimentalCoroutinesApi::class) +internal class YieldSupplyStartEarningModelTest : YieldSupplyActionModelTestBase() { + + private val getUserWalletUseCase: GetUserWalletUseCase = mockk() + private val accountStatusListSupplier: SingleAccountStatusListSupplier = mockk() + private val startEarningUseCase: YieldSupplyStartEarningUseCase = mockk() + private val estimateEnterFeeUseCase: YieldSupplyEstimateEnterFeeUseCase = mockk() + private val activateUseCase: YieldSupplyActivateUseCase = mockk() + private val minAmountUseCase: YieldSupplyMinAmountUseCase = mockk() + private val getMaxFeeUseCase: YieldSupplyGetMaxFeeUseCase = mockk() + private val getCurrentFeeUseCase: YieldSupplyGetCurrentFeeUseCase = mockk() + + private val accountStatusList: AccountStatusList = mockk() + private val callback: YieldSupplyStartEarningComponent.ModelCallback = mockk(relaxed = true) + + @BeforeEach + fun setUp() { + mockkObject(CryptoCurrencyStatusOperations) + every { getUserWalletUseCase(userWalletId) } returns userWallet.right() + every { accountStatusListSupplier(userWalletId) } returns flowOf(accountStatusList) + stubCurrencyStatusLookup(cryptoCurrencyStatus.some()) + coEvery { minAmountUseCase(any(), any()) } returns BigDecimal("5").right() + coEvery { getMaxFeeUseCase(any(), any()) } returns maxFee().right() + coEvery { getCurrentFeeUseCase(any(), any()) } returns YieldSupplyFee(BigDecimal("0.001")).right() + coEvery { startEarningUseCase(any(), any(), any()) } returns listOf(uncompiledTx()).right() + coEvery { estimateEnterFeeUseCase(any(), any(), any()) } returns listOf(uncompiledTx()).right() + coEvery { + sendTransactionUseCase(txsData = any(), userWallet = any(), network = any(), sendMode = any()) + } returns listOf("0xhash").right() + coEvery { activateUseCase(any(), any(), any()) } returns true.right() + } + + @AfterEach + fun tearDown() { + unmockkObject(CryptoCurrencyStatusOperations) + } + + @Test + fun `GIVEN successful fee load WHEN model created THEN fee content and button enabled`() = runTest { + // Act + val model = createModel() + advanceUntilIdle() + + // Assert + assertThat(model.uiState.value.yieldSupplyFeeUM).isInstanceOf(YieldSupplyFeeUM.Content::class.java) + assertThat(model.uiState.value.isPrimaryButtonEnabled).isTrue() + coVerify { notificationsUpdateTrigger.triggerUpdate(any()) } + } + + @Test + fun `GIVEN estimate fee fails WHEN model created THEN fee error state`() = runTest { + // Arrange + coEvery { estimateEnterFeeUseCase(any(), any(), any()) } returns GetFeeError.UnknownError.left() + + // Act + val model = createModel() + advanceUntilIdle() + + // Assert + assertThat(model.uiState.value.yieldSupplyFeeUM).isEqualTo(YieldSupplyFeeUM.Error) + } + + @Test + fun `GIVEN max fee unavailable WHEN model created THEN fee error state`() = runTest { + // Arrange + coEvery { getMaxFeeUseCase(any(), any()) } returns Throwable("no max fee").left() + + // Act + val model = createModel() + advanceUntilIdle() + + // Assert + assertThat(model.uiState.value.yieldSupplyFeeUM).isEqualTo(YieldSupplyFeeUM.Error) + coVerify(exactly = 0) { estimateEnterFeeUseCase(any(), any(), any()) } + } + + @Test + fun `GIVEN user wallet unavailable WHEN model created THEN shows generic error`() = runTest { + // Arrange + every { getUserWalletUseCase(userWalletId) } returns mockk(relaxed = true).left() + + // Act + createModel() + advanceUntilIdle() + + // Assert + verify { alertFactory.getGenericErrorState(any(), any()) } + coVerify(exactly = 0) { getMaxFeeUseCase(any(), any()) } + } + + @Test + fun `GIVEN currency status not found WHEN model created THEN shows generic error`() = runTest { + // Arrange + stubCurrencyStatusLookup(none()) + + // Act + createModel() + advanceUntilIdle() + + // Assert + verify { alertFactory.getGenericErrorState(any(), any()) } + coVerify(exactly = 0) { getMaxFeeUseCase(any(), any()) } + } + + @Test + fun `GIVEN content loaded WHEN onClick THEN sends activates tracks pending and notifies sent`() = runTest { + // Arrange + val model = createModel() + advanceUntilIdle() + + // Act + model.onClick() + advanceUntilIdle() + + // Assert + coVerify { yieldSupplyRepository.saveTokenProtocolPendingStatus(userWalletId, any(), any()) } + coVerify { activateUseCase(userWalletId, any(), any()) } + coVerify { pendingTracker.addPending(userWalletId, any(), any()) } + verify { callback.onTransactionSent() } + } + + @Test + fun `GIVEN content loaded WHEN onClick and send fails THEN shows error and not sent`() = runTest { + // Arrange + coEvery { + sendTransactionUseCase(txsData = any(), userWallet = any(), network = any(), sendMode = any()) + } returns SendTransactionError.UnknownError().left() + val model = createModel() + advanceUntilIdle() + + // Act + model.onClick() + advanceUntilIdle() + + // Assert + assertThat(model.uiState.value.isTransactionSending).isFalse() + verify { alertFactory.getSendTransactionErrorState(any(), any(), any()) } + verify(exactly = 0) { callback.onTransactionSent() } + } + + @Test + fun `GIVEN fee not loaded WHEN onClick THEN does not send transactions`() = runTest { + // Arrange — estimate fee fails so the fee state is Error; onClick must early-return before sending + coEvery { estimateEnterFeeUseCase(any(), any(), any()) } returns GetFeeError.UnknownError.left() + val model = createModel() + advanceUntilIdle() + + // Act + model.onClick() + advanceUntilIdle() + + // Assert + coVerify(exactly = 0) { + sendTransactionUseCase(txsData = any(), userWallet = any(), network = any(), sendMode = any()) + } + } + + @Test + fun `GIVEN notifications report an error WHEN flag emitted THEN primary button disabled`() = runTest { + // Arrange + val hasErrorFlow = MutableStateFlow(false) + every { notificationsUpdateTrigger.hasErrorFlow } returns hasErrorFlow + val model = createModel() + advanceUntilIdle() + assertThat(model.uiState.value.isPrimaryButtonEnabled).isTrue() + + // Act + hasErrorFlow.value = true + advanceUntilIdle() + + // Assert + assertThat(model.uiState.value.isPrimaryButtonEnabled).isFalse() + } + + private fun stubCurrencyStatusLookup(result: arrow.core.Option) { + every { + with(CryptoCurrencyStatusOperations) { + accountStatusList.getCryptoCurrencyStatus(any()) + } + } returns result + } + + private fun maxFee(): YieldSupplyMaxFee = YieldSupplyMaxFee( + nativeMaxFee = BigDecimal("0.01"), + tokenMaxFee = BigDecimal("2"), + fiatMaxFee = BigDecimal("4"), + ) + + private fun TestScope.createModel(): YieldSupplyStartEarningModel = YieldSupplyStartEarningModel( + dispatchers = createTestingCoroutineDispatcherProvider(), + paramsContainer = MutableParamsContainer( + YieldSupplyStartEarningComponent.Params( + userWalletId = userWalletId, + cryptoCurrency = token, + yieldSupplyActionUM = actionUM(), + callback = callback, + ), + ), + analytics = analytics, + getUserWalletUseCase = getUserWalletUseCase, + singleAccountStatusListSupplier = accountStatusListSupplier, + getFeePaidCryptoCurrencyStatusSyncUseCase = getFeePaidCryptoCurrencyStatusSyncUseCase, + sendTransactionUseCase = sendTransactionUseCase, + yieldSupplyStartEarningUseCase = startEarningUseCase, + yieldSupplyEstimateEnterFeeUseCase = estimateEnterFeeUseCase, + getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase, + yieldSupplyNotificationsUpdateTrigger = notificationsUpdateTrigger, + yieldSupplyAlertFactory = alertFactory, + yieldSupplyActivateUseCase = activateUseCase, + yieldSupplyMinAmountUseCase = minAmountUseCase, + yieldSupplyGetMaxFeeUseCase = getMaxFeeUseCase, + yieldSupplyGetCurrentFeeUseCase = getCurrentFeeUseCase, + yieldSupplyRepository = yieldSupplyRepository, + yieldSupplyPendingTracker = pendingTracker, + appsFlyerStore = appsFlyerStore, + ) + + private fun actionUM(): YieldSupplyActionUM = YieldSupplyActionUM( + title = stringReference(""), + subtitle = stringReference(""), + footer = stringReference(""), + footerLink = stringReference(""), + currencyIconState = mockk(relaxed = true), + yieldSupplyFeeUM = YieldSupplyFeeUM.Loading, + isPrimaryButtonEnabled = false, + isTransactionSending = false, + isHoldToConfirmEnabled = false, + ) +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/transformers/YieldSupplyStartEarningFeeContentTransformerTest.kt b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/transformers/YieldSupplyStartEarningFeeContentTransformerTest.kt new file mode 100644 index 0000000000..92246b3d35 --- /dev/null +++ b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/startearning/model/transformers/YieldSupplyStartEarningFeeContentTransformerTest.kt @@ -0,0 +1,192 @@ +package com.tangem.features.yield.supply.impl.subcomponents.startearning.model.transformers + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.network.NetworkAddress +import com.tangem.domain.yield.supply.models.YieldSupplyMaxFee +import com.tangem.features.yield.supply.impl.R +import com.tangem.features.yield.supply.impl.common.entity.YieldSupplyActionUM +import com.tangem.features.yield.supply.impl.common.entity.YieldSupplyFeeUM +import io.mockk.mockk +import kotlinx.collections.immutable.persistentListOf +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +internal class YieldSupplyStartEarningFeeContentTransformerTest { + + private val token = createToken() + private val appCurrency = AppCurrency.Default + + @Test + fun `GIVEN currency status loading WHEN transform THEN fee Loading and button flag preserved`() { + // Arrange — prevState button flag is false; the Loading branch must not flip it + val transformer = createTransformer(currencyStatus = loadingStatus()) + + // Act + val result = transformer.transform(prevState()) + + // Assert + assertThat(result.yieldSupplyFeeUM).isEqualTo(YieldSupplyFeeUM.Loading) + assertThat(result.isPrimaryButtonEnabled).isFalse() + } + + @Test + fun `GIVEN loaded status with rates WHEN transform THEN fee Content with every fiat field computed`() { + // Arrange — tokenFiatRate 1, feeFiatRate 2; feeValue 0.5, estimatedToken 0.4, minAmount 3, maxFee 2 token / 4 fiat + val transformer = createTransformer(currencyStatus = customStatus(BigDecimal("1")), feeFiatRate = BigDecimal("2")) + + // Act + val result = transformer.transform(prevState()) + + // Assert — whole Content compared field-by-field (no fields touched on isPrimaryButtonEnabled) + assertThat(result.yieldSupplyFeeUM).isEqualTo( + expectedContent(tokenFiatRate = BigDecimal("1"), feeFiatRate = BigDecimal("2")), + ) + assertThat(result.isPrimaryButtonEnabled).isFalse() + } + + @Test + fun `GIVEN loaded status but missing rates WHEN transform THEN fiat fields collapse to placeholders`() { + // Arrange — negative: both token and fee fiat rates unavailable + val transformer = createTransformer(currencyStatus = customStatus(null), feeFiatRate = null) + + // Act + val result = transformer.transform(prevState()) + + // Assert — fiat-derived fields become the placeholder; crypto fields and the max fiat fee stay populated + assertThat(result.yieldSupplyFeeUM).isEqualTo( + expectedContent(tokenFiatRate = null, feeFiatRate = null), + ) + } + + private fun expectedContent(tokenFiatRate: BigDecimal?, feeFiatRate: BigDecimal?): YieldSupplyFeeUM.Content { + val feeFiatText = fiatText(feeFiatRate?.let(FEE_VALUE::multiply)) + val estimatedFiatText = fiatText(tokenFiatRate?.let(ESTIMATED_TOKEN::multiply)) + val estimatedCryptoText = cryptoText(ESTIMATED_TOKEN) + val maxFiatText = fiatText(MAX_FIAT_FEE) + val maxCryptoText = cryptoText(MAX_TOKEN_FEE) + val minFiatText = fiatText(tokenFiatRate?.let(MIN_AMOUNT::multiply)) + val minCryptoText = cryptoText(MIN_AMOUNT) + return YieldSupplyFeeUM.Content( + transactionDataList = persistentListOf(), + feeFiatValue = stringReference(feeFiatText), + estimatedFiatValue = stringReference(estimatedFiatText), + maxNetworkFeeFiatValue = stringReference(maxFiatText), + minTopUpFiatValue = stringReference(minFiatText), + feeNoteValue = resourceReference( + id = R.string.yield_module_fee_policy_sheet_fee_note, + formatArgs = wrappedList(estimatedFiatText, estimatedCryptoText, maxFiatText, maxCryptoText), + ), + minFeeNoteValue = resourceReference( + id = R.string.yield_module_fee_policy_sheet_min_amount_note, + formatArgs = wrappedList(minFiatText, minCryptoText), + ), + ) + } + + private fun cryptoText(value: BigDecimal): String = value.format { crypto(token) } + + private fun fiatText(value: BigDecimal?): String = value.format { fiat(appCurrency.code, appCurrency.symbol) } + + private fun createTransformer( + currencyStatus: CryptoCurrencyStatus, + feeFiatRate: BigDecimal? = BigDecimal("1"), + ): YieldSupplyStartEarningFeeContentTransformer = YieldSupplyStartEarningFeeContentTransformer( + cryptoCurrencyStatus = currencyStatus, + feeCryptoCurrencyStatus = customStatus(feeFiatRate), + appCurrency = appCurrency, + updatedTransactionList = emptyList(), + feeValue = FEE_VALUE, + estimatedFeeValueInTokenCurrency = ESTIMATED_TOKEN, + maxNetworkFee = YieldSupplyMaxFee( + nativeMaxFee = BigDecimal("0.01"), + tokenMaxFee = MAX_TOKEN_FEE, + fiatMaxFee = MAX_FIAT_FEE, + ), + minAmount = MIN_AMOUNT, + ) + + private fun customStatus(fiatRate: BigDecimal?): CryptoCurrencyStatus = CryptoCurrencyStatus( + currency = token, + value = CryptoCurrencyStatus.Custom( + amount = BigDecimal.ZERO, + fiatAmount = BigDecimal.ZERO, + fiatRate = fiatRate, + priceChange = BigDecimal.ZERO, + stakingBalance = null, + yieldSupplyStatus = null, + hasCurrentNetworkTransactions = false, + pendingTransactions = emptySet(), + networkAddress = NetworkAddress.Single( + defaultAddress = NetworkAddress.Address( + value = "0x0000000000000000000000000000000000000000", + type = NetworkAddress.Address.Type.Primary, + ), + ), + sources = CryptoCurrencyStatus.Sources(), + ), + ) + + private fun loadingStatus(): CryptoCurrencyStatus = + CryptoCurrencyStatus(currency = token, value = CryptoCurrencyStatus.Loading) + + private fun prevState(): YieldSupplyActionUM = YieldSupplyActionUM( + title = stringReference(""), + subtitle = stringReference(""), + footer = stringReference(""), + footerLink = stringReference(""), + currencyIconState = mockk(relaxed = true), + yieldSupplyFeeUM = YieldSupplyFeeUM.Error, + isPrimaryButtonEnabled = false, + isTransactionSending = false, + isHoldToConfirmEnabled = false, + ) + + private fun createToken(): CryptoCurrency.Token { + val derivationPath = Network.DerivationPath.None + val network = Network( + id = Network.ID(value = "ethereum", derivationPath = derivationPath), + name = "Ethereum", + currencySymbol = "ETH", + derivationPath = derivationPath, + isTestnet = false, + standardType = Network.StandardType.Unspecified("UNSPECIFIED"), + hasFiatFeeRate = true, + canHandleTokens = true, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ) + return CryptoCurrency.Token( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId("ethereum"), + suffix = CryptoCurrency.ID.Suffix.RawID("ethereum"), + ), + network = network, + name = "TEST_TOKEN", + symbol = "TTK", + decimals = 6, + iconUrl = null, + isCustom = false, + contractAddress = "0xToken", + ) + } + + private companion object { + val FEE_VALUE: BigDecimal = BigDecimal("0.5") + val ESTIMATED_TOKEN: BigDecimal = BigDecimal("0.4") + val MIN_AMOUNT: BigDecimal = BigDecimal("3") + val MAX_TOKEN_FEE: BigDecimal = BigDecimal("2") + val MAX_FIAT_FEE: BigDecimal = BigDecimal("4") + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModelTest.kt b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModelTest.kt new file mode 100644 index 0000000000..7fcf23b5a0 --- /dev/null +++ b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/YieldSupplyStopEarningModelTest.kt @@ -0,0 +1,247 @@ +package com.tangem.features.yield.supply.impl.subcomponents.stopearning.model + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.common.TangemBlogUrlBuilder +import com.tangem.core.analytics.models.AnalyticsEvent +import com.tangem.core.analytics.models.AnalyticsParam +import com.tangem.core.analytics.models.Basic +import com.tangem.core.decompose.model.MutableParamsContainer +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.error.SendTransactionError +import com.tangem.domain.yield.supply.YieldSupplyError +import com.tangem.domain.yield.supply.usecase.YieldSupplyDeactivateUseCase +import com.tangem.domain.yield.supply.usecase.YieldSupplyStopEarningUseCase +import com.tangem.features.yield.supply.impl.common.entity.YieldSupplyFeeUM +import com.tangem.features.yield.supply.impl.subcomponents.YieldSupplyActionModelTestBase +import com.tangem.features.yield.supply.impl.subcomponents.stopearning.YieldSupplyStopEarningComponent +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +internal class YieldSupplyStopEarningModelTest : YieldSupplyActionModelTestBase() { + + private val stopEarningUseCase: YieldSupplyStopEarningUseCase = mockk() + private val deactivateUseCase: YieldSupplyDeactivateUseCase = mockk() + private val callback: YieldSupplyStopEarningComponent.ModelCallback = mockk(relaxed = true) + + @BeforeEach + fun setUp() { + coEvery { stopEarningUseCase(any(), any(), any()) } returns uncompiledTx().right() + coEvery { getFeeUseCase(any(), any(), any()) } returns transactionFee().right() + coEvery { sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) } returns "0xhash".right() + coEvery { deactivateUseCase(any(), any()) } returns true.right() + } + + @Test + fun `GIVEN successful fee load WHEN model created THEN fee content and button enabled`() = runTest { + // Act + val model = createModel() + advanceUntilIdle() + + // Assert + assertThat(model.uiState.value.yieldSupplyFeeUM).isInstanceOf(YieldSupplyFeeUM.Content::class.java) + assertThat(model.uiState.value.isPrimaryButtonEnabled).isTrue() + coVerify { notificationsUpdateTrigger.triggerUpdate(any()) } + } + + @Test + fun `GIVEN get fee fails WHEN model created THEN fee error state`() = runTest { + // Arrange + coEvery { getFeeUseCase(any(), any(), any()) } returns GetFeeError.UnknownError.left() + + // Act + val model = createModel() + advanceUntilIdle() + + // Assert + assertThat(model.uiState.value.yieldSupplyFeeUM).isEqualTo(YieldSupplyFeeUM.Error) + } + + @Test + fun `GIVEN non-token currency WHEN model created THEN fee not loaded`() = runTest { + // Act + val model = createModel(statusFlow = MutableStateFlow(statusOf(coin))) + advanceUntilIdle() + + // Assert + assertThat(model.uiState.value.yieldSupplyFeeUM).isEqualTo(YieldSupplyFeeUM.Loading) + coVerify(exactly = 0) { getFeeUseCase(any(), any(), any()) } + } + + @Test + fun `GIVEN stop earning use case fails WHEN model created THEN fee not loaded`() = runTest { + // Arrange + coEvery { stopEarningUseCase(any(), any(), any()) } returns YieldSupplyError.DataError(Throwable()).left() + + // Act + val model = createModel() + advanceUntilIdle() + + // Assert + assertThat(model.uiState.value.yieldSupplyFeeUM).isEqualTo(YieldSupplyFeeUM.Loading) + coVerify(exactly = 0) { getFeeUseCase(any(), any(), any()) } + } + + @Test + fun `GIVEN content loaded WHEN onClick THEN sends deactivates tracks pending and notifies sent`() = runTest { + // Arrange + val model = createModel() + advanceUntilIdle() + + // Act + model.onClick() + advanceUntilIdle() + + // Assert + verify { callback.onTransactionProgress(true) } + coVerify { yieldSupplyRepository.saveTokenProtocolPendingStatus(userWalletId, any(), any()) } + coVerify { deactivateUseCase(any(), any()) } + coVerify { pendingTracker.addPending(userWalletId, any(), any()) } + verify { callback.onStopEarningTransactionSent() } + + // Token fee asset (default fee currency is the token itself) + val events = mutableListOf() + verify { analytics.send(capture(events)) } + val sent = events.filterIsInstance().single() + assertThat(sent.params["Fee Token"]).isEqualTo("TTK") + assertThat(sent.params["Fee Asset Type"]).isEqualTo(AnalyticsParam.FeeAssetType.Token.value) + } + + @Test + fun `GIVEN coin fee currency WHEN onClick succeeds THEN transaction sent analytics carries coin fee asset`() = runTest { + // Arrange — network fee paid in the native coin, not the token + coEvery { getFeePaidCryptoCurrencyStatusSyncUseCase(any(), any()) } returns statusOf(coin).right() + val model = createModel() + advanceUntilIdle() + + // Act + model.onClick() + advanceUntilIdle() + + // Assert + val events = mutableListOf() + verify { analytics.send(capture(events)) } + val sent = events.filterIsInstance().single() + assertThat(sent.params["Fee Token"]).isEqualTo("ETH") + assertThat(sent.params["Fee Asset Type"]).isEqualTo(AnalyticsParam.FeeAssetType.Coin.value) + } + + @Test + fun `GIVEN fee not loaded WHEN onClick THEN does not send transaction`() = runTest { + // Arrange — fee load fails so the fee state is Error; onClick reports progress then early-returns + coEvery { getFeeUseCase(any(), any(), any()) } returns GetFeeError.UnknownError.left() + val model = createModel() + advanceUntilIdle() + + // Act + model.onClick() + advanceUntilIdle() + + // Assert + verify { callback.onTransactionProgress(true) } + coVerify(exactly = 0) { sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) } + } + + @Test + fun `GIVEN notifications report an error WHEN flag emitted THEN primary button disabled`() = runTest { + // Arrange + val hasErrorFlow = MutableStateFlow(false) + every { notificationsUpdateTrigger.hasErrorFlow } returns hasErrorFlow + val model = createModel() + advanceUntilIdle() + assertThat(model.uiState.value.isPrimaryButtonEnabled).isTrue() + + // Act + hasErrorFlow.value = true + advanceUntilIdle() + + // Assert + assertThat(model.uiState.value.isPrimaryButtonEnabled).isFalse() + } + + @Test + fun `GIVEN content loaded WHEN onClick and send fails THEN shows error and stops progress`() = runTest { + // Arrange + coEvery { + sendTransactionUseCase(txData = any(), userWallet = any(), network = any()) + } returns SendTransactionError.UnknownError().left() + val model = createModel() + advanceUntilIdle() + + // Act + model.onClick() + advanceUntilIdle() + + // Assert + assertThat(model.uiState.value.isTransactionSending).isFalse() + verify { alertFactory.getSendTransactionErrorState(any(), any(), any()) } + verify { callback.onTransactionProgress(false) } + verify(exactly = 0) { callback.onStopEarningTransactionSent() } + } + + @Test + fun `WHEN onReadMoreClick THEN opens url`() = runTest { + // Arrange + mockkObject(TangemBlogUrlBuilder) + try { + coEvery { TangemBlogUrlBuilder.build(any()) } returns BLOG_URL + val model = createModel() + advanceUntilIdle() + + // Act + model.onReadMoreClick() + advanceUntilIdle() + + // Assert + verify { urlOpener.openUrl(BLOG_URL) } + } finally { + unmockkObject(TangemBlogUrlBuilder) + } + } + + private fun TestScope.createModel( + statusFlow: StateFlow = cryptoCurrencyStatusFlow, + ): YieldSupplyStopEarningModel = YieldSupplyStopEarningModel( + dispatchers = createTestingCoroutineDispatcherProvider(), + paramsContainer = MutableParamsContainer( + YieldSupplyStopEarningComponent.Params( + userWallet = userWallet, + cryptoCurrencyStatusFlow = statusFlow, + callback = callback, + ), + ), + analytics = analytics, + getFeeUseCase = getFeeUseCase, + getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase, + getFeePaidCryptoCurrencyStatusSyncUseCase = getFeePaidCryptoCurrencyStatusSyncUseCase, + sendTransactionUseCase = sendTransactionUseCase, + yieldSupplyStopEarningUseCase = stopEarningUseCase, + urlOpener = urlOpener, + yieldSupplyNotificationsUpdateTrigger = notificationsUpdateTrigger, + yieldSupplyAlertFactory = alertFactory, + yieldSupplyDeactivateUseCase = deactivateUseCase, + yieldSupplyRepository = yieldSupplyRepository, + yieldSupplyPendingTracker = pendingTracker, + appsFlyerStore = appsFlyerStore, + ) + + private companion object { + const val BLOG_URL = "https://tangem.com/blog" + } +} \ No newline at end of file diff --git a/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/transformer/YieldSupplyStopEarningFeeContentTransformerTest.kt b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/transformer/YieldSupplyStopEarningFeeContentTransformerTest.kt new file mode 100644 index 0000000000..a0b390a229 --- /dev/null +++ b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/subcomponents/stopearning/model/transformer/YieldSupplyStopEarningFeeContentTransformerTest.kt @@ -0,0 +1,161 @@ +package com.tangem.features.yield.supply.impl.subcomponents.stopearning.model.transformer + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.network.NetworkAddress +import com.tangem.features.yield.supply.impl.common.entity.YieldSupplyActionUM +import com.tangem.features.yield.supply.impl.common.entity.YieldSupplyFeeUM +import io.mockk.mockk +import kotlinx.collections.immutable.persistentListOf +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +internal class YieldSupplyStopEarningFeeContentTransformerTest { + + private val token = createToken() + private val appCurrency = AppCurrency.Default + + @Test + fun `GIVEN currency status loading WHEN transform THEN fee Loading and button flag preserved`() { + // Arrange — prevState button flag is false; the Loading branch must not flip it + val transformer = createTransformer(currencyStatus = loadingStatus(), feeFiatRate = BigDecimal("1")) + + // Act + val result = transformer.transform(prevState()) + + // Assert + assertThat(result.yieldSupplyFeeUM).isEqualTo(YieldSupplyFeeUM.Loading) + assertThat(result.isPrimaryButtonEnabled).isFalse() + } + + @Test + fun `GIVEN loaded status with fee rate WHEN transform THEN only fiat fee set and the rest EMPTY`() { + // Arrange — feeValue 0.5, feeFiatRate 2 → fiat fee = 1.0; all other fee fields are intentionally EMPTY + val transformer = createTransformer(currencyStatus = customStatus(BigDecimal("1")), feeFiatRate = BigDecimal("2")) + + // Act + val result = transformer.transform(prevState()) + + // Assert + assertThat(result.isPrimaryButtonEnabled).isTrue() + assertThat(result.yieldSupplyFeeUM).isEqualTo( + YieldSupplyFeeUM.Content( + transactionDataList = persistentListOf(), + feeFiatValue = stringReference(fiatText(BigDecimal("0.5").multiply(BigDecimal("2")))), + estimatedFiatValue = TextReference.EMPTY, + maxNetworkFeeFiatValue = TextReference.EMPTY, + minTopUpFiatValue = TextReference.EMPTY, + feeNoteValue = TextReference.EMPTY, + ), + ) + } + + @Test + fun `GIVEN loaded status but missing fee rate WHEN transform THEN fiat fee is the placeholder`() { + // Arrange — negative: fee fiat rate unavailable, fiat fee text becomes the placeholder + val transformer = createTransformer(currencyStatus = customStatus(BigDecimal("1")), feeFiatRate = null) + + // Act + val result = transformer.transform(prevState()) + + // Assert + assertThat(result.isPrimaryButtonEnabled).isTrue() + assertThat(result.yieldSupplyFeeUM).isEqualTo( + YieldSupplyFeeUM.Content( + transactionDataList = persistentListOf(), + feeFiatValue = stringReference(fiatText(null)), + estimatedFiatValue = TextReference.EMPTY, + maxNetworkFeeFiatValue = TextReference.EMPTY, + minTopUpFiatValue = TextReference.EMPTY, + feeNoteValue = TextReference.EMPTY, + ), + ) + } + + private fun fiatText(value: BigDecimal?): String = value.format { fiat(appCurrency.code, appCurrency.symbol) } + + private fun createTransformer( + currencyStatus: CryptoCurrencyStatus, + feeFiatRate: BigDecimal?, + ): YieldSupplyStopEarningFeeContentTransformer = YieldSupplyStopEarningFeeContentTransformer( + cryptoCurrencyStatus = currencyStatus, + feeCryptoCurrencyStatus = customStatus(feeFiatRate), + appCurrency = appCurrency, + transactions = emptyList(), + feeValue = BigDecimal("0.5"), + ) + + private fun customStatus(fiatRate: BigDecimal?): CryptoCurrencyStatus = CryptoCurrencyStatus( + currency = token, + value = CryptoCurrencyStatus.Custom( + amount = BigDecimal.ZERO, + fiatAmount = BigDecimal.ZERO, + fiatRate = fiatRate, + priceChange = BigDecimal.ZERO, + stakingBalance = null, + yieldSupplyStatus = null, + hasCurrentNetworkTransactions = false, + pendingTransactions = emptySet(), + networkAddress = NetworkAddress.Single( + defaultAddress = NetworkAddress.Address( + value = "0x0000000000000000000000000000000000000000", + type = NetworkAddress.Address.Type.Primary, + ), + ), + sources = CryptoCurrencyStatus.Sources(), + ), + ) + + private fun loadingStatus(): CryptoCurrencyStatus = + CryptoCurrencyStatus(currency = token, value = CryptoCurrencyStatus.Loading) + + private fun prevState(): YieldSupplyActionUM = YieldSupplyActionUM( + title = stringReference(""), + subtitle = stringReference(""), + footer = stringReference(""), + footerLink = stringReference(""), + currencyIconState = mockk(relaxed = true), + yieldSupplyFeeUM = YieldSupplyFeeUM.Error, + isPrimaryButtonEnabled = false, + isTransactionSending = false, + isHoldToConfirmEnabled = false, + ) + + private fun createToken(): CryptoCurrency.Token { + val derivationPath = Network.DerivationPath.None + val network = Network( + id = Network.ID(value = "ethereum", derivationPath = derivationPath), + name = "Ethereum", + currencySymbol = "ETH", + derivationPath = derivationPath, + isTestnet = false, + standardType = Network.StandardType.Unspecified("UNSPECIFIED"), + hasFiatFeeRate = true, + canHandleTokens = true, + transactionExtrasType = Network.TransactionExtrasType.NONE, + nameResolvingType = Network.NameResolvingType.NONE, + ) + return CryptoCurrency.Token( + id = CryptoCurrency.ID( + prefix = CryptoCurrency.ID.Prefix.TOKEN_PREFIX, + body = CryptoCurrency.ID.Body.NetworkId("ethereum"), + suffix = CryptoCurrency.ID.Suffix.RawID("ethereum"), + ), + network = network, + name = "TEST_TOKEN", + symbol = "TTK", + decimals = 6, + iconUrl = null, + isCustom = false, + contractAddress = "0xToken", + ) + } +} \ No newline at end of file From 416dd75fde14d90ff08d42ff4d399088d6bea0f6 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 26 Jun 2026 14:57:12 +0200 Subject: [PATCH 052/210] Updated on 2026-08-14 --- features/send/impl/build.gradle.kts | 9 +- .../tangem/features/send/SendTestFixtures.kt | 59 ++ .../model/FeeSelectorAlertFactoryTest.kt | 175 ++++++ .../feeselector/model/FeeSelectorLogicTest.kt | 340 +++++++++++ .../transformers/FeeItemConverterTest.kt | 189 ++++++ .../FeeSelectorCustomFieldConverterTest.kt | 83 +++ ...lectorCustomValueChangedTransformerTest.kt | 111 ++++ .../FeeSelectorErrorTransformerTest.kt | 67 +++ .../FeeSelectorLoadedTransformerTest.kt | 186 ++++++ .../FeeSelectorNonceChangeTransformerTest.kt | 81 +++ ...eSelectorRemoveSuggestedTransformerTest.kt | 65 +++ .../features/send/send/SendModelTestBase.kt | 282 +++++++++ .../confirm/model/SendConfirmModelTest.kt | 288 +++++++++ ...firmationNotificationsTransformerV2Test.kt | 0 ...firmationNotificationsTransformerV2Test.kt | 0 .../features/send/send/model/SendModelTest.kt | 265 +++++++++ .../confirm/model/NFTSendConfirmModelTest.kt | 344 +++++++++++ .../send/sendnft/model/NFTSendModelTest.kt | 229 ++++++++ .../amount/model/SendAmountModelTest.kt | 321 ++++++++++ .../model/SendDestinationModelTest.kt | 551 ++++++++++++++++++ .../SendRecipientHistoryListConverterTest.kt | 134 +++++ .../SendRecipientWalletListConverterTest.kt | 130 +++++ ...tinationValidationResultTransformerTest.kt | 14 +- .../bitcoin/BitcoinCustomFeeConverterTest.kt | 234 ++++++++ .../EthereumCustomFeeConverterTest.kt | 139 +++++ .../EthereumEIPCustomFeeConverterTest.kt | 180 ++++++ .../EthereumLegacyCustomFeeConverterTest.kt | 165 ++++++ .../custom/ethereum/EthereumTestUtils.kt | 21 + .../kaspa/KaspaCustomFeeConverterTest.kt | 150 +++++ 29 files changed, 4798 insertions(+), 14 deletions(-) create mode 100644 features/send/impl/src/test/java/com/tangem/features/send/SendTestFixtures.kt create mode 100644 features/send/impl/src/test/java/com/tangem/features/send/feeselector/model/FeeSelectorAlertFactoryTest.kt create mode 100644 features/send/impl/src/test/java/com/tangem/features/send/feeselector/model/FeeSelectorLogicTest.kt create mode 100644 features/send/impl/src/test/java/com/tangem/features/send/feeselector/model/transformers/FeeItemConverterTest.kt create mode 100644 features/send/impl/src/test/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorCustomFieldConverterTest.kt create mode 100644 features/send/impl/src/test/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorCustomValueChangedTransformerTest.kt create mode 100644 features/send/impl/src/test/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorErrorTransformerTest.kt create mode 100644 features/send/impl/src/test/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorLoadedTransformerTest.kt create mode 100644 features/send/impl/src/test/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorNonceChangeTransformerTest.kt create mode 100644 features/send/impl/src/test/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorRemoveSuggestedTransformerTest.kt create mode 100644 features/send/impl/src/test/java/com/tangem/features/send/send/SendModelTestBase.kt create mode 100644 features/send/impl/src/test/java/com/tangem/features/send/send/confirm/model/SendConfirmModelTest.kt rename features/send/impl/src/test/java/com/tangem/features/send/{v2 => }/send/confirm/model/transformers/NFTSendConfirmationNotificationsTransformerV2Test.kt (100%) rename features/send/impl/src/test/java/com/tangem/features/send/{v2 => }/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2Test.kt (100%) create mode 100644 features/send/impl/src/test/java/com/tangem/features/send/send/model/SendModelTest.kt create mode 100644 features/send/impl/src/test/java/com/tangem/features/send/sendnft/confirm/model/NFTSendConfirmModelTest.kt create mode 100644 features/send/impl/src/test/java/com/tangem/features/send/sendnft/model/NFTSendModelTest.kt create mode 100644 features/send/impl/src/test/java/com/tangem/features/send/subcomponents/amount/model/SendAmountModelTest.kt create mode 100644 features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModelTest.kt create mode 100644 features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/converters/SendRecipientHistoryListConverterTest.kt create mode 100644 features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/converters/SendRecipientWalletListConverterTest.kt rename features/send/impl/src/test/java/com/tangem/features/send/{v2 => }/subcomponents/destination/model/transformers/SendDestinationValidationResultTransformerTest.kt (94%) create mode 100644 features/send/impl/src/test/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/bitcoin/BitcoinCustomFeeConverterTest.kt create mode 100644 features/send/impl/src/test/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/ethereum/EthereumCustomFeeConverterTest.kt create mode 100644 features/send/impl/src/test/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/ethereum/EthereumEIPCustomFeeConverterTest.kt create mode 100644 features/send/impl/src/test/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/ethereum/EthereumLegacyCustomFeeConverterTest.kt create mode 100644 features/send/impl/src/test/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/ethereum/EthereumTestUtils.kt create mode 100644 features/send/impl/src/test/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/kaspa/KaspaCustomFeeConverterTest.kt diff --git a/features/send/impl/build.gradle.kts b/features/send/impl/build.gradle.kts index d57e08c9b0..750b35d367 100644 --- a/features/send/impl/build.gradle.kts +++ b/features/send/impl/build.gradle.kts @@ -89,12 +89,7 @@ dependencies { /** DI */ implementation(deps.hilt.android) kapt(deps.hilt.kapt) - - // region Tests - testImplementation(deps.test.coroutine) - testImplementation(deps.test.junit5) - testImplementation(deps.test.mockk) - testImplementation(deps.test.truth) + testImplementation(projects.common.test) - // endregion + testImplementation(projects.test.core) } \ No newline at end of file diff --git a/features/send/impl/src/test/java/com/tangem/features/send/SendTestFixtures.kt b/features/send/impl/src/test/java/com/tangem/features/send/SendTestFixtures.kt new file mode 100644 index 0000000000..6101aeda92 --- /dev/null +++ b/features/send/impl/src/test/java/com/tangem/features/send/SendTestFixtures.kt @@ -0,0 +1,59 @@ +package com.tangem.features.send + +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.NetworkAddress +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import java.math.BigDecimal + +/** + * Builds a [TestingCoroutineDispatcherProvider] backed by a single [StandardTestDispatcher] wired to this scope's + * [TestScope.testScheduler], so `advanceUntilIdle()` drives all five dispatcher roles. Use in `Model`-layer tests + * instead of copying the wiring per file. + */ +@OptIn(ExperimentalCoroutinesApi::class) +internal fun TestScope.testDispatcherProvider(): TestingCoroutineDispatcherProvider { + val testDispatcher = StandardTestDispatcher(testScheduler) + return TestingCoroutineDispatcherProvider( + main = testDispatcher, + mainImmediate = testDispatcher, + io = testDispatcher, + default = testDispatcher, + single = testDispatcher, + ) +} + +/** + * Shared `Loaded` status fixture for send-impl tests. Only [currency], [fiatRate] and [balance] differ between + * call sites; the rest is incidental and never asserted. + */ +internal fun loadedStatus( + currency: CryptoCurrency, + fiatRate: BigDecimal = BigDecimal.ONE, + balance: BigDecimal = BigDecimal.ONE, +): CryptoCurrencyStatus = CryptoCurrencyStatus( + currency = currency, + value = CryptoCurrencyStatus.Loaded( + amount = balance, + fiatAmount = fiatRate, + fiatRate = fiatRate, + priceChange = BigDecimal.ZERO, + stakingBalance = null, + yieldSupplyStatus = null, + hasCurrentNetworkTransactions = false, + pendingTransactions = emptySet(), + networkAddress = NetworkAddress.Single( + NetworkAddress.Address(value = "address", type = NetworkAddress.Address.Type.Primary), + ), + sources = CryptoCurrencyStatus.Sources(), + ), +) + +/** Throwaway [Fee.Common] for tests that only need "some fee" of a non-special type. */ +internal fun commonFee(blockchain: Blockchain = Blockchain.Ethereum): Fee.Common = Fee.Common(Amount(blockchain)) \ No newline at end of file diff --git a/features/send/impl/src/test/java/com/tangem/features/send/feeselector/model/FeeSelectorAlertFactoryTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/feeselector/model/FeeSelectorAlertFactoryTest.kt new file mode 100644 index 0000000000..c6f6f1d12e --- /dev/null +++ b/features/send/impl/src/test/java/com/tangem/features/send/feeselector/model/FeeSelectorAlertFactoryTest.kt @@ -0,0 +1,175 @@ +package com.tangem.features.send.feeselector.model + +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.core.decompose.ui.UiMessageSender +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeNonce +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.utils.FeeCalculationUtils +import com.tangem.features.send.commonFee +import com.tangem.test.core.ProvideTestModels +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import java.math.BigDecimal + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class FeeSelectorAlertFactoryTest { + + private val messageSender: UiMessageSender = mockk(relaxed = true) + private val factory = FeeSelectorAlertFactory(messageSender) + + @BeforeEach + fun resetSender() { + clearMocks(messageSender) + } + + private fun ethFee(value: String): Fee = + Fee.Common(Amount(currencySymbol = "ETH", value = BigDecimal(value), decimals = 18)) + + private fun content(selected: FeeItem) = FeeSelectorUM.Content( + isPrimaryButtonEnabled = true, + fees = TransactionFee.Single(normal = commonFee()), + feeItems = persistentListOf(selected), + selectedFeeItem = selected, + feeExtraInfo = mockk(), + feeFiatRateUM = null, + feeNonce = FeeNonce.None, + ) + + private fun choosable(normal: Fee, minimum: Fee, priority: Fee) = + TransactionFee.Choosable(normal = normal, minimum = minimum, priority = priority) + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GetFeeUpdatedAlert { + + @ParameterizedTest + @ProvideTestModels + fun `GIVEN reloaded fee WHEN getFeeUpdatedAlert THEN resolves to warn proceed or nothing`(model: UpdatedModel) { + // Arrange + val proceed: () -> Unit = mockk(relaxed = true) + + // Act + factory.getFeeUpdatedAlert( + model.newFee, + model.state, + proceedAction = proceed, + stopAction = mockk(relaxed = true), + ) + + // Assert + verify(exactly = if (model.outcome == Outcome.DIALOG) 1 else 0) { messageSender.send(any()) } + verify(exactly = if (model.outcome == Outcome.PROCEED) 1 else 0) { proceed() } + } + + private fun provideTestModels() = listOf( + // Market -> normal, higher -> warn + UpdatedModel( + content(FeeItem.Market(ethFee("1"))), + choosable(ethFee("2"), ethFee("0"), ethFee("0")), + Outcome.DIALOG + ), + // Market -> normal, not higher -> proceed + UpdatedModel( + content(FeeItem.Market(ethFee("2"))), + choosable(ethFee("1"), ethFee("0"), ethFee("0")), + Outcome.PROCEED + ), + // Slow -> minimum + UpdatedModel( + content(FeeItem.Slow(ethFee("1"))), + choosable(ethFee("0"), ethFee("2"), ethFee("0")), + Outcome.DIALOG + ), + // Fast -> priority + UpdatedModel( + content(FeeItem.Fast(ethFee("1"))), + choosable(ethFee("0"), ethFee("0"), ethFee("2")), + Outcome.DIALOG + ), + // Single -> normal + UpdatedModel( + content(FeeItem.Market(ethFee("1"))), + TransactionFee.Single(ethFee("2")), + Outcome.DIALOG + ), + // Suggested -> its own fee == old fee, never higher -> proceed + UpdatedModel( + content(FeeItem.Suggested(title = mockk(), fee = ethFee("5"))), + choosable(ethFee("9"), ethFee("9"), ethFee("9")), + Outcome.PROCEED, + ), + // Custom selected -> early return, nothing happens + UpdatedModel( + content(FeeItem.Custom(fee = ethFee("1"), customValues = persistentListOf())), + choosable(ethFee("9"), ethFee("9"), ethFee("9")), + Outcome.NOTHING, + ), + // non-content state -> early return, nothing happens + UpdatedModel( + FeeSelectorUM.Loading, + TransactionFee.Single(ethFee("2")), + Outcome.NOTHING + ), + ) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class CheckAndShowAlerts { + + @BeforeEach + fun mockUtils() { + mockkObject(FeeCalculationUtils) + } + + @AfterEach + fun unmockUtils() { + unmockkObject(FeeCalculationUtils) + } + + @ParameterizedTest + @ProvideTestModels + fun `GIVEN fee validity WHEN checkAndShowAlerts THEN confirms only when no alert shown`(model: AlertsModel) { + // Arrange + every { FeeCalculationUtils.checkIfCustomFeeTooLow(any()) } returns model.tooLow + every { FeeCalculationUtils.checkIfCustomFeeTooHigh(any()) } returns (model.tooHigh to "5") + val onConfirm: () -> Unit = mockk(relaxed = true) + + // Act + factory.checkAndShowAlerts(content(FeeItem.Market(ethFee("1"))), onConfirm) + + // Assert + verify(exactly = model.expectedSends) { messageSender.send(any()) } + verify(exactly = if (model.expectConfirm) 1 else 0) { onConfirm() } + } + + private fun provideTestModels() = listOf( + AlertsModel(tooLow = false, tooHigh = false, expectedSends = 0, expectConfirm = true), + AlertsModel(tooLow = true, tooHigh = false, expectedSends = 1, expectConfirm = false), + AlertsModel(tooLow = false, tooHigh = true, expectedSends = 1, expectConfirm = false), + ) + } + + enum class Outcome { DIALOG, PROCEED, NOTHING } + + data class UpdatedModel(val state: FeeSelectorUM, val newFee: TransactionFee, val outcome: Outcome) + data class AlertsModel( + val tooLow: Boolean, + val tooHigh: Boolean, + val expectedSends: Int, + val expectConfirm: Boolean, + ) +} \ No newline at end of file diff --git a/features/send/impl/src/test/java/com/tangem/features/send/feeselector/model/FeeSelectorLogicTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/feeselector/model/FeeSelectorLogicTest.kt new file mode 100644 index 0000000000..d31996806e --- /dev/null +++ b/features/send/impl/src/test/java/com/tangem/features/send/feeselector/model/FeeSelectorLogicTest.kt @@ -0,0 +1,340 @@ +package com.tangem.features.send.feeselector.model + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.domain.transaction.models.TransactionFeeExtended +import com.tangem.domain.transaction.usecase.IsFeeApproximateUseCase +import com.tangem.domain.transaction.usecase.gasless.GetAvailableFeeTokensUseCase +import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeExtraInfo +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeNonce +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams +import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams.FeeStateConfiguration +import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorCheckReloadListener +import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorCheckReloadTrigger +import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadListener +import com.tangem.features.send.api.subcomponents.feeSelector.analytics.CommonSendFeeAnalyticEvents +import com.tangem.features.send.loadedStatus +import com.tangem.test.core.ProvideTestModels +import io.mockk.MockKAnnotations +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import java.math.BigDecimal + +@OptIn(ExperimentalCoroutinesApi::class) +internal class FeeSelectorLogicTest { + + private val testUserWalletId = UserWalletId("1234567890ABCDEF") + private val coinStatus: CryptoCurrencyStatus = loadedStatus(mockk(relaxed = true)) + private val tokenStatus: CryptoCurrencyStatus = loadedStatus(mockk(relaxed = true)) + + private val isFeeApproximateUseCase: IsFeeApproximateUseCase = mockk(relaxed = true) + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk(relaxed = true) + private val feeSelectorReloadListener: FeeSelectorReloadListener = mockk(relaxed = true) + private val feeSelectorCheckReloadListener: FeeSelectorCheckReloadListener = mockk(relaxed = true) + private val feeSelectorCheckReloadTrigger: FeeSelectorCheckReloadTrigger = mockk(relaxed = true) + private val feeSelectorAlertFactory: FeeSelectorAlertFactory = mockk(relaxed = true) + private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true) + private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier = mockk(relaxed = true) + private val getUserWalletUseCase: GetUserWalletUseCase = mockk(relaxed = true) + private val getAvailableFeeTokensUseCase: GetAvailableFeeTokensUseCase = mockk(relaxed = true) + private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk(relaxed = true) + + private val onLoadFee: suspend () -> Either = mockk() + private val onLoadFeeExtended: suspend (CryptoCurrencyStatus?) -> Either = + mockk() + + private val checkReloadTriggerFlow = MutableSharedFlow(extraBufferCapacity = 1) + + @BeforeEach + fun setUp() { + MockKAnnotations.init(this) + // PER_CLASS parameterized nested classes reuse one instance — reset analytics recorded calls between rows. + clearMocks(analyticsEventHandler, answers = false, recordedCalls = true, childMocks = false) + coEvery { onLoadFee() } returns GetFeeError.UnknownError.left() + coEvery { onLoadFeeExtended(any()) } returns GetFeeError.UnknownError.left() + coEvery { getSelectedAppCurrencyUseCase.invokeSync() } returns AppCurrency.Default.right() + every { feeSelectorReloadListener.reloadTriggerFlow } returns emptyFlow() + every { feeSelectorReloadListener.loadingStateTriggerFlow } returns emptyFlow() + every { feeSelectorCheckReloadListener.checkReloadTriggerFlow } returns checkReloadTriggerFlow + every { isGaslessFeeSupportedForNetwork(any()) } returns false + every { isFeeApproximateUseCase(any(), any()) } returns false + } + + @Nested + inner class CallLoadFee { + + @Test + fun `GIVEN gasless disabled WHEN load fee THEN use basic onLoadFee only`() = + runTest(UnconfinedTestDispatcher()) { + // Act — init triggers loadFee() + buildModel(gaslessEnabled = false) + advanceUntilIdle() + + // Assert + coVerify(exactly = 1) { onLoadFee() } + coVerify(exactly = 0) { onLoadFeeExtended(any()) } + } + + @Test + fun `GIVEN gasless not enough funds WHEN load fee THEN surface error without basic fallback`() = + runTest(UnconfinedTestDispatcher()) { + // Arrange + coEvery { onLoadFeeExtended(any()) } returns GetFeeError.GaslessError.NotEnoughFunds.left() + + // Act + val sut = buildModel(gaslessEnabled = true) + advanceUntilIdle() + + // Assert + coVerify(exactly = 1) { onLoadFeeExtended(any()) } + coVerify(exactly = 0) { onLoadFee() } + assertThat(sut.uiState.value).isInstanceOf(FeeSelectorUM.Error::class.java) + } + + @Test + fun `GIVEN gasless generic error WHEN load fee THEN fallback to basic and show only speed option`() = + runTest(UnconfinedTestDispatcher()) { + // Arrange + coEvery { onLoadFeeExtended(any()) } returns GetFeeError.GaslessError.NetworkIsNotSupported.left() + coEvery { onLoadFee() } returns GetFeeError.UnknownError.left() + + // Act + val sut = buildModel(gaslessEnabled = true) + advanceUntilIdle() + + // Assert + coVerify(exactly = 1) { onLoadFeeExtended(any()) } + coVerify(exactly = 1) { onLoadFee() } + assertThat(sut.shouldShowOnlySpeedOption.value).isTrue() + } + + @Test + fun `GIVEN gasless success WHEN load fee THEN use extended and clear speed-only option`() = + runTest(UnconfinedTestDispatcher()) { + // Arrange — populateExtendedFee then fails (token not found) but the dispatch decision is already made + val feeExtended = TransactionFeeExtended( + transactionFee = singleFee(), + feeTokenId = mockk(relaxed = true), // != feeCryptoCurrencyStatus.currency.id -> token lookup + ) + coEvery { onLoadFeeExtended(any()) } returns feeExtended.right() + coEvery { singleAccountStatusListSupplier.getSyncOrNull(any()) } returns null + + // Act + val sut = buildModel(gaslessEnabled = true) + advanceUntilIdle() + + // Assert + coVerify(exactly = 1) { onLoadFeeExtended(any()) } + assertThat(sut.shouldShowOnlySpeedOption.value).isFalse() + } + } + + @Nested + inner class CheckLoadFee { + + @Test + fun `GIVEN fee reloads successfully WHEN check requested THEN show fee-updated alert`() = + runTest(UnconfinedTestDispatcher()) { + // Arrange + coEvery { onLoadFee() } returns singleFee().right() + buildModel(gaslessEnabled = false) + advanceUntilIdle() + + // Act + checkReloadTriggerFlow.tryEmit(Unit) + advanceUntilIdle() + + // Assert + verify(atLeast = 1) { feeSelectorAlertFactory.getFeeUpdatedAlert(any(), any(), any(), any()) } + } + + @Test + fun `GIVEN fee reload fails WHEN check requested THEN report failure and show unreachable error`() = + runTest(UnconfinedTestDispatcher()) { + // Arrange + coEvery { onLoadFee() } returns GetFeeError.UnknownError.left() + buildModel(gaslessEnabled = false) + advanceUntilIdle() + + // Act + checkReloadTriggerFlow.tryEmit(Unit) + advanceUntilIdle() + + // Assert + coVerify(atLeast = 1) { feeSelectorCheckReloadTrigger.callbackCheckResult(false) } + verify(atLeast = 1) { feeSelectorAlertFactory.getFeeUnreachableErrorState(any()) } + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class OnFeeItemSelected { + + @ParameterizedTest + @ProvideTestModels + fun `WHEN fee item selected THEN send custom-fee analytics only for custom`(model: FeeItemSelectedModel) = + runTest(UnconfinedTestDispatcher()) { + // Arrange + val sut = buildModel(gaslessEnabled = false) + advanceUntilIdle() + + // Act + sut.onFeeItemSelected(model.feeItem) + + // Assert + verify(exactly = model.expectedAnalyticsCalls) { + analyticsEventHandler.send(ofType()) + } + } + + private fun provideTestModels() = listOf( + FeeItemSelectedModel( + feeItem = FeeItem.Custom(fee = realFee(), customValues = persistentListOf()), + expectedAnalyticsCalls = 1, + ), + FeeItemSelectedModel(feeItem = FeeItem.Market(fee = realFee()), expectedAnalyticsCalls = 0), + ) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class OnDoneClick { + + @ParameterizedTest + @ProvideTestModels + fun `WHEN done THEN always send selected-fee and gas-price only for edited custom`(model: DoneClickModel) = + runTest(UnconfinedTestDispatcher()) { + // Arrange + val sut = buildModel(gaslessEnabled = false) + advanceUntilIdle() + sut.uiState.value = contentState(selected = model.selected, normalValue = model.normalValue) + + // Act + sut.onDoneClick() + + // Assert + verify(exactly = 1) { analyticsEventHandler.send(ofType()) } + verify(exactly = model.expectedGasPriceCalls) { analyticsEventHandler.send(ofType()) } + } + + private fun provideTestModels() = listOf( + // not custom -> no gas-price + DoneClickModel( + selected = FeeItem.Market(realFee("0.001")), + normalValue = "0.001", + expectedGasPriceCalls = 0 + ), + // custom but unedited (== normal) -> no gas-price + DoneClickModel( + selected = FeeItem.Custom(realFee("0.001"), persistentListOf()), + normalValue = "0.001", + expectedGasPriceCalls = 0, + ), + // custom edited (!= normal) -> gas-price + DoneClickModel( + selected = FeeItem.Custom(realFee("0.005"), persistentListOf()), + normalValue = "0.001", + expectedGasPriceCalls = 1, + ), + ) + } + + // region fixtures + + private fun TestScope.buildModel(gaslessEnabled: Boolean): FeeSelectorLogic { + val currencyStatus = if (gaslessEnabled) tokenStatus else coinStatus + every { isGaslessFeeSupportedForNetwork(any()) } returns gaslessEnabled + val params = FeeSelectorParams.FeeSelectorBlockParams( + state = FeeSelectorUM.Loading, + userWalletId = testUserWalletId, + onLoadFeeExtended = if (gaslessEnabled) onLoadFeeExtended else null, + onLoadFee = onLoadFee, + cryptoCurrencyStatus = currencyStatus, + feeCryptoCurrencyStatus = currencyStatus, + feeStateConfiguration = FeeStateConfiguration.None, + feeDisplaySource = FeeSelectorParams.FeeDisplaySource.BottomSheet, + analyticsCategoryName = "test_fee", + analyticsSendSource = CommonSendAnalyticEvents.CommonSendSource.Send, + ) + return FeeSelectorLogic( + params = params, + modelScope = backgroundScope, + isFeeApproximateUseCase = isFeeApproximateUseCase, + getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase, + feeSelectorReloadListener = feeSelectorReloadListener, + feeSelectorCheckReloadListener = feeSelectorCheckReloadListener, + feeSelectorCheckReloadTrigger = feeSelectorCheckReloadTrigger, + feeSelectorAlertFactory = feeSelectorAlertFactory, + analyticsEventHandler = analyticsEventHandler, + singleAccountStatusListSupplier = singleAccountStatusListSupplier, + getUserWalletUseCase = getUserWalletUseCase, + getAvailableFeeTokensUseCase = getAvailableFeeTokensUseCase, + isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork, + ) + } + + private fun contentState(selected: FeeItem, normalValue: String): FeeSelectorUM.Content { + val extraInfo = FeeExtraInfo( + isFeeApproximate = false, + isFeeConvertibleToFiat = true, + isTronToken = false, + feeCryptoCurrencyStatus = coinStatus, + ) + return FeeSelectorUM.Content( + isPrimaryButtonEnabled = true, + fees = singleFee(normalValue), + feeItems = persistentListOf(selected), + selectedFeeItem = selected, + feeExtraInfo = extraInfo, + feeFiatRateUM = null, + feeNonce = FeeNonce.None, + ) + } + + private fun realFee(value: String = "0.001"): Fee = Fee.Common( + Amount(currencySymbol = "ETH", value = BigDecimal(value), decimals = 18), + ) + + private fun singleFee(value: String = "0.001"): TransactionFee = TransactionFee.Single(normal = realFee(value)) + + data class FeeItemSelectedModel(val feeItem: FeeItem, val expectedAnalyticsCalls: Int) + + data class DoneClickModel(val selected: FeeItem, val normalValue: String, val expectedGasPriceCalls: Int) + + // endregion +} \ No newline at end of file diff --git a/features/send/impl/src/test/java/com/tangem/features/send/feeselector/model/transformers/FeeItemConverterTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/feeselector/model/transformers/FeeItemConverterTest.kt new file mode 100644 index 0000000000..425436e61e --- /dev/null +++ b/features/send/impl/src/test/java/com/tangem/features/send/feeselector/model/transformers/FeeItemConverterTest.kt @@ -0,0 +1,189 @@ +package com.tangem.features.send.feeselector.model.transformers + +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem +import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams +import com.tangem.features.send.commonFee +import com.tangem.features.send.loadedStatus +import com.tangem.test.core.ProvideTestModels +import io.mockk.mockk +import kotlinx.collections.immutable.persistentListOf +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import java.math.BigDecimal + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class FeeItemConverterTest { + + // Bitcoin status so the custom-fee field converter yields fields for a Bitcoin normalFee. + private val feeStatus = loadedStatus( + currency = MockCryptoCurrencyFactory().createCoin(Blockchain.Bitcoin), + fiatRate = BigDecimal("50000"), + ) + + private val bitcoinFee: Fee = Fee.Bitcoin( + amount = Amount(currencySymbol = "BTC", value = BigDecimal("0.0001"), decimals = 8), + satoshiPerByte = BigDecimal("10"), + txSize = BigDecimal("250"), + ) + + private fun converter( + config: FeeSelectorParams.FeeStateConfiguration = FeeSelectorParams.FeeStateConfiguration.None, + normalFee: Fee = commonFee(), + shouldDisableCustomFee: Boolean = true, + ) = FeeItemConverter( + feeStateConfiguration = config, + normalFee = normalFee, + feeSelectorIntents = mockk(relaxed = true), + appCurrency = AppCurrency.Default, + cryptoCurrencyStatus = feeStatus, + shouldDisableCustomFee = shouldDisableCustomFee, + ) + + private fun choosable() = + TransactionFee.Choosable(normal = commonFee(), minimum = commonFee(), priority = commonFee()) + + private fun single() = TransactionFee.Single(normal = commonFee()) + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Items { + + @ParameterizedTest + @ProvideTestModels + fun `GIVEN config and transaction fee WHEN convert THEN fee items match configuration`(model: ItemsModel) { + // Act (custom fee disabled -> the list is purely config driven) + val actual = converter(config = model.config) + .convert(FeeItemConverter.Input(transactionFee = model.transactionFee, customFee = null)) + + // Assert + assertThat(actual.map { it::class.java }).containsExactlyElementsIn(model.expectedTypes).inOrder() + } + + private fun provideTestModels() = listOf( + ItemsModel( + none(), + choosable(), + listOf(FeeItem.Slow::class.java, FeeItem.Market::class.java, FeeItem.Fast::class.java) + ), + ItemsModel(none(), single(), listOf(FeeItem.Market::class.java)), + ItemsModel( + suggestion(), + choosable(), + listOf( + FeeItem.Suggested::class.java, + FeeItem.Slow::class.java, + FeeItem.Market::class.java, + FeeItem.Fast::class.java + ), + ), + ItemsModel( + suggestion(), + single(), + listOf(FeeItem.Suggested::class.java, FeeItem.Market::class.java) + ), + ItemsModel( + excludeLow(), + choosable(), + listOf(FeeItem.Market::class.java, FeeItem.Fast::class.java) + ), + ItemsModel( + excludeLow(), + single(), + listOf(FeeItem.Market::class.java) + ), + ) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class FeeAssignment { + + @Test + fun `GIVEN choosable fee WHEN convert THEN slow market fast map to minimum normal priority`() { + // Arrange (distinct fees to detect any mis-mapping) + val minimum = ethFee(value = "1") + val normal = ethFee(value = "2") + val priority = ethFee(value = "3") + val fees = TransactionFee.Choosable(normal = normal, minimum = minimum, priority = priority) + + // Act + val actual = converter(config = none()).convert(FeeItemConverter.Input(fees, customFee = null)) + + // Assert + assertThat((actual[0] as FeeItem.Slow).fee).isEqualTo(minimum) + assertThat((actual[1] as FeeItem.Market).fee).isEqualTo(normal) + assertThat((actual[2] as FeeItem.Fast).fee).isEqualTo(priority) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class CustomFee { + + @Test + fun `GIVEN custom enabled and supported fee WHEN convert THEN custom fee appended`() { + // Act + val actual = converter(normalFee = bitcoinFee, shouldDisableCustomFee = false) + .convert(FeeItemConverter.Input(TransactionFee.Single(bitcoinFee), customFee = null)) + + // Assert + assertThat(actual).hasSize(2) // Market + Custom + assertThat(actual.last()).isInstanceOf(FeeItem.Custom::class.java) + } + + @Test + fun `GIVEN custom disabled WHEN convert THEN no custom fee`() { + // Act + val actual = converter(normalFee = bitcoinFee, shouldDisableCustomFee = true) + .convert(FeeItemConverter.Input(TransactionFee.Single(bitcoinFee), customFee = null)) + + // Assert + assertThat(actual).hasSize(1) // Market only + } + + @Test + fun `GIVEN unsupported fee with no custom fields WHEN convert THEN no custom fee`() { + // Act (Fee.Common has no custom field converter -> constructCustomFee returns null) + val actual = converter(normalFee = commonFee(), shouldDisableCustomFee = false) + .convert(FeeItemConverter.Input(TransactionFee.Single(commonFee()), customFee = null)) + + // Assert + assertThat(actual).hasSize(1) // Market only + } + + @Test + fun `GIVEN custom fee provided WHEN convert THEN provided custom reused`() { + // Arrange + val provided = FeeItem.Custom(fee = bitcoinFee, customValues = persistentListOf()) + + // Act + val actual = converter(normalFee = bitcoinFee, shouldDisableCustomFee = false) + .convert(FeeItemConverter.Input(TransactionFee.Single(bitcoinFee), customFee = provided)) + + // Assert + assertThat(actual.last()).isEqualTo(provided) + } + } + + private fun none() = FeeSelectorParams.FeeStateConfiguration.None + private fun excludeLow() = FeeSelectorParams.FeeStateConfiguration.ExcludeLow + private fun suggestion() = FeeSelectorParams.FeeStateConfiguration.Suggestion(title = mockk(), fee = commonFee()) + + private fun ethFee(value: String) = + Fee.Common(Amount(currencySymbol = "ETH", value = BigDecimal(value), decimals = 18)) + + data class ItemsModel( + val config: FeeSelectorParams.FeeStateConfiguration, + val transactionFee: TransactionFee, + val expectedTypes: List>, + ) +} \ No newline at end of file diff --git a/features/send/impl/src/test/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorCustomFieldConverterTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorCustomFieldConverterTest.kt new file mode 100644 index 0000000000..0df270eb9d --- /dev/null +++ b/features/send/impl/src/test/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorCustomFieldConverterTest.kt @@ -0,0 +1,83 @@ +package com.tangem.features.send.feeselector.model.transformers + +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.features.send.loadedStatus +import com.tangem.test.core.ProvideTestModels +import io.mockk.mockk +import kotlinx.collections.immutable.persistentListOf +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import java.math.BigDecimal +import java.math.BigInteger + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class FeeSelectorCustomFieldConverterTest { + + private val currencyFactory = MockCryptoCurrencyFactory() + + // Bitcoin network so the Bitcoin converter passes its isUseBitcoinFeeConverter() check; other converters + // don't read the network, so a single status drives every dispatch branch. + private val feeStatus = loadedStatus( + currency = currencyFactory.createCoin(Blockchain.Bitcoin), + fiatRate = BigDecimal("50000"), + ) + + private val commonFee: Fee = Fee.Common(Amount(Blockchain.Ethereum)) + private val bitcoinFee: Fee = Fee.Bitcoin( + amount = Amount(currencySymbol = "BTC", value = BigDecimal("0.0001"), decimals = 8), + satoshiPerByte = BigDecimal("10"), + txSize = BigDecimal("250"), + ) + private val ethereumFee: Fee = Fee.Ethereum.EIP1559( + amount = Amount(Blockchain.Ethereum), + gasLimit = BigInteger.valueOf(21_000), + maxFeePerGas = BigInteger.valueOf(30_000_000_000), + priorityFee = BigInteger.valueOf(2_000_000_000), + ) + private val kaspaFee: Fee = Fee.Kaspa( + amount = Amount(currencySymbol = "KAS", value = BigDecimal("0.0001"), decimals = 8), + mass = BigInteger.valueOf(2000), + feeRate = BigInteger.valueOf(5), + ) + + private fun converter(normalFee: Fee = commonFee) = FeeSelectorCustomFieldConverter( + feeSelectorIntents = mockk(relaxed = true), + appCurrency = AppCurrency.Default, + feeCryptoCurrencyStatus = feeStatus, + normalFee = normalFee, + ) + + @ParameterizedTest + @ProvideTestModels + fun `GIVEN fee type WHEN convert THEN routed to matching custom fee converter`(model: DispatchModel) { + // Act + val actual = converter().convert(model.fee) + + // Assert (each converter emits a distinct number of fields - a fingerprint of correct routing) + assertThat(actual).hasSize(model.expectedFieldCount) + } + + private fun provideTestModels() = listOf( + DispatchModel(fee = bitcoinFee, expectedFieldCount = 2), // amount + satoshi/byte + DispatchModel(fee = ethereumFee, expectedFieldCount = 4), // amount + maxFee + priority + gasLimit + DispatchModel(fee = kaspaFee, expectedFieldCount = 1), // amount + DispatchModel(fee = commonFee, expectedFieldCount = 0), // unsupported -> empty + ) + + @Test + fun `GIVEN empty custom values WHEN convertBack THEN returns normal fee unchanged`() { + // Act + val actual = converter(normalFee = commonFee).convertBack(persistentListOf()) + + // Assert + assertThat(actual).isSameInstanceAs(commonFee) + } + + data class DispatchModel(val fee: Fee, val expectedFieldCount: Int) +} \ No newline at end of file diff --git a/features/send/impl/src/test/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorCustomValueChangedTransformerTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorCustomValueChangedTransformerTest.kt new file mode 100644 index 0000000000..2921bfdebb --- /dev/null +++ b/features/send/impl/src/test/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorCustomValueChangedTransformerTest.kt @@ -0,0 +1,111 @@ +package com.tangem.features.send.feeselector.model.transformers + +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeNonce +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM +import com.tangem.features.send.loadedStatus +import com.tangem.features.send.subcomponents.fee.model.converters.custom.kaspa.KaspaCustomFeeConverter +import com.tangem.test.core.ProvideTestModels +import io.mockk.mockk +import kotlinx.collections.immutable.toImmutableList +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import java.math.BigDecimal +import java.math.BigInteger + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class FeeSelectorCustomValueChangedTransformerTest { + + private val currencyFactory = MockCryptoCurrencyFactory() + + private val feeStatus = loadedStatus( + currency = currencyFactory.createCoin(Blockchain.Kaspa), + fiatRate = BigDecimal("0.1"), + ) + + private val kaspaFee = Fee.Kaspa( + amount = Amount(currencySymbol = "KAS", value = BigDecimal("0.0001"), decimals = 8), + mass = BigInteger.valueOf(2000), + feeRate = BigInteger.valueOf(5), + ) + + private val customItem = FeeItem.Custom( + fee = kaspaFee, + customValues = KaspaCustomFeeConverter( + onCustomFeeValueChange = { _, _ -> }, + appCurrency = AppCurrency.Default, + feeCryptoCurrencyStatus = feeStatus, + ).convert(kaspaFee), + ) + + private fun transformer(index: Int, value: String) = FeeSelectorCustomValueChangedTransformer( + index = index, + value = value, + intents = mockk(relaxed = true), + appCurrency = AppCurrency.Default, + feeCryptoCurrencyStatus = feeStatus, + ) + + private fun content(feeItems: List, selected: FeeItem) = FeeSelectorUM.Content( + isPrimaryButtonEnabled = true, + fees = TransactionFee.Single(normal = kaspaFee), + feeItems = feeItems.toImmutableList(), + selectedFeeItem = selected, + feeExtraInfo = mockk(), + feeFiatRateUM = null, + feeNonce = FeeNonce.None, + ) + + @Test + fun `GIVEN custom fee and non-zero value WHEN transform THEN custom updated selected and button enabled`() { + // Arrange + val state = content(feeItems = listOf(customItem), selected = customItem) + + // Act (index 0 = amount field of the Kaspa custom fee) + val result = transformer(index = 0, value = "0.0002").transform(state) as FeeSelectorUM.Content + + // Assert + assertThat(result.isPrimaryButtonEnabled).isTrue() + assertThat(result.selectedFeeItem).isInstanceOf(FeeItem.Custom::class.java) + val updatedCustom = result.feeItems.filterIsInstance().first() + assertThat(updatedCustom.customValues.first().value).isEqualTo("0.0002") + assertThat(result.selectedFeeItem).isEqualTo(updatedCustom) + } + + @Test + fun `GIVEN custom fee edited to zero WHEN transform THEN button disabled`() { + // Arrange + val state = content(feeItems = listOf(customItem), selected = customItem) + + // Act + val result = transformer(index = 0, value = "0").transform(state) as FeeSelectorUM.Content + + // Assert + assertThat(result.isPrimaryButtonEnabled).isFalse() + } + + @ParameterizedTest + @ProvideTestModels + fun `GIVEN non-applicable state WHEN transform THEN returned unchanged`(model: UnchangedModel) { + // Act + val result = transformer(index = 0, value = "0.0002").transform(model.state) + + // Assert + assertThat(result).isSameInstanceAs(model.state) + } + + private fun provideTestModels() = listOf( + UnchangedModel(state = FeeSelectorUM.Loading), // not a content state + UnchangedModel(state = content(feeItems = listOf(FeeItem.Market(kaspaFee)), selected = FeeItem.Market(kaspaFee))), + ) + + data class UnchangedModel(val state: FeeSelectorUM) +} \ No newline at end of file diff --git a/features/send/impl/src/test/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorErrorTransformerTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorErrorTransformerTest.kt new file mode 100644 index 0000000000..df43371e79 --- /dev/null +++ b/features/send/impl/src/test/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorErrorTransformerTest.kt @@ -0,0 +1,67 @@ +package com.tangem.features.send.feeselector.model.transformers + +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeExtraInfo +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeNonce +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM +import com.tangem.features.send.commonFee +import com.tangem.test.core.ProvideTestModels +import io.mockk.mockk +import kotlinx.collections.immutable.persistentListOf +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class FeeSelectorErrorTransformerTest { + + private val fee = commonFee() + + private fun content() = FeeSelectorUM.Content( + isPrimaryButtonEnabled = true, + fees = TransactionFee.Single(normal = fee), + feeItems = persistentListOf(FeeItem.Market(fee)), + selectedFeeItem = FeeItem.Market(fee), + feeExtraInfo = FeeExtraInfo( + isFeeApproximate = false, + isFeeConvertibleToFiat = false, + isTronToken = false, + feeCryptoCurrencyStatus = mockk(), + ), + feeFiatRateUM = null, + feeNonce = FeeNonce.None, + ) + + @Test + fun `GIVEN content state and not-enough-funds error WHEN transform THEN stays content with flag and disabled button`() { + // Act + val result = FeeSelectorErrorTransformer(GetFeeError.GaslessError.NotEnoughFunds) + .transform(content()) as FeeSelectorUM.Content + + // Assert + assertThat(result.isPrimaryButtonEnabled).isFalse() + assertThat(result.feeExtraInfo.isNotEnoughFunds).isTrue() + } + + @ParameterizedTest + @ProvideTestModels + fun `GIVEN other state or error WHEN transform THEN transitions to error`(model: ErrorModel) { + // Act + val result = FeeSelectorErrorTransformer(model.error).transform(model.state) + + // Assert + assertThat(result).isEqualTo(FeeSelectorUM.Error(error = model.error)) + } + + private fun provideTestModels() = listOf( + // content but a different error -> the special branch needs NotEnoughFunds specifically + ErrorModel(state = content(), error = GetFeeError.UnknownError), + // not-enough-funds but not a content state -> the special branch needs a Content state + ErrorModel(state = FeeSelectorUM.Loading, error = GetFeeError.GaslessError.NotEnoughFunds), + ) + + data class ErrorModel(val state: FeeSelectorUM, val error: GetFeeError) +} \ No newline at end of file diff --git a/features/send/impl/src/test/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorLoadedTransformerTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorLoadedTransformerTest.kt new file mode 100644 index 0000000000..6cc90dc348 --- /dev/null +++ b/features/send/impl/src/test/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorLoadedTransformerTest.kt @@ -0,0 +1,186 @@ +package com.tangem.features.send.feeselector.model.transformers + +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeNonce +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams +import com.tangem.features.send.feeselector.model.FeeSelectorLogic +import com.tangem.features.send.loadedStatus +import com.tangem.test.core.ProvideTestModels +import io.mockk.mockk +import kotlinx.collections.immutable.persistentListOf +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import java.math.BigDecimal +import java.math.BigInteger + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class FeeSelectorLoadedTransformerTest { + + private val currencyFactory = MockCryptoCurrencyFactory() + private val coin: CryptoCurrency = currencyFactory.ethereum + + private val commonFee: Fee = Fee.Common(Amount(Blockchain.Ethereum)) + private val ethereumFee: Fee = Fee.Ethereum.Legacy( + amount = Amount(Blockchain.Ethereum), + gasLimit = BigInteger.valueOf(21_000), + gasPrice = BigInteger.valueOf(1_000_000_000), + ) + + private fun status(currency: CryptoCurrency = coin): CryptoCurrencyStatus = + loadedStatus(currency = currency, fiatRate = BigDecimal("2000")) + + private fun basic(normal: Fee): FeeSelectorLogic.LoadedFeeResult = + FeeSelectorLogic.LoadedFeeResult.Basic(TransactionFee.Choosable(normal = normal, minimum = normal, priority = normal)) + + private fun transformer( + fees: FeeSelectorLogic.LoadedFeeResult, + feeStateConfiguration: FeeSelectorParams.FeeStateConfiguration = FeeSelectorParams.FeeStateConfiguration.None, + ) = FeeSelectorLoadedTransformer( + cryptoCurrencyStatus = status(), + feeCryptoCurrencyStatus = status(), + appCurrency = AppCurrency.Default, + fees = fees, + feeStateConfiguration = feeStateConfiguration, + isFeeApproximate = false, + feeSelectorIntents = mockk(relaxed = true), + shouldDisableCustomFee = true, + ) + + private fun prevContent(selected: FeeItem, feeNonce: FeeNonce = FeeNonce.None) = FeeSelectorUM.Content( + isPrimaryButtonEnabled = true, + fees = TransactionFee.Single(normal = commonFee), + feeItems = persistentListOf(selected), + selectedFeeItem = selected, + feeExtraInfo = mockk(), + feeFiatRateUM = null, + feeNonce = feeNonce, + ) + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class SelectedFee { + + @ParameterizedTest + @ProvideTestModels + fun `GIVEN previous state WHEN transform THEN selected fee item resolved`(model: SelectedModel) { + // Act + val result = transformer(basic(commonFee)).transform(model.prevState) as FeeSelectorUM.Content + + // Assert + assertThat(result.selectedFeeItem).isInstanceOf(model.expected) + } + + private fun provideTestModels() = listOf( + // no prior selection -> defaults to market (no suggested in this config) + SelectedModel(FeeSelectorUM.Loading, FeeItem.Market::class.java), + // prior loading selection -> market + SelectedModel(prevContent(FeeItem.Loading), FeeItem.Market::class.java), + // prior concrete selection -> same class preserved + SelectedModel(prevContent(FeeItem.Fast(commonFee)), FeeItem.Fast::class.java), + // prior class no longer present -> falls back to loading + SelectedModel(prevContent(FeeItem.Suggested(title = mockk(), fee = commonFee)), FeeItem.Loading::class.java), + ) + + @Test + fun `GIVEN selection falls back to loading WHEN transform THEN primary button disabled`() { + // Act + val result = transformer(basic(commonFee)) + .transform(prevContent(FeeItem.Suggested(title = mockk(), fee = commonFee))) as FeeSelectorUM.Content + + // Assert + assertThat(result.selectedFeeItem).isEqualTo(FeeItem.Loading) + assertThat(result.isPrimaryButtonEnabled).isFalse() + } + + @Test + fun `GIVEN resolved fee item WHEN transform THEN primary button enabled`() { + // Act + val result = transformer(basic(commonFee)).transform(FeeSelectorUM.Loading) as FeeSelectorUM.Content + + // Assert + assertThat(result.isPrimaryButtonEnabled).isTrue() + } + + @Test + fun `GIVEN no prior selection and suggested available WHEN transform THEN suggested preselected`() { + // Arrange (Suggestion config makes the converter emit a Suggested item) + val config = FeeSelectorParams.FeeStateConfiguration.Suggestion(title = mockk(), fee = commonFee) + + // Act + val result = transformer(basic(commonFee), feeStateConfiguration = config) + .transform(FeeSelectorUM.Loading) as FeeSelectorUM.Content + + // Assert + assertThat(result.selectedFeeItem).isInstanceOf(FeeItem.Suggested::class.java) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Nonce { + + @ParameterizedTest + @ProvideTestModels + fun `GIVEN normal fee type WHEN transform THEN nonce field present only for ethereum`(model: NonceTypeModel) { + // Act + val result = transformer(basic(model.normal)).transform(FeeSelectorUM.Loading) as FeeSelectorUM.Content + + // Assert + assertThat(result.feeNonce).isInstanceOf(model.expected) + } + + private fun provideTestModels() = listOf( + NonceTypeModel(ethereumFee, FeeNonce.Nonce::class.java), + NonceTypeModel(commonFee, FeeNonce.None::class.java), + ) + + @Test + fun `GIVEN ethereum fee and previous nonce WHEN transform THEN previous nonce preserved`() { + // Arrange + val prev = prevContent( + selected = FeeItem.Market(commonFee), + feeNonce = FeeNonce.Nonce(nonce = BigInteger.valueOf(7), onNonceChange = {}), + ) + + // Act + val result = transformer(basic(ethereumFee)).transform(prev) as FeeSelectorUM.Content + + // Assert + assertThat((result.feeNonce as FeeNonce.Nonce).nonce).isEqualTo(BigInteger.valueOf(7)) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class ExtraInfo { + + @Test + fun `GIVEN basic fee result WHEN transform THEN extra info reflects basic non-tron status`() { + // Act + val result = transformer(basic(commonFee)).transform(FeeSelectorUM.Loading) as FeeSelectorUM.Content + + // Assert + val info = result.feeExtraInfo + assertThat(info.availableFeeCurrencies).isNull() // Extended-only + assertThat(info.transactionFeeExtended).isNull() // Extended-only + assertThat(info.isTronToken).isFalse() + assertThat(info.isFeeConvertibleToFiat).isEqualTo(coin.network.hasFiatFeeRate) + assertThat(result.feeFiatRateUM).isNotNull() + } + } + + data class SelectedModel(val prevState: FeeSelectorUM, val expected: Class) + data class NonceTypeModel(val normal: Fee, val expected: Class) +} \ No newline at end of file diff --git a/features/send/impl/src/test/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorNonceChangeTransformerTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorNonceChangeTransformerTest.kt new file mode 100644 index 0000000000..829fbfde7c --- /dev/null +++ b/features/send/impl/src/test/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorNonceChangeTransformerTest.kt @@ -0,0 +1,81 @@ +package com.tangem.features.send.feeselector.model.transformers + +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeExtraInfo +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeNonce +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM +import com.tangem.features.send.commonFee +import com.tangem.test.core.ProvideTestModels +import io.mockk.mockk +import kotlinx.collections.immutable.persistentListOf +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import java.math.BigInteger + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class FeeSelectorNonceChangeTransformerTest { + + private val fee = commonFee() + + private fun content(feeNonce: FeeNonce) = FeeSelectorUM.Content( + isPrimaryButtonEnabled = true, + fees = TransactionFee.Single(normal = fee), + feeItems = persistentListOf(FeeItem.Market(fee)), + selectedFeeItem = FeeItem.Market(fee), + feeExtraInfo = mockk(), + feeFiatRateUM = null, + feeNonce = feeNonce, + ) + + private fun nonceState(nonce: BigInteger?) = content(FeeNonce.Nonce(nonce = nonce, onNonceChange = {})) + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Update { + + @ParameterizedTest + @ProvideTestModels + fun `GIVEN nonce field WHEN transform THEN nonce updated`(model: UpdateModel) { + // Arrange + val state = nonceState(nonce = BigInteger.ONE) + + // Act + val result = FeeSelectorNonceChangeTransformer(model.value).transform(state) as FeeSelectorUM.Content + + // Assert + assertThat((result.feeNonce as FeeNonce.Nonce).nonce).isEqualTo(model.expectedNonce) + } + + private fun provideTestModels() = listOf( + UpdateModel(value = "42", expectedNonce = BigInteger.valueOf(42)), // valid number + UpdateModel(value = "", expectedNonce = null), // empty -> cleared + ) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Unchanged { + + @ParameterizedTest + @ProvideTestModels + fun `GIVEN non-applicable input WHEN transform THEN state returned unchanged`(model: UnchangedModel) { + // Act + val result = FeeSelectorNonceChangeTransformer(model.value).transform(model.state) + + // Assert + assertThat(result).isSameInstanceAs(model.state) + } + + private fun provideTestModels() = listOf( + UnchangedModel(value = "abc", state = nonceState(nonce = BigInteger.ONE)), // non-numeric + UnchangedModel(value = "42", state = content(FeeNonce.None)), // no editable nonce + UnchangedModel(value = "42", state = FeeSelectorUM.Loading), // not a content state + ) + } + + data class UpdateModel(val value: String, val expectedNonce: BigInteger?) + data class UnchangedModel(val value: String, val state: FeeSelectorUM) +} \ No newline at end of file diff --git a/features/send/impl/src/test/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorRemoveSuggestedTransformerTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorRemoveSuggestedTransformerTest.kt new file mode 100644 index 0000000000..7ad5cd7a2e --- /dev/null +++ b/features/send/impl/src/test/java/com/tangem/features/send/feeselector/model/transformers/FeeSelectorRemoveSuggestedTransformerTest.kt @@ -0,0 +1,65 @@ +package com.tangem.features.send.feeselector.model.transformers + +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.core.ui.extensions.TextReference +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeExtraInfo +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeNonce +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM +import com.tangem.features.send.commonFee +import com.tangem.test.core.ProvideTestModels +import io.mockk.mockk +import kotlinx.collections.immutable.toImmutableList +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class FeeSelectorRemoveSuggestedTransformerTest { + + private val fee = commonFee() + private val market = FeeItem.Market(fee) + private val fast = FeeItem.Fast(fee) + private val suggested = FeeItem.Suggested(title = TextReference.EMPTY, fee = fee) + + @ParameterizedTest + @ProvideTestModels + fun `GIVEN suggested present WHEN transform THEN suggested removed and selection resolved`(model: SelectionModel) { + // Arrange + val state = content(feeItems = listOf(suggested, market, fast), selected = model.selected) + + // Act + val result = FeeSelectorRemoveSuggestedTransformer.transform(state) as FeeSelectorUM.Content + + // Assert + assertThat(result.feeItems).containsExactly(market, fast).inOrder() + assertThat(result.selectedFeeItem).isEqualTo(model.expectedSelected) + } + + private fun provideTestModels() = listOf( + SelectionModel(selected = suggested, expectedSelected = market), + SelectionModel(selected = fast, expectedSelected = fast), + ) + + @Test + fun `GIVEN non-content state WHEN transform THEN returned unchanged`() { + // Act + val result = FeeSelectorRemoveSuggestedTransformer.transform(FeeSelectorUM.Loading) + + // Assert + assertThat(result).isEqualTo(FeeSelectorUM.Loading) + } + + private fun content(feeItems: List, selected: FeeItem) = FeeSelectorUM.Content( + isPrimaryButtonEnabled = true, + fees = TransactionFee.Single(normal = fee), + feeItems = feeItems.toImmutableList(), + selectedFeeItem = selected, + feeExtraInfo = mockk(), + feeFiatRateUM = null, + feeNonce = FeeNonce.None, + ) + + data class SelectionModel(val selected: FeeItem, val expectedSelected: FeeItem) +} \ No newline at end of file diff --git a/features/send/impl/src/test/java/com/tangem/features/send/send/SendModelTestBase.kt b/features/send/impl/src/test/java/com/tangem/features/send/send/SendModelTestBase.kt new file mode 100644 index 0000000000..db5b2e13d6 --- /dev/null +++ b/features/send/impl/src/test/java/com/tangem/features/send/send/SendModelTestBase.kt @@ -0,0 +1,282 @@ +package com.tangem.features.send.send + +import arrow.core.Either +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.common.routing.AppRouter +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.model.MutableParamsContainer +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.navigation.Router +import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase +import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase +import com.tangem.domain.feedback.SaveBlockchainErrorUseCase +import com.tangem.domain.feedback.SendFeedbackEmailUseCase +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase +import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase +import com.tangem.domain.transaction.usecase.CreateTransferTransactionUseCase +import com.tangem.domain.transaction.usecase.GetFeeUseCase +import com.tangem.domain.transaction.usecase.SendTransactionUseCase +import com.tangem.domain.transaction.usecase.gasless.GetFeeForGaslessUseCase +import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.features.send.api.SendComponent +import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents +import com.tangem.features.send.api.entity.PredefinedValues +import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorCheckReloadListener +import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorCheckReloadTrigger +import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger +import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateListener +import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateTrigger +import com.tangem.features.send.common.SendBalanceUpdater +import com.tangem.features.send.common.SendConfirmAlertFactory +import com.tangem.features.send.send.analytics.SendAnalyticHelper +import com.tangem.features.send.send.confirm.SendConfirmComponent +import com.tangem.features.send.send.confirm.model.SendConfirmModel +import com.tangem.features.send.send.ui.state.SendUM +import com.tangem.features.send.api.subcomponents.amount.SendAmountReduceTrigger +import com.tangem.features.send.api.subcomponents.amount.SendAmountUpdateTrigger +import com.tangem.features.send.testDispatcherProvider +import com.tangem.core.navigation.share.ShareManager +import com.tangem.core.navigation.url.UrlOpener +import com.tangem.domain.settings.IsSendTapHelpEnabledUseCase +import com.tangem.domain.settings.NeverShowTapHelpUseCase +import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase +import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase +import com.tangem.domain.tokens.repository.CurrenciesRepository +import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase +import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase +import com.tangem.domain.qrscanning.models.SourceType +import arrow.core.right +import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.common.ui.navigationButtons.NavigationUM +import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM +import com.tangem.features.send.common.ui.state.ConfirmUM +import com.tangem.features.send.send.model.SendModel +import io.mockk.MockKAnnotations +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import org.junit.jupiter.api.BeforeEach + +@OptIn(ExperimentalCoroutinesApi::class) +internal abstract class SendModelTestBase { + + protected val testUserWalletId = UserWalletId("1234567890ABCDEF") + protected val testCryptoCurrency: CryptoCurrency = mockk(relaxed = true) + protected val testUserWallet: UserWallet = mockk(relaxed = true) + protected val testCryptoCurrencyStatus: CryptoCurrencyStatus = mockk(relaxed = true) { + io.mockk.every { currency } returns testCryptoCurrency + } + + protected val router: Router = mockk(relaxed = true) + protected val appRouter: AppRouter = mockk(relaxed = true) + protected val getUserWalletUseCase: GetUserWalletUseCase = mockk(relaxed = true) + protected val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase = mockk(relaxed = true) + protected val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk(relaxed = true) + protected val listenToQrScanningUseCase: ListenToQrScanningUseCase = mockk(relaxed = true) + protected val parseQrCodeUseCase: ParseQrCodeUseCase = mockk(relaxed = true) + protected val sendConfirmAlertFactory: SendConfirmAlertFactory = mockk(relaxed = true) + protected val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase = mockk(relaxed = true) + protected val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase = mockk(relaxed = true) + protected val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase = mockk(relaxed = true) + protected val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase = mockk(relaxed = true) + protected val createTransferTransactionUseCase: CreateTransferTransactionUseCase = mockk(relaxed = true) + protected val getFeeUseCase: GetFeeUseCase = mockk(relaxed = true) + protected val getFeeForGaslessUseCase: GetFeeForGaslessUseCase = mockk(relaxed = true) + protected val getFeeForTokenUseCase: GetFeeForTokenUseCase = mockk(relaxed = true) + protected val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase = mockk(relaxed = true) + protected val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase = mockk(relaxed = true) + protected val sendAmountUpdateTrigger: SendAmountUpdateTrigger = mockk(relaxed = true) + protected val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true) + protected val sendTransactionUseCase: SendTransactionUseCase = mockk(relaxed = true) + + // SendConfirmModel-specific dependencies + protected val isSendTapHelpEnabledUseCase: IsSendTapHelpEnabledUseCase = mockk(relaxed = true) + protected val neverShowTapHelpUseCase: NeverShowTapHelpUseCase = mockk(relaxed = true) + protected val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase = mockk(relaxed = true) + protected val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase = mockk(relaxed = true) + protected val feeSelectorCheckReloadListener: FeeSelectorCheckReloadListener = mockk(relaxed = true) + protected val feeSelectorCheckReloadTrigger: FeeSelectorCheckReloadTrigger = mockk(relaxed = true) + protected val notificationsUpdateTrigger: SendNotificationsUpdateTrigger = mockk(relaxed = true) + protected val notificationsUpdateListener: SendNotificationsUpdateListener = mockk(relaxed = true) + protected val urlOpener: UrlOpener = mockk(relaxed = true) + protected val shareManager: ShareManager = mockk(relaxed = true) + protected val feeSelectorReloadTrigger: FeeSelectorReloadTrigger = mockk(relaxed = true) + protected val sendAmountReduceTrigger: SendAmountReduceTrigger = mockk(relaxed = true) + protected val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase = mockk(relaxed = true) + protected val currenciesRepository: CurrenciesRepository = mockk(relaxed = true) + protected val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase = mockk(relaxed = true) + protected val sendAnalyticHelper: SendAnalyticHelper = mockk(relaxed = true) + protected val sendBalanceUpdaterFactory: SendBalanceUpdater.Factory = mockk(relaxed = true) + + @BeforeEach + fun setUp() { + MockKAnnotations.init(this) + + // Reset recorded calls on use-cases asserted via coVerify(exactly=N). PER_CLASS parameterized + // tests (e.g. SendConfirmModelTest) reuse one instance, so calls would otherwise accumulate + // across rows. answers=false keeps the happy-path stubs re-applied below. + clearMocks( + createTransferTransactionUseCase, + sendTransactionUseCase, + createAndSendGaslessTransactionUseCase, + feeSelectorCheckReloadTrigger, + answers = false, + recordedCalls = true, + childMocks = false, + ) + + // --- SendModel init-path happy stubs --- + every { getUserWalletUseCase(testUserWalletId) } returns testUserWallet.right() + coEvery { getSelectedAppCurrencyUseCase.invokeSync() } returns AppCurrency.Default.right() + every { getSelectedAppCurrencyUseCase() } returns flowOf(AppCurrency.Default.right()) + every { listenToQrScanningUseCase(SourceType.SEND) } returns emptyFlow().right() + every { getBalanceHidingSettingsUseCase() } returns emptyFlow() + every { getAccountCurrencyStatusUseCase(testUserWalletId, testCryptoCurrency) } returns emptyFlow() + coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns false + coEvery { getFeePaidCryptoCurrencyStatusSyncUseCase(any(), any()) } returns testCryptoCurrencyStatus.right() + // no-fee overload (disambiguated by memo: String at position 2); 6 matchers cover defaulted nonce + coEvery { + createTransferTransactionUseCase(any(), any(), any(), any(), any(), any()) + } returns mockk(relaxed = true).right() + // with-fee overload (disambiguated by Fee at position 2); 7 matchers cover defaulted nonce + coEvery { + createTransferTransactionUseCase(any(), any(), any(), any(), any(), any(), any()) + } returns mockk(relaxed = true).right() + coEvery { sendTransactionUseCase(any(), any(), any()) } returns "txHash".right() + coEvery { createAndSendGaslessTransactionUseCase(any(), any(), any()) } returns "txHash".right() + every { getExplorerTransactionUrlUseCase(any(), any()) } returns "https://explorer/tx".right() + + // --- SendConfirmModel init-path happy stubs --- + coEvery { isSendTapHelpEnabledUseCase.invokeSync() } returns false.right() + every { isSendTapHelpEnabledUseCase() } returns emptyFlow().right() + coEvery { isAmountSubtractAvailableUseCase(any(), any(), any()) } returns false.right() + every { feeSelectorCheckReloadListener.checkReloadResultFlow } returns emptyFlow() + every { notificationsUpdateListener.hasErrorFlow } returns emptyFlow() + } + + protected fun createSendModel( + testScope: TestScope, + paramsContainer: ParamsContainer = MutableParamsContainer(defaultSendParams()), + ): SendModel { + return SendModel( + paramsContainer = paramsContainer, + dispatchers = testScope.testDispatcherProvider(), + router = router, + getUserWalletUseCase = getUserWalletUseCase, + getFeePaidCryptoCurrencyStatusSyncUseCase = getFeePaidCryptoCurrencyStatusSyncUseCase, + getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase, + listenToQrScanningUseCase = listenToQrScanningUseCase, + parseQrCodeUseCase = parseQrCodeUseCase, + sendConfirmAlertFactory = sendConfirmAlertFactory, + saveBlockchainErrorUseCase = saveBlockchainErrorUseCase, + getWalletMetaInfoUseCase = getWalletMetaInfoUseCase, + sendFeedbackEmailUseCase = sendFeedbackEmailUseCase, + getBalanceHidingSettingsUseCase = getBalanceHidingSettingsUseCase, + createTransferTransactionUseCase = createTransferTransactionUseCase, + getFeeUseCase = getFeeUseCase, + getFeeForGaslessUseCase = getFeeForGaslessUseCase, + getFeeForTokenUseCase = getFeeForTokenUseCase, + getAccountCurrencyStatusUseCase = getAccountCurrencyStatusUseCase, + isAccountsModeEnabledUseCase = isAccountsModeEnabledUseCase, + sendAmountUpdateTrigger = sendAmountUpdateTrigger, + analyticsEventHandler = analyticsEventHandler, + ) + } + + protected fun createSendConfirmModel( + testScope: TestScope, + paramsContainer: ParamsContainer = MutableParamsContainer(defaultSendConfirmParams()), + ): SendConfirmModel { + return SendConfirmModel( + paramsContainer = paramsContainer, + dispatchers = testScope.testDispatcherProvider(), + analyticsEventHandler = analyticsEventHandler, + appRouter = appRouter, + router = router, + isSendTapHelpEnabledUseCase = isSendTapHelpEnabledUseCase, + neverShowTapHelpUseCase = neverShowTapHelpUseCase, + createTransferTransactionUseCase = createTransferTransactionUseCase, + sendTransactionUseCase = sendTransactionUseCase, + saveBlockchainErrorUseCase = saveBlockchainErrorUseCase, + getWalletMetaInfoUseCase = getWalletMetaInfoUseCase, + sendFeedbackEmailUseCase = sendFeedbackEmailUseCase, + getExplorerTransactionUrlUseCase = getExplorerTransactionUrlUseCase, + isAmountSubtractAvailableUseCase = isAmountSubtractAvailableUseCase, + feeSelectorCheckReloadListener = feeSelectorCheckReloadListener, + feeSelectorCheckReloadTrigger = feeSelectorCheckReloadTrigger, + notificationsUpdateTrigger = notificationsUpdateTrigger, + notificationsUpdateListener = notificationsUpdateListener, + alertFactory = sendConfirmAlertFactory, + sendAnalyticHelper = sendAnalyticHelper, + urlOpener = urlOpener, + shareManager = shareManager, + feeSelectorReloadTrigger = feeSelectorReloadTrigger, + sendAmountReduceTrigger = sendAmountReduceTrigger, + getBalanceHidingSettingsUseCase = getBalanceHidingSettingsUseCase, + manageCryptoCurrenciesUseCase = manageCryptoCurrenciesUseCase, + currenciesRepository = currenciesRepository, + createAndSendGaslessTransactionUseCase = createAndSendGaslessTransactionUseCase, + sendBalanceUpdaterFactory = sendBalanceUpdaterFactory, + ) + } + + protected open fun defaultSendParams(): SendComponent.Params = SendComponent.Params( + userWalletId = testUserWalletId, + currency = testCryptoCurrency, + amount = null, + destinationAddress = null, + tag = null, + transactionId = null, + entryType = SendComponent.EntryType.Manual, + callback = mockk(relaxed = true), + ) + + protected fun defaultSendConfirmParams( + state: SendUM = SendUM( + amountUM = AmountState.Empty, + destinationUM = DestinationUM.Empty(), + feeSelectorUM = FeeSelectorUM.Loading, + confirmUM = ConfirmUM.Empty, + navigationUM = NavigationUM.Empty, + confirmData = null, + ), + cryptoCurrencyStatus: CryptoCurrencyStatus = testCryptoCurrencyStatus, + feeCryptoCurrencyStatus: CryptoCurrencyStatus = testCryptoCurrencyStatus, + ): SendConfirmComponent.Params = SendConfirmComponent.Params( + state = state, + analyticsCategoryName = "test_send", + analyticsSendSource = CommonSendAnalyticEvents.CommonSendSource.Send, + userWallet = testUserWallet, + cryptoCurrencyStatus = cryptoCurrencyStatus, + feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, + cryptoCurrencyStatusFlow = kotlinx.coroutines.flow.MutableStateFlow(cryptoCurrencyStatus), + feeCryptoCurrencyStatusFlow = kotlinx.coroutines.flow.MutableStateFlow(feeCryptoCurrencyStatus), + accountFlow = kotlinx.coroutines.flow.MutableStateFlow(null), + isAccountModeFlow = kotlinx.coroutines.flow.MutableStateFlow(false), + appCurrency = AppCurrency.Default, + callback = mockk(relaxed = true), + currentRoute = kotlinx.coroutines.flow.flowOf(), + isBalanceHidingFlow = kotlinx.coroutines.flow.MutableStateFlow(false), + predefinedValues = PredefinedValues.Empty, + onLoadFee = { Either.Right(mockk(relaxed = true)) }, + onLoadFeeExtended = { Either.Right(mockk(relaxed = true)) }, + onSendTransaction = {}, + ) +} \ No newline at end of file diff --git a/features/send/impl/src/test/java/com/tangem/features/send/send/confirm/model/SendConfirmModelTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/send/confirm/model/SendConfirmModelTest.kt new file mode 100644 index 0000000000..2ee87f066a --- /dev/null +++ b/features/send/impl/src/test/java/com/tangem/features/send/send/confirm/model/SendConfirmModelTest.kt @@ -0,0 +1,288 @@ +package com.tangem.features.send.send.confirm.model + +import android.os.SystemClock +import arrow.core.left +import arrow.core.right +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.common.ui.navigationButtons.NavigationUM +import com.tangem.core.decompose.model.MutableParamsContainer +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeExtraInfo +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeNonce +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM +import com.tangem.features.send.common.ui.state.ConfirmUM +import com.tangem.features.send.send.ui.state.SendUM +import com.tangem.domain.transaction.models.TransactionFeeExtended +import com.tangem.features.send.send.SendModelTestBase +import com.tangem.test.core.ProvideTestModels +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import java.math.BigDecimal + +@OptIn(ExperimentalCoroutinesApi::class) +internal class SendConfirmModelTest : SendModelTestBase() { + + @BeforeEach + fun mockSystemClock() { + // SystemClock.elapsedRealtime() is read in init/subscription paths; default to a fresh timer. + mockkStatic(SystemClock::class) + every { SystemClock.elapsedRealtime() } returns 0L + } + + @AfterEach + fun tearDown() { + unmockkStatic(SystemClock::class) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class OnSendClick { + + @ParameterizedTest + @ProvideTestModels + fun `WHEN onSendClick THEN send fresh fee else trigger check reload`(model: OnSendClickModel) = runTest { + // Arrange + every { SystemClock.elapsedRealtime() } returns model.elapsedRealtime + val sut = createSendConfirmModel(this, confirmParams(normalFeeState())) + advanceUntilIdle() + + // Act + sut.onSendClick() + advanceUntilIdle() + + // Assert + if (model.expectedSendInitiated) { + coVerify(exactly = 1) { createTransferTransactionUseCase(any(), any(), any(), any(), any(), any(), any()) } + coVerify(exactly = 0) { feeSelectorCheckReloadTrigger.triggerCheckUpdate() } + } else { + coVerify(exactly = 0) { createTransferTransactionUseCase(any(), any(), any(), any(), any(), any(), any()) } + coVerify(exactly = 1) { feeSelectorCheckReloadTrigger.triggerCheckUpdate() } + } + } + + private fun provideTestModels() = listOf( + // diff = elapsedRealtime - sendIdleTimer(0); < 10s = fresh -> verify & send + OnSendClickModel(elapsedRealtime = 0L, expectedSendInitiated = true), + OnSendClickModel(elapsedRealtime = 20_000L, expectedSendInitiated = false), + ) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class CheckFeeResult { + + @ParameterizedTest + @ProvideTestModels + fun `WHEN check reload result emitted THEN send transaction only on success`(model: CheckFeeResultModel) = + runTest { + // Arrange + val resultFlow = MutableSharedFlow(extraBufferCapacity = 1) + every { feeSelectorCheckReloadListener.checkReloadResultFlow } returns resultFlow + createSendConfirmModel(this, confirmParams(normalFeeState())) + advanceUntilIdle() + + // Act + resultFlow.tryEmit(model.checkResult) + advanceUntilIdle() + + // Assert + if (model.expectedSendInitiated) { + coVerify(exactly = 1) { createTransferTransactionUseCase(any(), any(), any(), any(), any(), any(), any()) } + } else { + coVerify(exactly = 0) { createTransferTransactionUseCase(any(), any(), any(), any(), any(), any(), any()) } + } + } + + private fun provideTestModels() = listOf( + CheckFeeResultModel(checkResult = true, expectedSendInitiated = true), + CheckFeeResultModel(checkResult = false, expectedSendInitiated = false), + ) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class SendTransactionDispatch { + + @ParameterizedTest + @ProvideTestModels + fun `WHEN send THEN use gasless use case only for token-currency fee`(model: DispatchModel) = runTest { + // Arrange + val state = if (model.isTokenCurrencyFee) gaslessFeeState() else normalFeeState() + val resultFlow = MutableSharedFlow(extraBufferCapacity = 1) + every { feeSelectorCheckReloadListener.checkReloadResultFlow } returns resultFlow + createSendConfirmModel(this, confirmParams(state)) + advanceUntilIdle() + + // Act + resultFlow.tryEmit(true) + advanceUntilIdle() + + // Assert + if (model.isTokenCurrencyFee) { + coVerify(exactly = 1) { createAndSendGaslessTransactionUseCase(any(), any(), any()) } + coVerify(exactly = 0) { sendTransactionUseCase(any(), any(), any()) } + } else { + coVerify(exactly = 0) { createAndSendGaslessTransactionUseCase(any(), any(), any()) } + coVerify(exactly = 1) { sendTransactionUseCase(any(), any(), any()) } + } + } + + private fun provideTestModels() = listOf( + DispatchModel(isTokenCurrencyFee = true), + DispatchModel(isTokenCurrencyFee = false), + ) + } + + @Nested + inner class VerifyAndSend { + + @Test + fun `GIVEN successful send WHEN verifyAndSend THEN notify onSendTransaction`() = runTest { + // Arrange + val onSendTransaction = mockk<() -> Unit>(relaxed = true) + val callback = mockk(relaxed = true) + val resultFlow = MutableSharedFlow(extraBufferCapacity = 1) + every { feeSelectorCheckReloadListener.checkReloadResultFlow } returns resultFlow + coEvery { sendTransactionUseCase(any(), any(), any()) } returns "txHash".right() + val params = MutableParamsContainer( + defaultSendConfirmParams( + state = normalFeeState(), + cryptoCurrencyStatus = loadedFeeStatus, + feeCryptoCurrencyStatus = loadedFeeStatus, + ).copy(onSendTransaction = onSendTransaction, callback = callback), + ) + createSendConfirmModel(this, params) + advanceUntilIdle() + + // Act + resultFlow.tryEmit(true) + advanceUntilIdle() + + // Assert + verify(exactly = 1) { onSendTransaction.invoke() } + verify(exactly = 1) { callback.onResult(any()) } + } + + @Test + fun `GIVEN transaction creation fails WHEN verifyAndSend THEN show generic error and do NOT send`() = runTest { + // Arrange + val resultFlow = MutableSharedFlow(extraBufferCapacity = 1) + every { feeSelectorCheckReloadListener.checkReloadResultFlow } returns resultFlow + coEvery { + createTransferTransactionUseCase(any(), any(), any(), any(), any(), any(), any()) + } returns IllegalStateException("boom").left() + createSendConfirmModel(this, confirmParams(normalFeeState())) + advanceUntilIdle() + + // Act + resultFlow.tryEmit(true) + advanceUntilIdle() + + // Assert + verify(exactly = 1) { sendConfirmAlertFactory.getGenericErrorState(any(), any()) } + coVerify(exactly = 0) { sendTransactionUseCase(any(), any(), any()) } + } + } + + // region fixtures + + private fun confirmParams(state: SendUM) = MutableParamsContainer( + defaultSendConfirmParams( + state = state, + cryptoCurrencyStatus = loadedFeeStatus, + feeCryptoCurrencyStatus = loadedFeeStatus, + ), + ) + + /** Populated Content state with a regular (main-currency) fee — drives the normal send path. */ + private fun normalFeeState(): SendUM = contentState( + fee = realFee(), + transactionFeeExtended = null, + ) + + /** Populated Content state where the extended fee is a gasless token-currency fee. */ + private fun gaslessFeeState(): SendUM = contentState( + fee = realFee(), + transactionFeeExtended = TransactionFeeExtended( + transactionFee = TransactionFee.Single(normal = tokenFee()), + feeTokenId = testCryptoCurrency.id, + ), + ) + + private fun contentState(fee: Fee, transactionFeeExtended: TransactionFeeExtended?): SendUM { + val amount = mockk(relaxed = true) { + every { amountTextField.cryptoAmount.value } returns BigDecimal.ONE + every { reduceAmountBy } returns BigDecimal.ZERO + every { isIgnoreReduce } returns false + } + val destination = mockk(relaxed = true) { + every { addressTextField.actualAddress } returns "destinationAddr" + every { memoTextField } returns null + every { wallets } returns persistentListOf() + } + val extraInfo = mockk(relaxed = true) { + every { this@mockk.transactionFeeExtended } returns transactionFeeExtended + every { feeCryptoCurrencyStatus } returns loadedFeeStatus + } + val feeSelector = mockk(relaxed = true) { + every { selectedFeeItem } returns FeeItem.Market(fee) + every { feeNonce } returns FeeNonce.None + every { feeExtraInfo } returns extraInfo + every { isPrimaryButtonEnabled } returns true + } + return SendUM( + amountUM = amount, + destinationUM = destination, + feeSelectorUM = feeSelector, + confirmUM = mockk(relaxed = true), + navigationUM = NavigationUM.Empty, + confirmData = null, + ) + } + + private val loadedFeeStatus: CryptoCurrencyStatus + get() = com.tangem.features.send.loadedStatus(testCryptoCurrency) + + // Can't reuse the shared commonFee(): it builds Amount(blockchain) whose value is null, and + // verifyAndSendTransaction early-returns on `fee.amount.value ?: return` — so the fee needs an explicit value. + private fun realFee(): Fee = Fee.Common( + Amount(currencySymbol = "ETH", value = BigDecimal("0.001"), decimals = 18), + ) + + private fun tokenFee(): Fee.Ethereum.TokenCurrency = Fee.Ethereum.TokenCurrency( + amount = Amount(currencySymbol = "ETH", value = BigDecimal("0.001"), decimals = 18), + gasLimit = java.math.BigInteger.valueOf(21_000), + coinPriceInToken = java.math.BigInteger.ONE, + feeTransferGasLimit = java.math.BigInteger.ONE, + baseGas = java.math.BigInteger.ONE, + ) + + data class OnSendClickModel(val elapsedRealtime: Long, val expectedSendInitiated: Boolean) + + data class CheckFeeResultModel(val checkResult: Boolean, val expectedSendInitiated: Boolean) + + data class DispatchModel(val isTokenCurrencyFee: Boolean) + + // endregion +} \ No newline at end of file diff --git a/features/send/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/NFTSendConfirmationNotificationsTransformerV2Test.kt b/features/send/impl/src/test/java/com/tangem/features/send/send/confirm/model/transformers/NFTSendConfirmationNotificationsTransformerV2Test.kt similarity index 100% rename from features/send/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/NFTSendConfirmationNotificationsTransformerV2Test.kt rename to features/send/impl/src/test/java/com/tangem/features/send/send/confirm/model/transformers/NFTSendConfirmationNotificationsTransformerV2Test.kt diff --git a/features/send/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2Test.kt b/features/send/impl/src/test/java/com/tangem/features/send/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2Test.kt similarity index 100% rename from features/send/impl/src/test/java/com/tangem/features/send/v2/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2Test.kt rename to features/send/impl/src/test/java/com/tangem/features/send/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2Test.kt diff --git a/features/send/impl/src/test/java/com/tangem/features/send/send/model/SendModelTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/send/model/SendModelTest.kt new file mode 100644 index 0000000000..0615266589 --- /dev/null +++ b/features/send/impl/src/test/java/com/tangem/features/send/send/model/SendModelTest.kt @@ -0,0 +1,265 @@ +package com.tangem.features.send.send.model + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.core.decompose.model.MutableParamsContainer +import com.tangem.domain.transaction.error.GetFeeError +import com.tangem.features.send.api.SendComponent +import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents +import com.tangem.features.send.api.entity.PredefinedValues +import com.tangem.features.send.common.CommonSendRoute +import com.tangem.features.send.common.ui.state.ConfirmUM +import com.tangem.common.ui.navigationButtons.NavigationUM +import com.tangem.features.send.send.SendModelTestBase +import io.mockk.coEvery +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM as FeeSelectorUMRedesigned + +@OptIn(ExperimentalCoroutinesApi::class) +internal class SendModelTest : SendModelTestBase() { + + @Nested + inner class OnNextClick { + + @Test + fun `GIVEN amount route AND predefined main screen QR WHEN onNextClick THEN push Confirm`() = runTest { + // Arrange + val model = createSendModel(this) + model.currentRoute.value = CommonSendRoute.Amount(isEditMode = false) + model.predefinedValues = PredefinedValues.Content.QrCode( + amount = "1.0", + address = "addr123", + memo = null, + source = PredefinedValues.Source.MAIN_SCREEN, + ) + + // Act + model.onNextClick() + + // Assert + verify(exactly = 1) { router.push(CommonSendRoute.Confirm, any()) } + } + + @Test + fun `GIVEN amount route AND NOT main screen QR WHEN onNextClick THEN push Destination`() = runTest { + // Arrange + val model = createSendModel(this) + model.currentRoute.value = CommonSendRoute.Amount(isEditMode = false) + model.predefinedValues = PredefinedValues.Empty + + // Act + model.onNextClick() + + // Assert + verify(exactly = 1) { router.push(CommonSendRoute.Destination(isEditMode = false), any()) } + } + + @Test + fun `GIVEN destination route WHEN onNextClick THEN push Confirm`() = runTest { + // Arrange + val model = createSendModel(this) + model.currentRoute.value = CommonSendRoute.Destination(isEditMode = false) + + // Act + model.onNextClick() + + // Assert + verify(exactly = 1) { router.push(CommonSendRoute.Confirm, any()) } + } + + @Test + fun `GIVEN route in edit mode WHEN onNextClick THEN pop without push`() = runTest { + // Arrange + val model = createSendModel(this) + model.currentRoute.value = CommonSendRoute.Amount(isEditMode = true) + + // Act + model.onNextClick() + + // Assert + verify(exactly = 1) { router.pop(any()) } + verify(exactly = 0) { router.push(any(), any()) } + } + + @Test + fun `GIVEN confirm route WHEN onNextClick THEN pop (Confirm isEditMode is true so push branch is dead)`() = + runTest { + // Arrange + // CommonSendRoute.Confirm.isEditMode == true, so onNextClick short-circuits to onBackClick(). + val model = createSendModel(this) + model.currentRoute.value = CommonSendRoute.Confirm + + // Act + model.onNextClick() + + // Assert + verify(exactly = 1) { router.pop(any()) } + verify(exactly = 0) { router.push(CommonSendRoute.ConfirmSuccess, any()) } + } + } + + @Nested + inner class ConsumeEntryType { + + @Test + fun `GIVEN entry type QR WHEN consumeEntryType first call THEN return QR`() = runTest { + // Arrange + val params = defaultSendParams().copy(entryType = SendComponent.EntryType.QR) + val model = createSendModel(this, MutableParamsContainer(params)) + + // Act + val result = model.consumeEntryType() + + // Assert + assertThat(result).isEqualTo(CommonSendAnalyticEvents.SendEntryType.QR) + } + + @Test + fun `GIVEN entry type QR WHEN consumeEntryType called twice THEN second returns Manual`() = runTest { + // Arrange + val params = defaultSendParams().copy(entryType = SendComponent.EntryType.QR) + val model = createSendModel(this, MutableParamsContainer(params)) + + // Act + val first = model.consumeEntryType() + val second = model.consumeEntryType() + + // Assert + assertThat(first).isEqualTo(CommonSendAnalyticEvents.SendEntryType.QR) + assertThat(second).isEqualTo(CommonSendAnalyticEvents.SendEntryType.Manual) + } + + @Test + fun `GIVEN entry type Manual WHEN consumeEntryType THEN return Manual`() = runTest { + // Arrange + val params = defaultSendParams().copy(entryType = SendComponent.EntryType.Manual) + val model = createSendModel(this, MutableParamsContainer(params)) + + // Act + val result = model.consumeEntryType() + + // Assert + assertThat(result).isEqualTo(CommonSendAnalyticEvents.SendEntryType.Manual) + } + } + + @Nested + inner class LoadFee { + + @Test + fun `GIVEN transaction created WHEN loadFee THEN return fee from use case`() = runTest { + // Arrange + val model = createSendModel(this) + advanceUntilIdle() + model.predefinedValues = deeplink(amount = "1.0") + val expectedFee = mockk(relaxed = true) + coEvery { getFeeUseCase(any(), any(), any()) } returns expectedFee.right() + + // Act + val result = model.loadFee() + + // Assert + assertThat(result).isEqualTo(expectedFee.right()) + } + + @Test + fun `GIVEN transaction creation fails WHEN loadFee THEN return DataError`() = runTest { + // Arrange + val model = createSendModel(this) + advanceUntilIdle() + model.predefinedValues = deeplink(amount = "1.0") + coEvery { + createTransferTransactionUseCase(any(), any(), any(), any(), any(), any()) + } returns IllegalStateException("boom").left() + + // Act + val result = model.loadFee() + + // Assert + assertThat(result.leftOrNull()).isInstanceOf(GetFeeError.DataError::class.java) + } + + @Test + fun `GIVEN fee use case fails WHEN loadFee THEN return that error`() = runTest { + // Arrange + val model = createSendModel(this) + advanceUntilIdle() + model.predefinedValues = deeplink(amount = "1.0") + coEvery { getFeeUseCase(any(), any(), any()) } returns GetFeeError.UnknownError.left() + + // Act + val result = model.loadFee() + + // Assert + assertThat(result).isEqualTo(GetFeeError.UnknownError.left()) + } + } + + @Nested + inner class OnBackClick { + + @Test + fun `GIVEN amount route non-edit WHEN onBackClick THEN send analytics and pop`() = runTest { + // Arrange + val model = createSendModel(this) + model.currentRoute.value = CommonSendRoute.Amount(isEditMode = false) + + // Act + model.onBackClick() + + // Assert + verify(exactly = 1) { analyticsEventHandler.send(any()) } + verify(exactly = 1) { router.pop(any()) } + } + + @Test + fun `GIVEN destination route edit WHEN onBackClick THEN pop without analytics`() = runTest { + // Arrange + val model = createSendModel(this) + model.currentRoute.value = CommonSendRoute.Destination(isEditMode = true) + + // Act + model.onBackClick() + + // Assert + verify(exactly = 0) { analyticsEventHandler.send(any()) } + verify(exactly = 1) { router.pop(any()) } + } + } + + @Nested + inner class ResetSendNavigation { + + @Test + fun `GIVEN any state WHEN resetSendNavigation THEN reset states and popTo Amount`() = runTest { + // Arrange + val model = createSendModel(this) + + // Act + model.resetSendNavigation() + + // Assert + val state = model.uiState.value + assertThat(state.feeSelectorUM).isEqualTo(FeeSelectorUMRedesigned.Loading) + assertThat(state.confirmUM).isEqualTo(ConfirmUM.Empty) + assertThat(state.confirmData).isNull() + assertThat(state.navigationUM).isEqualTo(NavigationUM.Empty) + verify(exactly = 1) { router.popTo(CommonSendRoute.Amount(isEditMode = false), any()) } + } + } + + private fun deeplink(amount: String) = PredefinedValues.Content.Deeplink( + amount = amount, + address = "addr123", + memo = null, + transactionId = "tx123", + ) +} \ No newline at end of file diff --git a/features/send/impl/src/test/java/com/tangem/features/send/sendnft/confirm/model/NFTSendConfirmModelTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/sendnft/confirm/model/NFTSendConfirmModelTest.kt new file mode 100644 index 0000000000..0d88f78693 --- /dev/null +++ b/features/send/impl/src/test/java/com/tangem/features/send/sendnft/confirm/model/NFTSendConfirmModelTest.kt @@ -0,0 +1,344 @@ +package com.tangem.features.send.sendnft.confirm.model + +import android.os.SystemClock +import arrow.core.left +import arrow.core.right +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.TransactionData +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.blockchain.nft.models.NFTAsset as SdkNFTAsset +import com.tangem.common.routing.AppRouter +import com.tangem.common.ui.navigationButtons.NavigationUM +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.model.MutableParamsContainer +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.navigation.Router +import com.tangem.core.navigation.share.ShareManager +import com.tangem.core.navigation.url.UrlOpener +import com.tangem.datasource.local.nft.converter.NFTSdkAssetConverter +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase +import com.tangem.domain.feedback.SaveBlockchainErrorUseCase +import com.tangem.domain.feedback.SendFeedbackEmailUseCase +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.nft.models.NFTAsset +import com.tangem.domain.settings.IsSendTapHelpEnabledUseCase +import com.tangem.domain.settings.NeverShowTapHelpUseCase +import com.tangem.domain.transaction.usecase.CreateNFTTransferTransactionUseCase +import com.tangem.domain.transaction.usecase.SendTransactionUseCase +import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase +import com.tangem.features.nft.entity.NFTSendSuccessTrigger +import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeExtraInfo +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeNonce +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM +import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorCheckReloadListener +import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorCheckReloadTrigger +import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger +import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateListener +import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateTrigger +import com.tangem.features.send.common.SendBalanceUpdater +import com.tangem.features.send.common.SendConfirmAlertFactory +import com.tangem.features.send.common.ui.state.ConfirmUM +import com.tangem.features.send.loadedStatus +import com.tangem.features.send.testDispatcherProvider +import com.tangem.features.send.sendnft.analytics.NFTSendAnalyticHelper +import com.tangem.features.send.sendnft.confirm.NFTSendConfirmComponent +import com.tangem.features.send.sendnft.ui.state.NFTSendUM +import com.tangem.test.core.ProvideTestModels +import io.mockk.MockKAnnotations +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkObject +import io.mockk.unmockkStatic +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import java.math.BigDecimal + +@OptIn(ExperimentalCoroutinesApi::class) +internal class NFTSendConfirmModelTest { + + private val network: Network = mockk(relaxed = true) + private val nftAsset: NFTAsset = mockk(relaxed = true) + private val testUserWallet: UserWallet = mockk(relaxed = true) + private val testCryptoCurrency: CryptoCurrency = mockk(relaxed = true) { + every { this@mockk.network } returns this@NFTSendConfirmModelTest.network + } + + private val router: Router = mockk(relaxed = true) + private val appRouter: AppRouter = mockk(relaxed = true) + private val isSendTapHelpEnabledUseCase: IsSendTapHelpEnabledUseCase = mockk(relaxed = true) + private val neverShowTapHelpUseCase: NeverShowTapHelpUseCase = mockk(relaxed = true) + private val createNFTTransferTransactionUseCase: CreateNFTTransferTransactionUseCase = mockk(relaxed = true) + private val sendTransactionUseCase: SendTransactionUseCase = mockk(relaxed = true) + private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase = mockk(relaxed = true) + private val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase = mockk(relaxed = true) + private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase = mockk(relaxed = true) + private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase = mockk(relaxed = true) + private val notificationsUpdateTrigger: SendNotificationsUpdateTrigger = mockk(relaxed = true) + private val notificationsUpdateListener: SendNotificationsUpdateListener = mockk(relaxed = true) + private val feeSelectorCheckReloadTrigger: FeeSelectorCheckReloadTrigger = mockk(relaxed = true) + private val feeSelectorCheckReloadListener: FeeSelectorCheckReloadListener = mockk(relaxed = true) + private val alertFactory: SendConfirmAlertFactory = mockk(relaxed = true) + private val urlOpener: UrlOpener = mockk(relaxed = true) + private val shareManager: ShareManager = mockk(relaxed = true) + private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true) + private val nftSendAnalyticHelper: NFTSendAnalyticHelper = mockk(relaxed = true) + private val nftSendSuccessTrigger: NFTSendSuccessTrigger = mockk(relaxed = true) + private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger = mockk(relaxed = true) + private val sendBalanceUpdaterFactory: SendBalanceUpdater.Factory = mockk(relaxed = true) + + private val loadedStatus: CryptoCurrencyStatus get() = loadedStatus(testCryptoCurrency) + + @BeforeEach + fun setUp() { + MockKAnnotations.init(this) + mockkStatic(SystemClock::class) + every { SystemClock.elapsedRealtime() } returns 0L + mockkObject(NFTSdkAssetConverter) + every { NFTSdkAssetConverter.convertBack(any()) } returns (network to mockk(relaxed = true)) + + clearMocks( + createNFTTransferTransactionUseCase, + sendTransactionUseCase, + feeSelectorCheckReloadTrigger, + alertFactory, + answers = false, + recordedCalls = true, + childMocks = false, + ) + + coEvery { isSendTapHelpEnabledUseCase.invokeSync() } returns false.right() + every { isSendTapHelpEnabledUseCase() } returns emptyFlow().right() + every { notificationsUpdateListener.hasErrorFlow } returns emptyFlow() + every { feeSelectorCheckReloadListener.checkReloadResultFlow } returns emptyFlow() + coEvery { + createNFTTransferTransactionUseCase(any(), any(), any(), any(), any(), any(), any()) + } returns mockk(relaxed = true).right() + coEvery { sendTransactionUseCase(any(), any(), any()) } returns "txHash".right() + every { getExplorerTransactionUrlUseCase(any(), any()) } returns "https://explorer/tx".right() + every { sendBalanceUpdaterFactory.create(any(), any()) } returns mockk(relaxed = true) + } + + @AfterEach + fun tearDown() { + unmockkStatic(SystemClock::class) + unmockkObject(NFTSdkAssetConverter) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class OnSendClick { + + @ParameterizedTest + @ProvideTestModels + fun `WHEN onSendClick THEN send fresh fee else trigger check reload`(model: OnSendClickModel) = runTest { + // Arrange + every { SystemClock.elapsedRealtime() } returns model.elapsedRealtime + val sut = buildModel() + advanceUntilIdle() + + // Act + sut.onSendClick() + advanceUntilIdle() + + // Assert + if (model.expectedSendInitiated) { + coVerify(exactly = 1) { createNFTTransferTransactionUseCase(any(), any(), any(), any(), any(), any(), any()) } + coVerify(exactly = 0) { feeSelectorCheckReloadTrigger.triggerCheckUpdate() } + } else { + coVerify(exactly = 0) { createNFTTransferTransactionUseCase(any(), any(), any(), any(), any(), any(), any()) } + coVerify(exactly = 1) { feeSelectorCheckReloadTrigger.triggerCheckUpdate() } + } + } + + private fun provideTestModels() = listOf( + OnSendClickModel(elapsedRealtime = 0L, expectedSendInitiated = true), + OnSendClickModel(elapsedRealtime = 20_000L, expectedSendInitiated = false), + ) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class CheckFeeResult { + + @ParameterizedTest + @ProvideTestModels + fun `WHEN check reload result emitted THEN send transaction only on success`(model: CheckFeeResultModel) = + runTest { + // Arrange + val resultFlow = MutableSharedFlow(extraBufferCapacity = 1) + every { feeSelectorCheckReloadListener.checkReloadResultFlow } returns resultFlow + buildModel() + advanceUntilIdle() + + // Act + resultFlow.tryEmit(model.checkResult) + advanceUntilIdle() + + // Assert + coVerify(exactly = model.expectedCreateNFTTTransferCalls) { + createNFTTransferTransactionUseCase(any(), any(), any(), any(), any(), any(), any()) + } + } + + private fun provideTestModels() = listOf( + CheckFeeResultModel(checkResult = true, expectedCreateNFTTTransferCalls = 1), + CheckFeeResultModel(checkResult = false, expectedCreateNFTTTransferCalls = 0), + ) + } + + @Nested + inner class VerifyAndSend { + + @Test + fun `GIVEN successful send WHEN verifyAndSend THEN notify onSendTransaction`() = runTest { + // Arrange + val onSendTransaction = mockk<() -> Unit>(relaxed = true) + val callback = mockk(relaxed = true) + val resultFlow = MutableSharedFlow(extraBufferCapacity = 1) + every { feeSelectorCheckReloadListener.checkReloadResultFlow } returns resultFlow + buildModel( + paramsContainer = MutableParamsContainer( + defaultParams().copy(onSendTransaction = onSendTransaction, callback = callback), + ), + ) + advanceUntilIdle() + + // Act + resultFlow.tryEmit(true) + advanceUntilIdle() + + // Assert + verify(exactly = 1) { onSendTransaction.invoke() } + verify(exactly = 1) { callback.onResult(any()) } + } + + @Test + fun `GIVEN transaction creation fails WHEN verifyAndSend THEN show generic error and do NOT send`() = runTest { + // Arrange + val resultFlow = MutableSharedFlow(extraBufferCapacity = 1) + every { feeSelectorCheckReloadListener.checkReloadResultFlow } returns resultFlow + coEvery { + createNFTTransferTransactionUseCase(any(), any(), any(), any(), any(), any(), any()) + } returns IllegalStateException("boom").left() + buildModel() + advanceUntilIdle() + + // Act + resultFlow.tryEmit(true) + advanceUntilIdle() + + // Assert + verify(exactly = 1) { alertFactory.getGenericErrorState(any(), any()) } + coVerify(exactly = 0) { sendTransactionUseCase(any(), any(), any()) } + } + } + + // region fixtures + + private fun TestScope.buildModel( + paramsContainer: ParamsContainer = MutableParamsContainer(defaultParams()), + ): NFTSendConfirmModel { + return NFTSendConfirmModel( + paramsContainer = paramsContainer, + dispatchers = testDispatcherProvider(), + router = router, + appRouter = appRouter, + isSendTapHelpEnabledUseCase = isSendTapHelpEnabledUseCase, + neverShowTapHelpUseCase = neverShowTapHelpUseCase, + createNFTTransferTransactionUseCase = createNFTTransferTransactionUseCase, + sendTransactionUseCase = sendTransactionUseCase, + getExplorerTransactionUrlUseCase = getExplorerTransactionUrlUseCase, + saveBlockchainErrorUseCase = saveBlockchainErrorUseCase, + getWalletMetaInfoUseCase = getWalletMetaInfoUseCase, + sendFeedbackEmailUseCase = sendFeedbackEmailUseCase, + notificationsUpdateTrigger = notificationsUpdateTrigger, + notificationsUpdateListener = notificationsUpdateListener, + feeSelectorCheckReloadTrigger = feeSelectorCheckReloadTrigger, + feeSelectorCheckReloadListener = feeSelectorCheckReloadListener, + alertFactory = alertFactory, + urlOpener = urlOpener, + shareManager = shareManager, + analyticsEventHandler = analyticsEventHandler, + nftSendAnalyticHelper = nftSendAnalyticHelper, + nftSendSuccessTrigger = nftSendSuccessTrigger, + feeSelectorReloadTrigger = feeSelectorReloadTrigger, + sendBalanceUpdaterFactory = sendBalanceUpdaterFactory, + ) + } + + private fun defaultParams(): NFTSendConfirmComponent.Params = NFTSendConfirmComponent.Params( + state = contentState(), + analyticsCategoryName = "test_nft_send", + analyticsSendSource = CommonSendAnalyticEvents.CommonSendSource.NFT, + userWallet = testUserWallet, + appCurrency = AppCurrency.Default, + nftAsset = nftAsset, + nftCollectionName = "Collection", + cryptoCurrencyStatus = loadedStatus, + feeCryptoCurrencyStatus = loadedStatus, + account = null, + isAccountsMode = false, + callback = mockk(relaxed = true), + currentRoute = flowOf(), + isBalanceHidingFlow = kotlinx.coroutines.flow.MutableStateFlow(false), + onLoadFee = { mockk(relaxed = true).right() }, + onSendTransaction = {}, + ) + + private fun contentState(): NFTSendUM { + val destination = mockk(relaxed = true) { + every { addressTextField.actualAddress } returns "destinationAddr" + every { memoTextField } returns null + } + val extraInfo = mockk(relaxed = true) { + every { transactionFeeExtended } returns null + every { feeCryptoCurrencyStatus } returns loadedStatus + } + val feeSelector = mockk(relaxed = true) { + every { selectedFeeItem } returns FeeItem.Market(realFee()) + every { feeNonce } returns FeeNonce.None + every { feeExtraInfo } returns extraInfo + every { isPrimaryButtonEnabled } returns true + } + return NFTSendUM( + destinationUM = destination, + feeSelectorUM = feeSelector, + confirmUM = mockk(relaxed = true), + navigationUM = NavigationUM.Empty, + ) + } + + private fun realFee(): Fee = Fee.Common( + Amount(currencySymbol = "ETH", value = BigDecimal("0.001"), decimals = 18), + ) + + data class OnSendClickModel(val elapsedRealtime: Long, val expectedSendInitiated: Boolean) + + data class CheckFeeResultModel(val checkResult: Boolean, val expectedCreateNFTTTransferCalls: Int) + + // endregion +} \ No newline at end of file diff --git a/features/send/impl/src/test/java/com/tangem/features/send/sendnft/model/NFTSendModelTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/sendnft/model/NFTSendModelTest.kt new file mode 100644 index 0000000000..a70fcd7035 --- /dev/null +++ b/features/send/impl/src/test/java/com/tangem/features/send/sendnft/model/NFTSendModelTest.kt @@ -0,0 +1,229 @@ +package com.tangem.features.send.sendnft.model + +import arrow.core.left +import arrow.core.right +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.model.MutableParamsContainer +import com.tangem.core.decompose.navigation.Router +import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatus +import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase +import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase +import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.feedback.GetWalletMetaInfoUseCase +import com.tangem.domain.feedback.SaveBlockchainErrorUseCase +import com.tangem.domain.feedback.SendFeedbackEmailUseCase +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.tokens.MultiWalletCryptoCurrenciesSupplier +import com.tangem.domain.transaction.usecase.CreateNFTTransferTransactionUseCase +import com.tangem.domain.transaction.usecase.GetFeeUseCase +import com.tangem.domain.wallets.models.errors.GetUserWalletError +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.features.nft.entity.NFTSendSuccessTrigger +import com.tangem.features.send.api.NFTSendComponent +import com.tangem.features.send.common.CommonSendRoute +import com.tangem.features.send.common.SendConfirmAlertFactory +import com.tangem.features.send.testDispatcherProvider +import com.tangem.test.core.ProvideTestModels +import io.mockk.MockKAnnotations +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest + +@OptIn(ExperimentalCoroutinesApi::class) +internal class NFTSendModelTest { + + private val testUserWalletId = UserWalletId("1234567890ABCDEF") + private val network: Network = mockk(relaxed = true) + private val nftAsset: com.tangem.domain.nft.models.NFTAsset = mockk(relaxed = true) + private val testUserWallet: UserWallet = mockk(relaxed = true) + private val testCryptoCurrencyStatus: CryptoCurrencyStatus = mockk(relaxed = true) + private val coin: CryptoCurrency.Coin = mockk(relaxed = true) + + private val router: Router = mockk(relaxed = true) + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk(relaxed = true) + private val getUserWalletUseCase: GetUserWalletUseCase = mockk(relaxed = true) + private val multiWalletCryptoCurrenciesSupplier: MultiWalletCryptoCurrenciesSupplier = mockk(relaxed = true) + private val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase = + mockk(relaxed = true) + private val createNFTTransferTransactionUseCase: CreateNFTTransferTransactionUseCase = mockk(relaxed = true) + private val getFeeUseCase: GetFeeUseCase = mockk(relaxed = true) + private val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase = mockk(relaxed = true) + private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase = mockk(relaxed = true) + private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase = mockk(relaxed = true) + private val alertFactory: SendConfirmAlertFactory = mockk(relaxed = true) + private val nftSendSuccessTrigger: NFTSendSuccessTrigger = mockk(relaxed = true) + private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase = mockk(relaxed = true) + private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase = mockk(relaxed = true) + private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true) + + @BeforeEach + fun setUp() { + MockKAnnotations.init(this) + // PER_CLASS parameterized nested classes reuse one instance — reset recorded calls between rows. + clearMocks(router, nftSendSuccessTrigger, alertFactory, answers = false, recordedCalls = true, childMocks = false) + + every { nftAsset.network } returns network + every { coin.network } returns network + every { getUserWalletUseCase(testUserWalletId) } returns testUserWallet.right() + coEvery { getSelectedAppCurrencyUseCase.invokeSync() } returns AppCurrency.Default.right() + coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any(), any()) } returns null + every { getAccountCurrencyStatusUseCase(any(), any()) } returns emptyFlow() + coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns false + coEvery { getFeePaidCryptoCurrencyStatusSyncUseCase(any(), any()) } returns testCryptoCurrencyStatus.right() + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class OnNextClick { + + @ParameterizedTest + @ProvideTestModels + fun `WHEN onNextClick THEN push Confirm for destination else navigate back`(model: NextClickModel) = runTest { + // Arrange + val sut = buildModel() + advanceUntilIdle() + sut.currentRouteFlow.value = model.route + + // Act + sut.onNextClick() + advanceUntilIdle() + + // Assert + if (model.expectPushConfirm) { + verify(exactly = 1) { router.push(CommonSendRoute.Confirm, any()) } + verify(exactly = 0) { router.pop(any()) } + } else { + verify(exactly = 1) { router.pop(any()) } + verify(exactly = 0) { router.push(any(), any()) } + // Confirm.isEditMode == true, so the `Confirm -> replaceAll(ConfirmSuccess)` branch is unreachable + verify(exactly = 0) { router.replaceAll(CommonSendRoute.ConfirmSuccess, onComplete = any()) } + } + } + + private fun provideTestModels() = listOf( + NextClickModel(route = CommonSendRoute.Destination(isEditMode = false), expectPushConfirm = true), + NextClickModel(route = CommonSendRoute.Destination(isEditMode = true), expectPushConfirm = false), + NextClickModel(route = CommonSendRoute.Confirm, expectPushConfirm = false), + ) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class OnBackClick { + + @ParameterizedTest + @ProvideTestModels + fun `WHEN onBackClick THEN trigger success only from ConfirmSuccess and always pop`(model: BackClickModel) = + runTest { + // Arrange + val sut = buildModel() + advanceUntilIdle() + sut.currentRouteFlow.value = model.route + + // Act + sut.onBackClick() + advanceUntilIdle() + + // Assert + coVerify(exactly = model.expectedTriggerCalls) { nftSendSuccessTrigger.triggerSuccessNFTSend() } + verify(exactly = 1) { router.pop(any()) } + } + + private fun provideTestModels() = listOf( + BackClickModel(route = CommonSendRoute.ConfirmSuccess, expectedTriggerCalls = 1), + BackClickModel(route = CommonSendRoute.Destination(isEditMode = false), expectedTriggerCalls = 0), + ) + } + + @Nested + inner class SubscribeOnCurrencyStatusUpdates { + + @Test + fun `GIVEN get user wallet fails WHEN init THEN show generic error`() = runTest { + // Arrange + every { getUserWalletUseCase(testUserWalletId) } returns GetUserWalletError.UserWalletNotFound.left() + + // Act + buildModel() + advanceUntilIdle() + + // Assert + verify(exactly = 1) { alertFactory.getGenericErrorState(any(), any()) } + } + + @Test + fun `GIVEN currency status loaded with empty destination WHEN init THEN navigate to destination`() = runTest { + // Arrange + coEvery { multiWalletCryptoCurrenciesSupplier.getSyncOrNull(any(), any()) } returns setOf(coin) + val accountStatus = mockk { + every { component1() } returns mockk(relaxed = true) + every { component2() } returns testCryptoCurrencyStatus + } + every { getAccountCurrencyStatusUseCase(testUserWalletId, coin) } returns flowOf(accountStatus) + + // Act + buildModel() + advanceUntilIdle() + + // Assert + verify(exactly = 1) { + router.replaceAll(CommonSendRoute.Destination(isEditMode = false), onComplete = any()) + } + } + } + + // region fixtures + + private fun TestScope.buildModel(): NFTSendModel { + val params = NFTSendComponent.Params( + userWalletId = testUserWalletId, + nftAsset = nftAsset, + nftCollectionName = "Collection", + ) + return NFTSendModel( + paramsContainer = MutableParamsContainer(params), + dispatchers = testDispatcherProvider(), + router = router, + getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase, + getUserWalletUseCase = getUserWalletUseCase, + multiWalletCryptoCurrenciesSupplier = multiWalletCryptoCurrenciesSupplier, + getFeePaidCryptoCurrencyStatusSyncUseCase = getFeePaidCryptoCurrencyStatusSyncUseCase, + createNFTTransferTransactionUseCase = createNFTTransferTransactionUseCase, + getFeeUseCase = getFeeUseCase, + saveBlockchainErrorUseCase = saveBlockchainErrorUseCase, + getWalletMetaInfoUseCase = getWalletMetaInfoUseCase, + sendFeedbackEmailUseCase = sendFeedbackEmailUseCase, + alertFactory = alertFactory, + nftSendSuccessTrigger = nftSendSuccessTrigger, + isAccountsModeEnabledUseCase = isAccountsModeEnabledUseCase, + getAccountCurrencyStatusUseCase = getAccountCurrencyStatusUseCase, + analyticsEventHandler = analyticsEventHandler, + ) + } + + data class NextClickModel(val route: CommonSendRoute, val expectPushConfirm: Boolean) + + data class BackClickModel(val route: CommonSendRoute, val expectedTriggerCalls: Int) + + // endregion +} \ No newline at end of file diff --git a/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/amount/model/SendAmountModelTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/amount/model/SendAmountModelTest.kt new file mode 100644 index 0000000000..19312d7771 --- /dev/null +++ b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/amount/model/SendAmountModelTest.kt @@ -0,0 +1,321 @@ +package com.tangem.features.send.subcomponents.amount.model + +import arrow.core.right +import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer.ReduceByData +import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.tokens.GetMinimumTransactionAmountSyncUseCase +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.domain.wallets.usecase.GetWalletsUseCase +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.model.MutableParamsContainer +import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents +import com.tangem.features.send.api.subcomponents.amount.analytics.CommonSendAmountAnalyticEvents +import com.tangem.features.send.api.entity.PredefinedValues +import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger +import com.tangem.features.send.common.CommonSendRoute +import com.tangem.features.send.loadedStatus +import com.tangem.features.send.testDispatcherProvider +import com.tangem.features.send.api.subcomponents.amount.AmountRoute +import com.tangem.features.send.api.subcomponents.amount.SendAmountComponent +import com.tangem.features.send.api.subcomponents.amount.SendAmountComponentParams +import com.tangem.features.send.api.subcomponents.amount.SendAmountReduceListener +import com.tangem.features.send.api.subcomponents.amount.SendAmountUpdateListener +import com.tangem.test.core.ProvideTestModels +import com.google.common.truth.Truth.assertThat +import io.mockk.MockKAnnotations +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Disabled +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import java.math.BigDecimal + +@OptIn(ExperimentalCoroutinesApi::class) +internal class SendAmountModelTest { + + private val testUserWalletId = UserWalletId("1234567890ABCDEF") + private val cryptoCurrency: CryptoCurrency = mockk(relaxed = true) { + every { isCustom } returns false + } + + private val getMinimumTransactionAmountSyncUseCase: GetMinimumTransactionAmountSyncUseCase = mockk(relaxed = true) + private val sendAmountReduceListener: SendAmountReduceListener = mockk(relaxed = true) + private val sendAmountUpdateListener: SendAmountUpdateListener = mockk(relaxed = true) + private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true) + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk(relaxed = true) + private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger = mockk(relaxed = true) + private val getUserWalletUseCase: GetUserWalletUseCase = mockk(relaxed = true) + private val sendAmountAlertFactory: SendAmountAlertFactory = mockk(relaxed = true) + private val getWalletsUseCase: GetWalletsUseCase = mockk(relaxed = true) + private val callback: SendAmountComponent.ModelCallback = mockk(relaxed = true) + + private val reduceToFlow = MutableSharedFlow(extraBufferCapacity = 1) + private val reduceByFlow = MutableSharedFlow(extraBufferCapacity = 1) + + @BeforeEach + fun setUp() { + MockKAnnotations.init(this) + // PER_CLASS parameterized nested classes reuse one instance — reset verified mocks between rows. + clearMocks(callback, sendAmountAlertFactory, analyticsEventHandler, answers = false, recordedCalls = true, childMocks = false) + every { getUserWalletUseCase.invokeFlow(testUserWalletId) } returns flowOf(coldWallet().right()) + coEvery { getMinimumTransactionAmountSyncUseCase(any(), any()) } returns BigDecimal.ONE.right() + coEvery { getSelectedAppCurrencyUseCase.invokeSync() } returns AppCurrency.Default.right() + every { getWalletsUseCase.invokeSync() } returns listOf(coldWallet()) + every { sendAmountReduceListener.reduceToTriggerFlow } returns reduceToFlow + every { sendAmountReduceListener.reduceByTriggerFlow } returns reduceByFlow + every { sendAmountReduceListener.ignoreReduceTriggerFlow } returns emptyFlow() + every { sendAmountUpdateListener.updateAmountTriggerFlow } returns emptyFlow() + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class IsSendWithSwapAvailable { + + @ParameterizedTest + @ProvideTestModels + fun availability(model: SwapModel) = runTest { + // Arrange + every { cryptoCurrency.isCustom } returns model.isCustom + val wallet = coldWallet(isMultiCurrency = model.isMultiCurrency) + every { getUserWalletUseCase.invokeFlow(testUserWalletId) } returns flowOf(wallet.right()) + val predefined = if (model.isFromMainScreenQr) { + PredefinedValues.Content.QrCode("1", "addr", null, PredefinedValues.Source.MAIN_SCREEN) + } else { + PredefinedValues.Empty + } + // Start off an Amount route so the navigation combine stays idle until the wallet is loaded. + val currentRoute = MutableStateFlow(CommonSendRoute.Confirm) + val sut = buildModel(predefinedValues = predefined, currentRoute = currentRoute) + advanceUntilIdle() + + // Act — flip to Amount so setSendWithSwapAvailability() re-runs with the loaded wallet + currentRoute.value = CommonSendRoute.Amount(isEditMode = false) + advanceUntilIdle() + + // Assert + assertThat(sut.isSendWithSwapAvailable.value).isEqualTo(model.expected) + } + + private fun provideTestModels() = listOf( + SwapModel(isCustom = false, isMultiCurrency = true, isFromMainScreenQr = false, expected = true), + SwapModel(isCustom = true, isMultiCurrency = true, isFromMainScreenQr = false, expected = false), + SwapModel(isCustom = false, isMultiCurrency = false, isFromMainScreenQr = false, expected = false), + SwapModel(isCustom = false, isMultiCurrency = true, isFromMainScreenQr = true, expected = false), + ) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + // Looks like currentRoute.collect{} in onConvertToAnotherToken never completes, so the branch is unreachable. + @Disabled("currentRoute flow never completes — re-enable after the amount-screen rework") + inner class OnConvertToAnotherToken { + + @ParameterizedTest + @ProvideTestModels + fun `WHEN onConvertToAnotherToken THEN reset-alert in edit mode else convert directly`(model: ConvertModel) = + runTest { + // Arrange + val sut = buildModel(currentRoute = MutableStateFlow(CommonSendRoute.Amount(isEditMode = model.isEditMode))) + advanceUntilIdle() + + // Act + sut.onConvertToAnotherToken() + advanceUntilIdle() + + // Assert + if (model.isEditMode) { + verify(exactly = 1) { sendAmountAlertFactory.showResetSendingAlert(any()) } + verify(exactly = 0) { callback.onConvertToAnotherToken(any(), any()) } + } else { + verify(exactly = 0) { sendAmountAlertFactory.showResetSendingAlert(any()) } + verify(exactly = 1) { callback.onConvertToAnotherToken(any(), any()) } + } + } + + private fun provideTestModels() = listOf( + ConvertModel(isEditMode = true), + ConvertModel(isEditMode = false), + ) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class OnMaxValueClick { + + @ParameterizedTest + @ProvideTestModels + fun `WHEN onMaxValueClick THEN send analytics only for non-zero balance`(model: MaxClickModel) = runTest { + // Arrange + val sut = buildModel( + cryptoCurrencyStatusFlow = MutableStateFlow(loadedStatus(cryptoCurrency, balance = model.balance)), + ) + advanceUntilIdle() + + // Act + sut.onMaxValueClick() + + // Assert + verify(exactly = model.expectedAnalyticsCalls) { + analyticsEventHandler.send(any()) + } + } + + private fun provideTestModels() = listOf( + MaxClickModel(balance = BigDecimal.ZERO, expectedAnalyticsCalls = 0), + MaxClickModel(balance = BigDecimal.TEN, expectedAnalyticsCalls = 1), + ) + } + + @Nested + inner class ReduceTriggers { + + @Test + fun `GIVEN reduceTo emitted WHEN handled THEN trigger fee reload`() = runTest { + // Arrange + buildModel() + advanceUntilIdle() + + // Act + reduceToFlow.tryEmit(BigDecimal.ONE) + advanceUntilIdle() + + // Assert + coVerify(exactly = 1) { feeSelectorReloadTrigger.triggerUpdate(any()) } + } + + @Test + fun `GIVEN reduceBy emitted WHEN handled THEN trigger fee reload`() = runTest { + // Arrange + buildModel() + advanceUntilIdle() + + // Act + reduceByFlow.tryEmit(ReduceByData(reduceAmountBy = BigDecimal.ONE, reduceAmountByDiff = BigDecimal.ONE)) + advanceUntilIdle() + + // Assert + coVerify(exactly = 1) { feeSelectorReloadTrigger.triggerUpdate(any()) } + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class OnAmountNext { + + @ParameterizedTest + @ProvideTestModels + fun `WHEN onAmountNext THEN send selected-currency analytics by entry type and save result`( + model: AmountNextModel, + ) = runTest { + // Arrange + val sut = buildModel() + advanceUntilIdle() + sut.updateState(dataState(isFiat = model.isFiat)) + + // Act + sut.onAmountNext() + + // Assert + verify(exactly = 1) { + analyticsEventHandler.send( + match { it.type == model.expectedType }, + ) + } + verify(exactly = 1) { callback.onAmountResult(any(), any()) } + } + + private fun provideTestModels() = listOf( + AmountNextModel(isFiat = true, expectedType = CommonSendAmountAnalyticEvents.SelectedCurrencyType.AppCurrency), + AmountNextModel(isFiat = false, expectedType = CommonSendAmountAnalyticEvents.SelectedCurrencyType.Token), + ) + } + + // region fixtures + + private fun TestScope.buildModel( + predefinedValues: PredefinedValues = PredefinedValues.Empty, + currentRoute: MutableStateFlow = MutableStateFlow(CommonSendRoute.Amount(isEditMode = false)), + cryptoCurrencyStatusFlow: MutableStateFlow = + MutableStateFlow(loadedStatus(cryptoCurrency, balance = BigDecimal.TEN)), + state: AmountState = AmountState.Empty, + ): SendAmountModel { + val params = SendAmountComponentParams.AmountParams( + state = state, + analyticsCategoryName = "test_send", + userWalletId = testUserWalletId, + appCurrency = AppCurrency.Default, + predefinedValues = predefinedValues, + cryptoCurrency = cryptoCurrency, + cryptoCurrencyStatusFlow = cryptoCurrencyStatusFlow, + isBalanceHidingFlow = MutableStateFlow(false), + analyticsSendSource = CommonSendAnalyticEvents.CommonSendSource.Send, + accountFlow = MutableStateFlow(null), + isAccountModeFlow = MutableStateFlow(false), + callback = callback, + currentRoute = currentRoute.filterIsInstance(), + ) + return SendAmountModel( + paramsContainer = MutableParamsContainer(params), + dispatchers = testDispatcherProvider(), + getMinimumTransactionAmountSyncUseCase = getMinimumTransactionAmountSyncUseCase, + sendAmountReduceListener = sendAmountReduceListener, + sendAmountUpdateListener = sendAmountUpdateListener, + analyticsEventHandler = analyticsEventHandler, + getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase, + feeSelectorReloadTrigger = feeSelectorReloadTrigger, + getUserWalletUseCase = getUserWalletUseCase, + sendAmountAlertFactory = sendAmountAlertFactory, + getWalletsUseCase = getWalletsUseCase, + ) + } + + private fun coldWallet(isMultiCurrency: Boolean = true): UserWallet.Cold = mockk(relaxed = true) { + every { this@mockk.isMultiCurrency } returns isMultiCurrency + } + + private fun dataState(isFiat: Boolean): AmountState.Data = mockk(relaxed = true) { + every { amountTextField.isFiatValue } returns isFiat + every { amountTextField.value } returns "1" + } + + data class SwapModel( + val isCustom: Boolean, + val isMultiCurrency: Boolean, + val isFromMainScreenQr: Boolean, + val expected: Boolean, + ) + + data class ConvertModel(val isEditMode: Boolean) + + data class MaxClickModel(val balance: BigDecimal, val expectedAnalyticsCalls: Int) + + data class AmountNextModel( + val isFiat: Boolean, + val expectedType: CommonSendAmountAnalyticEvents.SelectedCurrencyType, + ) + + // endregion +} \ No newline at end of file diff --git a/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModelTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModelTest.kt new file mode 100644 index 0000000000..e1017e7b10 --- /dev/null +++ b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModelTest.kt @@ -0,0 +1,551 @@ +package com.tangem.features.send.subcomponents.destination.model + +import arrow.core.left +import arrow.core.right +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.model.MutableParamsContainer +import com.tangem.core.decompose.navigation.Router +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier +import com.tangem.domain.account.status.usecase.GetBackupProblematicWalletForAddressUseCase +import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase +import com.tangem.domain.feedback.SendBackupProblemEmailUseCase +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.CryptoCurrencyAddress +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.network.TxInfo +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase +import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase +import com.tangem.domain.tokens.GetNetworkAddressesUseCase +import com.tangem.domain.transaction.error.AddressValidation +import com.tangem.domain.transaction.error.AddressValidationResult +import com.tangem.domain.transaction.usecase.IsMemoRequiredUseCase +import com.google.common.truth.Truth.assertThat +import com.tangem.common.ui.account.AccountIconUM +import com.tangem.domain.addressbook.model.AddressEntry +import com.tangem.domain.addressbook.model.AddressEntryId +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.usecase.GetContactsUseCase +import com.tangem.features.addressbook.MatchedContact +import com.tangem.features.addressbook.SelectedContact +import com.tangem.features.send.api.entity.PredefinedValues +import kotlinx.collections.immutable.toImmutableList +import com.tangem.domain.transaction.usecase.IsSelfSendAvailableUseCase +import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase +import com.tangem.domain.transaction.usecase.ValidateWalletMemoUseCase +import com.tangem.domain.txhistory.usecase.GetFixedTxHistoryItemsUseCase +import com.tangem.domain.wallets.usecase.GetWalletsUseCase +import com.tangem.features.addressbook.ContactSelectionListener +import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents +import com.tangem.features.send.api.subcomponents.destination.DestinationRoute +import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponent +import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams +import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM +import com.tangem.features.send.common.CommonSendRoute +import com.tangem.features.send.subcomponents.destination.SendDestinationAlertFactory +import com.tangem.features.send.subcomponents.destination.analytics.EnterAddressSource +import com.tangem.features.send.subcomponents.destination.analytics.SendDestinationAnalyticEvents +import com.tangem.features.send.testDispatcherProvider +import io.mockk.MockKAnnotations +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import com.tangem.test.core.ProvideTestModels +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest + +@OptIn(ExperimentalCoroutinesApi::class) +internal class SendDestinationModelTest { + + private val testUserWalletId = UserWalletId("1234567890ABCDEF") + private val networkRawId = "eth" + private val cryptoCurrency: CryptoCurrency = mockk(relaxed = true) + private val contactIcon: AccountIconUM.CryptoPortfolio = mockk(relaxed = true) + + private val router: Router = mockk(relaxed = true) + private val validateWalletAddressUseCase: ValidateWalletAddressUseCase = mockk(relaxed = true) + private val validateWalletMemoUseCase: ValidateWalletMemoUseCase = mockk(relaxed = true) + private val isMemoRequiredUseCase: IsMemoRequiredUseCase = mockk(relaxed = true) + private val getWalletsUseCase: GetWalletsUseCase = mockk(relaxed = true) + private val getNetworkAddressesUseCase: GetNetworkAddressesUseCase = mockk(relaxed = true) + private val getFixedTxHistoryItemsUseCase: GetFixedTxHistoryItemsUseCase = mockk(relaxed = true) + private val isSelfSendAvailableUseCase: IsSelfSendAvailableUseCase = mockk(relaxed = true) + private val listenToQrScanningUseCase: ListenToQrScanningUseCase = mockk(relaxed = true) + private val parseQrCodeUseCase: ParseQrCodeUseCase = mockk(relaxed = true) + private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase = mockk(relaxed = true) + private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true) + private val multiAccountStatusListSupplier: MultiAccountStatusListSupplier = mockk(relaxed = true) + private val getBackupProblematicWalletForAddressUseCase: GetBackupProblematicWalletForAddressUseCase = + mockk(relaxed = true) + private val sendDestinationAlertFactory: SendDestinationAlertFactory = mockk(relaxed = true) + private val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase = mockk(relaxed = true) + private val getContactsUseCase: GetContactsUseCase = mockk(relaxed = true) + private val contactSelectionListener: ContactSelectionListener = mockk(relaxed = true) + private val callback: SendDestinationComponent.ModelCallback = mockk(relaxed = true) + + @BeforeEach + fun setUp() { + MockKAnnotations.init(this) + // PER_CLASS parameterized nested classes reuse one instance — reset verified mocks between rows. + clearMocks(callback, validateWalletAddressUseCase, answers = false, recordedCalls = true, childMocks = false) + coEvery { getNetworkAddressesUseCase.invokeSync(any(), any()) } returns emptyList() + every { getWalletsUseCase() } returns flowOf(emptyList()) + every { multiAccountStatusListSupplier() } returns flowOf(emptyList()) + every { getFixedTxHistoryItemsUseCase(any(), any(), any()) } returns flowOf(emptyList()).right() + every { isAccountsModeEnabledUseCase() } returns flowOf(false) + coEvery { isSelfSendAvailableUseCase.invokeSync(any(), any()) } returns false + every { listenToQrScanningUseCase(any()) } returns emptyFlow().right() + coEvery { validateWalletMemoUseCase(any(), any(), any()) } returns Unit.right() + coEvery { isMemoRequiredUseCase(any(), any()) } returns false + every { getContactsUseCase(any(), any()) } returns flowOf(emptyList()) + every { contactSelectionListener.resultFlow } returns MutableSharedFlow() + coEvery { getBackupProblematicWalletForAddressUseCase(any()) } returns null + every { cryptoCurrency.network.rawId } returns networkRawId + } + + @Nested + inner class Validate { + + @Test + fun `GIVEN valid non-problematic address WHEN address entered THEN send valid analytics without backup alert`() = + runTest { + // Arrange + coEvery { validateWalletAddressUseCase(any(), any(), any(), any>(), any()) } returns + AddressValidation.Success.Valid.right() + val sut = buildModel() + advanceUntilIdle() + + // Act + sut.onRecipientAddressValueChange("validAddr", EnterAddressSource.InputField) + advanceUntilIdle() + + // Assert + verify(exactly = 1) { + analyticsEventHandler.send( + match { it.isValid }, + ) + } + verify(exactly = 0) { sendDestinationAlertFactory.showRecipientBackupErrorAlert(any()) } + // InputField is not an auto-next source → no auto-advance even for a valid address + verify(exactly = 0) { callback.onNextClick() } + } + + @Test + fun `GIVEN valid backup-problematic address WHEN address entered THEN show recipient backup error alert`() = + runTest { + // Arrange + coEvery { validateWalletAddressUseCase(any(), any(), any(), any>(), any()) } returns + AddressValidation.Success.Valid.right() + coEvery { getBackupProblematicWalletForAddressUseCase(any()) } returns testUserWalletId + val sut = buildModel() + advanceUntilIdle() + + // Act + sut.onRecipientAddressValueChange("problematicAddr", EnterAddressSource.InputField) + advanceUntilIdle() + + // Assert + verify(exactly = 1) { sendDestinationAlertFactory.showRecipientBackupErrorAlert(any()) } + // backup override flips the (format-valid) result to error → analytics reports it as invalid + verify(exactly = 1) { + analyticsEventHandler.send(match { !it.isValid }) + } + } + + @Test + fun `GIVEN invalid address WHEN address entered THEN send invalid analytics`() = runTest { + // Arrange + coEvery { validateWalletAddressUseCase(any(), any(), any(), any>(), any()) } returns + AddressValidation.Error.InvalidAddress.left() + val sut = buildModel() + advanceUntilIdle() + + // Act + sut.onRecipientAddressValueChange("badAddr", EnterAddressSource.InputField) + advanceUntilIdle() + + // Assert + verify(exactly = 1) { + analyticsEventHandler.send( + match { !it.isValid }, + ) + } + } + + @Test + fun `GIVEN memo change with null type WHEN handled THEN no address-entered analytics and no auto-next`() = + runTest { + // Arrange + coEvery { validateWalletAddressUseCase(any(), any(), any(), any>(), any()) } returns + AddressValidation.Success.Valid.right() + val sut = buildModel() + advanceUntilIdle() + + // Act — onRecipientMemoValueChange calls validate(type = null) + sut.onRecipientMemoValueChange("memo", isValuePasted = false) + advanceUntilIdle() + + // Assert + verify(exactly = 0) { + analyticsEventHandler.send(any()) + } + verify(exactly = 0) { callback.onNextClick() } + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class AutoNext { + + @ParameterizedTest + @ProvideTestModels + fun `GIVEN auto-next source WHEN address entered THEN advance only when address valid`(model: AutoNextModel) = + runTest { + // Arrange + coEvery { + validateWalletAddressUseCase(any(), any(), any(), any>(), any()) + } returns model.addressValidation + + val sut = buildModel() + advanceUntilIdle() + + // Act — RecentAddress is an auto-next source + sut.onRecipientAddressValueChange("addr", EnterAddressSource.RecentAddress) + advanceUntilIdle() + + // Assert + verify(exactly = model.expectedNextClicks) { callback.onNextClick() } + } + + private fun provideTestModels() = listOf( + AutoNextModel(addressValidation = AddressValidation.Success.Valid.right(), expectedNextClicks = 1), + AutoNextModel(addressValidation = AddressValidation.Error.InvalidAddress.left(), expectedNextClicks = 0), + ) + } + + @Nested + inner class QrScan { + + @Test + fun `GIVEN unparseable QR WHEN scanned THEN do NOT validate`() = runTest { + // Arrange + val qrFlow = MutableStateFlow("rawQr") + every { listenToQrScanningUseCase(any()) } returns qrFlow.right() + every { parseQrCodeUseCase("rawQr", cryptoCurrency) } returns + IllegalStateException("bad qr").left() + buildModel() + + // Act + advanceUntilIdle() + + // Assert + coVerify(exactly = 0) { validateWalletAddressUseCase(any(), any(), any(), any>(), any()) } + } + } + + @Nested + inner class Contacts { + + @Test + fun `GIVEN a selected contact WHEN applySelectedContact THEN address filled validated and contact set`() = + runTest { + // Arrange + coEvery { + validateWalletAddressUseCase(any(), any(), any(), any>(), any()) + } returns AddressValidation.Success.Valid.right() + val sut = buildModel() + advanceUntilIdle() + + // Act + sut.applySelectedContact(selectedContact(name = "Bob", address = "0xBob")) + advanceUntilIdle() + + // Assert — the contact's address is filled in and validated, and the contact name is shown + coVerify { + validateWalletAddressUseCase(any(), any(), eq("0xBob"), any>(), any()) + } + assertThat(content(sut).addressTextField.value).isEqualTo("0xBob") + assertThat(content(sut).addressTextField.contactName).isEqualTo("Bob") + } + + @Test + fun `GIVEN a contact is set WHEN route switches to edit mode THEN the contact is reset`() = runTest { + // Arrange + coEvery { + validateWalletAddressUseCase(any(), any(), any(), any>(), any()) + } returns AddressValidation.Success.Valid.right() + val currentRoute = MutableStateFlow(CommonSendRoute.Destination(isEditMode = false)) + val sut = buildModel(currentRoute = currentRoute) + advanceUntilIdle() + sut.applySelectedContact(selectedContact(name = "Dave", address = "0xDave")) + advanceUntilIdle() + assertThat(content(sut).addressTextField.contactName).isEqualTo("Dave") + + // Act — entering edit mode must clear the bound contact + currentRoute.value = CommonSendRoute.Destination(isEditMode = true) + advanceUntilIdle() + + // Assert + assertThat(content(sut).addressTextField.contactName).isNull() + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class ContactRecognition { + + @ParameterizedTest + @ProvideTestModels + fun `WHEN address entered THEN recognize matching saved contact case-insensitively`( + model: ContactRecognitionModel, + ) = runTest { + // Arrange + coEvery { + validateWalletAddressUseCase(any(), any(), any(), any>(), any()) + } returns AddressValidation.Success.Valid.right() + every { getContactsUseCase(any(), any()) } returns + flowOf(listOf(buildContact(name = model.savedName, address = model.savedAddress))) + val sut = buildModel() + advanceUntilIdle() + + // Act + sut.onRecipientAddressValueChange(model.enteredAddress, EnterAddressSource.InputField) + advanceUntilIdle() + + // Assert + assertThat(content(sut).addressTextField.contactName).isEqualTo(model.expectedContactName) + } + + private fun provideTestModels() = listOf( + // saved "0xAddr", entered "0xaddr" → case-insensitive match + ContactRecognitionModel(savedName = "Alice", savedAddress = "0xAddr", enteredAddress = "0xaddr", expectedContactName = "Alice"), + // entered address not among saved contacts → no recognition + ContactRecognitionModel(savedName = "Alice", savedAddress = "0xOther", enteredAddress = "0xAddr", expectedContactName = null), + ) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class OnContactClick { + + @ParameterizedTest + @ProvideTestModels + fun `WHEN onContactClick THEN apply single-address contact directly else open selector`( + model: ContactClickModel, + ) = runTest { + // Arrange + coEvery { + validateWalletAddressUseCase(any(), any(), any(), any>(), any()) + } returns AddressValidation.Success.Valid.right() + val sut = buildModel() + advanceUntilIdle() + + // Act + sut.onContactClick(matchedContact(addresses = model.addresses)) + advanceUntilIdle() + + // Assert + if (model.expectedValidatedAddress != null) { + // single entry → applied directly → that address gets validated + coVerify { + validateWalletAddressUseCase( + any(), any(), eq(model.expectedValidatedAddress), any>(), any(), + ) + } + } else { + // multiple entries → selector opened, nothing applied/validated yet + coVerify(exactly = 0) { + validateWalletAddressUseCase(any(), any(), any(), any>(), any()) + } + } + } + + private fun provideTestModels() = listOf( + ContactClickModel(addresses = listOf("0xSingle"), expectedValidatedAddress = "0xSingle"), + ContactClickModel(addresses = listOf("0xA", "0xB"), expectedValidatedAddress = null), + ) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class ShowAddContact { + + @ParameterizedTest + @ProvideTestModels + fun `WHEN address entered THEN show add-contact only when available and not already saved`( + model: AddContactModel, + ) = runTest { + // Arrange + coEvery { + validateWalletAddressUseCase(any(), any(), any(), any>(), any()) + } returns AddressValidation.Success.Valid.right() + every { getContactsUseCase(any(), any()) } returns + flowOf(model.savedAddresses.map { buildContact(address = it) }) + val sut = buildBlockModel(isAddContactAvailable = model.isAddContactAvailable) + advanceUntilIdle() + + // Act + sut.onRecipientAddressValueChange(model.enteredAddress, EnterAddressSource.InputField) + advanceUntilIdle() + + // Assert + assertThat(sut.showAddContact.value).isEqualTo(model.expectedShown) + } + + private fun provideTestModels() = listOf( + // not available -> never shown, even for a fresh valid address + AddContactModel(isAddContactAvailable = false, savedAddresses = emptyList(), enteredAddress = "0xFresh", expectedShown = false), + // available + address not in the book -> shown + AddContactModel(isAddContactAvailable = true, savedAddresses = emptyList(), enteredAddress = "0xFresh", expectedShown = true), + // available but address already saved -> hidden + AddContactModel(isAddContactAvailable = true, savedAddresses = listOf("0xSaved"), enteredAddress = "0xSaved", expectedShown = false), + ) + } + + // region fixtures + + private fun TestScope.buildModel( + currentRoute: MutableStateFlow = + MutableStateFlow(CommonSendRoute.Destination(isEditMode = false)), + ): SendDestinationModel { + val params = SendDestinationComponentParams.DestinationParams( + state = DestinationUM.Empty(), + analyticsCategoryName = "test_send", + analyticsSendSource = CommonSendAnalyticEvents.CommonSendSource.Send, + cryptoCurrency = cryptoCurrency, + userWalletId = testUserWalletId, + title = stringReference("Send to"), + isBalanceHidingFlow = MutableStateFlow(false), + currentRoute = currentRoute, + callback = callback, + isAllowSelfSend = false, + ) + return createModel(params) + } + + /** Builds the model with the success-screen block flavor ([DestinationBlockParams]) used by `showAddContact`. */ + private fun TestScope.buildBlockModel(isAddContactAvailable: Boolean): SendDestinationModel { + val params = SendDestinationComponentParams.DestinationBlockParams( + state = DestinationUM.Empty(), + analyticsCategoryName = "test_send", + analyticsSendSource = CommonSendAnalyticEvents.CommonSendSource.Send, + userWalletId = testUserWalletId, + cryptoCurrency = cryptoCurrency, + blockClickEnableFlow = MutableStateFlow(true), + predefinedValues = PredefinedValues.Empty, + isAllowSelfSend = false, + isAddContactAvailable = isAddContactAvailable, + ) + return createModel(params) + } + + private fun TestScope.createModel(params: SendDestinationComponentParams): SendDestinationModel { + return SendDestinationModel( + paramsContainer = MutableParamsContainer(params), + dispatchers = testDispatcherProvider(), + router = router, + validateWalletAddressUseCase = validateWalletAddressUseCase, + validateWalletMemoUseCase = validateWalletMemoUseCase, + isMemoRequiredUseCase = isMemoRequiredUseCase, + getWalletsUseCase = getWalletsUseCase, + getNetworkAddressesUseCase = getNetworkAddressesUseCase, + getFixedTxHistoryItemsUseCase = getFixedTxHistoryItemsUseCase, + isSelfSendAvailableUseCase = isSelfSendAvailableUseCase, + listenToQrScanningUseCase = listenToQrScanningUseCase, + parseQrCodeUseCase = parseQrCodeUseCase, + isAccountsModeEnabledUseCase = isAccountsModeEnabledUseCase, + analyticsEventHandler = analyticsEventHandler, + multiAccountStatusListSupplier = multiAccountStatusListSupplier, + getBackupProblematicWalletForAddressUseCase = getBackupProblematicWalletForAddressUseCase, + sendDestinationAlertFactory = sendDestinationAlertFactory, + sendBackupProblemEmailUseCase = sendBackupProblemEmailUseCase, + getContactsUseCase = getContactsUseCase, + contactSelectionListener = contactSelectionListener, + ) + } + + private fun buildContact(name: String = "Alice", address: String = "0xAddr"): Contact = Contact( + id = ContactId("c1"), + walletId = testUserWalletId, + name = ContactName(name).getOrNull()!!, + icon = "icon", + iconColor = "#FFFFFF", + createdAt = "2026-01-01T00:00:00.000Z", + updatedAt = "2026-01-01T00:00:00.000Z", + addressEntries = listOf( + AddressEntry( + id = AddressEntryId("e1"), + address = address, + networkId = Network.RawID(networkRawId), + networkName = "Ethereum", + memo = null, + signature = "", + ), + ), + ) + + private fun matchedContact(name: String = "Alice", addresses: List = listOf("0xAddr")): MatchedContact = + MatchedContact( + contactId = "c1", + walletId = testUserWalletId.stringValue, + name = name, + icon = contactIcon, + networkId = networkRawId, + entries = addresses + .map { MatchedContact.ContactAddress(address = it, memo = null, networkName = "Ethereum") } + .toImmutableList(), + ) + + private fun selectedContact( + name: String = "Alice", + address: String = "0xAddr", + memo: String? = null, + ): SelectedContact = SelectedContact( + contactId = "c1", + name = name, + icon = contactIcon, + address = address, + networkId = networkRawId, + memo = memo, + ) + + private fun content(model: SendDestinationModel): DestinationUM.Content = + model.uiState.value as DestinationUM.Content + + data class AutoNextModel(val addressValidation: AddressValidationResult, val expectedNextClicks: Int) + + data class AddContactModel( + val isAddContactAvailable: Boolean, + val savedAddresses: List, + val enteredAddress: String, + val expectedShown: Boolean, + ) + + data class ContactClickModel(val addresses: List, val expectedValidatedAddress: String?) + + data class ContactRecognitionModel( + val savedName: String, + val savedAddress: String, + val enteredAddress: String, + val expectedContactName: String?, + ) + + // endregion +} \ No newline at end of file diff --git a/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/converters/SendRecipientHistoryListConverterTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/converters/SendRecipientHistoryListConverterTest.kt new file mode 100644 index 0000000000..063b28d4c7 --- /dev/null +++ b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/converters/SendRecipientHistoryListConverterTest.kt @@ -0,0 +1,134 @@ +package com.tangem.features.send.subcomponents.destination.model.converters + +import android.text.format.DateFormat +import com.google.common.truth.Truth.assertThat +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.network.TxInfo +import com.tangem.features.send.impl.R +import com.tangem.features.send.subcomponents.destination.model.transformers.RECENT_DEFAULT_COUNT +import com.tangem.features.send.subcomponents.destination.model.transformers.RECENT_KEY_TAG +import com.tangem.features.send.subcomponents.destination.model.transformers.emptyListState +import com.tangem.test.core.ProvideTestModels +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import java.math.BigDecimal + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class SendRecipientHistoryListConverterTest { + + private val cryptoCurrency = MockCryptoCurrencyFactory().ethereum + + private val converter = SendRecipientHistoryListConverter(cryptoCurrency) + + @BeforeEach + fun setUp() { + // Mapping formats the timestamp via DateTimeFormatters -> DateFormat.getBestDateTimePattern, + // which is an Android stub on the JVM. Mirror the project pattern so convert() runs. + mockkStatic(DateFormat::class) + every { DateFormat.getBestDateTimePattern(any(), any()) } answers { secondArg() } + } + + @AfterEach + fun tearDown() { + unmockkStatic(DateFormat::class) + } + + private fun txInfo( + isOutgoing: Boolean = true, + type: TxInfo.TransactionType = TxInfo.TransactionType.Transfer, + interactionAddressType: TxInfo.InteractionAddressType? = TxInfo.InteractionAddressType.User(RECIPIENT), + destinationType: TxInfo.DestinationType = TxInfo.DestinationType.Single(TxInfo.AddressType.User(RECIPIENT)), + sourceType: TxInfo.SourceType = TxInfo.SourceType.Single(SOURCE), + amount: BigDecimal = BigDecimal.ONE, + txHash: String = "hash", + ) = TxInfo( + txHash = txHash, + timestampInMillis = 1_700_000_000_000L, + isOutgoing = isOutgoing, + destinationType = destinationType, + sourceType = sourceType, + interactionAddressType = interactionAddressType, + status = TxInfo.TransactionStatus.Confirmed, + type = type, + amount = amount, + ) + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Filtering { + + @ParameterizedTest + @ProvideTestModels + fun `GIVEN excluded transaction WHEN convert THEN filtered out leaving empty placeholder`(model: FilterModel) { + // Act + val actual = converter.convert(listOf(model.tx)) + + // Assert + assertThat(actual).isEqualTo(emptyListState(RECENT_KEY_TAG, RECENT_DEFAULT_COUNT)) + } + + private fun provideTestModels() = listOf( + FilterModel("non-transfer type", txInfo(type = TxInfo.TransactionType.Swap)), + FilterModel( + "contract interaction", + txInfo(interactionAddressType = TxInfo.InteractionAddressType.Contract(RECIPIENT)), + ), + FilterModel("null interaction", txInfo(interactionAddressType = null)), + FilterModel("incoming", txInfo(isOutgoing = false)), + FilterModel( + "multiple destinations", + txInfo(destinationType = TxInfo.DestinationType.Multiple(listOf(TxInfo.AddressType.User(RECIPIENT)))), + ), + FilterModel("zero amount", txInfo(amount = BigDecimal.ZERO)), + ) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Mapping { + + @Test + fun `GIVEN valid outgoing transfer WHEN convert THEN mapped to recipient item`() { + // Act + val actual = converter.convert(listOf(txInfo())) + + // Assert + assertThat(actual).hasSize(1) + val item = actual.first() + assertThat(item.id).isEqualTo("${RECENT_KEY_TAG}0") + assertThat(item.title).isEqualTo(stringReference(RECIPIENT)) + assertThat(item.subtitleEndOffset).isEqualTo(cryptoCurrency.symbol.length) + assertThat(item.subtitleIconRes).isEqualTo(R.drawable.ic_arrow_up_24) + assertThat(item.isVisible).isTrue() + } + + @Test + fun `GIVEN more than ten valid transactions WHEN convert THEN capped at ten`() { + // Arrange + val txs = (1..12).map { txInfo(txHash = "hash$it") } + + // Act + val actual = converter.convert(txs) + + // Assert + assertThat(actual).hasSize(10) + assertThat(actual.first().id).isEqualTo("${RECENT_KEY_TAG}0") + assertThat(actual.last().id).isEqualTo("${RECENT_KEY_TAG}9") + } + } + + data class FilterModel(val case: String, val tx: TxInfo) + + private companion object { + private const val RECIPIENT = "0xRecipientAddress" + private const val SOURCE = "0xSourceAddress" + } +} \ No newline at end of file diff --git a/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/converters/SendRecipientWalletListConverterTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/converters/SendRecipientWalletListConverterTest.kt new file mode 100644 index 0000000000..fe6bb3cf63 --- /dev/null +++ b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/converters/SendRecipientWalletListConverterTest.kt @@ -0,0 +1,130 @@ +package com.tangem.features.send.subcomponents.destination.model.converters + +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.Blockchain +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.core.ui.extensions.stringReference +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.send.subcomponents.destination.model.transformers.WALLET_DEFAULT_COUNT +import com.tangem.features.send.subcomponents.destination.model.transformers.WALLET_KEY_TAG +import com.tangem.features.send.subcomponents.destination.model.transformers.emptyListState +import com.tangem.features.send.subcomponents.destination.ui.state.DestinationWalletUM +import com.tangem.test.core.ProvideTestModels +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class SendRecipientWalletListConverterTest { + + private val currencyFactory = MockCryptoCurrencyFactory() + private val coin: CryptoCurrency = currencyFactory.ethereum + private val token: CryptoCurrency = currencyFactory.createToken(Blockchain.Ethereum) + + private fun converter( + senderAddress: String? = SENDER, + isSelfSendAvailable: Boolean = false, + isAccountsMode: Boolean = false, + ) = SendRecipientWalletListConverter( + senderAddress = senderAddress, + isSelfSendAvailable = isSelfSendAvailable, + isAccountsMode = isAccountsMode, + ) + + private fun wallet( + name: String = "Wallet", + userWalletId: UserWalletId = UserWalletId("a1"), + address: String = "0xWalletAddress", + cryptoCurrency: CryptoCurrency = coin, + ) = DestinationWalletUM( + name = name, + userWalletId = userWalletId, + address = address, + cryptoCurrency = cryptoCurrency, + account = null, + ) + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Filtering { + + @ParameterizedTest + @ProvideTestModels + fun `GIVEN excluded wallet WHEN convert THEN filtered out leaving empty placeholder`(model: ExcludedModel) { + // Act + val actual = model.converter.convert(listOf(model.wallet)) + + // Assert + assertThat(actual).isEqualTo(emptyListState(WALLET_KEY_TAG, WALLET_DEFAULT_COUNT)) + } + + private fun provideTestModels() = listOf( + ExcludedModel("blank address", wallet(address = ""), converter()), + ExcludedModel("token and not a payment account", wallet(cryptoCurrency = token), converter()), + ExcludedModel( + "own address while self-send disabled", + wallet(address = SENDER), + converter(senderAddress = SENDER, isSelfSendAvailable = false), + ), + ) + + @Test + fun `GIVEN own address while self-send enabled WHEN convert THEN included`() { + // Act + val actual = converter(senderAddress = SENDER, isSelfSendAvailable = true) + .convert(listOf(wallet(address = SENDER))) + + // Assert + assertThat(actual).hasSize(1) + assertThat(actual.first().title).isEqualTo(stringReference(SENDER)) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Grouping { + + @Test + fun `GIVEN same name across multiple wallets WHEN convert THEN names disambiguated with index`() { + // Arrange (same name, different userWalletId -> group size > 1) + val wallets = listOf( + wallet(name = "Main", userWalletId = UserWalletId("a1"), address = "0xA"), + wallet(name = "Main", userWalletId = UserWalletId("a2"), address = "0xB"), + ) + + // Act + val actual = converter().convert(wallets) + + // Assert + assertThat(actual).hasSize(2) + assertThat(actual[0].id).isEqualTo("${WALLET_KEY_TAG}0") + assertThat(actual[1].id).isEqualTo("${WALLET_KEY_TAG}1") + assertThat(actual[0].subtitle).isEqualTo(stringReference("Main 1")) + assertThat(actual[1].subtitle).isEqualTo(stringReference("Main 2")) + assertThat(actual[0].title).isEqualTo(stringReference("0xA")) + assertThat(actual[1].title).isEqualTo(stringReference("0xB")) + } + + @Test + fun `GIVEN single wallet for a name WHEN convert THEN name kept without index`() { + // Act + val actual = converter().convert(listOf(wallet(name = "Solo", address = "0xA"))) + + // Assert + assertThat(actual).hasSize(1) + assertThat(actual.first().subtitle).isEqualTo(stringReference("Solo")) + } + } + + data class ExcludedModel( + val case: String, + val wallet: DestinationWalletUM, + val converter: SendRecipientWalletListConverter, + ) + + private companion object { + private const val SENDER = "0xSenderAddress" + } +} \ No newline at end of file diff --git a/features/send/impl/src/test/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationValidationResultTransformerTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/transformers/SendDestinationValidationResultTransformerTest.kt similarity index 94% rename from features/send/impl/src/test/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationValidationResultTransformerTest.kt rename to features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/transformers/SendDestinationValidationResultTransformerTest.kt index 7c18b9b971..0df06b593e 100644 --- a/features/send/impl/src/test/java/com/tangem/features/send/v2/subcomponents/destination/model/transformers/SendDestinationValidationResultTransformerTest.kt +++ b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/transformers/SendDestinationValidationResultTransformerTest.kt @@ -151,18 +151,18 @@ class SendDestinationValidationResultTransformerTest { isPrimaryButtonEnabled = false, addressTextField = DestinationTextFieldUM.RecipientAddress( value = "0xRecipient", - keyboardOptions = KeyboardOptions.Default, - placeholder = TextReference.EMPTY, - label = TextReference.EMPTY, + keyboardOptions = KeyboardOptions.Companion.Default, + placeholder = TextReference.Companion.EMPTY, + label = TextReference.Companion.EMPTY, isValuePasted = false, ), memoTextField = DestinationTextFieldUM.RecipientMemo( value = memo, - keyboardOptions = KeyboardOptions.Default, - placeholder = TextReference.EMPTY, - label = TextReference.EMPTY, + keyboardOptions = KeyboardOptions.Companion.Default, + placeholder = TextReference.Companion.EMPTY, + label = TextReference.Companion.EMPTY, error = formatErrorRef, - disabledText = TextReference.EMPTY, + disabledText = TextReference.Companion.EMPTY, isEnabled = true, isValuePasted = false, ), diff --git a/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/bitcoin/BitcoinCustomFeeConverterTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/bitcoin/BitcoinCustomFeeConverterTest.kt new file mode 100644 index 0000000000..6bf6be6dc1 --- /dev/null +++ b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/bitcoin/BitcoinCustomFeeConverterTest.kt @@ -0,0 +1,234 @@ +package com.tangem.features.send.subcomponents.fee.model.converters.custom.bitcoin + +import androidx.compose.ui.text.input.ImeAction +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.features.send.loadedStatus +import com.tangem.test.core.ProvideTestModels +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import java.math.BigDecimal + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class BitcoinCustomFeeConverterTest { + + private val currencyFactory = MockCryptoCurrencyFactory() + + private val feeStatus = loadedStatus( + currency = currencyFactory.createCoin(Blockchain.Bitcoin), + fiatRate = BigDecimal("50000"), + ) + + private val converter = bitcoinConverter(feeStatus) + + private fun bitcoinConverter(status: CryptoCurrencyStatus) = BitcoinCustomFeeConverter( + onCustomFeeValueChange = { _, _ -> }, + onNextClick = {}, + appCurrency = AppCurrency.Default, + feeCryptoCurrencyStatus = status, + ) + + private fun amount(amount: BigDecimal?) = Amount( + currencySymbol = "BTC", + value = amount, + decimals = BTC_DECIMALS, + ) + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Convert { + + @ParameterizedTest + @ProvideTestModels + fun `GIVEN bitcoin fee WHEN convert THEN amount readonly and satoshiPerByte computed`( + model: ConvertModel, + ) { + // Act + val actual = converter.convert(model.fee) + + // Assert + assertThat(actual).hasSize(2) + assertThat(actual[FEE_AMOUNT_INDEX].isReadonly).isTrue() + assertThat(actual[FEE_AMOUNT_INDEX].value).isEqualTo(model.expectedAmount) + assertThat(actual[FEE_SATOSHI_INDEX].value).isEqualTo(model.expectedSatoshi) + } + + private fun provideTestModels() = listOf( + ConvertModel( + fee = Fee.Bitcoin( + amount(BigDecimal("0.000025")), + BigDecimal("10"), + BigDecimal("250") + ), + expectedAmount = "0.000025", + expectedSatoshi = "10", + ), // exact: 2500 sat / 250 byte + ConvertModel( + fee = Fee.Bitcoin( + amount(BigDecimal("0.00002875")), + BigDecimal("10"), + BigDecimal("250") + ), + expectedAmount = "0.00002875", + expectedSatoshi = "12", + ), // 2875 sat / 250 byte = 11.5 -> HALF_UP -> 12 + ConvertModel( + fee = Fee.Bitcoin( + amount(null), + BigDecimal("10"), + BigDecimal("250") + ), + expectedAmount = "", + expectedSatoshi = "", + ), // null amount -> both fields empty + ) + + @Test + fun `GIVEN non-bitcoin network WHEN convert THEN returns empty list`() { + // Arrange + val ethStatus = feeStatus.copy(currency = currencyFactory.createCoin(Blockchain.Ethereum)) + + // Act + val actual = bitcoinConverter(ethStatus).convert( + Fee.Bitcoin( + amount(BigDecimal("0.000025")), + BigDecimal("10"), + BigDecimal("250") + ), + ) + + // Assert + assertThat(actual).isEmpty() + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Affordability { + + @ParameterizedTest + @ProvideTestModels + fun `GIVEN fee compared to balance WHEN convert THEN satoshi field imeAction reflects affordability`( + model: ImeActionModel, + ) { + // Act (balance = 1 BTC) + val actual = converter.convert(model.fee) + + // Assert + assertThat(actual[FEE_SATOSHI_INDEX].keyboardOptions.imeAction).isEqualTo(model.expectedImeAction) + } + + private fun provideTestModels() = listOf( + ImeActionModel( + fee = Fee.Bitcoin( + amount(BigDecimal("0.000025")), + BigDecimal("10"), + BigDecimal("250") + ), + expectedImeAction = ImeAction.Done, + ), // within balance + ImeActionModel( + fee = Fee.Bitcoin( + amount(BigDecimal("2")), + BigDecimal("10"), + BigDecimal("250") + ), + expectedImeAction = ImeAction.None, + ), // exceeds balance + ) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class ConvertBack { + + @Test + fun `GIVEN custom fields WHEN convertBack THEN amount and satoshiPerByte parsed back`() { + // Arrange + val normalFee = Fee.Bitcoin(amount(BigDecimal("0.000025")), BigDecimal("10"), BigDecimal("250")) + val fields = converter.convert(normalFee) + + // Act + val actual = converter.convertBack(normalFee, fields) + + // Assert + assertThat(actual.amount.value!!.compareTo(BigDecimal("0.000025"))).isEqualTo(0) + assertThat(actual.satoshiPerByte.compareTo(BigDecimal("10"))).isEqualTo(0) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class OnValueChange { + + @ParameterizedTest + @ProvideTestModels + fun `GIVEN satoshi changed WHEN onValueChange THEN fee amount recalculated`( + model: OnValueChangeModel, + ) { + // Arrange + val fields = converter.convert( + Fee.Bitcoin( + amount(BigDecimal("0.000025")), + BigDecimal("10"), + BigDecimal("250") + ), + ) + + // Act + val actual = converter.onValueChange(fields, FEE_SATOSHI_INDEX, model.inputSatoshi, model.txSize) + + // Assert + assertThat(actual[FEE_AMOUNT_INDEX].value).isEqualTo(model.expectedAmount) + assertThat(actual[FEE_SATOSHI_INDEX].value).isEqualTo(model.inputSatoshi) + } + + private fun provideTestModels() = listOf( + OnValueChangeModel( + inputSatoshi = "20", + txSize = BigDecimal("250"), + expectedAmount = "0.00005", + ), // 20 * 250 = 5000 sat = 0.00005 BTC + OnValueChangeModel( + inputSatoshi = "11", + txSize = BigDecimal("250.5"), + expectedAmount = "0.00002755", + ), // 11 * 250.5 = 2755.5 sat -> 0.000027555 -> DOWN to 8 decimals + ) + + @Test + fun `GIVEN non-satoshi index WHEN onValueChange THEN values unchanged`() { + // Arrange + val fields = converter.convert( + Fee.Bitcoin( + amount(BigDecimal("0.000025")), + BigDecimal("10"), + BigDecimal("250") + ), + ) + + // Act + val actual = converter.onValueChange(fields, FEE_AMOUNT_INDEX, "999", BigDecimal("250")) + + // Assert + assertThat(actual).isEqualTo(fields) + } + } + + data class ConvertModel(val fee: Fee.Bitcoin, val expectedAmount: String, val expectedSatoshi: String) + data class ImeActionModel(val fee: Fee.Bitcoin, val expectedImeAction: ImeAction) + data class OnValueChangeModel(val inputSatoshi: String, val txSize: BigDecimal, val expectedAmount: String) + + private companion object { + private const val BTC_DECIMALS = 8 + private const val FEE_AMOUNT_INDEX = 0 + private const val FEE_SATOSHI_INDEX = 1 + } +} \ No newline at end of file diff --git a/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/ethereum/EthereumCustomFeeConverterTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/ethereum/EthereumCustomFeeConverterTest.kt new file mode 100644 index 0000000000..45dda52824 --- /dev/null +++ b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/ethereum/EthereumCustomFeeConverterTest.kt @@ -0,0 +1,139 @@ +package com.tangem.features.send.subcomponents.fee.model.converters.custom.ethereum + +import androidx.compose.ui.text.input.ImeAction +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.test.core.ProvideTestModels +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import java.math.BigDecimal +import java.math.BigInteger + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class EthereumCustomFeeConverterTest { + + private val feeStatus = ethFeeStatus() + + private val converter = EthereumCustomFeeConverter( + onCustomFeeValueChange = { _, _ -> }, + onNextClick = {}, + appCurrency = AppCurrency.Default, + feeCryptoCurrencyStatus = feeStatus, + ) + + private fun legacyFee(amount: BigDecimal? = BigDecimal("0.01")) = Fee.Ethereum.Legacy( + amount = ethAmount(amount), + gasLimit = GAS_LIMIT, + gasPrice = BigInteger.valueOf(1_000_000_000), + ) + + private fun eipFee(amount: BigDecimal? = BigDecimal("0.01")) = Fee.Ethereum.EIP1559( + amount = ethAmount(amount), + gasLimit = GAS_LIMIT, + maxFeePerGas = BigInteger.valueOf(2_000_000_000), + priorityFee = BigInteger.valueOf(1_000_000_000), + ) + + private fun tokenFee() = Fee.Ethereum.TokenCurrency( + amount = ethAmount(BigDecimal("0.01")), + gasLimit = GAS_LIMIT, + coinPriceInToken = BigInteger.ONE, + feeTransferGasLimit = BigInteger.ONE, + baseGas = BigInteger.ONE, + ) + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Convert { + + @Test + fun `GIVEN token currency fee WHEN convert THEN returns empty list`() { + // Act + val actual = converter.convert(tokenFee()) + + // Assert + assertThat(actual).isEmpty() + } + + @ParameterizedTest + @ProvideTestModels + fun `GIVEN ethereum fee WHEN convert THEN amount is first and gasLimit at reported index`( + model: AssemblyModel, + ) { + // Act + val actual = converter.convert(model.fee) + + // Assert + assertThat(actual).hasSize(model.expectedFieldCount) + assertThat(actual.first().value).isEqualTo("0.01") + assertThat(actual[converter.getGasLimitIndex(model.fee)].value).isEqualTo(GAS_LIMIT.toString()) + } + + private fun provideTestModels() = listOf( + AssemblyModel(fee = legacyFee(), expectedFieldCount = LEGACY_FIELD_COUNT), // [amount, gasPrice, gasLimit] + AssemblyModel(fee = eipFee(), expectedFieldCount = EIP_FIELD_COUNT), // [amount, maxFee, priorityFee, gasLimit] + ) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class GasLimitImeAction { + + @ParameterizedTest + @ProvideTestModels + fun `GIVEN fee compared to balance WHEN convert THEN gasLimit imeAction reflects affordability`( + model: ImeActionModel, + ) { + // Act (balance = 1 ETH) + val actual = converter.convert(legacyFee(amount = model.feeAmount)) + + // Assert + assertThat(actual.last().keyboardOptions.imeAction).isEqualTo(model.expectedImeAction) + } + + private fun provideTestModels() = listOf( + ImeActionModel(feeAmount = BigDecimal("0.01"), expectedImeAction = ImeAction.Done), // within balance + ImeActionModel(feeAmount = BigDecimal("2"), expectedImeAction = ImeAction.None), // exceeds balance + ) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class ConvertBack { + + @ParameterizedTest + @ProvideTestModels + fun `GIVEN ethereum fee WHEN convertBack THEN delegates to matching converter`( + model: ConvertBackModel, + ) { + // Arrange + val fields = converter.convert(model.fee) + + // Act + val actual = converter.convertBack(model.fee, fields) + + // Assert + assertThat(actual).isInstanceOf(model.expectedClazz) + } + + private fun provideTestModels() = listOf( + ConvertBackModel(fee = legacyFee(), expectedClazz = Fee.Ethereum.Legacy::class.java), + ConvertBackModel(fee = eipFee(), expectedClazz = Fee.Ethereum.EIP1559::class.java), + ) + } + + data class AssemblyModel(val fee: Fee.Ethereum, val expectedFieldCount: Int) + data class ImeActionModel(val feeAmount: BigDecimal, val expectedImeAction: ImeAction) + data class ConvertBackModel(val fee: Fee.Ethereum, val expectedClazz: Class<*>) + + private companion object { + private val GAS_LIMIT: BigInteger = BigInteger.valueOf(21_000) + + // Router assembles [amount, ...type-specific, gasLimit]; Legacy adds 1 field, EIP adds 2. + private const val LEGACY_FIELD_COUNT = 3 + private const val EIP_FIELD_COUNT = 4 + } +} \ No newline at end of file diff --git a/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/ethereum/EthereumEIPCustomFeeConverterTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/ethereum/EthereumEIPCustomFeeConverterTest.kt new file mode 100644 index 0000000000..8b32839ca6 --- /dev/null +++ b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/ethereum/EthereumEIPCustomFeeConverterTest.kt @@ -0,0 +1,180 @@ +package com.tangem.features.send.subcomponents.fee.model.converters.custom.ethereum + +import androidx.compose.ui.text.input.ImeAction +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.features.send.api.subcomponents.feeSelector.entity.CustomFeeFieldUM +import com.tangem.test.core.ProvideTestModels +import kotlinx.collections.immutable.ImmutableList +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import java.math.BigDecimal +import java.math.BigInteger + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class EthereumEIPCustomFeeConverterTest { + + private val feeStatus = ethFeeStatus() + + private val converter = EthereumEIPCustomFeeConverter( + onCustomFeeValueChange = { _, _ -> }, + appCurrency = AppCurrency.Default, + feeCryptoCurrencyStatus = feeStatus, + ) + + // The leaf operates on the full field list assembled by the router: [amount, maxFee, priorityFee, gasLimit]. + private val router = EthereumCustomFeeConverter( + onCustomFeeValueChange = { _, _ -> }, + onNextClick = {}, + appCurrency = AppCurrency.Default, + feeCryptoCurrencyStatus = feeStatus, + ) + + private fun eipFee( + gasLimit: BigInteger = BigInteger.valueOf(21_000) + ) = Fee.Ethereum.EIP1559( + amount = ethAmount(BigDecimal("0.00063")), + gasLimit = gasLimit, + maxFeePerGas = BigInteger.valueOf(30_000_000_000), // 30 GWEI + priorityFee = BigInteger.valueOf(2_000_000_000), // 2 GWEI + ) + + private fun fullFields(fee: Fee.Ethereum.EIP1559): ImmutableList = router.convert(fee) + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Convert { + + @Test + fun `GIVEN eip fee WHEN convert THEN max fee and priority fee fields in GWEI`() { + // Act + val actual = converter.convert(eipFee()) + + // Assert + assertThat(actual).hasSize(2) + assertThat(actual[0].value).isEqualTo("30") // maxFeePerGas + assertThat(actual[1].value).isEqualTo("2") // priorityFee + assertThat(actual[0].symbol).isEqualTo("GWEI") + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class ConvertBack { + + @Test + fun `GIVEN fields WHEN convertBack THEN all fields parsed back`() { + // Arrange + val fee = eipFee() + val fields = fullFields(fee) + + // Act + val actual = converter.convertBack(fee, fields) + + // Assert + assertThat(actual.amount.value!!.compareTo(BigDecimal("0.00063"))).isEqualTo(0) + assertThat(actual.gasLimit).isEqualTo(BigInteger.valueOf(21_000)) + assertThat(actual.maxFeePerGas).isEqualTo(BigInteger.valueOf(30_000_000_000)) + assertThat(actual.priorityFee).isEqualTo(BigInteger.valueOf(2_000_000_000)) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class OnValueChange { + + @Test + fun `GIVEN max fee changed WHEN onValueChange THEN fee amount recalculated`() { + // Arrange (21000 * 40 GWEI = 0.00084 ETH) + val fee = eipFee() + val fields = fullFields(fee) + + // Act + val actual = converter.onValueChange(fee, fields, MAX_FEE_INDEX, "40") + + // Assert + assertThat(actual[FEE_AMOUNT_INDEX].value).isEqualTo("0.00084") + assertThat(actual[MAX_FEE_INDEX].value).isEqualTo("40") + } + + @Test + fun `GIVEN amount changed WHEN onValueChange THEN max fee recalculated`() { + // Arrange (0.00084 ETH / 21000 gas = 40 GWEI) + val fee = eipFee() + val fields = fullFields(fee) + + // Act + val actual = converter.onValueChange(fee, fields, FEE_AMOUNT_INDEX, "0.00084") + + // Assert + assertThat(actual[MAX_FEE_INDEX].value).isEqualTo("40") + assertThat(actual[FEE_AMOUNT_INDEX].value).isEqualTo("0.00084") + } + + @Test + fun `GIVEN amount changed and gas limit field is zero WHEN onValueChange THEN gas limit pulled from fee`() { + // Arrange: gas limit field shows "0" (cleared), but the original fee keeps gasLimit = 21000 + val fee = eipFee(gasLimit = BigInteger.valueOf(21_000)) + val fields = fullFields(eipFee(gasLimit = BigInteger.ZERO)) + + // Act (gasLimit pulled from fee = 21000 -> 0.00084 / 21000 = 40 GWEI) + val actual = converter.onValueChange(fee, fields, FEE_AMOUNT_INDEX, "0.00084") + + // Assert + assertThat(actual[GAS_LIMIT_INDEX].value).isEqualTo("21000") + assertThat(actual[MAX_FEE_INDEX].value).isEqualTo("40") + assertThat(actual[FEE_AMOUNT_INDEX].value).isEqualTo("0.00084") + } + + @Test + fun `GIVEN gas limit changed WHEN onValueChange THEN fee amount recalculated`() { + // Arrange (42000 * 30 GWEI = 0.00126 ETH, balance = 1 ETH) + val fee = eipFee() + val fields = fullFields(fee) + + // Act + val actual = converter.onValueChange(fee, fields, GAS_LIMIT_INDEX, "42000") + + // Assert + assertThat(actual[FEE_AMOUNT_INDEX].value).isEqualTo("0.00126") + assertThat(actual[GAS_LIMIT_INDEX].value).isEqualTo("42000") + // FIXME [AND-XXXXX]: same inverted imeAction as EthereumLegacyCustomFeeConverter.setOnGasLimitChange. + // checkExceedBalance() returns true when the fee EXCEEDS balance, but the code does + // `if (!isNotExceedBalance) None else Done`, so an affordable fee (0.00126 < 1 ETH) yields None. + // Asserting current (buggy) behavior until the converter is fixed. + assertThat(actual[GAS_LIMIT_INDEX].keyboardOptions.imeAction).isEqualTo(ImeAction.None) + } + + @ParameterizedTest + @ProvideTestModels + fun `GIVEN blank value WHEN onValueChange THEN dependent fields cleared`(model: BlankModel) { + // Arrange + val fee = eipFee() + val fields = fullFields(fee) + + // Act + val actual = converter.onValueChange(fee, fields, model.index, "") + + // Assert + model.clearedIndices.forEach { index -> + assertThat(actual[index].value).isEmpty() + } + } + + private fun provideTestModels() = listOf( + BlankModel(index = FEE_AMOUNT_INDEX, clearedIndices = listOf(FEE_AMOUNT_INDEX, MAX_FEE_INDEX)), + BlankModel(index = MAX_FEE_INDEX, clearedIndices = listOf(FEE_AMOUNT_INDEX, MAX_FEE_INDEX)), + BlankModel(index = GAS_LIMIT_INDEX, clearedIndices = listOf(FEE_AMOUNT_INDEX, GAS_LIMIT_INDEX)), + ) + } + + data class BlankModel(val index: Int, val clearedIndices: List) + + private companion object { + private const val MAX_FEE_INDEX = 1 + private const val GAS_LIMIT_INDEX = 3 + } +} \ No newline at end of file diff --git a/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/ethereum/EthereumLegacyCustomFeeConverterTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/ethereum/EthereumLegacyCustomFeeConverterTest.kt new file mode 100644 index 0000000000..82fde64087 --- /dev/null +++ b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/ethereum/EthereumLegacyCustomFeeConverterTest.kt @@ -0,0 +1,165 @@ +package com.tangem.features.send.subcomponents.fee.model.converters.custom.ethereum + +import androidx.compose.ui.text.input.ImeAction +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.features.send.api.subcomponents.feeSelector.entity.CustomFeeFieldUM +import com.tangem.test.core.ProvideTestModels +import kotlinx.collections.immutable.ImmutableList +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import java.math.BigDecimal +import java.math.BigInteger + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class EthereumLegacyCustomFeeConverterTest { + + private val feeStatus = ethFeeStatus() + + private val converter = EthereumLegacyCustomFeeConverter( + onCustomFeeValueChange = { _, _ -> }, + appCurrency = AppCurrency.Default, + feeCryptoCurrencyStatus = feeStatus, + ) + + // The leaf operates on the full field list assembled by the router: [amount, gasPrice, gasLimit]. + private val router = EthereumCustomFeeConverter( + onCustomFeeValueChange = { _, _ -> }, + onNextClick = {}, + appCurrency = AppCurrency.Default, + feeCryptoCurrencyStatus = feeStatus, + ) + + private fun legacyFee( + amount: BigDecimal? = BigDecimal("0.00042"), + gasLimit: BigInteger = BigInteger.valueOf(21_000), + gasPrice: BigInteger = BigInteger.valueOf(20_000_000_000), // 20 GWEI + ) = Fee.Ethereum.Legacy(amount = ethAmount(amount), gasLimit = gasLimit, gasPrice = gasPrice) + + private fun fullFields(fee: Fee.Ethereum.Legacy): ImmutableList = router.convert(fee) + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Convert { + + @Test + fun `GIVEN legacy fee WHEN convert THEN single gas price field in GWEI`() { + // Act + val actual = converter.convert(legacyFee(gasPrice = BigInteger.valueOf(20_000_000_000))) + + // Assert + assertThat(actual).hasSize(1) + assertThat(actual[0].value).isEqualTo("20") + assertThat(actual[0].symbol).isEqualTo("GWEI") + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class ConvertBack { + + @Test + fun `GIVEN fields WHEN convertBack THEN amount gasPrice and gasLimit parsed back`() { + // Arrange + val fee = legacyFee() + val fields = fullFields(fee) + + // Act + val actual = converter.convertBack(fee, fields) + + // Assert + assertThat(actual.amount.value!!.compareTo(BigDecimal("0.00042"))).isEqualTo(0) + assertThat(actual.gasLimit).isEqualTo(BigInteger.valueOf(21_000)) + // FIXME [AND-XXXXX]: convertBack does not convert gasPrice GWEI->wei (missing movePointRight(9)), + // unlike EthereumEIPCustomFeeConverter. Correct value is 20_000_000_000. + // Asserting current (buggy) behavior to keep the suite green until the converter is fixed. + // BUT is it any case when we will use ethereum legacy network? + assertThat(actual.gasPrice).isEqualTo(BigInteger.valueOf(20)) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class OnValueChange { + + @Test + fun `GIVEN gas price changed WHEN onValueChange THEN fee amount recalculated`() { + // Arrange (21000 * 30 GWEI = 0.00063 ETH) + val fee = legacyFee() + val fields = fullFields(fee) + + // Act + val actual = converter.onValueChange(fee, fields, GAS_PRICE_INDEX, "30") + + // Assert + assertThat(actual[FEE_AMOUNT_INDEX].value).isEqualTo("0.00063") + assertThat(actual[GAS_PRICE_INDEX].value).isEqualTo("30") + } + + @Test + fun `GIVEN gas limit changed WHEN onValueChange THEN fee amount recalculated`() { + // Arrange (42000 * 20 GWEI = 0.00084 ETH, balance = 1 ETH) + val fee = legacyFee() + val fields = fullFields(fee) + + // Act + val actual = converter.onValueChange(fee, fields, GAS_LIMIT_INDEX, "42000") + + // Assert + assertThat(actual[FEE_AMOUNT_INDEX].value).isEqualTo("0.00084") + assertThat(actual[GAS_LIMIT_INDEX].value).isEqualTo("42000") + // FIXME [AND-XXXXX]: imeAction is inverted here. checkExceedBalance() returns true when the fee EXCEEDS + // the balance, but setOnGasLimitChange does `if (!isNotExceedBalance) None else Done`, so an affordable + // fee (0.00084 < 1 ETH) yields None instead of Done. Router/Bitcoin use the correct `if (exceed) None`. + // Asserting current (buggy) behavior until the converter is fixed. + // BUT it looks like we do not use keyboardOptions to draw UI + assertThat(actual[GAS_LIMIT_INDEX].keyboardOptions.imeAction).isEqualTo(ImeAction.None) + } + + @Test + fun `GIVEN amount changed WHEN onValueChange THEN gas price recalculated`() { + // Arrange (0.00084 ETH / 21000 gas = 40 GWEI) + val fee = legacyFee() + val fields = fullFields(fee) + + // Act + val actual = converter.onValueChange(fee, fields, FEE_AMOUNT_INDEX, "0.00084") + + // Assert + assertThat(actual[GAS_PRICE_INDEX].value).isEqualTo("40") + assertThat(actual[FEE_AMOUNT_INDEX].value).isEqualTo("0.00084") + } + + @ParameterizedTest + @ProvideTestModels + fun `GIVEN blank value WHEN onValueChange THEN dependent fields cleared`(model: BlankModel) { + // Arrange + val fee = legacyFee() + val fields = fullFields(fee) + + // Act + val actual = converter.onValueChange(fee, fields, model.index, "") + + // Assert + model.clearedIndices.forEach { index -> + assertThat(actual[index].value).isEmpty() + } + } + + private fun provideTestModels() = listOf( + BlankModel(index = FEE_AMOUNT_INDEX, clearedIndices = listOf(FEE_AMOUNT_INDEX, GAS_PRICE_INDEX)), + BlankModel(index = GAS_PRICE_INDEX, clearedIndices = listOf(FEE_AMOUNT_INDEX, GAS_PRICE_INDEX)), + BlankModel(index = GAS_LIMIT_INDEX, clearedIndices = listOf(FEE_AMOUNT_INDEX, GAS_LIMIT_INDEX)), + ) + } + + data class BlankModel(val index: Int, val clearedIndices: List) + + private companion object { + private const val GAS_PRICE_INDEX = 1 + private const val GAS_LIMIT_INDEX = 2 + } +} \ No newline at end of file diff --git a/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/ethereum/EthereumTestUtils.kt b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/ethereum/EthereumTestUtils.kt new file mode 100644 index 0000000000..1e0fed51e9 --- /dev/null +++ b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/ethereum/EthereumTestUtils.kt @@ -0,0 +1,21 @@ +package com.tangem.features.send.subcomponents.fee.model.converters.custom.ethereum + +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.Blockchain +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.features.send.loadedStatus +import java.math.BigDecimal + +internal const val ETH_DECIMALS = 18 + +/** Index of the read-only fee-amount field, shared by every Ethereum custom-fee field layout. */ +internal const val FEE_AMOUNT_INDEX = 0 + +internal fun ethAmount(value: BigDecimal?) = Amount(currencySymbol = "ETH", value = value, decimals = ETH_DECIMALS) + +/** Loaded ETH status with a 1 ETH balance — the shared fixture for the Ethereum custom-fee converter tests. */ +internal fun ethFeeStatus(): CryptoCurrencyStatus = loadedStatus( + currency = MockCryptoCurrencyFactory().createCoin(Blockchain.Ethereum), + fiatRate = BigDecimal("2000"), +) \ No newline at end of file diff --git a/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/kaspa/KaspaCustomFeeConverterTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/kaspa/KaspaCustomFeeConverterTest.kt new file mode 100644 index 0000000000..b8ab02b464 --- /dev/null +++ b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/fee/model/converters/custom/kaspa/KaspaCustomFeeConverterTest.kt @@ -0,0 +1,150 @@ +package com.tangem.features.send.subcomponents.fee.model.converters.custom.kaspa + +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.Amount +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.transaction.Fee +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.features.send.loadedStatus +import com.tangem.test.core.ProvideTestModels +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import java.math.BigDecimal +import java.math.BigInteger + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class KaspaCustomFeeConverterTest { + + private val currencyFactory = MockCryptoCurrencyFactory() + + private val feeStatus = loadedStatus( + currency = currencyFactory.createCoin(Blockchain.Kaspa), + fiatRate = BigDecimal("0.1"), + ) + + private val converter = KaspaCustomFeeConverter( + onCustomFeeValueChange = { _, _ -> }, + appCurrency = AppCurrency.Default, + feeCryptoCurrencyStatus = feeStatus, + ) + + private fun kaspaAmount(value: BigDecimal?) = Amount(currencySymbol = "KAS", value = value, decimals = KAS_DECIMALS) + + private fun kaspaFee( + amount: BigDecimal? = BigDecimal("0.0001"), + mass: BigInteger = BigInteger.valueOf(2000), + feeRate: BigInteger = BigInteger.valueOf(5), + revealTransactionFee: Amount? = null, + ) = Fee.Kaspa(amount = kaspaAmount(amount), mass = mass, feeRate = feeRate, revealTransactionFee = revealTransactionFee) + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Convert { + + @ParameterizedTest + @ProvideTestModels + fun `GIVEN kaspa fee WHEN convert THEN single amount field`(model: ConvertModel) { + // Act + val actual = converter.convert(kaspaFee(amount = model.amount)) + + // Assert + assertThat(actual).hasSize(1) + assertThat(actual[FEE_AMOUNT_INDEX].symbol).isEqualTo("KAS") + assertThat(actual[FEE_AMOUNT_INDEX].value).isEqualTo(model.expected) + } + + private fun provideTestModels() = listOf( + ConvertModel(amount = BigDecimal("0.0001"), expected = "0.0001"), + ConvertModel(amount = null, expected = ""), + ) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class ConvertBack { + + @Test + fun `GIVEN fields WHEN convertBack THEN amount kept mass kept and feeRate recomputed`() { + // Arrange (feeRate seed 999 must be overwritten: 0.0001 / 2000 = 5e-8 -> *1e8 = 5) + val normalFee = kaspaFee(amount = BigDecimal("0.0001"), mass = BigInteger.valueOf(2000), feeRate = BigInteger.valueOf(999)) + val fields = converter.convert(normalFee) + + // Act + val actual = converter.convertBack(normalFee, fields) + + // Assert + assertThat(actual.amount.value!!.compareTo(BigDecimal("0.0001"))).isEqualTo(0) + assertThat(actual.mass).isEqualTo(BigInteger.valueOf(2000)) + assertThat(actual.feeRate).isEqualTo(BigInteger.valueOf(5)) + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class OnValueChange { + + @Test + fun `GIVEN amount changed WHEN onValueChange THEN field value updated`() { + // Arrange + val fields = converter.convert(kaspaFee(amount = BigDecimal("0.0001"))) + + // Act + val actual = converter.onValueChange(fields, FEE_AMOUNT_INDEX, "0.0002") + + // Assert + assertThat(actual[FEE_AMOUNT_INDEX].value).isEqualTo("0.0002") + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class TryAutoFixValue { + + @ParameterizedTest + @ProvideTestModels + fun `GIVEN minimum fee WHEN tryAutoFixValue THEN value clamped only for krc-20 below minimum`( + model: AutoFixModel, + ) { + // Arrange + val fields = converter.convert(kaspaFee(amount = model.currentValue)) + + // Act + val actual = converter.tryAutoFixValue(model.minimumFee, fields) + + // Assert + assertThat(actual[FEE_AMOUNT_INDEX].value).isEqualTo(model.expected) + } + + private fun provideTestModels() = listOf( + // not a krc-20 transfer (revealTransactionFee == null) -> never clamps, even below minimum + AutoFixModel( + currentValue = BigDecimal("0.0001"), + minimumFee = kaspaFee(amount = BigDecimal("0.0005"), revealTransactionFee = null), + expected = "0.0001", + ), + // krc-20 transfer, value below minimum -> clamped up to minimum + AutoFixModel( + currentValue = BigDecimal("0.0001"), + minimumFee = kaspaFee(amount = BigDecimal("0.0005"), revealTransactionFee = kaspaAmount(BigDecimal("0.0001"))), + expected = "0.0005", + ), + // krc-20 transfer, value at/above minimum -> unchanged + AutoFixModel( + currentValue = BigDecimal("0.001"), + minimumFee = kaspaFee(amount = BigDecimal("0.0005"), revealTransactionFee = kaspaAmount(BigDecimal("0.0001"))), + expected = "0.001", + ), + ) + } + + data class ConvertModel(val amount: BigDecimal?, val expected: String) + data class AutoFixModel(val currentValue: BigDecimal, val minimumFee: Fee.Kaspa, val expected: String) + + private companion object { + private const val KAS_DECIMALS = 8 + private const val FEE_AMOUNT_INDEX = 0 + } +} \ No newline at end of file From 6ca26f33d1fa316ac40eb1e2ef7a3fd2a6a5ac74 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 22 Jun 2026 20:59:27 +0300 Subject: [PATCH 053/210] Updated on 2026-08-14 --- .../bigdecimal/BigDecimalCryptoFormat.kt | 2 + features/txhistory/impl/build.gradle.kts | 1 + .../ExpressTxToTransactionItemUMConverter.kt | 43 ++- ...istoryInfoToTxHistoryDetailsUMConverter.kt | 277 ++++++++++++-- .../txhistory/entity/TxHistoryDetailsUM.kt | 29 +- .../txhistory/model/TxHistoryDetailsModel.kt | 25 +- .../txhistory/model/TxHistoryLookupContext.kt | 29 +- .../txhistory/model/TxHistoryModel.kt | 26 +- .../ui/TxHistoryDetailsAmountBlock.kt | 50 +-- .../txhistory/ui/TxHistoryDetailsContent.kt | 10 +- .../txhistory/ui/TxHistoryDetailsInfoRows.kt | 42 ++- .../ui/TxHistoryDetailsStatusBanner.kt | 30 +- .../ui/TxHistoryDetailsTopNavigation.kt | 3 +- .../ui/TxHistoryDetailsTwoAssetsBlock.kt | 16 +- ...ryInfoToTxHistoryDetailsUMConverterTest.kt | 350 +++++++++++++++++- 15 files changed, 795 insertions(+), 138 deletions(-) diff --git a/core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalCryptoFormat.kt b/core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalCryptoFormat.kt index 89aaa014aa..5b8e158f7c 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalCryptoFormat.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalCryptoFormat.kt @@ -52,11 +52,13 @@ open class BigDecimalCryptoFormatStyled( fun BigDecimalFormatScope.crypto( symbol: String, decimals: Int, + ignoreSymbolPosition: Boolean = false, locale: Locale = Locale.getDefault(), ): BigDecimalCryptoFormat { return BigDecimalCryptoFormat( symbol = symbol, decimals = decimals, + shouldIgnoreSymbolPosition = ignoreSymbolPosition, locale = locale, ) } diff --git a/features/txhistory/impl/build.gradle.kts b/features/txhistory/impl/build.gradle.kts index 245b8d24c9..8bd1f5e086 100644 --- a/features/txhistory/impl/build.gradle.kts +++ b/features/txhistory/impl/build.gradle.kts @@ -44,6 +44,7 @@ dependencies { implementation(projects.domain.balanceHiding) implementation(projects.domain.balanceHiding.models) implementation(projects.domain.account.status) + implementation(projects.domain.onramp.models) /* AndroidX */ implementation(deps.androidx.activity.compose) diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/ExpressTxToTransactionItemUMConverter.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/ExpressTxToTransactionItemUMConverter.kt index 6cec8475d1..628d829e53 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/ExpressTxToTransactionItemUMConverter.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/ExpressTxToTransactionItemUMConverter.kt @@ -4,6 +4,7 @@ import com.tangem.core.ui.components.transactions.state.TransactionItemUM import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Direction as RowDirection import com.tangem.core.ui.components.transactions.state.TransactionItemUM.Content.Status import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.transactions.state.TransactionItemUM.ContentSubtitle import com.tangem.core.ui.components.transactions.state.TransactionItemUM.ContentSubtitle.Direction as SubtitleDirection import com.tangem.core.ui.extensions.TextReference @@ -12,6 +13,8 @@ import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.format.bigdecimal.crypto import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.utils.toTimeFormat +import com.tangem.domain.express.models.ExpressExchangeStatus +import com.tangem.domain.express.models.ExpressOnrampStatus import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.txhistory.model.ExpressTx import com.tangem.domain.txhistory.model.explorerHash @@ -29,7 +32,7 @@ import java.math.BigDecimal * express statuses collapse into the three [Status] buckets (those drive title/icon/amount colors in the row UI). * * The counterparty ticker symbol+icon come from the resolved [ExpressTransactionAsset.cryptoCurrency] (swap); - * onramp shows the real fiat code with no icon yet (fiat carries no `CryptoCurrency`). The row click routes through + * onramp shows the fiat code with the onramp country flag as the icon (fiat carries no `CryptoCurrency`). The row click routes through * [TxHistoryUiActions.onTransactionClick] (express rows open the in-app details sheet). */ internal class ExpressTxToTransactionItemUMConverter( @@ -67,16 +70,15 @@ internal class ExpressTxToTransactionItemUMConverter( symbol = counterparty.cryptoCurrency?.symbol ?: counterparty.id.networkId, icon = counterparty.cryptoCurrency?.let(iconStateConverter::convert), ), - // TODO: replace null to warning logic. - warning = null, + warning = swapWarning(swap), ) } private fun onrampContent(onramp: ExpressTx.Onramp): TransactionItemUM.Content { val status = onrampStatusConverter.convert(onramp.tx.status) - val prefix = when { - status is Status.Failed -> "" - status is Status.Confirmed -> StringsSigns.PLUS + val prefix = when (status) { + is Status.Failed -> "" + is Status.Confirmed -> StringsSigns.PLUS else -> StringsSigns.TILDE_SIGN } return buildContent( @@ -89,11 +91,12 @@ internal class ExpressTxToTransactionItemUMConverter( subtitle = ContentSubtitle.Asset( direction = SubtitleDirection.FROM, symbol = onramp.tx.fromFiat.currencySymbol, - // TODO: fiat carries no OnrampCurrency, so no icon yet — render with a fiat country flag once available. - icon = null, + icon = CurrencyIconState.FiatIcon( + url = onramp.tx.country?.image, + fallbackResId = R.drawable.ic_currency_24, + ), ), - // TODO: replace null to warning logic. - warning = null, + warning = onrampWarning(onramp), ) } @@ -143,4 +146,24 @@ internal class ExpressTxToTransactionItemUMConverter( wrappedList(resourceReference(R.string.tx_history_onramp_top_up)), ) } + + /** + * KYC-verification warning. Other "problem" statuses (failed / refunded / expired) already surface as the red + * [Status.Failed] row title, so they need no extra warning line; only [ExpressExchangeStatus.Verifying] — + * which buckets into the in-progress [Status.Unconfirmed] — requires it to signal the pending user action. + */ + private fun swapWarning(swap: ExpressTx.Swap): TextReference? = + if (swap.tx.status == ExpressExchangeStatus.Verifying) { + resourceReference(R.string.express_exchange_notification_verification_title) + } else { + null + } + + /** KYC-verification warning; see [swapWarning] for why failed statuses are intentionally excluded. */ + private fun onrampWarning(onramp: ExpressTx.Onramp): TextReference? = + if (onramp.tx.status == ExpressOnrampStatus.Verifying) { + resourceReference(R.string.express_exchange_notification_verification_title) + } else { + null + } } \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverter.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverter.kt index 71b734ba25..e0b98c3582 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverter.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverter.kt @@ -8,11 +8,18 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.fiat import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.utils.DateTimeFormatters +import com.tangem.domain.express.models.ExpressExchangeStatus +import com.tangem.domain.express.models.ExpressOnrampStatus +import com.tangem.domain.express.models.ExpressProvider +import com.tangem.domain.express.models.ExpressTransactionAsset import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.TxInfo import com.tangem.domain.models.network.TxInfo.TransactionType +import com.tangem.domain.tokens.model.Amount +import com.tangem.domain.tokens.model.AmountType import com.tangem.domain.txhistory.model.ExpressTx import com.tangem.domain.txhistory.model.OnChainTx import com.tangem.domain.txhistory.model.TxHistoryInfo @@ -23,20 +30,25 @@ import com.tangem.utils.StringsSigns import com.tangem.utils.converter.Converter import com.tangem.utils.extensions.isZero import com.tangem.utils.toBriefAddressFormat +import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import org.joda.time.DateTime +import java.math.BigDecimal /** * Converts a [TxHistoryInfo] row to a [TxHistoryDetailsUM] for the in-app transaction details card. * * The dispatch mirrors the row converters: an [OnChainTx.BSDK] always renders as [TxHistoryDetailsUM.SingleAsset] - * (a two-asset swap surfaces as [ExpressTx.Swap], handled separately), while an [ExpressTx] (swap / onramp) currently - * produces a header-only [TxHistoryDetailsUM.TwoAssets] with the express status banner. The express legs (`from`/`to` - * amounts, currencies, fiat) are populated in a follow-up ([REDACTED_TASK_KEY]). + * (a two-asset swap surfaces as [ExpressTx.Swap], handled separately), while an [ExpressTx] (swap / onramp) renders as + * [TxHistoryDetailsUM.TwoAssets] — the `from`/`to` legs come from the express deal ([ExchangeTransaction] asset pair / + * [OnrampTransaction] fiat→asset), and the network-fee row comes from the matched on-chain leg ([ExpressTx.txInfo]). */ internal class TxHistoryInfoToTxHistoryDetailsUMConverter( private val currency: CryptoCurrency, private val onCopyAddress: (String) -> Unit, + /** Own deposit addresses for this currency's network — used to label own-transfers as "Transfer". */ + private val ownAddresses: Set = emptySet(), ) : Converter { private val iconStateConverter = CryptoCurrencyToIconStateConverter() @@ -60,8 +72,8 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter( header = value.toHeaderUM(), amountBlock = value.toAmountBlockUM(), counterparty = value.toCounterpartyUM(), - // TODO: TxInfo has no network fee / rate yet — empty until those fields are added to TxInfo. - rows = persistentListOf(), + // Network fee from the tx itself; rate is not surfaced (no data). + rows = value.toInfoRows(), ) private fun TxInfo.toHeaderUM(): TxHistoryDetailsUM.HeaderUM = TxHistoryDetailsUM.HeaderUM( @@ -74,9 +86,6 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter( private fun TxInfo.toAmountBlockUM(): TxHistoryDetailsUM.AmountBlockUM = TxHistoryDetailsUM.AmountBlockUM( currencyIcon = iconStateConverter.convert(currency), amount = stringReference(signedAmount(currency)), - // TODO: TxInfo has no fiat amount yet — empty until the fiat field is added to TxInfo; a hardcoded - // placeholder would show a misleading value. - fiatAmount = TextReference.EMPTY, isFailed = status is TxInfo.TransactionStatus.Failed, ) @@ -102,10 +111,34 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter( private fun TxInfo.counterpartyLabel(): TextReference = if (isOutgoing) resourceReference(R.string.send_recipient) else resourceReference(R.string.common_from) + private fun TxInfo.headerTitle(): TextReference = when (type) { + is TransactionType.Swap -> statusAwareTitle(R.string.common_swapping, R.string.common_swapped) + is TransactionType.Transfer -> transferTitle() + else -> stringReference(type.toString()) + } + + /** + * Transfer header label, mirroring the history row: a transfer between the user's own accounts/wallets reads + * "Transfer", an outgoing transfer to an external address "Send", an incoming one "Receive" (status-aware). + */ + private fun TxInfo.transferTitle(): TextReference { + val counterpartyAddress = (interactionAddressType as? TxInfo.InteractionAddressType.User)?.address + val isOwnTransfer = counterpartyAddress != null && counterpartyAddress in ownAddresses + return when { + isOwnTransfer -> statusAwareTitle(R.string.common_transfer, R.string.common_transferred) + isOutgoing -> statusAwareTitle(R.string.common_sending, R.string.common_sent) + else -> statusAwareTitle(R.string.common_receiving, R.string.common_received) + } + } + // endregion // region Express (swap / onramp) + /** + * The two-asset block always renders the deal's `fromAsset`→`toAsset` regardless of [ExpressTx.Swap.isOutgoing] — + * `isOutgoing` only selects which leg is the *viewed* one in the history row, it does not reorder the detail legs. + */ private fun convertExpressSwap(swap: ExpressTx.Swap): TxHistoryDetailsUM.TwoAssets { val status = exchangeStatusConverter.convert(swap.tx.status) return TxHistoryDetailsUM.TwoAssets( @@ -115,7 +148,18 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter( title = status.statusAwareTitle(R.string.common_swapping, R.string.common_swapped), subtitle = headerSubtitle(swap.timestampMillis), ), - statusBanner = status.toStatusBannerUM(), + from = swap.tx.fromAsset.toAssetUM( + label = resourceReference(R.string.swapping_from_title_v2), + sign = status.outgoingSign(), + isFaded = status is Status.Failed, + ), + to = swap.tx.toAsset.toAssetUM( + label = resourceReference(R.string.swapping_to_title), + sign = status.incomingSign(), + isFaded = status is Status.Failed, + ), + statusBanner = swap.tx.status.toStatusBannerUM(), + rows = swap.toInfoRows(), ) } @@ -131,7 +175,59 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter( ), subtitle = headerSubtitle(onramp.timestampMillis), ), - statusBanner = status.toStatusBannerUM(), + from = onramp.tx.fromFiat.toFiatAssetUM( + label = resourceReference(R.string.swapping_from_title_v2), + isFaded = status is Status.Failed, + ), + to = onramp.tx.toAsset.toAssetUM( + label = resourceReference(R.string.swapping_to_title), + sign = status.incomingSign(), + isFaded = status is Status.Failed, + ), + statusBanner = onramp.tx.status.toStatusBannerUM(), + rows = onramp.toInfoRows(), + ) + } + + /** + * Builds one crypto leg of the two-asset block. The ticker symbol and icon come from the resolved + * [ExpressTransactionAsset.cryptoCurrency]; when it is unresolved the symbol falls back to the network id and the + * icon slot is left empty ([currencyIcon] = `null`). + */ + private fun ExpressTransactionAsset.toAssetUM( + label: TextReference, + sign: String, + isFaded: Boolean, + ): TxHistoryDetailsUM.AssetUM { + val symbol = cryptoCurrency?.symbol ?: id.networkId + val formatted = amount.format { crypto( + symbol = symbol, + decimals = decimals, + ignoreSymbolPosition = true, + ) }.trim() + return TxHistoryDetailsUM.AssetUM( + label = label, + owner = null, + amount = stringReference((sign + formatted).trim()), + currencyIcon = cryptoCurrency?.let(iconStateConverter::convert), + isFaded = isFaded, + ) + } + + /** + * Builds the fiat ("You paid") leg of an onramp. The paid fiat amount is exact and carries no sign — neither `+`/`−` + * nor the `~` estimate — so only the value is shown. Fiat has no `CryptoCurrency`, so it also has no icon. + */ + private fun Amount.toFiatAssetUM(label: TextReference, isFaded: Boolean): TxHistoryDetailsUM.AssetUM { + val code = (type as? AmountType.FiatType)?.code ?: currencySymbol + val formatted = (value ?: BigDecimal.ZERO) + .format { fiat(fiatCurrencyCode = code, fiatCurrencySymbol = currencySymbol) } + return TxHistoryDetailsUM.AssetUM( + label = label, + owner = null, + amount = stringReference(formatted.trim()), + currencyIcon = null, + isFaded = isFaded, ) } @@ -141,30 +237,94 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter( // region Status helpers /** - * Express status plaque under the two-asset block, keyed on the collapsed UI [Status] bucket. + * Express swap status → the status plaque under the two-asset block. * - * A stopgap shared by on-chain swaps and express ops — [Severity.Warning] (verification) is not reachable here yet. - * [REDACTED_TODO_COMMENT] + * In-flight stages render as [Severity.Info] with the rotating loader; [Verifying][ExpressExchangeStatus.Verifying] + * (KYC) and the paused / refunded terminals as [Severity.Warning]; the failure terminals as [Severity.Error]; the + * [Finished][ExpressExchangeStatus.Finished] success as [Severity.Success] (the plaque then auto-collapses — see + * `TxHistoryDetailsStatusBanner`). [Unknown][ExpressExchangeStatus.Unknown] carries nothing to show, so it hides the + * plaque (`null`). */ -private fun Status.toStatusBannerUM(): TxHistoryDetailsUM.StatusBannerUM = when (this) { - is Status.Unconfirmed -> TxHistoryDetailsUM.StatusBannerUM( - severity = Severity.Info, - title = resourceReference(R.string.express_exchange_status_receiving_active), - isLoading = true, - ) - is Status.Confirmed -> TxHistoryDetailsUM.StatusBannerUM( - severity = Severity.Success, - title = resourceReference(R.string.express_exchange_status_exchanged), - isLoading = false, - ) - is Status.Failed -> TxHistoryDetailsUM.StatusBannerUM( - severity = Severity.Error, - title = resourceReference(R.string.express_exchange_status_failed), - subtitle = resourceReference(R.string.express_exchange_notification_failed_text), - isLoading = false, - ) +private fun ExpressExchangeStatus.toStatusBannerUM(): TxHistoryDetailsUM.StatusBannerUM? = when (this) { + ExpressExchangeStatus.Preview, + ExpressExchangeStatus.Created, + ExpressExchangeStatus.ExchangeTxSent, + ExpressExchangeStatus.Waiting, + -> loadingBanner(R.string.express_exchange_status_receiving_active) + ExpressExchangeStatus.WaitingTxHash -> loadingBanner(R.string.express_exchange_status_waiting_tx_hash) + ExpressExchangeStatus.Confirming -> loadingBanner(R.string.express_exchange_status_confirming_active) + ExpressExchangeStatus.Exchanging -> loadingBanner(R.string.express_exchange_status_exchanging_active) + ExpressExchangeStatus.Sending -> loadingBanner(R.string.express_exchange_status_sending_active) + ExpressExchangeStatus.Verifying -> verificationBanner() + ExpressExchangeStatus.Refunded -> warningBanner(R.string.express_exchange_status_refunded) + ExpressExchangeStatus.Paused -> warningBanner(R.string.express_exchange_status_paused) + ExpressExchangeStatus.Failed, + ExpressExchangeStatus.TxFailed, + -> failedBanner() + ExpressExchangeStatus.Expired -> errorBanner(R.string.express_exchange_status_failed) + ExpressExchangeStatus.Finished -> successBanner(R.string.express_exchange_status_exchanged) + ExpressExchangeStatus.Unknown -> null } +/** + * Express onramp status → the status plaque under the two-asset block. Same severity mapping as the swap variant; the + * [Finished][ExpressOnrampStatus.Finished] success ("Purchase completed") is the only [Severity.Success] (auto-collapsed). + */ +private fun ExpressOnrampStatus.toStatusBannerUM(): TxHistoryDetailsUM.StatusBannerUM? = when (this) { + ExpressOnrampStatus.Created, + ExpressOnrampStatus.WaitingForPayment, + -> loadingBanner(R.string.express_exchange_status_receiving_active) + ExpressOnrampStatus.PaymentProcessing -> loadingBanner(R.string.express_exchange_status_confirming_active) + ExpressOnrampStatus.Verifying -> verificationBanner() + ExpressOnrampStatus.Paid -> loadingBanner(R.string.express_exchange_status_buying_active) + ExpressOnrampStatus.Sending -> loadingBanner(R.string.express_exchange_status_sending_active) + ExpressOnrampStatus.Paused -> warningBanner(R.string.express_exchange_status_paused) + ExpressOnrampStatus.Failed -> failedBanner() + ExpressOnrampStatus.Expired -> errorBanner(R.string.express_exchange_status_failed) + ExpressOnrampStatus.Finished -> successBanner(R.string.express_exchange_status_bought) + ExpressOnrampStatus.Unknown -> null +} + +private fun loadingBanner(@StringRes title: Int) = TxHistoryDetailsUM.StatusBannerUM( + severity = Severity.Info, + title = resourceReference(title), + isLoading = true, +) + +private fun successBanner(@StringRes title: Int) = TxHistoryDetailsUM.StatusBannerUM( + severity = Severity.Success, + title = resourceReference(title), + isLoading = false, +) + +private fun warningBanner(@StringRes title: Int) = TxHistoryDetailsUM.StatusBannerUM( + severity = Severity.Warning, + title = resourceReference(title), + isLoading = false, +) + +private fun errorBanner(@StringRes title: Int) = TxHistoryDetailsUM.StatusBannerUM( + severity = Severity.Error, + title = resourceReference(title), + isLoading = false, +) + +/** Failure terminal: red plaque with the shared "visit provider to refund" hint. */ +private fun failedBanner() = TxHistoryDetailsUM.StatusBannerUM( + severity = Severity.Error, + title = resourceReference(R.string.express_exchange_status_failed), + subtitle = resourceReference(R.string.express_exchange_notification_failed_text), + isLoading = false, +) + +/** KYC verification: amber plaque with the "visit provider for verification" hint. */ +private fun verificationBanner() = TxHistoryDetailsUM.StatusBannerUM( + severity = Severity.Warning, + title = resourceReference(R.string.express_exchange_status_verifying), + subtitle = resourceReference(R.string.express_exchange_notification_verification_text), + isLoading = false, +) + private fun Status.statusAwareTitle(@StringRes pending: Int, @StringRes confirmed: Int): TextReference = when (this) { is Status.Failed -> resourceReference(R.string.common_action_failed, wrappedList(resourceReference(pending))) is Status.Unconfirmed -> resourceReference(pending) @@ -173,8 +333,59 @@ private fun Status.statusAwareTitle(@StringRes pending: Int, @StringRes confirme // endregion +// region Info rows (network fee) + +/** Detail rows of an on-chain tx: the network-fee row when a fee with a value is present (rate is not surfaced). */ +private fun TxInfo.toInfoRows(): ImmutableList = listOfNotNull(feeRow()).toImmutableList() + +/** + * Detail rows of an express op: the [provider] row (its name) followed by the network-fee row from the matched on-chain + * leg. The provider row is dropped while the provider is unresolved; the fee row while no on-chain leg / fee is present. + * (Rate is not surfaced yet — no data.) + */ +private fun ExpressTx.toInfoRows(): ImmutableList = buildList { + provider?.let { add(it.providerRow()) } + addAll(txInfo.toInfoRows()) +}.toImmutableList() + +private fun ExpressProvider.providerRow(): TxHistoryDetailsUM.InfoRowUM = TxHistoryDetailsUM.InfoRowUM( + label = resourceReference(R.string.express_provider), + value = stringReference(name), + trailingIconRes = R.drawable.ic_arrow_top_right_24, +) + +/** Detail rows pulled from the matched on-chain leg of an express op; empty while the leg has not loaded. */ +private fun OnChainTx?.toInfoRows(): ImmutableList = + (this as? OnChainTx.BSDK)?.txInfo?.toInfoRows() ?: persistentListOf() + +private fun TxInfo.feeRow(): TxHistoryDetailsUM.InfoRowUM? { + val fee = fee ?: return null + val value = fee.value ?: return null + return TxHistoryDetailsUM.InfoRowUM( + label = resourceReference(R.string.common_network_fee_title), + value = stringReference( + value.format { crypto(symbol = fee.currencySymbol, decimals = fee.decimals, ignoreSymbolPosition = true) }, + ), + ) +} + +// endregion + // region Amount building helpers +/** Leading sign of the pay-in / "You send" leg: `−` while in flight or settled, dropped on a failed deal. */ +private fun Status.outgoingSign(): String = if (this is Status.Failed) "" else "${StringsSigns.MINUS} " + +/** + * Leading sign of the payout / "You receive" leg: `~` while in flight (the final received amount is still an estimate), + * `+` once the funds have settled, and dropped on a failed deal (the amount is then only struck through). + */ +private fun Status.incomingSign(): String = when (this) { + is Status.Unconfirmed -> "${StringsSigns.TILDE_SIGN} " + is Status.Confirmed -> "${StringsSigns.PLUS} " + is Status.Failed -> "" +} + /** * Signed crypto amount with inline symbol, e.g. `+ 350.31 USDT` / `- 350.31 USDT`. The sign is `-` for outgoing, `+` * otherwise, and is dropped for zero amounts and for the failed state (a failed tx moved nothing) — the UI then only @@ -201,12 +412,6 @@ private fun TxInfo.headerIcon(): Int = when (type) { else -> if (isOutgoing) R.drawable.ic_arrow_up_24 else R.drawable.ic_arrow_down_24 } -private fun TxInfo.headerTitle(): TextReference = when (type) { - is TransactionType.Swap -> statusAwareTitle(R.string.common_swapping, R.string.common_swapped) - is TransactionType.Transfer -> statusAwareTitle(R.string.common_transfer, R.string.common_transferred) - else -> stringReference(type.toString()) -} - private fun headerSubtitle(timestampMillis: Long): TextReference { val dateTime = DateTime(timestampMillis) val date = DateTimeFormatters.dateMMMdYYYY.print(dateTime) diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt index de525f1387..1a6e454f79 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt @@ -9,6 +9,7 @@ import com.tangem.core.ui.components.transactions.state.TransactionItemUM import com.tangem.core.ui.ds.image.DeviceIconUM import com.tangem.core.ui.extensions.TextReference import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf /** * UI model for the in-app transaction details ("Operation") card. @@ -35,15 +36,18 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent { /** * Two-asset layout: Swap / Onramp. * - * [from] ("You sent") → [to] ("You receive") exchange block. Both are nullable: the converter can't populate the - * legs yet (`TxInfo` exposes no swap amounts/currencies/fiat), so the card falls back to a header-only placeholder - * until that data lands. [statusBanner] is the express status plaque under the block, `null` until status is known. + * [from] ("You send") → [to] ("You receive") exchange block. Both are nullable: when a leg cannot be built (e.g. a + * future express variant with no asset data) the card falls back to a header-only placeholder. [statusBanner] is + * the express status plaque under the block, `null` until status is known. [rows] carries the provider row (its + * name) followed by the network-fee row pulled from the matched on-chain leg (`ExpressTx.txInfo`); each is dropped + * when its data is unavailable (rate is not surfaced yet — no data). */ data class TwoAssets( override val header: HeaderUM, val from: AssetUM? = null, val to: AssetUM? = null, val statusBanner: StatusBannerUM? = null, + val rows: ImmutableList = persistentListOf(), ) : TxHistoryDetailsUM /** @@ -67,14 +71,18 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent { /** * One side of the two-asset block: the [label] over the signed [amount], with the [currencyIcon] on the trailing - * side. [owner] `null` → plain label ("You sent"); non-null → "From"/"To" prefix plus the resolved own account / - * wallet decoration. [isFaded] renders the unsettled/failed amount (struck through, recolored to tertiary). + * side. [owner] `null` → plain label ("You send"); non-null → "From"/"To" prefix plus the resolved own account / + * wallet decoration. [isFaded] renders the failed amount (struck through, recolored to tertiary); an in-flight leg is + * not faded — it carries a `~` estimate sign instead. + * + * [currencyIcon] is `null` when the leg has no icon to show — the onramp fiat side carries no `CryptoCurrency` and + * no country flag is rendered (no data); the trailing icon slot is then left empty. */ data class AssetUM( val label: TextReference, val owner: AssetOwnerUM?, val amount: TextReference, - val currencyIcon: CurrencyIconState, + val currencyIcon: CurrencyIconState?, val isFaded: Boolean, ) @@ -106,23 +114,30 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent { * Centered amount block of the single-asset card: token avatar (with network badge), the big signed crypto * [amount] and the secondary [fiatAmount]. * + * [fiatAmount] is `null` while no fiat value is available (`TxInfo` has no fiat field yet) — the fiat line is then + * omitted entirely rather than shown as a placeholder. + * * [isFailed] drives the failed visual state — the amount is struck through, recolored to tertiary and carries no * `+`/`−` sign (mirrors the status-driven recolor in the shared header). */ data class AmountBlockUM( val currencyIcon: CurrencyIconState, val amount: TextReference, - val fiatAmount: TextReference, + val fiatAmount: TextReference? = null, val isFailed: Boolean, ) /** * A single info row of the details card: a [label] on the leading side and its [value] on the trailing side * (e.g. `Network fee` → `0.00056 ETH`, `Rate` → `1 POL ≈ 0.36 USDT`). Rendered by [TxHistoryDetailsInfoRows]. + * + * [trailingIconRes] is an optional glyph drawn after the [value] (e.g. the arrow-up-right link affordance on the + * provider row); `null` leaves the trailing slot text-only. */ data class InfoRowUM( val label: TextReference, val value: TextReference, + @DrawableRes val trailingIconRes: Int? = null, ) /** diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryDetailsModel.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryDetailsModel.kt index 59c3845cb1..52c3732eab 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryDetailsModel.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryDetailsModel.kt @@ -5,12 +5,16 @@ import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.ui.clipboard.ClipboardManager +import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier import com.tangem.features.txhistory.component.TxHistoryDetailsComponent import com.tangem.features.txhistory.converter.TxHistoryInfoToTxHistoryDetailsUMConverter import com.tangem.features.txhistory.entity.TxHistoryDetailsUM import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -21,18 +25,27 @@ import javax.inject.Inject internal class TxHistoryDetailsModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val clipboardManager: ClipboardManager, + multiAccountStatusListSupplier: MultiAccountStatusListSupplier, paramsContainer: ParamsContainer, ) : Model() { private val params: TxHistoryDetailsComponent.Params = paramsContainer.require() - private val converter = TxHistoryInfoToTxHistoryDetailsUMConverter( - currency = params.currency, - onCopyAddress = ::onCopyAddress, - ) + /** Own deposit addresses for the viewed currency's network — drives the own-vs-external transfer title. */ + private val ownAddressesFlow: Flow> = multiAccountStatusListSupplier() + .map { lists -> buildOwnAccountAddressMap(lists, params.currency.network.id.rawId).keys } + .distinctUntilChanged() - val uiState: StateFlow = params.txHistoryInfo - .map(converter::convert) + val uiState: StateFlow = combine( + params.txHistoryInfo, + ownAddressesFlow, + ) { txInfo, ownAddresses -> + TxHistoryInfoToTxHistoryDetailsUMConverter( + currency = params.currency, + onCopyAddress = ::onCopyAddress, + ownAddresses = ownAddresses, + ).convert(txInfo) + } .flowOn(dispatchers.default) .stateIn(modelScope, SharingStarted.WhileSubscribed(), initialValue = null) diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryLookupContext.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryLookupContext.kt index 32f290cf21..f8e90d6a5b 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryLookupContext.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryLookupContext.kt @@ -1,7 +1,10 @@ package com.tangem.features.txhistory.model import com.tangem.core.ui.ds.image.DeviceIconUM +import com.tangem.domain.account.models.AccountStatusList import com.tangem.domain.models.account.Account +import com.tangem.domain.models.account.filterCryptoPortfolio +import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId /** @@ -17,4 +20,28 @@ internal data class TxHistoryLookupContext( val walletInfoById: Map, ) -internal data class WalletInfo(val name: String, val deviceIconUM: DeviceIconUM) \ No newline at end of file +internal data class WalletInfo(val name: String, val deviceIconUM: DeviceIconUM) + +/** + * Flattens every crypto-portfolio account of every wallet into an `address -> account` map for the network identified + * by [networkRawId]. Shared by the history list and the details screen to decide whether a transfer counterparty is one + * of the user's own accounts/wallets. + */ +internal fun buildOwnAccountAddressMap( + lists: List, + networkRawId: Network.RawID, +): Map { + val map = mutableMapOf() + lists.forEach { accountList -> + accountList.accountStatuses + .filterCryptoPortfolio() + .forEach { status -> + status.flattenCurrencies().forEach { currencyStatus -> + if (currencyStatus.currency.network.id.rawId != networkRawId) return@forEach + val address = currencyStatus.value.networkAddress?.defaultAddress?.value ?: return@forEach + map[address] = status.account + } + } + } + return map +} \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt index c5515de93e..e85b818e28 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryModel.kt @@ -9,16 +9,12 @@ import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.navigation.url.UrlOpener import com.tangem.core.ui.DesignFeatureToggles import com.tangem.core.ui.utils.toDateFormatWithTodayYesterday -import com.tangem.domain.account.models.AccountStatusList import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCryptoCurrencyStatus import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase import com.tangem.domain.common.wallets.UserWalletsListRepository -import com.tangem.domain.models.account.Account -import com.tangem.domain.models.account.AccountStatus -import com.tangem.domain.models.account.filterCryptoPortfolio import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.network.TxInfo import com.tangem.domain.txhistory.model.ExpressTx @@ -92,7 +88,10 @@ internal class TxHistoryModel @Inject constructor( ) .map { (accountLists, modeEnabled, wallets) -> TxHistoryLookupContext( - ownAccountByAddress = buildOwnAccountAddressMap(accountLists), + ownAccountByAddress = buildOwnAccountAddressMap( + lists = accountLists, + networkRawId = params.currency.network.id.rawId, + ), isAccountsModeEnabled = modeEnabled, walletInfoById = wallets.associate { wallet -> wallet.walletId to WalletInfo( @@ -151,23 +150,6 @@ internal class TxHistoryModel @Inject constructor( subscribeOnCurrencyStatusUpdates() } - private fun buildOwnAccountAddressMap(lists: List): Map { - val networkRawId = params.currency.network.id.rawId - val map = mutableMapOf() - lists.forEach { accountList -> - accountList.accountStatuses - .filterCryptoPortfolio() - .forEach { status: AccountStatus.CryptoPortfolio -> - status.flattenCurrencies().forEach { currencyStatus -> - if (currencyStatus.currency.network.id.rawId != networkRawId) return@forEach - val address = currencyStatus.value.networkAddress?.defaultAddress?.value ?: return@forEach - map[address] = status.account - } - } - } - return map - } - private fun subscribeToUiItemChanges() { txHistoryListManager ?.uiItems diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsAmountBlock.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsAmountBlock.kt index 39ec21f76e..e5c7804469 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsAmountBlock.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsAmountBlock.kt @@ -18,6 +18,7 @@ import com.tangem.core.ui.R import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.currency.icon.TangemCurrencyIcon +import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme @@ -56,17 +57,19 @@ internal fun TxHistoryDetailsAmountBlock(amountBlock: TxHistoryDetailsUM.AmountB textAlign = TextAlign.Center, textDecoration = if (amountBlock.isFailed) TextDecoration.LineThrough else null, ) - SpacerH(4.dp) - Text( - text = amountBlock.fiatAmount.resolveReference(), - color = if (amountBlock.isFailed) { - TangemTheme.colors3.text.tertiary - } else { - TangemTheme.colors3.text.secondary - }, - style = TangemTheme.typography3.body.medium, - textAlign = TextAlign.Center, - ) + amountBlock.fiatAmount?.let { fiatAmount -> + SpacerH(4.dp) + Text( + text = fiatAmount.resolveReference(), + color = if (amountBlock.isFailed) { + TangemTheme.colors3.text.tertiary + } else { + TangemTheme.colors3.text.secondary + }, + style = TangemTheme.typography3.body.medium, + textAlign = TextAlign.Center, + ) + } } } @@ -82,20 +85,23 @@ private fun TxHistoryDetailsAmountBlockPreview() { ) { TxHistoryDetailsAmountBlock(amountBlock = previewAmountBlock(isFailed = false)) TxHistoryDetailsAmountBlock(amountBlock = previewAmountBlock(isFailed = true)) + // No fiat — the fiat line is omitted entirely. + TxHistoryDetailsAmountBlock(amountBlock = previewAmountBlock(isFailed = false, fiatAmount = null)) } } } -private fun previewAmountBlock(isFailed: Boolean) = TxHistoryDetailsUM.AmountBlockUM( - currencyIcon = CurrencyIconState.CoinIcon( - url = null, - fallbackResId = R.drawable.img_eth_22, - isGrayscale = false, - shouldShowCustomBadge = false, - ), - amount = stringReference("+ 350.31 USDT"), - fiatAmount = stringReference("$350.31"), - isFailed = isFailed, -) +private fun previewAmountBlock(isFailed: Boolean, fiatAmount: TextReference? = stringReference("$350.31")) = + TxHistoryDetailsUM.AmountBlockUM( + currencyIcon = CurrencyIconState.CoinIcon( + url = null, + fallbackResId = R.drawable.img_eth_22, + isGrayscale = false, + shouldShowCustomBadge = false, + ), + amount = stringReference("+ 350.31 USDT"), + fiatAmount = fiatAmount, + isFailed = isFailed, + ) // endregion \ No newline at end of file diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsContent.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsContent.kt index 236d3ffeca..14ed97a642 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsContent.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsContent.kt @@ -58,8 +58,7 @@ private fun TwoAssetsContent(state: TxHistoryDetailsUM.TwoAssets, modifier: Modi .padding(start = 16.dp, end = 16.dp), ) } else { - // TODO([REDACTED_TASK_KEY]): the converter cannot populate the swap legs yet (TxInfo exposes no two-leg / fiat / - // provider data). Until those fields land, fall back to the header-only placeholder. + // Safety fallback for a future express variant that yields no asset legs — render the header-only card. TwoAssetsPlaceholder(state = state) } // Express status plaque under the exchange block. The top gap is owned by the banner (inside its collapsing @@ -70,6 +69,13 @@ private fun TwoAssetsContent(state: TxHistoryDetailsUM.TwoAssets, modifier: Modi .fillMaxWidth() .padding(horizontal = 16.dp), ) + // Network fee (and later rate) pulled from the matched on-chain leg; the block is skipped when [rows] is empty. + TxHistoryDetailsInfoRows( + rows = state.rows, + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 16.dp), + ) } } diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsInfoRows.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsInfoRows.kt index 9a9a742589..3341e7358b 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsInfoRows.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsInfoRows.kt @@ -2,11 +2,17 @@ package com.tangem.features.txhistory.ui import android.content.res.Configuration.UI_MODE_NIGHT_YES import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -20,6 +26,7 @@ import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.InfoRowUM +import com.tangem.features.txhistory.impl.R import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -50,14 +57,27 @@ internal fun TxHistoryDetailsInfoRows(rows: ImmutableList, modifier: contentLead = TangemRowContentLead.Start, titleSlot = { TangemRowText(text = row.label, role = TangemRowTextRole.Title) }, valueSlot = { - Text( - text = row.value.resolveReference(), - color = TangemTheme.colors3.text.secondary, - style = TangemTheme.typography3.body.medium, - textAlign = TextAlign.End, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = row.value.resolveReference(), + color = TangemTheme.colors3.text.secondary, + style = TangemTheme.typography3.body.medium, + textAlign = TextAlign.End, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + row.trailingIconRes?.let { iconRes -> + Icon( + painter = painterResource(id = iconRes), + contentDescription = null, + tint = TangemTheme.colors3.text.secondary, + modifier = Modifier.size(20.dp), + ) + } + } }, ) } @@ -77,7 +97,11 @@ private fun TxHistoryDetailsInfoRowsPreview() { // Multiple rows — dividers between rows, none after the last TxHistoryDetailsInfoRows( rows = persistentListOf( - InfoRowUM(label = stringReference("Network fee"), value = stringReference("0.00056 ETH")), + InfoRowUM( + label = stringReference("Provider"), + value = stringReference("Mercuryo"), + trailingIconRes = R.drawable.ic_arrow_top_right_24, + ), InfoRowUM(label = stringReference("Rate"), value = stringReference("1 POL ≈ 0.36 USDT")), InfoRowUM(label = stringReference("Network fee"), value = stringReference("0.00056 ETH")), ), diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsStatusBanner.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsStatusBanner.kt index 3c8eb60f34..766ffaba83 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsStatusBanner.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsStatusBanner.kt @@ -26,6 +26,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -51,6 +52,7 @@ import com.tangem.core.ui.res.generated.icons.ic_success_20 import com.tangem.core.ui.res.generated.icons.ic_warning_20 import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.StatusBannerUM import com.tangem.features.txhistory.entity.TxHistoryDetailsUM.StatusBannerUM.Severity +import kotlinx.coroutines.delay // Animation timings in ms (ProtoPie spec). The status swap is two-phase: the old status fades out, then the new one // fades/slides in after ENTER_DELAY. Most steps run over the default duration; the trailing loader/glyph fades faster @@ -61,6 +63,9 @@ private const val GROW_MILLIS = 400 private const val ENTER_DELAY_MILLIS = DEFAULT_ANIMATION_MILLIS // phase 2 waits for the phase-1 fade-out to clear private const val SUBTITLE_DELAY_MILLIS = ENTER_DELAY_MILLIS + 100 // subtitle trails the title +/** How long the success terminal ("Confirmed") lingers before the plaque auto-collapses — it shows only as a transition. */ +private const val CONFIRMED_VISIBLE_MILLIS = 1_000L + private const val TITLE_SLIDE_FRACTION = 12 // in-progress/Success title slides in 1/12 width from the right private const val CONTENT_RISE_FRACTION = 2 // Warning/Error title floats up 1/2 height from below private const val ICON_ENTER_SCALE = 0.6f @@ -134,8 +139,31 @@ internal fun TxHistoryDetailsStatusBanner(state: StatusBannerUM?, modifier: Modi SideEffect { if (state != null) lastState.value = state } val content = state ?: lastState.value + // Auto-hide rules for the success terminal ("Confirmed"). It is the only [Severity.Success] state and must read as a + // *transition*, not a resting state: opening the details on an already-finished deal (no in-flight status was ever + // seen) shows nothing, and once it does appear it lingers only briefly before collapsing. Failure / verification + // terminals are not Success, so they stay put. + val seenNonSuccess = remember { mutableStateOf(false) } + SideEffect { if (state != null && state.severity != Severity.Success) seenNonSuccess.value = true } + + val isTerminalSuccess = state?.severity == Severity.Success + val confirmedDismissed = remember { mutableStateOf(false) } + LaunchedEffect(isTerminalSuccess) { + if (isTerminalSuccess && seenNonSuccess.value) { + delay(CONFIRMED_VISIBLE_MILLIS) + confirmedDismissed.value = true + } + } + + val isVisible = when { + state == null -> false + isTerminalSuccess && !seenNonSuccess.value -> false // opened already on the success terminal → never shown + isTerminalSuccess && confirmedDismissed.value -> false // "Confirmed" lingered long enough → collapse away + else -> true + } + AnimatedVisibility( - visible = state != null, + visible = isVisible, // Fade and size share one tween so alpha and height finish together (mismatched default springs leave a jerk). enter = fadeIn(tween(DEFAULT_ANIMATION_MILLIS)) + expandVertically(tween(DEFAULT_ANIMATION_MILLIS), expandFrom = Alignment.Top), diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsTopNavigation.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsTopNavigation.kt index f57cc7e849..35772b1847 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsTopNavigation.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsTopNavigation.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon @@ -58,7 +57,7 @@ internal fun TxHistoryDetailsTopNavigation( modifier: Modifier = Modifier, ) { TangemTopNavigation( - modifier = modifier.padding(top = 8.dp), + modifier = modifier, windowInsets = WindowInsets(0), blurBackground = false, startButton = { StatusActionIcon(iconRes = header.iconRes, status = header.status) }, diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsTwoAssetsBlock.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsTwoAssetsBlock.kt index 62e9dc2a16..c69eebdaa2 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsTwoAssetsBlock.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsTwoAssetsBlock.kt @@ -112,10 +112,13 @@ private fun TwoAssetsSideRow(asset: AssetUM, modifier: Modifier = Modifier) { ) }, endSlot = { - TangemCurrencyIcon( - state = asset.currencyIcon, - modifier = Modifier.size(40.dp), - ) + // The fiat leg of an onramp carries no icon (no CryptoCurrency, no country flag) — leave the slot empty. + asset.currencyIcon?.let { icon -> + TangemCurrencyIcon( + state = icon, + modifier = Modifier.size(40.dp), + ) + } }, ) } @@ -231,10 +234,11 @@ private fun TxHistoryDetailsTwoAssetsBlockPreview() { from = previewAsset(label = "You sent", amount = "- 390 USDT", isFaded = false), to = previewAsset(label = "You receive", amount = "+ 1,800.00 POL", isFaded = false), ) - // Unsettled swap — the "You receive" side is struck through until the funds arrive. + // Unsettled swap — the "You receive" side shows the estimated amount with a `~` until the funds arrive + // (struck through is reserved for the failed state). TxHistoryDetailsTwoAssetsBlock( from = previewAsset(label = "You sent", amount = "- 390 USDT", isFaded = false), - to = previewAsset(label = "You receive", amount = "1,800.00 POL", isFaded = true), + to = previewAsset(label = "You receive", amount = "~ 1,800.00 POL", isFaded = false), ) // Account -> another account (own-to-own transfer between two of the user's accounts). TxHistoryDetailsTwoAssetsBlock( diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverterTest.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverterTest.kt index a8330706ac..505744835a 100644 --- a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverterTest.kt +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverterTest.kt @@ -10,8 +10,12 @@ import com.tangem.domain.express.models.ExchangeTransaction import com.tangem.domain.express.models.ExpressAsset.ID as ExpressAssetId import com.tangem.domain.express.models.ExpressExchangeStatus import com.tangem.domain.express.models.ExpressOnrampStatus +import com.tangem.domain.express.models.ExpressProvider +import com.tangem.domain.express.models.ExpressProviderType import com.tangem.domain.express.models.ExpressTransactionAsset import com.tangem.domain.express.models.OnrampTransaction +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.SdkAmount import com.tangem.domain.models.network.TxInfo import com.tangem.domain.models.network.TxInfo.TransactionType import com.tangem.domain.tokens.model.Amount @@ -94,9 +98,12 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest { ) @Test - fun `GIVEN incoming confirmed Transfer WHEN convert THEN header has down icon, confirmed status, transferred title`() { + fun `GIVEN incoming confirmed external Transfer WHEN convert THEN header has down icon, confirmed status, received title`() { // Arrange - val tx = onChain(type = TransactionType.Transfer) + val tx = onChain( + type = TransactionType.Transfer, + interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS), + ) // Act val header = converter.convert(tx).header @@ -104,6 +111,64 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest { // Assert assertThat(header.iconRes).isEqualTo(R.drawable.ic_arrow_down_24) assertThat(header.status).isEqualTo(TransactionItemUM.Content.Status.Confirmed) + assertThat(header.title).isEqualTo(resourceReference(R.string.common_received)) + } + + @Test + fun `GIVEN outgoing external Transfer WHEN convert THEN sent title`() { + // Arrange + val tx = onChain( + type = TransactionType.Transfer, + isOutgoing = true, + interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS), + ) + + // Act + val header = converter.convert(tx).header + + // Assert + assertThat(header.title).isEqualTo(resourceReference(R.string.common_sent)) + } + + @Test + fun `GIVEN incoming Transfer from own address WHEN convert THEN transferred title`() { + // Arrange — the counterparty is one of the user's own deposit addresses. + val ownConverter = TxHistoryInfoToTxHistoryDetailsUMConverter( + currency = currency, + onCopyAddress = copiedAddresses::add, + ownAddresses = setOf(USER_ADDRESS), + ) + val tx = onChain( + type = TransactionType.Transfer, + isOutgoing = false, + interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS), + ) + + // Act + val header = ownConverter.convert(tx).header + + // Assert + assertThat(header.title).isEqualTo(resourceReference(R.string.common_transferred)) + } + + @Test + fun `GIVEN outgoing Transfer to own address WHEN convert THEN transferred title`() { + // Arrange + val ownConverter = TxHistoryInfoToTxHistoryDetailsUMConverter( + currency = currency, + onCopyAddress = copiedAddresses::add, + ownAddresses = setOf(USER_ADDRESS), + ) + val tx = onChain( + type = TransactionType.Transfer, + isOutgoing = true, + interactionAddressType = TxInfo.InteractionAddressType.User(USER_ADDRESS), + ) + + // Act + val header = ownConverter.convert(tx).header + + // Assert assertThat(header.title).isEqualTo(resourceReference(R.string.common_transferred)) } @@ -238,6 +303,35 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest { assertThat(copiedAddresses).containsExactly(USER_ADDRESS) } + @Test + fun `GIVEN tx with fee WHEN convert THEN single network-fee row`() { + // Arrange + val tx = onChain( + type = TransactionType.Transfer, + fee = SdkAmount(currencySymbol = "ETH", value = BigDecimal("0.0005"), decimals = 18), + ) + + // Act + val rows = (converter.convert(tx) as TxHistoryDetailsUM.SingleAsset).rows + + // Assert + assertThat(rows).hasSize(1) + assertThat(rows.first().label).isEqualTo(resourceReference(R.string.common_network_fee_title)) + assertThat(rows.first().value.resolveString()).contains("ETH") + } + + @Test + fun `GIVEN tx without fee WHEN convert THEN no rows`() { + // Arrange + val tx = onChain(type = TransactionType.Transfer, fee = null) + + // Act + val rows = (converter.convert(tx) as TxHistoryDetailsUM.SingleAsset).rows + + // Assert + assertThat(rows).isEmpty() + } + // endregion // region Express (swap / onramp) @@ -253,7 +347,7 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest { } @Test - fun `GIVEN in-progress express swap WHEN convert THEN info status banner with loader`() { + fun `GIVEN exchanging express swap WHEN convert THEN info status banner with loader`() { // Act val swap = converter.convert(expressSwap(status = ExpressExchangeStatus.Exchanging)) val banner = (swap as TxHistoryDetailsUM.TwoAssets).statusBanner @@ -262,12 +356,54 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest { assertThat(banner).isEqualTo( TxHistoryDetailsUM.StatusBannerUM( severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Info, - title = resourceReference(R.string.express_exchange_status_receiving_active), + title = resourceReference(R.string.express_exchange_status_exchanging_active), isLoading = true, ), ) } + @Test + fun `GIVEN verifying express swap WHEN convert THEN warning status banner with verification subtitle`() { + // Act + val swap = converter.convert(expressSwap(status = ExpressExchangeStatus.Verifying)) + val banner = (swap as TxHistoryDetailsUM.TwoAssets).statusBanner + + // Assert + assertThat(banner).isEqualTo( + TxHistoryDetailsUM.StatusBannerUM( + severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Warning, + title = resourceReference(R.string.express_exchange_status_verifying), + subtitle = resourceReference(R.string.express_exchange_notification_verification_text), + isLoading = false, + ), + ) + } + + @Test + fun `GIVEN finished express swap WHEN convert THEN success status banner`() { + // Act + val swap = converter.convert(expressSwap(status = ExpressExchangeStatus.Finished)) + val banner = (swap as TxHistoryDetailsUM.TwoAssets).statusBanner + + // Assert + assertThat(banner).isEqualTo( + TxHistoryDetailsUM.StatusBannerUM( + severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Success, + title = resourceReference(R.string.express_exchange_status_exchanged), + isLoading = false, + ), + ) + } + + @Test + fun `GIVEN unknown express swap WHEN convert THEN no status banner`() { + // Act + val swap = converter.convert(expressSwap(status = ExpressExchangeStatus.Unknown)) + + // Assert — nothing to surface, the plaque is hidden. + assertThat((swap as TxHistoryDetailsUM.TwoAssets).statusBanner).isNull() + } + @Test fun `GIVEN failed express swap WHEN convert THEN error status banner with refund subtitle`() { // Act @@ -285,6 +421,131 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest { ) } + @Test + fun `GIVEN in-progress express swap WHEN convert THEN from is minus and to is approx, neither faded`() { + // Act + val result = converter.convert(expressSwap(status = ExpressExchangeStatus.Exchanging)) as TxHistoryDetailsUM.TwoAssets + + // Assert + assertThat(result.from?.amount?.resolveString()).startsWith("- ") + assertThat(result.from?.isFaded).isFalse() + // Receive amount is still an estimate while in flight: `~`, not `+`, and not struck through. + assertThat(result.to?.amount?.resolveString()).startsWith("~ ") + assertThat(result.to?.isFaded).isFalse() + // Counterparty (to) symbol comes from the resolved CryptoCurrency; the unresolved from leg falls back to network id. + assertThat(result.to?.currencyIcon).isNotNull() + assertThat(result.from?.currencyIcon).isNull() + } + + @Test + fun `GIVEN finished express swap WHEN convert THEN to is plus and neither leg faded`() { + // Act + val result = converter.convert(expressSwap(status = ExpressExchangeStatus.Finished)) as TxHistoryDetailsUM.TwoAssets + + // Assert + assertThat(result.from?.amount?.resolveString()).startsWith("- ") + assertThat(result.to?.amount?.resolveString()).startsWith("+ ") + assertThat(result.from?.isFaded).isFalse() + assertThat(result.to?.isFaded).isFalse() + } + + @Test + fun `GIVEN failed express swap WHEN convert THEN both legs faded and signs dropped`() { + // Act + val result = converter.convert(expressSwap(status = ExpressExchangeStatus.Failed)) as TxHistoryDetailsUM.TwoAssets + + // Assert + assertThat(result.from?.isFaded).isTrue() + assertThat(result.to?.isFaded).isTrue() + assertThat(result.from?.amount?.resolveString()).doesNotContain("-") + assertThat(result.to?.amount?.resolveString()).doesNotContain("+") + } + + @Test + fun `GIVEN express swap with matched on-chain leg WHEN convert THEN network-fee row from leg`() { + // Arrange + val leg = onChain( + type = TransactionType.Swap, + fee = SdkAmount(currencySymbol = "ETH", value = BigDecimal("0.0005"), decimals = 18), + ) + + // Act + val result = converter.convert(expressSwap(status = ExpressExchangeStatus.Finished, txInfo = leg)) as TxHistoryDetailsUM.TwoAssets + + // Assert + assertThat(result.rows).hasSize(1) + assertThat(result.rows.first().label).isEqualTo(resourceReference(R.string.common_network_fee_title)) + } + + @Test + fun `GIVEN express swap with provider WHEN convert THEN provider row with its name and link icon`() { + // Act + val result = converter.convert( + expressSwap(status = ExpressExchangeStatus.Finished, provider = provider(name = "Mercuryo")), + ) as TxHistoryDetailsUM.TwoAssets + + // Assert + assertThat(result.rows).hasSize(1) + val providerRow = result.rows.first() + assertThat(providerRow.label).isEqualTo(resourceReference(R.string.express_provider)) + assertThat(providerRow.value.resolveString()).isEqualTo("Mercuryo") + assertThat(providerRow.trailingIconRes).isEqualTo(R.drawable.ic_arrow_top_right_24) + } + + @Test + fun `GIVEN express swap with provider and on-chain leg WHEN convert THEN provider row precedes network-fee row`() { + // Arrange + val leg = onChain( + type = TransactionType.Swap, + fee = SdkAmount(currencySymbol = "ETH", value = BigDecimal("0.0005"), decimals = 18), + ) + + // Act + val result = converter.convert( + expressSwap(status = ExpressExchangeStatus.Finished, txInfo = leg, provider = provider(name = "Changelly")), + ) as TxHistoryDetailsUM.TwoAssets + + // Assert + assertThat(result.rows.map { it.label }).containsExactly( + resourceReference(R.string.express_provider), + resourceReference(R.string.common_network_fee_title), + ).inOrder() + } + + @Test + fun `GIVEN finished express onramp WHEN convert THEN paid fiat is unsigned and topped-up crypto is plus`() { + // Act + val result = converter.convert(expressOnramp(status = ExpressOnrampStatus.Finished)) as TxHistoryDetailsUM.TwoAssets + + // Assert + // "You paid" fiat carries no icon and no sign — the exact amount paid. + assertThat(result.from?.currencyIcon).isNull() + assertThat(result.from?.amount?.resolveString()).contains("SEK") + assertThat(result.from?.amount?.resolveString()).doesNotContain("-") + assertThat(result.from?.amount?.resolveString()).doesNotContain("+") + assertThat(result.from?.amount?.resolveString()).doesNotContain("~") + // Topped-up crypto leg is settled: `+`, with an icon. + assertThat(result.to?.currencyIcon).isNotNull() + assertThat(result.to?.amount?.resolveString()).startsWith("+ ") + assertThat(result.to?.isFaded).isFalse() + } + + @Test + fun `GIVEN in-progress express onramp WHEN convert THEN paid fiat is unsigned and top-up crypto is approx`() { + // Act + val result = converter.convert(expressOnramp(status = ExpressOnrampStatus.Sending)) as TxHistoryDetailsUM.TwoAssets + + // Assert + // "You paid" stays unsigned regardless of status. + assertThat(result.from?.amount?.resolveString()).doesNotContain("-") + assertThat(result.from?.amount?.resolveString()).doesNotContain("+") + assertThat(result.from?.amount?.resolveString()).doesNotContain("~") + assertThat(result.from?.isFaded).isFalse() + // Crypto to-be-received is an estimate while in flight: `~`, not struck through. + assertThat(result.to?.amount?.resolveString()).startsWith("~ ") + assertThat(result.to?.isFaded).isFalse() + } + @Test fun `GIVEN finished express onramp WHEN convert THEN TwoAssets with success banner`() { // Act @@ -295,12 +556,37 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest { assertThat(result.statusBanner).isEqualTo( TxHistoryDetailsUM.StatusBannerUM( severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Success, - title = resourceReference(R.string.express_exchange_status_exchanged), + title = resourceReference(R.string.express_exchange_status_bought), isLoading = false, ), ) } + @Test + fun `GIVEN verifying express onramp WHEN convert THEN warning status banner with verification subtitle`() { + // Act + val result = converter.convert(expressOnramp(status = ExpressOnrampStatus.Verifying)) as TxHistoryDetailsUM.TwoAssets + + // Assert + assertThat(result.statusBanner).isEqualTo( + TxHistoryDetailsUM.StatusBannerUM( + severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Warning, + title = resourceReference(R.string.express_exchange_status_verifying), + subtitle = resourceReference(R.string.express_exchange_notification_verification_text), + isLoading = false, + ), + ) + } + + @Test + fun `GIVEN unknown express onramp WHEN convert THEN no status banner`() { + // Act + val result = converter.convert(expressOnramp(status = ExpressOnrampStatus.Unknown)) as TxHistoryDetailsUM.TwoAssets + + // Assert — nothing to surface, the plaque is hidden. + assertThat(result.statusBanner).isNull() + } + // endregion private fun onChain( @@ -309,6 +595,7 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest { status: TxInfo.TransactionStatus = TxInfo.TransactionStatus.Confirmed, amount: BigDecimal = BigDecimal.ONE, interactionAddressType: TxInfo.InteractionAddressType? = null, + fee: SdkAmount? = null, ): OnChainTx.BSDK = OnChainTx.BSDK( TxInfo( txHash = TX_HASH, @@ -320,25 +607,49 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest { status = status, type = type, amount = amount, + fee = fee, ), ) - private fun expressSwap(status: ExpressExchangeStatus): ExpressTx.Swap = ExpressTx.Swap( + private fun provider(name: String): ExpressProvider = ExpressProvider( + providerId = "provider-1", + name = name, + type = ExpressProviderType.CEX, + imageLarge = "", + termsOfUse = null, + privacyPolicy = null, + slippage = null, + ) + + private fun expressSwap( + status: ExpressExchangeStatus, + isOutgoing: Boolean = true, + txInfo: OnChainTx? = null, + provider: ExpressProvider? = null, + ): ExpressTx.Swap = ExpressTx.Swap( tx = ExchangeTransaction( txId = "swap-1", status = status, createdAtMillis = TIMESTAMP, - provider = null, + provider = provider, payinHash = null, payoutHash = null, fromAsset = expressAsset(networkId = "ethereum", amount = BigDecimal("1.5"), decimals = 18), - toAsset = expressAsset(networkId = "bitcoin", amount = BigDecimal("0.001"), decimals = 8), + toAsset = expressAsset( + networkId = "bitcoin", + amount = BigDecimal("0.001"), + decimals = 8, + cryptoCurrency = currency, + ), ), - isOutgoing = true, - txInfo = null, + isOutgoing = isOutgoing, + txInfo = txInfo, ) - private fun expressOnramp(status: ExpressOnrampStatus): ExpressTx.Onramp = ExpressTx.Onramp( + private fun expressOnramp( + status: ExpressOnrampStatus, + txInfo: OnChainTx? = null, + ): ExpressTx.Onramp = ExpressTx.Onramp( tx = OnrampTransaction( txId = "onramp-1", status = status, @@ -351,16 +662,27 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest { decimals = 2, type = AmountType.FiatType(code = "SEK"), ), - toAsset = expressAsset(networkId = "bitcoin", amount = BigDecimal("0.006"), decimals = 8), + toAsset = expressAsset( + networkId = "bitcoin", + amount = BigDecimal("0.006"), + decimals = 8, + cryptoCurrency = currency, + ), ), - txInfo = null, + txInfo = txInfo, ) - private fun expressAsset(networkId: String, amount: BigDecimal, decimals: Int): ExpressTransactionAsset = + private fun expressAsset( + networkId: String, + amount: BigDecimal, + decimals: Int, + cryptoCurrency: CryptoCurrency? = null, + ): ExpressTransactionAsset = ExpressTransactionAsset( id = ExpressAssetId(networkId = networkId, contractAddress = "0"), amount = amount, decimals = decimals, + cryptoCurrency = cryptoCurrency, ) private fun TextReference.resolveString(): String = (this as TextReference.Str).value From 3815e5fdf3d5e67acc9392c84b222bb5735f7faa Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 26 Jun 2026 14:53:39 +0100 Subject: [PATCH 054/210] Updated on 2026-08-14 --- .../api/addressbook/AddressBookApi.kt | 25 +++ .../models/SyncAddressBooksRequest.kt | 22 ++ .../models/SyncAddressBooksResponse.kt | 27 +++ .../models/UpdateAddressBookRequest.kt | 13 ++ .../models/UpdateAddressBookResponse.kt | 12 ++ .../com/tangem/datasource/di/NetworkModule.kt | 11 + data/address-book/build.gradle.kts | 4 + .../DefaultAddressBookRepository.kt | 173 +++++++++++++-- .../addressbook/di/AddressBookDataModule.kt | 11 +- .../addressbook/store/AddressBookBlobStore.kt | 7 - .../store/DefaultAddressBookBlobStore.kt | 25 +-- .../store/StoredAddressBookBlob.kt | 15 -- .../DefaultAddressBookRepositoryTest.kt | 201 +++++++++++++++++- .../store/DefaultAddressBookBlobStoreTest.kt | 18 +- .../data/common/cache/etag/ETagsStore.kt | 1 + .../addressbook/error/AddressBookSyncError.kt | 27 +++ .../addressbook/error/SaveContactError.kt | 2 + .../interactor/SaveContactInteractor.kt | 4 + .../repository/AddressBookRepository.kt | 9 +- .../interactor/SaveContactInteractorTest.kt | 25 ++- 20 files changed, 537 insertions(+), 95 deletions(-) create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/addressbook/AddressBookApi.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/addressbook/models/SyncAddressBooksRequest.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/addressbook/models/SyncAddressBooksResponse.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/addressbook/models/UpdateAddressBookRequest.kt create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/addressbook/models/UpdateAddressBookResponse.kt delete mode 100644 data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/StoredAddressBookBlob.kt create mode 100644 domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/error/AddressBookSyncError.kt diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/addressbook/AddressBookApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/addressbook/AddressBookApi.kt new file mode 100644 index 0000000000..d2a31680a2 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/addressbook/AddressBookApi.kt @@ -0,0 +1,25 @@ +package com.tangem.datasource.api.addressbook + +import com.tangem.datasource.api.addressbook.models.SyncAddressBooksRequest +import com.tangem.datasource.api.addressbook.models.SyncAddressBooksResponse +import com.tangem.datasource.api.addressbook.models.UpdateAddressBookRequest +import com.tangem.datasource.api.addressbook.models.UpdateAddressBookResponse +import com.tangem.datasource.api.common.response.ApiResponse +import retrofit2.http.Body +import retrofit2.http.Header +import retrofit2.http.PUT +import retrofit2.http.POST +import retrofit2.http.Path + +interface AddressBookApi { + + @POST("v1/address-books/sync") + suspend fun syncAddressBooks(@Body body: SyncAddressBooksRequest): ApiResponse + + @PUT("v1/address-books/{walletId}") + suspend fun updateAddressBook( + @Path("walletId") walletId: String, + @Header("If-Match") eTag: String?, + @Body body: UpdateAddressBookRequest, + ): ApiResponse +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/addressbook/models/SyncAddressBooksRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/addressbook/models/SyncAddressBooksRequest.kt new file mode 100644 index 0000000000..f6b28a0dd6 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/addressbook/models/SyncAddressBooksRequest.kt @@ -0,0 +1,22 @@ +package com.tangem.datasource.api.addressbook.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * Request body for `POST /address-books/sync`. + * + * Each [Wallet.etag] is optional: when it matches the backend's etag, that wallet is omitted from the + * response and the local copy is kept. + */ +@JsonClass(generateAdapter = true) +data class SyncAddressBooksRequest( + @Json(name = "wallets") val wallets: List, +) { + + @JsonClass(generateAdapter = true) + data class Wallet( + @Json(name = "walletId") val walletId: String, + @Json(name = "etag") val etag: String? = null, + ) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/addressbook/models/SyncAddressBooksResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/addressbook/models/SyncAddressBooksResponse.kt new file mode 100644 index 0000000000..c1d186c4fa --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/addressbook/models/SyncAddressBooksResponse.kt @@ -0,0 +1,27 @@ +package com.tangem.datasource.api.addressbook.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * Response body for `POST /address-books/sync`. + * + * [items] contains only the wallets whose backend etag differs from the one sent in the request; wallets + * with a matching etag are omitted and their local copy must be kept. + */ +@JsonClass(generateAdapter = true) +data class SyncAddressBooksResponse( + @Json(name = "items") val items: List, +) { + + @JsonClass(generateAdapter = true) + data class Item( + @Json(name = "walletId") val walletId: String, + @Json(name = "etag") val etag: String, + @Json(name = "version") val version: String, + @Json(name = "updatedAt") val updatedAt: String, + @Json(name = "nonce") val nonce: String, + @Json(name = "ciphertext") val ciphertext: String, + @Json(name = "authTag") val authTag: String, + ) +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/addressbook/models/UpdateAddressBookRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/addressbook/models/UpdateAddressBookRequest.kt new file mode 100644 index 0000000000..ddbabd6f2e --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/addressbook/models/UpdateAddressBookRequest.kt @@ -0,0 +1,13 @@ +package com.tangem.datasource.api.addressbook.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** Request body for `PUT /address-books/{walletId}`. */ +@JsonClass(generateAdapter = true) +data class UpdateAddressBookRequest( + @Json(name = "version") val version: String, + @Json(name = "nonce") val nonce: String, + @Json(name = "ciphertext") val ciphertext: String, + @Json(name = "authTag") val authTag: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/addressbook/models/UpdateAddressBookResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/addressbook/models/UpdateAddressBookResponse.kt new file mode 100644 index 0000000000..48dbfc538c --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/addressbook/models/UpdateAddressBookResponse.kt @@ -0,0 +1,12 @@ +package com.tangem.datasource.api.addressbook.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** Response body for `PUT /address-books/{walletId}`. */ +@JsonClass(generateAdapter = true) +data class UpdateAddressBookResponse( + @Json(name = "walletId") val walletId: String, + @Json(name = "etag") val etag: String, + @Json(name = "updatedAt") val updatedAt: String, +) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt index f937877421..cf0af7d6f0 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/NetworkModule.kt @@ -1,6 +1,7 @@ package com.tangem.datasource.di import com.tangem.datasource.BuildConfig +import com.tangem.datasource.api.addressbook.AddressBookApi import com.tangem.datasource.api.auth.AuthApi import com.tangem.datasource.api.common.blockaid.BlockAidApi import com.tangem.datasource.api.surveysparrow.SurveySparrowApi @@ -118,6 +119,16 @@ internal object NetworkModule { ) } + @Provides + @Singleton + fun provideAddressBookApi(retrofitApiBuilder: RetrofitApiBuilder): AddressBookApi { + return retrofitApiBuilder.build( + apiConfigId = ApiConfig.ID.TangemTech, + applyTimeoutAnnotations = false, + sessionAuth = false, + ) + } + @Provides @Singleton fun provideYieldSupplyApi(retrofitApiBuilder: RetrofitApiBuilder): YieldSupplyApi { diff --git a/data/address-book/build.gradle.kts b/data/address-book/build.gradle.kts index 734a559f8c..31c3011678 100644 --- a/data/address-book/build.gradle.kts +++ b/data/address-book/build.gradle.kts @@ -16,6 +16,10 @@ dependencies { implementation(projects.core.utils) // endregion + // region Project - Data + implementation(projects.data.common) + // endregion + // region Project - Domain implementation(projects.domain.addressBook) implementation(projects.domain.common) 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 8ce2414259..ae71fcc84f 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 @@ -1,7 +1,20 @@ package com.tangem.data.addressbook +import arrow.core.Either +import arrow.core.flatMap +import arrow.core.left +import arrow.core.right import com.tangem.data.addressbook.store.AddressBookBlobStore +import com.tangem.data.common.api.safeApiCall +import com.tangem.data.common.cache.etag.ETagsStore +import com.tangem.datasource.api.addressbook.AddressBookApi +import com.tangem.datasource.api.addressbook.models.SyncAddressBooksRequest +import com.tangem.datasource.api.addressbook.models.SyncAddressBooksResponse +import com.tangem.datasource.api.addressbook.models.UpdateAddressBookRequest +import com.tangem.datasource.api.common.response.ApiResponseError +import com.tangem.datasource.api.common.response.ApiResponseError.HttpException.Code import com.tangem.domain.addressbook.crypto.AddressBookCipher +import com.tangem.domain.addressbook.error.AddressBookSyncError import com.tangem.domain.addressbook.model.AddressBook import com.tangem.domain.addressbook.model.AddressBookBlob import com.tangem.domain.addressbook.model.Contact @@ -12,6 +25,7 @@ import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.models.wallet.UserWallet 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 @@ -19,14 +33,18 @@ 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.withLock import kotlinx.coroutines.withContext import org.joda.time.DateTime +@Suppress("LongParameterList") internal class DefaultAddressBookRepository( private val blobStore: AddressBookBlobStore, private val cipher: AddressBookCipher, + private val addressBookApi: AddressBookApi, + private val eTagsStore: ETagsStore, private val userWalletsListRepository: UserWalletsListRepository, private val timestampProvider: IsoTimestampProvider, private val dispatchers: CoroutineDispatcherProvider, @@ -36,6 +54,7 @@ internal class DefaultAddressBookRepository( override fun getContacts(userWalletId: UserWalletId): Flow> { return getContactsForWallet(userWalletId) + .onStart { syncAddressBooks() } .distinctUntilChanged() .flowOn(dispatchers.default) } @@ -55,6 +74,7 @@ internal class DefaultAddressBookRepository( } } } + .onStart { syncAddressBooks() } .distinctUntilChanged() .flowOn(dispatchers.default) } @@ -73,27 +93,68 @@ internal class DefaultAddressBookRepository( decryptContacts(blob, userWallet).find { it.name.value == name } } - override suspend fun saveContact(contact: Contact) = withContext(dispatchers.default) { - writeMutex.withLock { - val userWallet = findUserWallet(contact.walletId.stringValue) ?: return@withLock - val current = currentContacts(contact.walletId, userWallet) - val merged = current.filterNot { it.id == contact.id } + contact - persist(userWallet, AddressBook(walletId = contact.walletId, contacts = merged)) - } - } - - override suspend fun deleteContact(id: ContactId) = withContext(dispatchers.default) { - writeMutex.withLock { - userWalletsListRepository.userWalletsSync().forEach { userWallet -> - val blob = blobStore.getBlobSync(userWallet.walletId) ?: return@forEach - val addressBook = cipher.decrypt(blob, userWallet).getOrNull() ?: return@forEach - if (addressBook.contacts.none { it.id == id }) return@forEach - - val remaining = addressBook.contacts.filterNot { it.id == id } - persist(userWallet, addressBook.copy(contacts = remaining)) - return@withLock + override suspend fun saveContact(contact: Contact): Either = + withContext(dispatchers.default) { + writeMutex.withLock { + val userWallet = findUserWallet(contact.walletId.stringValue) + ?: return@withLock AddressBookSyncError.Unknown.left() + val current = currentContacts(contact.walletId, userWallet) + val merged = current.filterNot { it.id == contact.id } + contact + persist(userWallet, AddressBook(walletId = contact.walletId, contacts = merged)) } } + + override suspend fun deleteContact(id: ContactId): Either = + withContext(dispatchers.default) { + writeMutex.withLock { + userWalletsListRepository.userWalletsSync().forEach { userWallet -> + val blob = blobStore.getBlobSync(userWallet.walletId) ?: return@forEach + val addressBook = cipher.decrypt(blob, userWallet).getOrNull() ?: return@forEach + if (addressBook.contacts.none { it.id == id }) return@forEach + + val remaining = addressBook.contacts.filterNot { it.id == id } + return@withLock persist(userWallet, addressBook.copy(contacts = remaining)) + } + // No wallet held the contact — nothing to push, treat as success. + Unit.right() + } + } + + override suspend fun syncAddressBooks(): Either = withContext(dispatchers.default) { + val wallets = userWalletsListRepository.userWalletsSync() + // The backend rejects more than MAX_SYNC_WALLETS per request, so sync in chunks and stop on the + // first failed chunk. + wallets.chunked(MAX_SYNC_WALLETS) + .fold(initial = Unit.right() as Either) { acc, chunk -> + acc.flatMap { syncWalletsChunk(chunk) } + } + } + + private suspend fun syncWalletsChunk(wallets: List): Either { + val request = SyncAddressBooksRequest( + wallets = wallets.map { wallet -> + SyncAddressBooksRequest.Wallet( + walletId = wallet.walletId.stringValue, + etag = eTagsStore.getSyncOrNull(wallet.walletId, ETagsStore.Key.AddressBook), + ) + }, + ) + return safeApiCall( + call = { + val response = withContext(dispatchers.io) { addressBookApi.syncAddressBooks(request).bind() } + // Only wallets whose etag changed are returned; the rest keep their local copy. + response.items.forEach { item -> + val userWalletId = UserWalletId(stringValue = item.walletId) + blobStore.storeBlob(item.toBlob()) + eTagsStore.store(userWalletId, ETagsStore.Key.AddressBook, item.etag) + } + Unit.right() + }, + onError = { error -> + TangemLogger.e(messageString = "Failed to sync address books: $error") + error.toSyncError().left() + }, + ) } private fun decryptContacts(blob: AddressBookBlob, userWallet: UserWallet): List { @@ -105,12 +166,80 @@ internal class DefaultAddressBookRepository( return decryptContacts(blob, userWallet) } - private suspend fun persist(userWallet: UserWallet, addressBook: AddressBook) { + /** + * Encrypts [addressBook], pushes it to the backend, and persists it locally **only** on success. + * On any failure (encryption, network, etag conflict, …) nothing is written locally. + */ + private suspend fun persist(userWallet: UserWallet, addressBook: AddressBook): Either { val updatedAt = DateTime.parse(timestampProvider.now()) - cipher.encrypt(addressBook, userWallet, updatedAt) - .onRight { blobStore.storeBlob(it) } + return cipher.encrypt(addressBook, userWallet, updatedAt) + .mapLeft { error -> + TangemLogger.e( + messageString = "Failed to encrypt address book for wallet ${userWallet.walletId}: $error", + ) + AddressBookSyncError.Unknown + } + .flatMap { blob -> pushBlob(addressBook.walletId, blob) } + } + + private suspend fun pushBlob( + userWalletId: UserWalletId, + blob: AddressBookBlob, + ): Either { + // Absent etag means the book has not been created on the backend yet → omit If-Match to create it. + val eTag = eTagsStore.getSyncOrNull(userWalletId, ETagsStore.Key.AddressBook) + return safeApiCall( + call = { + val response = withContext(dispatchers.io) { + addressBookApi.updateAddressBook( + walletId = blob.walletId, + eTag = eTag, + body = UpdateAddressBookRequest( + version = blob.version, + nonce = blob.nonce, + ciphertext = blob.ciphertext, + authTag = blob.authTag, + ), + ).bind() + } + blobStore.storeBlob(blob) + eTagsStore.store(userWalletId, ETagsStore.Key.AddressBook, response.etag) + Unit.right() + }, + onError = { error -> + TangemLogger.e(messageString = "Failed to push address book for wallet $userWalletId: $error") + error.toSyncError().left() + }, + ) + } + + private fun SyncAddressBooksResponse.Item.toBlob(): AddressBookBlob = AddressBookBlob( + version = version, + walletId = walletId, + updatedAt = updatedAt, + nonce = nonce, + ciphertext = ciphertext, + authTag = authTag, + ) + + private fun ApiResponseError.toSyncError(): AddressBookSyncError = when (this) { + is ApiResponseError.HttpException -> when (code) { + Code.PRECONDITION_FAILED -> AddressBookSyncError.Conflict + Code.NOT_FOUND -> AddressBookSyncError.NotFound + Code.UNAUTHORIZED -> AddressBookSyncError.Unauthorized + Code.BAD_REQUEST -> AddressBookSyncError.BadRequest + else -> AddressBookSyncError.Unknown + } + is ApiResponseError.NetworkException, + is ApiResponseError.TimeoutException, + -> AddressBookSyncError.Network + is ApiResponseError.UnknownException -> AddressBookSyncError.Unknown } private suspend fun findUserWallet(walletId: String): UserWallet? = userWalletsListRepository.userWalletsSync().find { it.walletId.stringValue == walletId } + + private companion object { + const val MAX_SYNC_WALLETS = 20 + } } \ No newline at end of file diff --git a/data/address-book/src/main/kotlin/com/tangem/data/addressbook/di/AddressBookDataModule.kt b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/di/AddressBookDataModule.kt index ce181b3417..118fa554d2 100644 --- a/data/address-book/src/main/kotlin/com/tangem/data/addressbook/di/AddressBookDataModule.kt +++ b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/di/AddressBookDataModule.kt @@ -6,9 +6,11 @@ import androidx.datastore.dataStoreFile import com.tangem.data.addressbook.DefaultAddressBookRepository import com.tangem.data.addressbook.store.AddressBookBlobStore import com.tangem.data.addressbook.store.DefaultAddressBookBlobStore -import com.tangem.data.addressbook.store.StoredAddressBookBlob +import com.tangem.data.common.cache.etag.ETagsStore +import com.tangem.datasource.api.addressbook.AddressBookApi import com.tangem.datasource.utils.KotlinxDataStoreSerializer import com.tangem.domain.addressbook.crypto.AddressBookCipher +import com.tangem.domain.addressbook.model.AddressBookBlob import com.tangem.domain.addressbook.repository.AddressBookRepository import com.tangem.domain.addressbook.time.IsoTimestampProvider import com.tangem.domain.common.wallets.UserWalletsListRepository @@ -39,7 +41,7 @@ internal object AddressBookDataModule { defaultValue = emptyMap(), serializer = MapSerializer( keySerializer = String.serializer(), - valueSerializer = StoredAddressBookBlob.serializer(), + valueSerializer = AddressBookBlob.serializer(), ), ), produceFile = { context.dataStoreFile(fileName = "address_book_blobs") }, @@ -50,9 +52,12 @@ internal object AddressBookDataModule { @Provides @Singleton + @Suppress("LongParameterList") fun provideAddressBookRepository( blobStore: AddressBookBlobStore, cipher: AddressBookCipher, + addressBookApi: AddressBookApi, + eTagsStore: ETagsStore, userWalletsListRepository: UserWalletsListRepository, timestampProvider: IsoTimestampProvider, dispatchers: CoroutineDispatcherProvider, @@ -60,6 +65,8 @@ internal object AddressBookDataModule { return DefaultAddressBookRepository( blobStore = blobStore, cipher = cipher, + addressBookApi = addressBookApi, + eTagsStore = eTagsStore, userWalletsListRepository = userWalletsListRepository, timestampProvider = timestampProvider, dispatchers = dispatchers, diff --git a/data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/AddressBookBlobStore.kt b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/AddressBookBlobStore.kt index de3e180d81..577fe407fd 100644 --- a/data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/AddressBookBlobStore.kt +++ b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/AddressBookBlobStore.kt @@ -12,14 +12,7 @@ interface AddressBookBlobStore { suspend fun getBlobSync(userWalletId: UserWalletId): AddressBookBlob? - /** Persists [blob] optimistically with `isBESynchronized = false`. Keyed by [AddressBookBlob.walletId]. */ suspend fun storeBlob(blob: AddressBookBlob) - /** Flips the BE-sync flag to `true` once the backend confirms the push. No-op if the blob is absent. */ - suspend fun markAsSynchronized(userWalletId: UserWalletId) - - /** Blobs still pending a backend push — the entry point for the future sync service. */ - suspend fun getUnsynchronizedBlobs(): List - suspend fun deleteBlob(userWalletId: UserWalletId) } \ No newline at end of file diff --git a/data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/DefaultAddressBookBlobStore.kt b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/DefaultAddressBookBlobStore.kt index 8aa6c8558e..829f5d3d68 100644 --- a/data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/DefaultAddressBookBlobStore.kt +++ b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/DefaultAddressBookBlobStore.kt @@ -8,7 +8,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map -internal typealias AddressBookBlobs = Map +internal typealias AddressBookBlobs = Map internal class DefaultAddressBookBlobStore( private val dataStore: DataStore, @@ -16,38 +16,23 @@ internal class DefaultAddressBookBlobStore( override fun getBlob(userWalletId: UserWalletId): Flow { return dataStore.data - .map { it[userWalletId.stringValue]?.blob } + .map { it[userWalletId.stringValue] } .distinctUntilChanged() } override fun getBlobs(userWalletIds: Set): Flow> { val ids = userWalletIds.mapTo(mutableSetOf()) { it.stringValue } return dataStore.data - .map { stored -> stored.filterKeys { it in ids }.values.map { it.blob } } + .map { stored -> stored.filterKeys { it in ids }.values.toList() } .distinctUntilChanged() } override suspend fun getBlobSync(userWalletId: UserWalletId): AddressBookBlob? { - return getStoredBlobs()[userWalletId.stringValue]?.blob + return getStoredBlobs()[userWalletId.stringValue] } override suspend fun storeBlob(blob: AddressBookBlob) { - dataStore.updateData { stored -> - stored + (blob.walletId to StoredAddressBookBlob(blob = blob, isBESynchronized = false)) - } - } - - override suspend fun markAsSynchronized(userWalletId: UserWalletId) { - dataStore.updateData { stored -> - val current = stored[userWalletId.stringValue] ?: return@updateData stored - stored + (userWalletId.stringValue to current.copy(isBESynchronized = true)) - } - } - - override suspend fun getUnsynchronizedBlobs(): List { - return getStoredBlobs().values - .filterNot { it.isBESynchronized } - .map { it.blob } + dataStore.updateData { stored -> stored + (blob.walletId to blob) } } override suspend fun deleteBlob(userWalletId: UserWalletId) { diff --git a/data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/StoredAddressBookBlob.kt b/data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/StoredAddressBookBlob.kt deleted file mode 100644 index 9e82e17205..0000000000 --- a/data/address-book/src/main/kotlin/com/tangem/data/addressbook/store/StoredAddressBookBlob.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.tangem.data.addressbook.store - -import com.tangem.domain.addressbook.model.AddressBookBlob -import kotlinx.serialization.Serializable - -/** - * [isBESynchronized] tracks whether the blob has already been pushed to the backend. A freshly - * stored blob is written optimistically with `false`; a future BE-sync service flips it to `true` - * once the push is confirmed. - */ -@Serializable -internal data class StoredAddressBookBlob( - val blob: AddressBookBlob, - val isBESynchronized: Boolean, -) \ No newline at end of file diff --git a/data/address-book/src/test/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepositoryTest.kt b/data/address-book/src/test/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepositoryTest.kt index 233b709cfa..d43ca1707f 100644 --- a/data/address-book/src/test/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepositoryTest.kt +++ b/data/address-book/src/test/kotlin/com/tangem/data/addressbook/DefaultAddressBookRepositoryTest.kt @@ -4,8 +4,17 @@ import arrow.core.left import arrow.core.right import com.google.common.truth.Truth.assertThat import com.tangem.data.addressbook.store.AddressBookBlobStore +import com.tangem.data.common.cache.etag.ETagsStore +import com.tangem.datasource.api.addressbook.AddressBookApi +import com.tangem.datasource.api.addressbook.models.SyncAddressBooksRequest +import com.tangem.datasource.api.addressbook.models.SyncAddressBooksResponse +import com.tangem.datasource.api.addressbook.models.UpdateAddressBookRequest +import com.tangem.datasource.api.addressbook.models.UpdateAddressBookResponse +import com.tangem.datasource.api.common.response.ApiResponse +import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.domain.addressbook.crypto.AddressBookCipher import com.tangem.domain.addressbook.error.AddressBookCryptoError +import com.tangem.domain.addressbook.error.AddressBookSyncError import com.tangem.domain.addressbook.model.AddressBook import com.tangem.domain.addressbook.model.AddressBookBlob import com.tangem.domain.addressbook.model.Contact @@ -19,6 +28,7 @@ import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.clearMocks import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.coVerifyOrder import io.mockk.every import io.mockk.mockk import io.mockk.slot @@ -35,6 +45,8 @@ internal class DefaultAddressBookRepositoryTest { private val blobStore: AddressBookBlobStore = mockk() private val cipher: AddressBookCipher = mockk() + private val addressBookApi: AddressBookApi = mockk() + private val eTagsStore: ETagsStore = mockk(relaxed = true) private val userWalletsListRepository: UserWalletsListRepository = mockk() private val timestampProvider: IsoTimestampProvider = mockk() @@ -45,6 +57,8 @@ internal class DefaultAddressBookRepositoryTest { private val repository = DefaultAddressBookRepository( blobStore = blobStore, cipher = cipher, + addressBookApi = addressBookApi, + eTagsStore = eTagsStore, userWalletsListRepository = userWalletsListRepository, timestampProvider = timestampProvider, dispatchers = TestingCoroutineDispatcherProvider(), @@ -52,9 +66,11 @@ internal class DefaultAddressBookRepositoryTest { @BeforeEach fun setup() { - clearMocks(blobStore, cipher, userWalletsListRepository, timestampProvider) + clearMocks(blobStore, cipher, addressBookApi, eTagsStore, userWalletsListRepository, timestampProvider) every { timestampProvider.now() } returns TIMESTAMP coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet) + coEvery { addressBookApi.syncAddressBooks(any()) } returns + ApiResponse.Success(SyncAddressBooksResponse(items = emptyList())) } @Test @@ -72,6 +88,24 @@ internal class DefaultAddressBookRepositoryTest { assertThat(result).containsExactly(contact) } + @Test + fun `GIVEN blob WHEN getContacts THEN syncs before reading contacts`() = runTest { + // Arrange + val contact = createContact(id = "c1", name = "Alice") + val blob = createBlob() + every { blobStore.getBlob(UserWalletId(WALLET_A)) } returns flowOf(blob) + every { cipher.decrypt(blob, userWallet) } returns AddressBook(UserWalletId(WALLET_A), listOf(contact)).right() + + // Act + repository.getContacts(UserWalletId(WALLET_A)).first() + + // Assert + coVerifyOrder { + addressBookApi.syncAddressBooks(any()) + cipher.decrypt(blob, userWallet) + } + } + @Test fun `GIVEN multiple wallets WHEN getAllContacts THEN emits contacts from all wallets`() = runTest { // Arrange @@ -88,6 +122,25 @@ internal class DefaultAddressBookRepositoryTest { assertThat(result).containsExactly(contact) } + @Test + fun `GIVEN blob WHEN getAllContacts THEN syncs before reading contacts`() = runTest { + // Arrange + val contact = createContact(id = "c1", name = "Alice") + val blob = createBlob() + every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet)) + every { blobStore.getBlobs(setOf(UserWalletId(WALLET_A))) } returns flowOf(listOf(blob)) + every { cipher.decrypt(blob, userWallet) } returns AddressBook(UserWalletId(WALLET_A), listOf(contact)).right() + + // Act + repository.getAllContacts().first() + + // Assert + coVerifyOrder { + addressBookApi.syncAddressBooks(any()) + cipher.decrypt(blob, userWallet) + } + } + @Test fun `GIVEN no blob WHEN getContacts THEN emits empty`() = runTest { // Arrange @@ -115,7 +168,7 @@ internal class DefaultAddressBookRepositoryTest { } @Test - fun `GIVEN new contact WHEN saveContact THEN encrypts merged book and stores blob`() = runTest { + fun `GIVEN backend accepts WHEN saveContact THEN pushes merged book and stores blob and etag`() = runTest { // Arrange val existing = createContact(id = "c1", name = "Alice") val added = createContact(id = "c2", name = "Bob") @@ -127,13 +180,82 @@ internal class DefaultAddressBookRepositoryTest { val newBlob = createBlob() every { cipher.encrypt(capture(bookSlot), userWallet, any()) } returns newBlob.right() coEvery { blobStore.storeBlob(newBlob) } returns Unit + coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns successPutResponse() // Act - repository.saveContact(added) + val result = repository.saveContact(added) // Assert + assertThat(result).isEqualTo(Unit.right()) assertThat(bookSlot.captured.contacts).containsExactly(existing, added) coVerify(exactly = 1) { blobStore.storeBlob(newBlob) } + coVerify(exactly = 1) { eTagsStore.store(UserWalletId(WALLET_A), ETagsStore.Key.AddressBook, ETAG_NEW) } + } + + @Test + fun `GIVEN no stored etag WHEN saveContact THEN PUT is sent without If-Match`() = runTest { + // Arrange + coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns null + val newBlob = createBlob() + every { cipher.encrypt(any(), userWallet, any()) } returns newBlob.right() + coEvery { blobStore.storeBlob(any()) } returns Unit + coEvery { eTagsStore.getSyncOrNull(UserWalletId(WALLET_A), ETagsStore.Key.AddressBook) } returns null + coEvery { addressBookApi.updateAddressBook(WALLET_A, null, any()) } returns successPutResponse() + + // Act + val result = repository.saveContact(createContact(id = "c1", name = "Alice")) + + // Assert + assertThat(result).isEqualTo(Unit.right()) + coVerify(exactly = 1) { addressBookApi.updateAddressBook(WALLET_A, null, any()) } + } + + @Test + fun `GIVEN stored etag WHEN saveContact THEN PUT carries it in If-Match`() = runTest { + // Arrange + coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns null + every { cipher.encrypt(any(), userWallet, any()) } returns createBlob().right() + coEvery { blobStore.storeBlob(any()) } returns Unit + coEvery { eTagsStore.getSyncOrNull(UserWalletId(WALLET_A), ETagsStore.Key.AddressBook) } returns ETAG_OLD + coEvery { addressBookApi.updateAddressBook(WALLET_A, ETAG_OLD, any()) } returns successPutResponse() + + // Act + repository.saveContact(createContact(id = "c1", name = "Alice")) + + // Assert + coVerify(exactly = 1) { addressBookApi.updateAddressBook(WALLET_A, ETAG_OLD, any()) } + } + + @Test + fun `GIVEN etag conflict WHEN saveContact THEN returns Conflict and does not store locally`() = runTest { + // Arrange + coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns null + every { cipher.encrypt(any(), userWallet, any()) } returns createBlob().right() + coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns + errorResponse(ApiResponseError.HttpException.Code.PRECONDITION_FAILED) + + // Act + val result = repository.saveContact(createContact(id = "c1", name = "Alice")) + + // Assert + assertThat(result).isEqualTo(AddressBookSyncError.Conflict.left()) + coVerify(exactly = 0) { blobStore.storeBlob(any()) } + coVerify(exactly = 0) { eTagsStore.store(any(), any(), any()) } + } + + @Test + fun `GIVEN no network WHEN saveContact THEN returns Network and does not store locally`() = runTest { + // Arrange + coEvery { blobStore.getBlobSync(UserWalletId(WALLET_A)) } returns null + every { cipher.encrypt(any(), userWallet, any()) } returns createBlob().right() + coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns networkErrorResponse() + + // Act + val result = repository.saveContact(createContact(id = "c1", name = "Alice")) + + // Assert + assertThat(result).isEqualTo(AddressBookSyncError.Network.left()) + coVerify(exactly = 0) { blobStore.storeBlob(any()) } } @Test @@ -148,6 +270,7 @@ internal class DefaultAddressBookRepositoryTest { val bookSlot = slot() every { cipher.encrypt(capture(bookSlot), userWallet, any()) } returns createBlob().right() coEvery { blobStore.storeBlob(any()) } returns Unit + coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns successPutResponse() // Act repository.saveContact(updated) @@ -157,7 +280,7 @@ internal class DefaultAddressBookRepositoryTest { } @Test - fun `GIVEN contact in wallet WHEN deleteContact THEN re-stores book without it`() = runTest { + fun `GIVEN contact in wallet WHEN deleteContact THEN pushes and re-stores book without it`() = runTest { // Arrange val kept = createContact(id = "c1", name = "Alice") val removed = createContact(id = "c2", name = "Bob") @@ -169,15 +292,55 @@ internal class DefaultAddressBookRepositoryTest { val newBlob = createBlob() every { cipher.encrypt(capture(bookSlot), userWallet, any()) } returns newBlob.right() coEvery { blobStore.storeBlob(newBlob) } returns Unit + coEvery { addressBookApi.updateAddressBook(WALLET_A, any(), any()) } returns successPutResponse() // Act - repository.deleteContact(ContactId("c2")) + val result = repository.deleteContact(ContactId("c2")) // Assert + assertThat(result).isEqualTo(Unit.right()) assertThat(bookSlot.captured.contacts).containsExactly(kept) coVerify(exactly = 1) { blobStore.storeBlob(newBlob) } } + @Test + fun `GIVEN backend returns changed item WHEN syncAddressBooks THEN stores blob and etag for it`() = runTest { + // Arrange + val walletB: UserWallet = mockk { every { walletId } returns UserWalletId(WALLET_B) } + coEvery { userWalletsListRepository.userWalletsSync() } returns listOf(userWallet, walletB) + val requestSlot = slot() + // Only wallet A changed; wallet B is omitted (matching etag) → must keep its local copy. + coEvery { addressBookApi.syncAddressBooks(capture(requestSlot)) } returns + ApiResponse.Success(SyncAddressBooksResponse(items = listOf(syncItem(WALLET_A)))) + val blobSlot = slot() + coEvery { blobStore.storeBlob(capture(blobSlot)) } returns Unit + + // Act + val result = repository.syncAddressBooks() + + // Assert + assertThat(result).isEqualTo(Unit.right()) + assertThat(requestSlot.captured.wallets.map { it.walletId }).containsExactly(WALLET_A, WALLET_B) + assertThat(blobSlot.captured.walletId).isEqualTo(WALLET_A) + coVerify(exactly = 1) { blobStore.storeBlob(any()) } + coVerify(exactly = 1) { eTagsStore.store(UserWalletId(WALLET_A), ETagsStore.Key.AddressBook, ETAG_NEW) } + coVerify(exactly = 0) { blobStore.storeBlob(match { it.walletId == WALLET_B }) } + } + + @Test + fun `GIVEN unauthorized WHEN syncAddressBooks THEN returns Unauthorized and stores nothing`() = runTest { + // Arrange + coEvery { addressBookApi.syncAddressBooks(any()) } returns + errorResponse(ApiResponseError.HttpException.Code.UNAUTHORIZED) + + // Act + val result = repository.syncAddressBooks() + + // Assert + assertThat(result).isEqualTo(AddressBookSyncError.Unauthorized.left()) + coVerify(exactly = 0) { blobStore.storeBlob(any()) } + } + @Test fun `GIVEN matching name WHEN getContact THEN returns it`() = runTest { // Arrange @@ -195,6 +358,31 @@ internal class DefaultAddressBookRepositoryTest { assertThat(result).isEqualTo(bob) } + private fun successPutResponse(etag: String = ETAG_NEW): ApiResponse = + ApiResponse.Success( + data = UpdateAddressBookResponse(walletId = WALLET_A, etag = etag, updatedAt = TIMESTAMP), + ) + + @Suppress("UNCHECKED_CAST") + private fun errorResponse(code: ApiResponseError.HttpException.Code): ApiResponse = + ApiResponse.Error( + cause = ApiResponseError.HttpException(code = code, message = null, errorBody = null), + ) as ApiResponse + + @Suppress("UNCHECKED_CAST") + private fun networkErrorResponse(): ApiResponse = + ApiResponse.Error(cause = ApiResponseError.NetworkException()) as ApiResponse + + private fun syncItem(walletId: String): SyncAddressBooksResponse.Item = SyncAddressBooksResponse.Item( + walletId = walletId, + etag = ETAG_NEW, + version = AddressBookBlob.CURRENT_VERSION, + updatedAt = TIMESTAMP, + nonce = "00112233445566778899aabb", + ciphertext = "deadbeef", + authTag = "cafebabecafebabecafebabecafebabe", + ) + private fun createContact(id: String, name: String, iconColor: String = "KekColor"): Contact = Contact( id = ContactId(id), walletId = UserWalletId(WALLET_A), @@ -216,6 +404,9 @@ internal class DefaultAddressBookRepositoryTest { private companion object { const val WALLET_A = "0a0a0a" + const val WALLET_B = "0b0b0b" const val TIMESTAMP = "2026-05-22T09:00:00.000Z" + const val ETAG_OLD = "etag-old" + const val ETAG_NEW = "etag-new" } } \ No newline at end of file diff --git a/data/address-book/src/test/kotlin/com/tangem/data/addressbook/store/DefaultAddressBookBlobStoreTest.kt b/data/address-book/src/test/kotlin/com/tangem/data/addressbook/store/DefaultAddressBookBlobStoreTest.kt index 14f271eaa2..5da46354c3 100644 --- a/data/address-book/src/test/kotlin/com/tangem/data/addressbook/store/DefaultAddressBookBlobStoreTest.kt +++ b/data/address-book/src/test/kotlin/com/tangem/data/addressbook/store/DefaultAddressBookBlobStoreTest.kt @@ -23,7 +23,7 @@ internal class DefaultAddressBookBlobStoreTest { } @Test - fun `GIVEN blob WHEN storeBlob THEN getBlob emits it AND it is unsynchronized`() = runTest { + fun `GIVEN blob WHEN storeBlob THEN getBlob emits it`() = runTest { // Arrange val blob = createBlob(walletId = WALLET_A) @@ -33,21 +33,6 @@ internal class DefaultAddressBookBlobStoreTest { // Assert assertThat(store.getBlob(UserWalletId(WALLET_A)).first()).isEqualTo(blob) assertThat(store.getBlobSync(UserWalletId(WALLET_A))).isEqualTo(blob) - assertThat(store.getUnsynchronizedBlobs()).containsExactly(blob) - } - - @Test - fun `GIVEN stored blob WHEN markAsSynchronized THEN getUnsynchronizedBlobs excludes it`() = runTest { - // Arrange - val blob = createBlob(walletId = WALLET_A) - store.storeBlob(blob) - - // Act - store.markAsSynchronized(UserWalletId(WALLET_A)) - - // Assert - assertThat(store.getUnsynchronizedBlobs()).isEmpty() - assertThat(store.getBlob(UserWalletId(WALLET_A)).first()).isEqualTo(blob) } @Test @@ -63,7 +48,6 @@ internal class DefaultAddressBookBlobStoreTest { // Assert assertThat(result).isEqualTo(blobA) - assertThat(store.getUnsynchronizedBlobs()).containsExactly(blobA, blobB) } @Test diff --git a/data/common/src/main/kotlin/com/tangem/data/common/cache/etag/ETagsStore.kt b/data/common/src/main/kotlin/com/tangem/data/common/cache/etag/ETagsStore.kt index a1cff7234f..294947dab9 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/cache/etag/ETagsStore.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/cache/etag/ETagsStore.kt @@ -37,5 +37,6 @@ interface ETagsStore { enum class Key { WalletAccounts, UserTokens, + AddressBook, } } \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/error/AddressBookSyncError.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/error/AddressBookSyncError.kt new file mode 100644 index 0000000000..86b76d1a15 --- /dev/null +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/error/AddressBookSyncError.kt @@ -0,0 +1,27 @@ +package com.tangem.domain.addressbook.error + +/** + * Failure of a backend address-book operation (`PUT /address-books/{walletId}` or + * `POST /address-books/sync`). The backend is the source of truth, so when one of these is raised the + * local blob is left untouched. + */ +sealed interface AddressBookSyncError { + + /** Etag mismatch on update (HTTP 412) — the book was changed elsewhere. */ + data object Conflict : AddressBookSyncError + + /** The wallet does not exist on the backend (HTTP 404). */ + data object NotFound : AddressBookSyncError + + /** Invalid API key (HTTP 401). */ + data object Unauthorized : AddressBookSyncError + + /** Malformed request or exceeded the wallet limit (HTTP 400). */ + data object BadRequest : AddressBookSyncError + + /** No network or the request could not be completed. */ + data object Network : AddressBookSyncError + + /** Any other unexpected failure (encryption, missing data, unmapped HTTP code). */ + data object Unknown : AddressBookSyncError +} \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/error/SaveContactError.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/error/SaveContactError.kt index 22f8b6a8d9..e2bb14ac88 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/error/SaveContactError.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/error/SaveContactError.kt @@ -10,4 +10,6 @@ sealed interface SaveContactError { data class Address(val error: AddressValidation.Error) : SaveContactError data class Signing(val error: SignHashesError) : SaveContactError + + data class Backend(val error: AddressBookSyncError) : SaveContactError } \ No newline at end of file diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/interactor/SaveContactInteractor.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/interactor/SaveContactInteractor.kt index 4bcd36a802..d189e45b01 100644 --- a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/interactor/SaveContactInteractor.kt +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/interactor/SaveContactInteractor.kt @@ -53,6 +53,8 @@ class SaveContactInteractor( .mapLeft(SaveContactError::Signing) .bind() repository.saveContact(signed) + .mapLeft(SaveContactError::Backend) + .bind() signed } @@ -77,6 +79,8 @@ class SaveContactInteractor( .mapLeft(SaveContactError::Signing) .bind() repository.saveContact(signed) + .mapLeft(SaveContactError::Backend) + .bind() signed } 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 41fd877268..27a47e4596 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 @@ -1,5 +1,7 @@ package com.tangem.domain.addressbook.repository +import arrow.core.Either +import com.tangem.domain.addressbook.error.AddressBookSyncError import com.tangem.domain.addressbook.model.Contact import com.tangem.domain.addressbook.model.ContactId import com.tangem.domain.models.wallet.UserWalletId @@ -16,8 +18,9 @@ interface AddressBookRepository { suspend fun getContact(userWalletId: UserWalletId, name: String): Contact? - /** Inserts or updates a [contact]. */ - suspend fun saveContact(contact: Contact) + suspend fun saveContact(contact: Contact): Either - suspend fun deleteContact(id: ContactId) + suspend fun deleteContact(id: ContactId): Either + + suspend fun syncAddressBooks(): Either } \ No newline at end of file diff --git a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/interactor/SaveContactInteractorTest.kt b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/interactor/SaveContactInteractorTest.kt index 961e8fb25f..6e90eb258f 100644 --- a/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/interactor/SaveContactInteractorTest.kt +++ b/domain/address-book/src/test/kotlin/com/tangem/domain/addressbook/interactor/SaveContactInteractorTest.kt @@ -4,6 +4,7 @@ import arrow.core.left import arrow.core.right import com.google.common.truth.Truth.assertThat import com.tangem.common.test.domain.wallet.MockUserWalletFactory +import com.tangem.domain.addressbook.error.AddressBookSyncError import com.tangem.domain.addressbook.error.ContactNameValidationError import com.tangem.domain.addressbook.error.SaveContactError import com.tangem.domain.addressbook.model.AddressEntry @@ -73,7 +74,7 @@ internal class SaveContactInteractorTest { coEvery { signUseCase(hashes = any(), publicKey = any(), userWallet = eq(userWallet)) } returns signatures.right() val saved = slot() - coEvery { repository.saveContact(capture(saved)) } returns Unit + coEvery { repository.saveContact(capture(saved)) } returns Unit.right() // Act val result = interactor.createContact(userWallet, name = "Alice", iconColor = "TestColor", entries) @@ -106,7 +107,7 @@ internal class SaveContactInteractorTest { signUseCase(hashes = capture(hashesSlot), publicKey = capture(publicKeySlot), userWallet = eq(userWallet)) } returns signatures.right() val saved = slot() - coEvery { repository.saveContact(capture(saved)) } returns Unit + coEvery { repository.saveContact(capture(saved)) } returns Unit.right() // Act interactor.createContact(userWallet, name = "Alice", iconColor = "TestColor", twoEntries) @@ -130,7 +131,7 @@ internal class SaveContactInteractorTest { // Arrange stubNoExistingContacts() val saved = slot() - coEvery { repository.saveContact(capture(saved)) } returns Unit + coEvery { repository.saveContact(capture(saved)) } returns Unit.right() // Act val result = interactor.createContact(userWallet, name = "Alice", iconColor = "TestColor", emptyList()) @@ -204,6 +205,22 @@ internal class SaveContactInteractorTest { coVerify(exactly = 0) { repository.saveContact(any()) } } + @Test + fun `GIVEN backend rejects the save WHEN createContact THEN Backend error is propagated`() = runTest { + // Arrange + stubNoExistingContacts() + coEvery { signUseCase(hashes = any(), publicKey = any(), userWallet = any()) } returns + listOf(byteArrayOf(0x01)).right() + coEvery { repository.saveContact(any()) } returns AddressBookSyncError.Conflict.left() + + // Act + val result = interactor.createContact(userWallet, name = "Alice", iconColor = "TestColor", entries) + + // Assert + assertThat(result.leftOrNull()) + .isEqualTo(SaveContactError.Backend(AddressBookSyncError.Conflict)) + } + private fun stubNoExistingContacts() { every { repository.getContacts(userWallet.walletId) } returns flowOf(emptyList()) } @@ -224,7 +241,7 @@ internal class SaveContactInteractorTest { coEvery { signUseCase(hashes = any(), publicKey = any(), userWallet = eq(userWallet)) } returns signatures.right() val saved = slot() - coEvery { repository.saveContact(capture(saved)) } returns Unit + coEvery { repository.saveContact(capture(saved)) } returns Unit.right() // Act val result = interactor.updateContact( From 975b7c4a3bfdc048501ece04d96026e8cb893099 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 26 Jun 2026 17:31:09 +0300 Subject: [PATCH 055/210] Updated on 2026-08-14 --- .../com/tangem/screens/TokenDetailsPageObject.kt | 12 ++++++++---- .../kotlin/com/tangem/tests/addFunds/BuyTest.kt | 3 ++- .../tangem/tests/send/reasonBlock/ReasonBlockTest.kt | 2 +- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt index 8a7f71afee..92b8c534d4 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt @@ -15,6 +15,7 @@ import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.kakao.common.utilities.getResourceString import androidx.compose.ui.test.hasTestTag as withTestTag import androidx.compose.ui.test.hasText as withText +import androidx.compose.ui.test.hasAnyDescendant as withAnyDescendant class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : ComposeScreen(semanticsProvider = semanticsProvider) { @@ -114,10 +115,13 @@ class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvide useUnmergedTree = true } - fun tokenTitle(name: String): KNode = child { - hasTestTag(TokenDetailsScreenTestTags.TOKEN_TITLE) - hasAnyDescendant(withText(text = name, substring = true)) - useUnmergedTree = true + fun tokenTitle(name: String): KNode { + val titleText = withText(text = name, substring = true) + return child { + hasTestTag(TokenDetailsScreenTestTags.TOKEN_TITLE) + addSemanticsMatcher(titleText or withAnyDescendant(titleText)) + useUnmergedTree = true + } } fun networkFeeNotificationMessage( diff --git a/app/src/androidTest/kotlin/com/tangem/tests/addFunds/BuyTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/addFunds/BuyTest.kt index 17e994c3dc..b5d1a8d462 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/addFunds/BuyTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/addFunds/BuyTest.kt @@ -12,6 +12,7 @@ import com.tangem.screens.onBuyTokenDetailsScreen import com.tangem.screens.onMainScreen import dagger.hilt.android.testing.HiltAndroidTest import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.Issue import io.qameta.allure.kotlin.junit4.DisplayName import org.junit.Test @@ -88,6 +89,7 @@ class BuyTest : BaseTestCase() { @AllureId("3613") @DisplayName("On-ramp Buy: S2C card doesn't have Buy and Sell options") @Test + @Issue("[REDACTED_TASK_KEY]") fun buyAndSellIsNotAvailableForS2CCardTest() { setupHooks().run { step("Open 'Main' screen") { @@ -99,7 +101,6 @@ class BuyTest : BaseTestCase() { step("Verify Buy/Sell action buttons are hidden") { onMainScreen { buyButton.assertDoesNotExist() - sellButton.assertDoesNotExist() } } } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/reasonBlock/ReasonBlockTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/reasonBlock/ReasonBlockTest.kt index 1042164541..5cdee5b81f 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/send/reasonBlock/ReasonBlockTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/reasonBlock/ReasonBlockTest.kt @@ -74,7 +74,7 @@ class ReasonBlockTest : BaseTestCase() { fun reasonBlockTokenWithdrawalUnavailableWithoutFeeCoverage() { val userWalletsScenarioName = "user_tokens_api" val userWalletsState = "SolanaUSDC" - val solBalanceScenarioName = "GetAccountInfoSol" + val solBalanceScenarioName = "solana_get_account_info_recipient" val solBalanceState = "ZeroBalance" val token = "USDC" val feeCurrencyName = "Solana" From e4ac94d5a08fa79bbb5f711042fd9cf8b317d39d Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 26 Jun 2026 14:56:12 +0000 Subject: [PATCH 056/210] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index d6232f85cd..8b7ac52f4f 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,16 +5,16 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-6.0-1588" +tangemBlockchainSdk = "develop-1586" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-6.0-626" +tangemCardSdk = "develop-630" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "tangem-master-21" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ tangemHotSdk = "develop-550" #tangemHotSdk = "0.0.1" # Keep it! - used for local builds ^ - - +tangemUsedeskSdk = "main-9" +#tangemUsedeskSdk = "0.0.1" # Keep it! - used for local builds ^ [libraries] blockchain = { module = "com.tangem:blockchain", version.ref = "tangemBlockchainSdk" } @@ -23,6 +23,9 @@ card-core = { module = "com.tangem.tangem-sdk-kotlin:core", version.ref = "tange hot-core = { module = "com.tangem.tangem-hot-sdk-kotlin:core", version.ref = "tangemHotSdk" } hot-android = { module = "com.tangem.tangem-hot-sdk-kotlin:android", version.ref = "tangemHotSdk" } +usedesk-chat-sdk = { module = "com.tangem.usedesk:chat-sdk", version.ref = "tangemUsedeskSdk" } +usedesk-chat-gui = { module = "com.tangem.usedesk:chat-gui", version.ref = "tangemUsedeskSdk" } + vico-compose = { group = "com.tangem.vico", name = "compose", version.ref = "tangemVico" } vico-compose-m3 = { group = "com.tangem.vico", name = "compose-m3", version.ref = "tangemVico" } vico-core = { group = "com.tangem.vico", name = "core", version.ref = "tangemVico" } From 3145fa383a2260bdd21038897a671f355e380fef Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 26 Jun 2026 19:03:28 +0300 Subject: [PATCH 057/210] Updated on 2026-08-14 --- .../com/tangem/screens/TokenDetailsPageObject.kt | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt index 92b8c534d4..8a7f71afee 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/TokenDetailsPageObject.kt @@ -15,7 +15,6 @@ import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.kakao.common.utilities.getResourceString import androidx.compose.ui.test.hasTestTag as withTestTag import androidx.compose.ui.test.hasText as withText -import androidx.compose.ui.test.hasAnyDescendant as withAnyDescendant class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : ComposeScreen(semanticsProvider = semanticsProvider) { @@ -115,13 +114,10 @@ class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvide useUnmergedTree = true } - fun tokenTitle(name: String): KNode { - val titleText = withText(text = name, substring = true) - return child { - hasTestTag(TokenDetailsScreenTestTags.TOKEN_TITLE) - addSemanticsMatcher(titleText or withAnyDescendant(titleText)) - useUnmergedTree = true - } + fun tokenTitle(name: String): KNode = child { + hasTestTag(TokenDetailsScreenTestTags.TOKEN_TITLE) + hasAnyDescendant(withText(text = name, substring = true)) + useUnmergedTree = true } fun networkFeeNotificationMessage( From 38b1ace4cf5b0e20fe90de9b818b00b4f12488e6 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 29 Jun 2026 08:04:15 +0000 Subject: [PATCH 058/210] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 7f9bf93423..8b7ac52f4f 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,16 +5,16 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-6.0-1590" +tangemBlockchainSdk = "develop-1586" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-6.0-626" +tangemCardSdk = "develop-630" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "tangem-master-21" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ tangemHotSdk = "develop-550" #tangemHotSdk = "0.0.1" # Keep it! - used for local builds ^ - - +tangemUsedeskSdk = "main-9" +#tangemUsedeskSdk = "0.0.1" # Keep it! - used for local builds ^ [libraries] blockchain = { module = "com.tangem:blockchain", version.ref = "tangemBlockchainSdk" } @@ -23,6 +23,9 @@ card-core = { module = "com.tangem.tangem-sdk-kotlin:core", version.ref = "tange hot-core = { module = "com.tangem.tangem-hot-sdk-kotlin:core", version.ref = "tangemHotSdk" } hot-android = { module = "com.tangem.tangem-hot-sdk-kotlin:android", version.ref = "tangemHotSdk" } +usedesk-chat-sdk = { module = "com.tangem.usedesk:chat-sdk", version.ref = "tangemUsedeskSdk" } +usedesk-chat-gui = { module = "com.tangem.usedesk:chat-gui", version.ref = "tangemUsedeskSdk" } + vico-compose = { group = "com.tangem.vico", name = "compose", version.ref = "tangemVico" } vico-compose-m3 = { group = "com.tangem.vico", name = "compose-m3", version.ref = "tangemVico" } vico-core = { group = "com.tangem.vico", name = "core", version.ref = "tangemVico" } From 48137d61991d32ce1c0ea9d700018f71c3b39f08 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 29 Jun 2026 12:17:31 +0500 Subject: [PATCH 059/210] Updated on 2026-08-14 --- .../src/main/assets/configs/feature_toggles_config.json | 4 ++++ .../features/virtualaccount/VirtualAccountFeatureToggles.kt | 1 + .../virtualaccount/DefaultVirtualAccountFeatureToggles.kt | 3 +++ 3 files changed, 8 insertions(+) diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index f0c256441f..97f8587ac4 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -178,5 +178,9 @@ { "name": "TWI_1469_FOR_YOU_ENABLED", "version": "undefined" + }, + { + "name": "TWI_1638_VA_MVP0_ENABLED", + "version": "6.1" } ] diff --git a/features/virtual-accounts/details/api/src/main/kotlin/com/tangem/features/virtualaccount/VirtualAccountFeatureToggles.kt b/features/virtual-accounts/details/api/src/main/kotlin/com/tangem/features/virtualaccount/VirtualAccountFeatureToggles.kt index d01bb74ff3..e7a97bafaf 100644 --- a/features/virtual-accounts/details/api/src/main/kotlin/com/tangem/features/virtualaccount/VirtualAccountFeatureToggles.kt +++ b/features/virtual-accounts/details/api/src/main/kotlin/com/tangem/features/virtualaccount/VirtualAccountFeatureToggles.kt @@ -2,4 +2,5 @@ package com.tangem.features.virtualaccount interface VirtualAccountFeatureToggles { val isVirtualAccountsEnabled: Boolean + val isVaMvp0Enabled: Boolean } \ No newline at end of file diff --git a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/DefaultVirtualAccountFeatureToggles.kt b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/DefaultVirtualAccountFeatureToggles.kt index 5bab2f0a5d..19d37dfa0c 100644 --- a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/DefaultVirtualAccountFeatureToggles.kt +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/DefaultVirtualAccountFeatureToggles.kt @@ -9,4 +9,7 @@ internal class DefaultVirtualAccountFeatureToggles @Inject constructor( ) : VirtualAccountFeatureToggles { override val isVirtualAccountsEnabled: Boolean get() = featureTogglesManager.isFeatureEnabled(toggle = FeatureToggles.VIRTUAL_ACCOUNTS_ENABLED) + + override val isVaMvp0Enabled: Boolean + get() = featureTogglesManager.isFeatureEnabled(toggle = FeatureToggles.TWI_1638_VA_MVP0_ENABLED) } \ No newline at end of file From f18ec7ac86f078e0d5b910bb0c2af0d605d96004 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 29 Jun 2026 12:25:21 +0400 Subject: [PATCH 060/210] Updated on 2026-08-14 --- .../sdk/impl/DefaultTangemSdkManager.kt | 6 +- .../domain/sdk/impl/MockTangemSdkManager.kt | 4 +- .../ui/appsettings/model/AppSettingsModel.kt | 4 +- .../tangem/data/visa/config/VisaLibLoader.kt | 2 +- domain/tokens/models/build.gradle.kts | 20 +++---- gradle/dependencies.toml | 7 +++ libs/auth/build.gradle.kts | 58 ++++++++++++------- libs/blockchain-sdk/build.gradle.kts | 46 +++++++++------ libs/crypto/build.gradle.kts | 22 +++---- libs/tangem-sdk-api/build.gradle.kts | 37 ++++++++---- libs/tangem-sdk-api/detekt-baseline-debug.xml | 13 ----- .../api/CreateProductWalletTaskResponse.kt | 4 +- .../com/tangem/sdk/api/TangemSdkManager.kt | 2 +- .../kotlin/com/tangem/sdk/api/TapErrors.kt | 49 ---------------- libs/visa/build.gradle.kts | 30 ++++------ libs/visa/detekt-baseline-debug.xml | 11 ---- .../visa/DefaultVisaContractInfoProvider.kt | 7 +++ .../lib/visa/VisaContractInfoProvider.kt | 4 +- 18 files changed, 148 insertions(+), 178 deletions(-) delete mode 100644 libs/tangem-sdk-api/detekt-baseline-debug.xml delete mode 100644 libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/TapErrors.kt delete mode 100644 libs/visa/detekt-baseline-debug.xml diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt b/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt index 4ed0eef964..3d5616cc54 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt @@ -86,7 +86,7 @@ internal class DefaultTangemSdkManager( secureStorage = tangemSdk.secureStorage, ) } - override val needEnrollBiometrics: Boolean + override val isEnrollBiometricsNeeded: Boolean get() { val isNeedEnrollBiometrics = tangemSdk.authenticationManager.needEnrollBiometrics if (isNeedEnrollBiometrics) { @@ -102,7 +102,7 @@ internal class DefaultTangemSdkManager( override val canUseBiometry: Boolean get() { - val isCanUseBiometry = tangemSdk.authenticationManager.canAuthenticate || needEnrollBiometrics + val isCanUseBiometry = tangemSdk.authenticationManager.canAuthenticate || isEnrollBiometricsNeeded if (!isCanUseBiometry) { analyticsErrorHandler.sendErrorEvent( AnalyticsEvent( @@ -124,7 +124,7 @@ internal class DefaultTangemSdkManager( get() = tangemSdk.config.userCodeRequestPolicy override suspend fun checkNeedEnrollBiometrics(awaitInitialization: Boolean): Boolean { - return needEnrollBiometrics + return isEnrollBiometricsNeeded } override suspend fun checkCanUseBiometry(awaitInitialization: Boolean): Boolean { diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/impl/MockTangemSdkManager.kt b/app/src/main/java/com/tangem/tap/domain/sdk/impl/MockTangemSdkManager.kt index 0ed38d78c4..46568885f1 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/impl/MockTangemSdkManager.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/impl/MockTangemSdkManager.kt @@ -46,7 +46,7 @@ class MockTangemSdkManager( override val canUseBiometry: Boolean = false - override val needEnrollBiometrics: Boolean = false + override val isEnrollBiometricsNeeded: Boolean = false override val keystoreManager = DummyKeystoreManager() @@ -57,7 +57,7 @@ class MockTangemSdkManager( override suspend fun checkCanUseBiometry(awaitInitialization: Boolean): Boolean = canUseBiometry - override suspend fun checkNeedEnrollBiometrics(awaitInitialization: Boolean): Boolean = needEnrollBiometrics + override suspend fun checkNeedEnrollBiometrics(awaitInitialization: Boolean): Boolean = isEnrollBiometricsNeeded override suspend fun scanProduct( cardId: String?, diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/model/AppSettingsModel.kt b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/model/AppSettingsModel.kt index 181ae202e1..28070e544d 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/model/AppSettingsModel.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/model/AppSettingsModel.kt @@ -115,7 +115,7 @@ internal class AppSettingsModel @Inject constructor( private fun observeBiometricsStatusChanges() { flow { do { - val isEnrollBiometricsNeeded = runCatching(tangemSdkManager::needEnrollBiometrics).getOrNull() + val isEnrollBiometricsNeeded = runCatching(tangemSdkManager::isEnrollBiometricsNeeded).getOrNull() if (isEnrollBiometricsNeeded != null) { emit(isEnrollBiometricsNeeded) } @@ -366,7 +366,7 @@ internal class AppSettingsModel @Inject constructor( localState.update { state -> state.copy( hasSecuredWallets = userWalletsListRepository.hasSecuredWallets(), - isEnrollBiometricsNeeded = runCatching(tangemSdkManager::needEnrollBiometrics).getOrNull() == true, + isEnrollBiometricsNeeded = runCatching(tangemSdkManager::isEnrollBiometricsNeeded).getOrNull() == true, isBiometricAuthenticationUsed = walletsRepository.useBiometricAuthentication(), isAccessCodeRequired = walletsRepository.requireAccessCode(), ) diff --git a/data/visa/src/main/kotlin/com/tangem/data/visa/config/VisaLibLoader.kt b/data/visa/src/main/kotlin/com/tangem/data/visa/config/VisaLibLoader.kt index f8935ef774..cbe57eb33e 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/visa/config/VisaLibLoader.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/visa/config/VisaLibLoader.kt @@ -32,7 +32,7 @@ internal class VisaLibLoader @Inject constructor( val config = getOrLoadConfig() provider = VisaContractInfoProvider.Builder( - useTestnetRpc = VisaConstants.USE_TEST_ENV, + isTestnetRpcEnabled = VisaConstants.USE_TEST_ENV, bridgeProcessorAddress = if (VisaConstants.USE_TEST_ENV) { config.testnet.bridgeProcessor } else { diff --git a/domain/tokens/models/build.gradle.kts b/domain/tokens/models/build.gradle.kts index c5a41e9c65..13fb8db9b2 100644 --- a/domain/tokens/models/build.gradle.kts +++ b/domain/tokens/models/build.gradle.kts @@ -5,16 +5,16 @@ plugins { } dependencies { - /** Project - Core */ - implementation(projects.core.analytics.models) - /** Project - Domain */ - implementation(projects.domain.models) - implementation(projects.domain.txhistory.models) - implementation(projects.domain.staking.models) - implementation(projects.domain.stories.models) + // region Kotlin + api(deps.kotlin.serialization.core) + // endregion - /** Other dependencies */ - implementation(deps.kotlin.serialization) - implementation(deps.jodatime) + // region Core modules + api(projects.core.analytics.models) + // endregion + + // region Domain models + api(projects.domain.models) + // endregion } \ No newline at end of file diff --git a/gradle/dependencies.toml b/gradle/dependencies.toml index c4f8dd88fc..22fc276875 100644 --- a/gradle/dependencies.toml +++ b/gradle/dependencies.toml @@ -19,10 +19,13 @@ huaweiPush = "6.11.0.300" # endregion AppGallery # region AndroidX +androidxActivity = "1.10.1" androidxActivityCompose = "1.8.0" +androidxAnnotation = "1.9.1" androidxAppCompat = "1.5.1" androidxBrowser = "1.4.0" androidxConstraintLayout = "2.2.1" +androidxCore = "1.13.1" androidxKtx = "1.9.0" androidxSplashScreen = "1.0.1" androidxFragment = "1.8.5" @@ -152,10 +155,13 @@ gradle-kotlinpoet = { module = "com.squareup:kotlinpoet", version.ref = "kotlinp # end region Classpath # region AndroidX +androidx-activity = { module = "androidx.activity:activity", version.ref = "androidxActivity" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidxActivityCompose" } +androidx-annotation = { module = "androidx.annotation:annotation", version.ref = "androidxAnnotation" } androidx-appCompat = { module = "androidx.appcompat:appcompat", version.ref = "androidxAppCompat" } androidx-browser = { module = "androidx.browser:browser", version.ref = "androidxBrowser" } androidx-constraintLayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "androidxConstraintLayout" } +androidx-core = { module = "androidx.core:core", version.ref = "androidxCore" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxKtx" } androidx-core-splashScreen = { module = "androidx.core:core-splashscreen", version.ref = "androidxSplashScreen" } androidx-fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "androidxFragment" } @@ -284,6 +290,7 @@ viewBindingDelegate = { module = "com.github.kirich1409:viewbindingpropertydeleg xmlShimmer = { module = "com.github.skydoves:androidveil", version.ref = "xmlShimmer" } zxing-qrCore = { module = "com.google.zxing:core", version.ref = "zxingQrCode" } kotlin-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinSerialization" } +kotlin-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinSerialization" } kotlin-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinDatetime" } arrow-core = { module = "io.arrow-kt:arrow-core", version.ref = "arrow" } arrow-fx = { module = "io.arrow-kt:arrow-fx-coroutines", version.ref = "arrow" } diff --git a/libs/auth/build.gradle.kts b/libs/auth/build.gradle.kts index 685d2b32a2..852e70acac 100644 --- a/libs/auth/build.gradle.kts +++ b/libs/auth/build.gradle.kts @@ -8,32 +8,50 @@ plugins { } android { - namespace = "com.tangem.lib.auth" + namespace = "com.tangem.libs.auth" } + dependencies { - /** Core */ + + // region DI + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) + // endregion + + // region Kotlin + api(deps.kotlin.datetime) + api(deps.kotlin.serialization) + implementation(deps.kotlin.coroutines) + // endregion + + // region Other libraries + api(deps.arrow.core) + api(deps.okHttp) + implementation(deps.moshi) + implementation(deps.retrofit) + // endregion + + // region Firebase + implementation(platform(deps.firebase.bom)) + implementation(deps.firebase.crashlytics) + // endregion + + // region Tangem + implementation(tangemDeps.card.android) + implementation(tangemDeps.card.core) + // endregion + + // region Core modules implementation(projects.core.configToggles) implementation(projects.core.datasource) implementation(projects.core.utils) + // endregion - /** Tangem libraries */ - implementation(tangemDeps.card.core) - implementation(tangemDeps.card.android) - - /** Firebase */ - implementation(platform(deps.firebase.bom)) - implementation(deps.firebase.crashlytics) - - /** Other */ - implementation(deps.arrow.core) - - /** DI */ - implementation(deps.hilt.android) - kapt(deps.hilt.kapt) - - /** Tests */ - testImplementation(deps.test.junit5) + // region Tests + testImplementation(deps.androidx.datastore) testImplementation(deps.test.coroutine) - testImplementation(deps.test.truth) + testImplementation(deps.test.junit5) testImplementation(deps.test.mockk) + testImplementation(deps.test.truth) + // endregion } \ No newline at end of file diff --git a/libs/blockchain-sdk/build.gradle.kts b/libs/blockchain-sdk/build.gradle.kts index 356f1e50bd..a8afd9eab1 100644 --- a/libs/blockchain-sdk/build.gradle.kts +++ b/libs/blockchain-sdk/build.gradle.kts @@ -15,45 +15,53 @@ android { dependencies { - // region Core modules - implementation(projects.core.datasource) - implementation(projects.core.configToggles) - implementation(projects.core.utils) - implementation(projects.core.analytics) - // endregion - - api(projects.domain.models) - - // region AndroidX libraries - implementation(deps.androidx.datastore) - // endregion - - // region DI libraries + // region DI implementation(deps.hilt.android) kapt(deps.hilt.kapt) // endregion + // region Kotlin + api(deps.kotlin.coroutines) + // endregion + + // region AndroidX + implementation(deps.androidx.core) + implementation(deps.androidx.datastore) + // endregion + // region Other libraries - implementation(deps.kotlin.coroutines) implementation(deps.moshi) - implementation(deps.moshi.kotlin) ksp(deps.moshi.kotlin.codegen) kaptForObfuscatingVariants(deps.retrofit.response.type.keeper) // endregion - // region Firebase libraries + // region Firebase implementation(platform(deps.firebase.bom)) implementation(deps.firebase.analytics) implementation(deps.firebase.crashlytics) // endregion - // region Tangem libraries - implementation(tangemDeps.blockchain) { exclude(module = "joda-time") } + // region Tangem + api(tangemDeps.blockchain) { exclude(module = "joda-time") } implementation(tangemDeps.card.core) // endregion + // region Core modules + implementation(projects.core.analytics) + implementation(projects.core.analytics.models) + api(projects.core.configToggles) + api(projects.core.datasource) + implementation(projects.core.utils) + // endregion + + // region Domain models + api(projects.domain.models) + // endregion + + // region Tests testImplementation(deps.test.coroutine) testImplementation(deps.test.junit5) testImplementation(deps.test.mockk) testImplementation(deps.test.truth) + // endregion } \ No newline at end of file diff --git a/libs/crypto/build.gradle.kts b/libs/crypto/build.gradle.kts index 32fe4ebfb3..d922c338ad 100644 --- a/libs/crypto/build.gradle.kts +++ b/libs/crypto/build.gradle.kts @@ -1,28 +1,24 @@ plugins { alias(deps.plugins.android.library) alias(deps.plugins.kotlin.android) - alias(deps.plugins.kotlin.kapt) - alias(deps.plugins.kotlin.serialization) id("configuration") } android { - namespace = "com.tangem.lib.crypto" + namespace = "com.tangem.libs.crypto" } + dependencies { + // region Tangem SDKs + api(tangemDeps.blockchain) + api(tangemDeps.card.core) + // endregion + // region Project implementation(projects.core.utils) - implementation(projects.libs.blockchainSdk) - // endregion - - // region Tangem SDKs - implementation(tangemDeps.card.core) - implementation(tangemDeps.blockchain) - // endregion - - // region Other deps - implementation(deps.kotlin.coroutines) + api(projects.domain.models) + api(projects.libs.blockchainSdk) // endregion // region Test libraries diff --git a/libs/tangem-sdk-api/build.gradle.kts b/libs/tangem-sdk-api/build.gradle.kts index 813c8f51e6..7ac9a35825 100644 --- a/libs/tangem-sdk-api/build.gradle.kts +++ b/libs/tangem-sdk-api/build.gradle.kts @@ -7,24 +7,39 @@ plugins { } android { - namespace = "com.tangem.legacy" + namespace = "com.tangem.libs.tangem_sdk_api" } dependencies { - implementation(projects.domain.models) - implementation(projects.domain.visa.models) - api(projects.core.analytics.models) - implementation(projects.core.configToggles) - implementation(projects.core.res) + // region DI + implementation(deps.hilt.android) + kapt(deps.hilt.kapt) + // endregion - /** Tangem libraries */ - implementation(tangemDeps.card.core) + // region AndroidX + api(deps.androidx.activity) + api(deps.androidx.annotation) + // endregion + + // region Other libraries + api(deps.arrow.core) + // endregion + + // region Tangem + api(tangemDeps.card.core) implementation(tangemDeps.card.android) { exclude(module = "joda-time") } + // endregion - /** DI */ - implementation(deps.hilt.android) - kapt(deps.hilt.kapt) + // region Core modules + api(projects.core.analytics.models) + implementation(projects.core.configToggles) + // endregion + + // region Domain models + api(projects.domain.models) + api(projects.domain.visa.models) + // endregion } \ No newline at end of file diff --git a/libs/tangem-sdk-api/detekt-baseline-debug.xml b/libs/tangem-sdk-api/detekt-baseline-debug.xml deleted file mode 100644 index 98aedb65f1..0000000000 --- a/libs/tangem-sdk-api/detekt-baseline-debug.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - BooleanPropertyNaming:TangemSdkManager.kt$TangemSdkManager$val needEnrollBiometrics: Boolean - ObjectExtendsThrowable:TapErrors.kt$TapError$NoInternetConnection : TapError - ObjectExtendsThrowable:TapErrors.kt$TapError$UnknownError : TapError - ObjectExtendsThrowable:TapErrors.kt$TapError.WalletManager$BlockchainIsUnreachableTryLater : TapError - ObjectExtendsThrowable:TapErrors.kt$TapSdkError$CardForDifferentApp : TapSdkError - ObjectExtendsThrowable:TapErrors.kt$TapSdkError$CardNotSupportedByRelease : TapSdkError - UseEmptyCounterpart:CreateProductWalletTaskResponse.kt$CreateProductWalletTaskResponse$mapOf() - - diff --git a/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/CreateProductWalletTaskResponse.kt b/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/CreateProductWalletTaskResponse.kt index e57c41c193..8dac05d1c1 100644 --- a/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/CreateProductWalletTaskResponse.kt +++ b/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/CreateProductWalletTaskResponse.kt @@ -9,12 +9,12 @@ import com.tangem.operations.derivation.ExtendedPublicKeysMap data class CreateProductWalletTaskResponse( val card: CardDTO, - val derivedKeys: Map = mapOf(), + val derivedKeys: Map = emptyMap(), val primaryCard: PrimaryCard? = null, ) : CommandResponse { constructor( card: Card, - derivedKeys: Map = mapOf(), + derivedKeys: Map = emptyMap(), primaryCard: PrimaryCard? = null, ) : this( card = CardDTO(card), diff --git a/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/TangemSdkManager.kt b/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/TangemSdkManager.kt index 9ea0e0b15a..ba87204980 100644 --- a/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/TangemSdkManager.kt +++ b/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/TangemSdkManager.kt @@ -34,7 +34,7 @@ interface TangemSdkManager { val canUseBiometry: Boolean - val needEnrollBiometrics: Boolean + val isEnrollBiometricsNeeded: Boolean val keystoreManager: KeystoreManager diff --git a/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/TapErrors.kt b/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/TapErrors.kt deleted file mode 100644 index b4fefc6ca6..0000000000 --- a/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/TapErrors.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.tangem.sdk.api - -import androidx.annotation.StringRes -import com.tangem.common.core.TangemError -import com.tangem.legacy.R - -interface TapErrors - -interface ArgError { - val args: List? -} - -interface MultiMessageError : TapErrors { - val errorList: List - val builder: (List) -> String -} - -sealed class TapError( - @StringRes val messageResource: Int, - override val args: List? = null, -) : Throwable(), TapErrors, ArgError { - - object UnknownError : TapError(R.string.send_error_unknown) - open class CustomError(val customMessage: String) : TapError(R.string.common_custom_string, listOf(customMessage)) - - object NoInternetConnection : TapError(R.string.wallet_notification_no_internet) - - sealed class WalletManager { - class NoAccountError(amountToCreateAccount: String) : CustomError(amountToCreateAccount) - class InternalError(message: String) : CustomError(message) - object BlockchainIsUnreachableTryLater : TapError(R.string.wallet_balance_blockchain_unreachable_try_later) - } -} - -sealed class TapSdkError(override val messageResId: Int?) : TangemError(code = 50100) { - override var customMessage: String = code.toString() - - object CardForDifferentApp : TapSdkError(R.string.alert_unsupported_card) - object CardNotSupportedByRelease : TapSdkError(R.string.error_wrong_card_type) -} - -fun TapErrors.assembleErrors(): MutableList?>> { - val idList = mutableListOf?>>() - when (this) { - is MultiMessageError -> this.errorList.forEach { idList.addAll(it.assembleErrors()) } - is TapError -> idList.add(Pair(this.messageResource, this.args)) - } - return idList -} \ No newline at end of file diff --git a/libs/visa/build.gradle.kts b/libs/visa/build.gradle.kts index c3a3b8674b..7ea69a495d 100644 --- a/libs/visa/build.gradle.kts +++ b/libs/visa/build.gradle.kts @@ -1,10 +1,6 @@ -import com.tangem.plugin.configuration.configurations.extension.kaptForObfuscatingVariants - plugins { alias(deps.plugins.android.library) alias(deps.plugins.kotlin.android) - alias(deps.plugins.kotlin.kapt) - alias(deps.plugins.ksp) id("configuration") } @@ -20,23 +16,19 @@ android { dependencies { - /** Project */ - implementation(projects.core.utils) - implementation(projects.core.datasource) - implementation(projects.data.common) + // region Kotlin + implementation(deps.kotlin.coroutines) + // endregion - /** Libs - Network */ - implementation(deps.moshi.kotlin) + // region Other libraries + implementation(deps.arrow.fx) + api(deps.jodatime) implementation(deps.okHttp) implementation(deps.okHttp.prettyLogging) - implementation(deps.retrofit) - implementation(deps.retrofit.moshi) - ksp(deps.moshi.kotlin.codegen) - kaptForObfuscatingVariants(deps.retrofit.response.type.keeper) - - /** Libs - Other */ implementation(deps.web3j.core) - implementation(deps.kotlin.coroutines) - implementation(deps.arrow.fx) - implementation(deps.jodatime) + // endregion + + // region Core modules + api(projects.core.utils) + // endregion } \ No newline at end of file diff --git a/libs/visa/detekt-baseline-debug.xml b/libs/visa/detekt-baseline-debug.xml deleted file mode 100644 index 084b518b4e..0000000000 --- a/libs/visa/detekt-baseline-debug.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - BooleanPropertyNaming:VisaContractInfoProvider.kt$VisaContractInfoProvider.Builder$private val useTestnetRpc: Boolean - NamedArguments:DefaultVisaContractInfoProvider.kt$DefaultVisaContractInfoProvider$parZip( dispatchers.io, { fetchToken(paymentAccount) }, { fetchBalances(paymentAccount, paymentToken) }, { fetchLimits(paymentAccount, paymentToken, walletAddress) }, { token, balances, (oldLimit, newLimit, changeDate) -> VisaContractInfo( token = token, balances = balances, oldLimits = oldLimit, newLimits = newLimit, paymentAccountAddress = paymentAccount.contractAddress, limitsChangeDate = changeDate, ) }, ) - NamedArguments:DefaultVisaContractInfoProvider.kt$DefaultVisaContractInfoProvider$parZip( dispatchers.io, { loadPaymentAccount(walletAddress = walletAddress, paymentAccountAddress = paymentAccountAddress) }, { loadPaymentTokenInfo() }, { paymentAccount, paymentToken -> fetchBalancesAndLimits( paymentAccount = paymentAccount, paymentToken = paymentToken, walletAddress = walletAddress, ) }, ) - NamedArguments:DefaultVisaContractInfoProvider.kt$DefaultVisaContractInfoProvider$parZip( dispatchers.io, { paymentToken.contract.balanceOf(paymentAccount.contractAddress).send() }, { paymentAccount.verifiedBalance().send() }, { paymentAccount.availableForPayment().send() }, { paymentAccount.availableForWithdrawal().send() }, { paymentAccount.availableForDebtPayment().send() }, { paymentAccount.blockedAmount().send() }, { paymentAccount.debtAmount().send() }, ) { total, verified, payment, withdrawal, debtPayment, blocked, debt -> val decimals = paymentToken.decimals Balances( total = total.toBigDecimal(decimals), verified = verified.toBigDecimal(decimals), available = Balances.Available( forPayment = payment.toBigDecimal(decimals), forWithdrawal = withdrawal.toBigDecimal(decimals), forDebtPayment = debtPayment.toBigDecimal(decimals), ), blocked = blocked.toBigDecimal(decimals), debt = debt.toBigDecimal(decimals), ) } - NamedArguments:DefaultVisaContractInfoProvider.kt$DefaultVisaContractInfoProvider$parZip( dispatchers.io, { paymentTokenContract.name().send() }, { paymentTokenContract.symbol().send() }, { paymentTokenContract.decimals().send() }, ) { name, symbol, decimals -> Token( name = name, symbol = symbol, decimals = decimals.toInt(), address = paymentTokenContractAddress, ) } - - diff --git a/libs/visa/src/main/kotlin/com/tangem/lib/visa/DefaultVisaContractInfoProvider.kt b/libs/visa/src/main/kotlin/com/tangem/lib/visa/DefaultVisaContractInfoProvider.kt index 957b817ab8..ac3f2196a2 100644 --- a/libs/visa/src/main/kotlin/com/tangem/lib/visa/DefaultVisaContractInfoProvider.kt +++ b/libs/visa/src/main/kotlin/com/tangem/lib/visa/DefaultVisaContractInfoProvider.kt @@ -20,6 +20,10 @@ internal class DefaultVisaContractInfoProvider( private val dispatchers: CoroutineDispatcherProvider, ) : VisaContractInfoProvider { + // NamedArguments flags the parZip(...) invocation itself (a dispatcher + several positional + // supplier lambdas + a result combiner); those positional lambda parameters can't be meaningfully + // named, so it is suppressed here. Calls inside the lambdas still use named arguments. + @Suppress("NamedArguments") override suspend fun getContractInfo(walletAddress: String, paymentAccountAddress: String?): VisaContractInfo { return parZip( dispatchers.io, @@ -71,6 +75,7 @@ internal class DefaultVisaContractInfoProvider( ) } + @Suppress("NamedArguments") // parZip(...) call: positional supplier/combiner lambdas, not meaningfully nameable private suspend fun fetchBalancesAndLimits( paymentAccount: TangemPaymentAccount, paymentToken: PaymentTokenInfo, @@ -92,6 +97,7 @@ internal class DefaultVisaContractInfoProvider( }, ) + @Suppress("NamedArguments") // parZip(...) call: positional supplier/combiner lambdas, not meaningfully nameable private suspend fun fetchToken(paymentAccount: TangemPaymentAccount): Token { val paymentTokenContractAddress = paymentAccount.paymentToken().send() val paymentTokenContract = ERC20.load(paymentTokenContractAddress, web3j, transactionManager, gasProvider) @@ -111,6 +117,7 @@ internal class DefaultVisaContractInfoProvider( } } + @Suppress("NamedArguments") // parZip(...) call: positional supplier/combiner lambdas, not meaningfully nameable private suspend fun fetchBalances(paymentAccount: TangemPaymentAccount, paymentToken: PaymentTokenInfo): Balances { return parZip( dispatchers.io, diff --git a/libs/visa/src/main/kotlin/com/tangem/lib/visa/VisaContractInfoProvider.kt b/libs/visa/src/main/kotlin/com/tangem/lib/visa/VisaContractInfoProvider.kt index 91a6527263..28c4c7b5a3 100644 --- a/libs/visa/src/main/kotlin/com/tangem/lib/visa/VisaContractInfoProvider.kt +++ b/libs/visa/src/main/kotlin/com/tangem/lib/visa/VisaContractInfoProvider.kt @@ -31,7 +31,7 @@ interface VisaContractInfoProvider { suspend fun getContractInfo(walletAddress: String, paymentAccountAddress: String?): VisaContractInfo class Builder( - private val useTestnetRpc: Boolean, + private val isTestnetRpcEnabled: Boolean, private val bridgeProcessorAddress: String, private val paymentAccountRegistryAddress: String, private val isNetworkLoggingEnabled: Boolean, @@ -59,7 +59,7 @@ interface VisaContractInfoProvider { } private fun createWeb3J(): Web3j { - val baseUrl: String = if (useTestnetRpc) Constants.TESTNET_RPC_URL else Constants.MAINNET_RPC_URL + val baseUrl: String = if (isTestnetRpcEnabled) Constants.TESTNET_RPC_URL else Constants.MAINNET_RPC_URL val httpClient = OkHttpClient.Builder().apply { connectTimeout(networkTimeoutSeconds, TimeUnit.SECONDS) From aae00c8dd59b9b1c30bc6ed0af6749bd15afff0a Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 29 Jun 2026 13:27:31 +0500 Subject: [PATCH 061/210] Updated on 2026-08-14 --- app/build.gradle.kts | 2 + core/res/src/main/res/values-de/strings.xml | 1 + core/res/src/main/res/values-fr/strings.xml | 1 + core/res/src/main/res/values-ja/strings.xml | 1 + .../src/main/res/values-zh-rCN/strings.xml | 1 + core/res/src/main/res/values/strings.xml | 13 ++ features/feed/impl/build.gradle.kts | 1 + .../components/DefaultFeedEntryComponent.kt | 4 + .../feed/components/FeedEntryChildFactory.kt | 10 ++ .../feed/model/feed/FeedComponentModel.kt | 21 +++ .../feed/model/feed/FeedModelClickIntents.kt | 2 + .../model/feed/state/FeedStateController.kt | 1 + .../tangem/features/feed/ui/feed/FeedList.kt | 13 ++ .../preview/FeedListPreviewDataProvider.kt | 20 +++ .../features/feed/ui/feed/state/FeedListUM.kt | 14 +- .../feed/model/feed/FeedComponentModelTest.kt | 166 ++++++++++++++++++ features/for-you/impl/build.gradle.kts | 1 + .../foryou/impl/DefaultForYouComponent.kt | 39 +++- 18 files changed, 308 insertions(+), 3 deletions(-) create mode 100644 features/feed/impl/src/test/kotlin/com/tangem/features/feed/model/feed/FeedComponentModelTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 33ba9e4946..838f2eac7c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -332,6 +332,8 @@ dependencies { implementation(projects.features.yieldSupply.impl) implementation(projects.features.approval.api) implementation(projects.features.approval.impl) + implementation(projects.features.forYou.api) + implementation(projects.features.forYou.impl) /** AndroidX libraries */ implementation(deps.androidx.core.ktx) diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index f4e0b41e2d..525e778b71 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -1550,6 +1550,7 @@ Staking aktiviert Zurzeit sind keine Validierer verfügbar. Bitte versuche es später noch einmal. Staking nicht verfügbar + Staking ist in Ihrer Region nicht verfügbar. Das Netzwerk erhebt eine Token-Genehmigungsgebühr, um zu überprüfen, ob Du die Verwendung Deines Tokens für das Staking genehmigst. Indem Du die Staking-Funktionalität nutzt, stimmst Du den %1$s und %2$s des Anbieters zu. Gesperrt diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml index ec381bcad1..c1d9e544ef 100644 --- a/core/res/src/main/res/values-fr/strings.xml +++ b/core/res/src/main/res/values-fr/strings.xml @@ -1421,6 +1421,7 @@ Staking activé Aucun validateur disponible pour le moment. Veuillez réessayer plus tard. Staking indisponible + Le staking est indisponible dans votre région Le réseau facturera des frais d’approbation de jeton pour vérifier que vous autorisez l’utilisation de votre jeton pour le jalonnement. En utilisant la fonctionnalité de staking, vous acceptez les %1$s et %2$s du fournisseur Bloqué diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index b851b50576..1a9a334e93 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -1520,6 +1520,7 @@ ステーキングが有効です 現在、利用可能なバリデーターは見つかりません。しばらくしてからもう一度お試しください。 ステーキングは利用できません + お住まいの地域ではステーキングをご利用いただけません ネットワークは、ステーキングのためにトークンの使用を承認していることを確認するために、トークン承認手数料を請求します。 ステーキング機能を使用すると、プロバイダーの%1$sと%2$sに同意したことになります ロック中 diff --git a/core/res/src/main/res/values-zh-rCN/strings.xml b/core/res/src/main/res/values-zh-rCN/strings.xml index d74f43edfc..e1ea9ae35f 100644 --- a/core/res/src/main/res/values-zh-rCN/strings.xml +++ b/core/res/src/main/res/values-zh-rCN/strings.xml @@ -1512,6 +1512,7 @@ 已启用质押 目前没有可用的验证节点。请稍后再试。 质押功能不可用 + 您所在的地区暂不支持质押功能 网络将收取代币批准费,以验证您是否授权使用您的代币进行质押。 使用质押功能即表示您同意提供商的 %1$s 和 %2$s 已锁定 diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index e644cefa16..a0ebd7f24c 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -115,10 +115,17 @@ Enter address Invalid address Keep editing + You can not create more than 20 addresses. Delete one to add new. + Can\'t add new address + Contact name is required + Contact name contains invalid characters + Contact name must not exceed 50 characters + That name is already taken on this wallet New contact No contacts yet Contacts added will appear here Remove address + Save to Wallet This contact will be linked to this wallet’s address book. Select network Address book @@ -352,6 +359,7 @@ Get token Go to provider Go to token + Go to verification Got it Hide Hold to %s @@ -701,6 +709,8 @@ Tangem feedback Can\'t send a transaction Coin description error + Review portfolio and explore earn opportunities + For You Update now Update the app to its latest version to ensure proper functionality Update needed @@ -732,6 +742,8 @@ Key Generation All cryptographic operations happen inside the secure chip, certified against cloning and physical tampering. Hardware-Level Security + Network activity is high. You can continue now or try again later when fees may be lower. + Network fee is higher than usual Add Existing Wallet Create New Wallet Order Tangem @@ -1402,6 +1414,7 @@ Memo Check your network connection Network fee info unreachable + from %1$s in %2$s You send From %s Gas limit diff --git a/features/feed/impl/build.gradle.kts b/features/feed/impl/build.gradle.kts index e2d888a68b..48926c4ffd 100644 --- a/features/feed/impl/build.gradle.kts +++ b/features/feed/impl/build.gradle.kts @@ -25,6 +25,7 @@ dependencies { api(projects.features.wallet.api) api(projects.features.account.api) api(projects.features.commonFeatures.api) + api(projects.features.forYou.api) implementation(projects.features.promoBanners.api) /* Data */ diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt index 9df57ca897..bd3a3e0d26 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt @@ -142,6 +142,10 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor( override fun openSearch(source: String) { stackNavigation.bringToFront(FeedEntryChildFactory.Child.Search(source)) } + + override fun openForYou() { + stackNavigation.bringToFront(FeedEntryChildFactory.Child.ForYou) + } } private val stack: Value> = diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt index b5814e9491..607cb93355 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/FeedEntryChildFactory.kt @@ -20,6 +20,7 @@ import com.tangem.features.feed.components.news.details.DefaultNewsDetailsCompon import com.tangem.features.feed.components.news.list.DefaultNewsListComponent import com.tangem.features.feed.components.search.DefaultSearchComponent import com.tangem.features.feed.model.market.list.state.SortByTypeUM +import com.tangem.features.foryou.ForYouComponent import com.tangem.features.promobanners.api.PromoBannersBlockComponent import kotlinx.serialization.Serializable import javax.inject.Inject @@ -33,6 +34,7 @@ internal class FeedEntryChildFactory @Inject constructor( private val manageFundsComponentFactory: ManageFundsComponent.Factory, private val promoBannersBlockComponentFactory: PromoBannersBlockComponent.Factory, private val designFeatureToggles: DesignFeatureToggles, + private val forYouComponentFactory: ForYouComponent.Factory, ) { @Serializable @@ -66,6 +68,10 @@ internal class FeedEntryChildFactory @Inject constructor( @Serializable @Immutable data class Search(val source: String) : Child + + @Serializable + @Immutable + data object ForYou : Child } @Suppress("LongMethod") @@ -147,6 +153,10 @@ internal class FeedEntryChildFactory @Inject constructor( onSeeAllMarketsClick = { feedEntryClickIntents.onMarketOpenClick(SortByTypeUM.Rating) }, ), ) + Child.ForYou -> forYouComponentFactory.create( + context = appComponentContext, + params = Unit, + ) } } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt index a0cb8e55db..515c813659 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedComponentModel.kt @@ -14,7 +14,12 @@ import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.ui.DesignFeatureToggles +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds.message.TangemMessageEffect +import com.tangem.core.ui.ds.message.TangemMessageUM import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.generated.icons.Icons +import com.tangem.core.ui.res.generated.icons.ic_heart_28 import com.tangem.core.ui.utils.DateTimeFormatters import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency @@ -37,6 +42,7 @@ import com.tangem.features.feed.model.feed.state.transformers.* import com.tangem.features.feed.model.market.list.state.SortByTypeUM import com.tangem.features.feed.ui.earn.state.EarnListUM import com.tangem.features.feed.ui.feed.state.* +import com.tangem.features.foryou.ForYouFeatureToggles import com.tangem.utils.Provider import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.persistentListOf @@ -61,6 +67,7 @@ internal class FeedComponentModel @Inject constructor( private val appRouter: AppRouter, private val designFeatureToggles: DesignFeatureToggles, private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory, + private val forYouFeatureToggles: ForYouFeatureToggles, getTopFiveMarketTokenUseCase: GetTopFiveMarketTokenUseCase, getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, paramsContainer: ParamsContainer, @@ -273,6 +280,20 @@ internal class FeedComponentModel @Inject constructor( ), globalState = GlobalFeedState.Loading, earnListUM = EarnListUM.Loading, + forYouBannerUM = if (forYouFeatureToggles.isForYouEnabled) { + ForYouBannerUM.Content( + TangemMessageUM( + id = ForYouBannerUM.Content::class.java.simpleName, + title = resourceReference(R.string.for_you_title), + subtitle = resourceReference(R.string.for_you_description), + iconUM = TangemIconUM.Icon(Icons.ic_heart_28), // TODO ForYou update icon, + messageEffect = TangemMessageEffect.Magic, + onClick = params.feedClickIntents::openForYou, + ), + ) + } else { + ForYouBannerUM.Empty + }, ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt index 7c81362bf3..766575f273 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/FeedModelClickIntents.kt @@ -31,4 +31,6 @@ internal interface FeedModelClickIntents { fun onOpenEarnPage() fun openSearch(source: String) + + fun openForYou() } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedStateController.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedStateController.kt index 73fe72d6b2..6e7f856229 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedStateController.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/feed/state/FeedStateController.kt @@ -65,6 +65,7 @@ internal class FeedStateController @Inject constructor() { ), globalState = GlobalFeedState.Loading, earnListUM = EarnListUM.Loading, + forYouBannerUM = ForYouBannerUM.Empty, ) } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt index 22fb987794..64176e2ffa 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/FeedList.kt @@ -16,6 +16,7 @@ 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.decompose.ComposableContentComponent +import com.tangem.core.ui.ds.message.TangemMessage import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.TangemThemePreview @@ -27,6 +28,7 @@ import com.tangem.features.feed.ui.feed.components.* import com.tangem.features.feed.ui.feed.preview.FeedListPreviewDataProvider.createFeedPreviewState import com.tangem.features.feed.ui.feed.state.FeedListSearchBar import com.tangem.features.feed.ui.feed.state.FeedListUM +import com.tangem.features.feed.ui.feed.state.ForYouBannerUM import com.tangem.features.feed.ui.feed.state.GlobalFeedState @Composable @@ -105,6 +107,17 @@ private fun FeedListContent( SpacerH(contentPadding.calculateTopPadding()) } DateBlock(state.currentDate) + + SpacerH(16.dp) + + if (state.forYouBannerUM is ForYouBannerUM.Content && LocalRedesignEnabled.current) { + // TODO ForYou replace with message banner + TangemMessage( + messageUM = state.forYouBannerUM.banner, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + SpacerH(32.dp) MarketBlock( diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt index ed2ebcdccb..4648ee5d8b 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/preview/FeedListPreviewDataProvider.kt @@ -7,9 +7,15 @@ import com.tangem.core.ui.components.currency.icon.CurrencyIconState import com.tangem.core.ui.components.label.entity.LabelLeadingContentUM import com.tangem.core.ui.components.label.entity.LabelUM import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds.message.TangemMessageEffect +import com.tangem.core.ui.ds.message.TangemMessageUM import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.res.TangemColorPalette +import com.tangem.core.ui.res.generated.icons.Icons +import com.tangem.core.ui.res.generated.icons.ic_heart_28 import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.earn.EarnType import com.tangem.features.feed.model.market.list.state.SortByTypeUM @@ -56,6 +62,7 @@ internal object FeedListPreviewDataProvider { earnListUM = EarnListUM.Content( items = createEarnListItemsUM(), ), + forYouBannerUM = createForYouItem(), ) } @@ -285,4 +292,17 @@ internal object FeedListPreviewDataProvider { ) }.toPersistentList() } + + private fun createForYouItem(): ForYouBannerUM { + return ForYouBannerUM.Content( + TangemMessageUM( + id = ForYouBannerUM.Content::class.java.simpleName, + title = resourceReference(R.string.for_you_title), + subtitle = resourceReference(R.string.for_you_description), + iconUM = TangemIconUM.Icon(Icons.ic_heart_28), // TODO ForYou update icon, + messageEffect = TangemMessageEffect.Magic, + onClick = {}, + ), + ) + } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt index 8ba119a2d1..bd522d2562 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/feed/state/FeedListUM.kt @@ -2,10 +2,11 @@ package com.tangem.features.feed.ui.feed.state import androidx.compose.runtime.Immutable import com.tangem.common.ui.markets.models.MarketsListItemUM -import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM +import com.tangem.core.ui.ds.message.TangemMessageUM import com.tangem.core.ui.extensions.TextReference import com.tangem.features.feed.model.market.list.state.SortByTypeUM import com.tangem.features.feed.ui.earn.state.EarnListUM +import com.tangem.features.feed.ui.feed.components.articles.ArticleConfigUM import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.persistentListOf @@ -19,6 +20,7 @@ internal data class FeedListUM( val marketChartConfig: MarketChartConfig, val globalState: GlobalFeedState = GlobalFeedState.Content, val earnListUM: EarnListUM, + val forYouBannerUM: ForYouBannerUM, ) internal data class FeedListCallbacks( @@ -80,6 +82,16 @@ internal data class SortChartConfigUM( val isSelected: Boolean, ) +@Immutable +internal sealed interface ForYouBannerUM { + + data class Content( + val banner: TangemMessageUM, + ) : ForYouBannerUM + + data object Empty : ForYouBannerUM +} + @Immutable internal sealed interface GlobalFeedState { data object Loading : GlobalFeedState diff --git a/features/feed/impl/src/test/kotlin/com/tangem/features/feed/model/feed/FeedComponentModelTest.kt b/features/feed/impl/src/test/kotlin/com/tangem/features/feed/model/feed/FeedComponentModelTest.kt new file mode 100644 index 0000000000..d00fdc6b7d --- /dev/null +++ b/features/feed/impl/src/test/kotlin/com/tangem/features/feed/model/feed/FeedComponentModelTest.kt @@ -0,0 +1,166 @@ +package com.tangem.features.feed.model.feed + +import android.text.format.DateFormat +import arrow.core.Either +import com.google.common.truth.Truth.assertThat +import com.tangem.common.routing.AppRouter +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.model.MutableParamsContainer +import com.tangem.core.ui.DesignFeatureToggles +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.earn.usecase.FetchTopEarnTokensUseCase +import com.tangem.domain.earn.usecase.GetTopEarnTokensUseCase +import com.tangem.domain.markets.GetTopFiveMarketTokenUseCase +import com.tangem.domain.news.usecase.FetchTrendingNewsUseCase +import com.tangem.domain.news.usecase.ManageTrendingNewsUseCase +import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager +import com.tangem.features.feed.components.feed.DefaultFeedComponent.FeedParams +import com.tangem.features.feed.model.feed.state.FeedStateController +import com.tangem.features.feed.ui.feed.state.ForYouBannerUM +import com.tangem.features.foryou.ForYouFeatureToggles +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import io.mockk.verify +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@OptIn(ExperimentalCoroutinesApi::class) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class FeedComponentModelTest { + + // --- shared mocks --- + private val fetchTrendingNewsUseCase: FetchTrendingNewsUseCase = mockk(relaxed = true) + private val manageTrendingNewsUseCase: ManageTrendingNewsUseCase = mockk() + private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true) + private val fetchTopEarnTokensUseCase: FetchTopEarnTokensUseCase = mockk(relaxed = true) + private val getTopEarnTokensUseCase: GetTopEarnTokensUseCase = mockk() + private val appRouter: AppRouter = mockk(relaxed = true) + private val designFeatureToggles: DesignFeatureToggles = mockk() + private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory = mockk(relaxed = true) + private val getTopFiveMarketTokenUseCase: GetTopFiveMarketTokenUseCase = mockk(relaxed = true) + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk() + private val feedClickIntents: FeedModelClickIntents = mockk(relaxed = true) + + @BeforeEach + fun setUpDateFormatMock() { + // DateTimeFormatters.dateDMMM is a lazy val that calls android.text.format.DateFormat + // .getBestDateTimePattern — an Android stub not available in JVM unit tests. + // Mirror the pattern used in TxHistoryInfoToTxHistoryDetailsUMConverterTest. + mockkStatic(DateFormat::class) + every { DateFormat.getBestDateTimePattern(any(), any()) } answers { secondArg() } + } + + @AfterEach + fun tearDownDateFormatMock() { + unmockkStatic(DateFormat::class) + } + + /** + * Builds a [FeedComponentModel] wired into the given [TestScope], using a real + * [FeedStateController] so we can read the initialised state directly. + * + * All deps unrelated to [ForYouFeatureToggles] are relaxed or stubbed with empty flows so + * the model's background coroutines don't throw. + */ + private fun TestScope.createModel(forYouFeatureToggles: ForYouFeatureToggles): FeedComponentModel { + val testDispatcher = StandardTestDispatcher(testScheduler) + val dispatchers = TestingCoroutineDispatcherProvider( + main = testDispatcher, + mainImmediate = testDispatcher, + io = testDispatcher, + default = testDispatcher, + single = testDispatcher, + ) + + every { getSelectedAppCurrencyUseCase() } returns flowOf(Either.Right(AppCurrency.Default)) + every { manageTrendingNewsUseCase.observeTrendingNews() } returns emptyFlow() + every { getTopEarnTokensUseCase() } returns emptyFlow() + every { designFeatureToggles.isRedesignEnabled } returns false + + val paramsContainer = MutableParamsContainer(FeedParams(feedClickIntents = feedClickIntents)) + + return FeedComponentModel( + dispatchers = dispatchers, + fetchTrendingNewsUseCase = fetchTrendingNewsUseCase, + manageTrendingNewsUseCase = manageTrendingNewsUseCase, + analyticsEventHandler = analyticsEventHandler, + stateController = FeedStateController(), + fetchTopEarnTokensUseCase = fetchTopEarnTokensUseCase, + getTopEarnTokensUseCase = getTopEarnTokensUseCase, + appRouter = appRouter, + designFeatureToggles = designFeatureToggles, + addToPortfolioManagerFactory = addToPortfolioManagerFactory, + forYouFeatureToggles = forYouFeatureToggles, + getTopFiveMarketTokenUseCase = getTopFiveMarketTokenUseCase, + getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase, + paramsContainer = paramsContainer, + ) + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class `initialState forYouBannerUM` { + + @Test + fun `GIVEN isForYouEnabled is true WHEN model initialises THEN forYouBannerUM is Content`() = runTest { + // Arrange + val toggles = mockk { every { isForYouEnabled } returns true } + + // Act + val model = createModel(forYouFeatureToggles = toggles) + advanceUntilIdle() + + // Assert + assertThat(model.state.value.forYouBannerUM).isInstanceOf(ForYouBannerUM.Content::class.java) + + model.onDestroy() + } + + @Test + fun `GIVEN isForYouEnabled is false WHEN model initialises THEN forYouBannerUM is Empty`() = runTest { + // Arrange + val toggles = mockk { every { isForYouEnabled } returns false } + + // Act + val model = createModel(forYouFeatureToggles = toggles) + advanceUntilIdle() + + // Assert + assertThat(model.state.value.forYouBannerUM).isEqualTo(ForYouBannerUM.Empty) + + model.onDestroy() + } + + @Test + fun `GIVEN isForYouEnabled is true WHEN Content banner clicked THEN openForYou invoked`() = runTest { + // Arrange + val toggles = mockk { every { isForYouEnabled } returns true } + + // Act + val model = createModel(forYouFeatureToggles = toggles) + advanceUntilIdle() + val banner = model.state.value.forYouBannerUM + (banner as? ForYouBannerUM.Content)?.banner?.onClick?.invoke() + + // Assert – onClick must be wired to feedClickIntents::openForYou, not just any lambda + assertThat(banner).isInstanceOf(ForYouBannerUM.Content::class.java) + verify(exactly = 1) { feedClickIntents.openForYou() } + + model.onDestroy() + } + } +} \ No newline at end of file diff --git a/features/for-you/impl/build.gradle.kts b/features/for-you/impl/build.gradle.kts index 86fa456438..93ffbfa390 100644 --- a/features/for-you/impl/build.gradle.kts +++ b/features/for-you/impl/build.gradle.kts @@ -23,6 +23,7 @@ dependencies { implementation(deps.compose.ui) implementation(deps.compose.foundation) implementation(deps.lifecycle.compose) + implementation(deps.compose.material3) /** DI */ implementation(deps.hilt.android) diff --git a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/DefaultForYouComponent.kt b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/DefaultForYouComponent.kt index 6962ef2efd..737af3b002 100644 --- a/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/DefaultForYouComponent.kt +++ b/features/for-you/impl/src/main/java/com/tangem/features/foryou/impl/DefaultForYouComponent.kt @@ -1,11 +1,27 @@ package com.tangem.features.foryou.impl import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.ui.R import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState +import com.tangem.core.ui.components.haze.hazeEffectTangem +import com.tangem.core.ui.ds.topbar.TangemTopBar +import com.tangem.core.ui.ds.topbar.TangemTopBarType +import com.tangem.core.ui.extensions.clickableSingle +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme import com.tangem.features.foryou.ForYouComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -18,7 +34,26 @@ internal class DefaultForYouComponent @AssistedInject constructor( @Composable override fun Title(bottomSheetState: State) { - TODO("Not yet implemented") + TangemTopBar( + title = resourceReference(R.string.for_you_title), + type = TangemTopBarType.BottomSheet, + startContent = { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_arrow_back_28), + contentDescription = null, + tint = TangemTheme.colors3.icon.primary, + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .hazeEffectTangem { blurRadius = 8.dp } + .clickableSingle( + onClick = router::pop, + enabled = bottomSheetState.value == BottomSheetState.EXPANDED, + ) + .padding(8.dp), + ) + }, + ) } @Composable @@ -27,7 +62,7 @@ internal class DefaultForYouComponent @AssistedInject constructor( contentPadding: PaddingValues, modifier: Modifier, ) { - TODO("Not yet implemented") + Text("FOR YOU") } @AssistedFactory From 9898764f4d3407a66cee9f5ebb075975962bb84c Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 26 Jun 2026 15:40:12 +0200 Subject: [PATCH 062/210] Updated on 2026-08-14 --- .../list/model/AddressBookListModel.kt | 15 +- ...UpdateAddressBookListContentTransformer.kt | 5 +- .../UpdateAddressBookListQueryTransformer.kt | 17 ++ .../list/ui/AddressBookListScreen.kt | 12 +- .../list/model/AddressBookListModelTest.kt | 169 ++++++++++++++++++ ...teAddressBookListContentTransformerTest.kt | 1 - .../success/NFTSendSuccessComponent.kt | 1 + .../confirm/SendWithSwapConfirmComponent.kt | 1 + 8 files changed, 206 insertions(+), 15 deletions(-) create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListQueryTransformer.kt create mode 100644 features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModelTest.kt diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt index 0f2cfb6a66..c000ba3ea3 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModel.kt @@ -18,6 +18,7 @@ import com.tangem.features.addressbook.SelectedContact import com.tangem.features.addressbook.list.DefaultAddressBookListComponent import com.tangem.features.addressbook.list.state.AddressBookListStateController import com.tangem.features.addressbook.list.state.transformers.UpdateAddressBookListContentTransformer +import com.tangem.features.addressbook.list.state.transformers.UpdateAddressBookListQueryTransformer import com.tangem.features.addressbook.list.ui.state.AddressBookListUM import com.tangem.features.addressbook.route.AddressBookRoute import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -68,15 +69,14 @@ internal class AddressBookListModel @Inject constructor( allContacts, matchedContacts, searchQuery, - combine(selectedWalletId, searchActive) { selected, active -> selected to active }, + selectedWalletId, getWalletsUseCase.invokeAsMap(isOnlyMultiCurrency = false, filterLocked = true), - ) { all, matched, query, (selected, active), wallets -> + ) { all, matched, query, selected, wallets -> ListInputs( allContacts = all, matchedContacts = matched, query = query, selectedWalletId = selected, - isSearchActive = active, wallets = wallets, ) } @@ -94,7 +94,6 @@ internal class AddressBookListModel @Inject constructor( wallets = inputs.wallets, selectedWalletId = inputs.selectedWalletId, query = inputs.query, - isSearchActive = inputs.isSearchActive, onContactClick = params.onContactClick, onPickContact = ::onPickContact, onQueryChange = ::onQueryChange, @@ -108,14 +107,21 @@ internal class AddressBookListModel @Inject constructor( private fun onQueryChange(query: String) { searchQuery.value = query + updateSearchBar(query = query, isActive = searchActive.value) } private fun onActiveChange(active: Boolean) { searchActive.value = active + updateSearchBar(query = searchQuery.value, isActive = active) } private fun onClearQuery() { searchQuery.value = "" + updateSearchBar(query = "", isActive = searchActive.value) + } + + private fun updateSearchBar(query: String, isActive: Boolean) { + stateController.update(UpdateAddressBookListQueryTransformer(query = query, isActive = isActive)) } private fun onChipSelected(walletId: String?) { @@ -143,7 +149,6 @@ internal class AddressBookListModel @Inject constructor( val matchedContacts: List, val query: String, val selectedWalletId: String?, - val isSearchActive: Boolean, val wallets: Map, ) } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListContentTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListContentTransformer.kt index c6eeaaf47d..d5d13cb810 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListContentTransformer.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListContentTransformer.kt @@ -28,7 +28,6 @@ internal class UpdateAddressBookListContentTransformer( private val mode: AddressBookRoute.ListMode, private val selectedWalletId: String?, private val query: String, - private val isSearchActive: Boolean, private val onContactClick: (String) -> Unit, private val onPickContact: (MatchedContact) -> Unit, private val onQueryChange: (String) -> Unit, @@ -61,7 +60,7 @@ internal class UpdateAddressBookListContentTransformer( .toImmutableList() return AddressBookListUM.Content( - searchBar = buildSearchBar(), + searchBar = (prevState as? AddressBookListUM.Content)?.searchBar ?: buildSearchBar(), chips = if (areChipsVisible) buildChips(matchingWalletIds, effectiveSelected) else persistentListOf(), contacts = displayContacts, isNothingFound = matchedItems.isEmpty(), @@ -93,7 +92,7 @@ internal class UpdateAddressBookListContentTransformer( placeholderText = resourceReference(R.string.common_search), query = query, onQueryChange = onQueryChange, - isActive = isSearchActive, + isActive = false, onActiveChange = onActiveChange, onClearClick = onClearQuery, onCloseClick = { onActiveChange(false) }, diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListQueryTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListQueryTransformer.kt new file mode 100644 index 0000000000..b78a450a7b --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListQueryTransformer.kt @@ -0,0 +1,17 @@ +package com.tangem.features.addressbook.list.state.transformers + +import com.tangem.features.addressbook.list.ui.state.AddressBookListUM +import com.tangem.utils.transformer.Transformer + +internal class UpdateAddressBookListQueryTransformer( + private val query: String, + private val isActive: Boolean, +) : Transformer { + + override fun transform(prevState: AddressBookListUM): AddressBookListUM = when (prevState) { + is AddressBookListUM.Content -> prevState.copy( + searchBar = prevState.searchBar.copy(query = query, isActive = isActive), + ) + is AddressBookListUM.Empty -> prevState + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookListScreen.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookListScreen.kt index e999fdeac3..9a17943008 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookListScreen.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/list/ui/AddressBookListScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp @@ -40,7 +41,9 @@ internal fun AddressBookListScreen( onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { - Column(modifier = modifier.navigationBarsPadding()) { + val density = LocalDensity.current + val bottomBarHeight = with(density) { WindowInsets.systemBars.getBottom(this).toDp() } + Column(modifier = modifier) { TangemTopBar( modifier = Modifier.statusBarsPadding(), title = resourceReference(R.string.address_book_title), @@ -91,15 +94,12 @@ internal fun AddressBookListScreen( modifier = Modifier .imePadding() .padding(top = 16.dp) + .padding(horizontal = 16.dp) .background( color = TangemTheme.colors3.bg.secondary, shape = RoundedCornerShape(24.dp), ), - contentPadding = PaddingValues( - start = 16.dp, - end = 16.dp, - bottom = 12.dp, - ), + contentPadding = PaddingValues(bottom = 12.dp + bottomBarHeight), ) { items(items = state.contacts, key = ContactUM::id) { contact -> ContactRow(contact = contact) diff --git a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModelTest.kt b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModelTest.kt new file mode 100644 index 0000000000..454ab14316 --- /dev/null +++ b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/list/model/AddressBookListModelTest.kt @@ -0,0 +1,169 @@ +package com.tangem.features.addressbook.list.model + +import com.google.common.truth.Truth.assertThat +import com.tangem.core.decompose.model.MutableParamsContainer +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.navigation.Router +import com.tangem.domain.addressbook.interactor.GetVerifiedContactsInteractor +import com.tangem.domain.addressbook.model.AddressEntry +import com.tangem.domain.addressbook.model.AddressEntryId +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.model.VerifiedContact +import com.tangem.domain.models.account.CryptoPortfolioIcon +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.wallets.usecase.GetWalletsUseCase +import com.tangem.features.addressbook.ContactSelectionTrigger +import com.tangem.features.addressbook.list.DefaultAddressBookListComponent +import com.tangem.features.addressbook.list.state.AddressBookListStateController +import com.tangem.features.addressbook.list.ui.state.AddressBookListUM +import com.tangem.features.addressbook.list.ui.state.ContentMode +import com.tangem.features.addressbook.route.AddressBookRoute +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.* + +@OptIn(ExperimentalCoroutinesApi::class) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class AddressBookListModelTest { + + private val router: Router = mockk(relaxed = true) + private val contactSelectionTrigger: ContactSelectionTrigger = mockk(relaxed = true) + private val getVerifiedContactsInteractor: GetVerifiedContactsInteractor = mockk() + private val getWalletsUseCase: GetWalletsUseCase = mockk() + + private var model: AddressBookListModel? = null + + @BeforeEach + fun resetMocks() { + clearMocks(getVerifiedContactsInteractor, getWalletsUseCase) + every { getWalletsUseCase.invokeAsMap(isOnlyMultiCurrency = false, filterLocked = true) } returns + flowOf(linkedMapOf()) + } + + @AfterEach + fun tearDown() { + model?.onDestroy() + model = null + } + + @Test + fun `GIVEN default mode AND verified contacts WHEN created THEN content shown`() = runTest { + // Arrange + every { getVerifiedContactsInteractor(query = "", userWalletId = null) } returns + flowOf(listOf(verifiedContact(id = "1", name = "Alice"), verifiedContact(id = "2", name = "Bob"))) + + // Act + val model = createModel(testScope = this, mode = AddressBookRoute.ListMode.Default) + advanceUntilIdle() + + // Assert + val state = model.state.value as AddressBookListUM.Content + assertThat(state.contentMode).isInstanceOf(ContentMode.Default::class.java) + assertThat(state.contacts.map { it.name }).containsExactly("Alice", "Bob") + } + + @Test + fun `GIVEN default mode AND no contacts WHEN created THEN empty state`() = runTest { + // Arrange + every { getVerifiedContactsInteractor(query = "", userWalletId = null) } returns flowOf(emptyList()) + + // Act + val model = createModel(testScope = this, mode = AddressBookRoute.ListMode.Default) + advanceUntilIdle() + + // Assert + assertThat(model.state.value).isInstanceOf(AddressBookListUM.Empty::class.java) + } + + @Test + fun `GIVEN default mode WHEN contact clicked THEN editor opened with contact id`() = runTest { + // Arrange + var clickedId: String? = null + every { getVerifiedContactsInteractor(query = "", userWalletId = null) } returns + flowOf(listOf(verifiedContact(id = "42", name = "Alice"))) + val model = createModel( + testScope = this, + mode = AddressBookRoute.ListMode.Default, + onContactClick = { clickedId = it }, + ) + advanceUntilIdle() + + // Act + (model.state.value as AddressBookListUM.Content).contacts.first().onClick() + + // Assert + assertThat(clickedId).isEqualTo("42") + } + + private fun verifiedContact(id: String, name: String): VerifiedContact = VerifiedContact( + contact = Contact( + id = ContactId(id), + walletId = UserWalletId("a"), + name = ContactName(name).getOrNull()!!, + icon = "", + iconColor = CryptoPortfolioIcon.Color.Azure.name, + createdAt = TIMESTAMP, + updatedAt = TIMESTAMP, + addressEntries = listOf( + AddressEntry( + id = AddressEntryId("e-$id"), + address = "0xABC", + networkId = Network.RawID("ethereum"), + networkName = "Ethereum", + memo = null, + signature = "sig", + ), + ), + ), + invalidEntries = emptyList(), + ) + + private fun createModel( + testScope: TestScope, + mode: AddressBookRoute.ListMode, + onContactClick: (String) -> Unit = {}, + onAddContactClick: () -> Unit = {}, + ): AddressBookListModel { + val params = DefaultAddressBookListComponent.Params( + mode = mode, + onContactClick = onContactClick, + onAddContactClick = onAddContactClick, + ) + return AddressBookListModel( + paramsContainer = MutableParamsContainer(value = params), + dispatchers = testScope.createTestingCoroutineDispatcherProvider(), + stateController = AddressBookListStateController(), + router = router, + contactSelectionTrigger = contactSelectionTrigger, + getVerifiedContactsInteractor = getVerifiedContactsInteractor, + getWalletsUseCase = getWalletsUseCase, + ).also { model = it } + } + + private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider { + val testDispatcher = StandardTestDispatcher(testScheduler) + return TestingCoroutineDispatcherProvider( + main = testDispatcher, + mainImmediate = testDispatcher, + io = testDispatcher, + default = testDispatcher, + single = testDispatcher, + ) + } + + private companion object { + const val TIMESTAMP = "2026-06-10T14:30:00.000Z" + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListContentTransformerTest.kt b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListContentTransformerTest.kt index a37e5b1bdf..fefa1bde60 100644 --- a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListContentTransformerTest.kt +++ b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/list/state/transformers/UpdateAddressBookListContentTransformerTest.kt @@ -144,7 +144,6 @@ internal class UpdateAddressBookListContentTransformerTest { wallets = wallets, selectedWalletId = selectedWalletId, query = query, - isSearchActive = false, onContactClick = {}, onPickContact = {}, onQueryChange = {}, diff --git a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/success/NFTSendSuccessComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/success/NFTSendSuccessComponent.kt index 48dca29d62..2642e9da0d 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/success/NFTSendSuccessComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/success/NFTSendSuccessComponent.kt @@ -63,6 +63,7 @@ internal class NFTSendSuccessComponent @AssistedInject constructor( cryptoCurrency = params.cryptoCurrencyStatus.currency, blockClickEnableFlow = MutableStateFlow(false), predefinedValues = PredefinedValues.Empty, + isAddContactAvailable = true, ), onResult = {}, onClick = {}, diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt index 5b4ab5611d..9ae5d97b6f 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt @@ -81,6 +81,7 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor( cryptoCurrency = model.secondaryCurrency, predefinedValues = PredefinedValues.Empty, isAllowSelfSend = true, + isAddContactAvailable = true, ), // No feedback: the read-only block is driven one-way by the model.uiState collector ([REDACTED_TASK_KEY]). onResult = {}, From 848b232384422e5cecf81ff72ecf4356971a8e4a Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 26 Jun 2026 19:12:13 +0300 Subject: [PATCH 063/210] Updated on 2026-08-14 --- .../kotlin/com/tangem/scenarios/GaslessScenarios.kt | 11 +++++++++++ .../com/tangem/screens/AppSettingsPageObject.kt | 4 ++++ .../kotlin/com/tangem/screens/DetailsPageObject.kt | 4 ++++ .../kotlin/com/tangem/tests/AppCurrencyTest.kt | 12 ++++++------ .../details/ui/appsettings/AppSettingsScreen.kt | 1 + .../details/ui/common/DetailsComposeElements.kt | 9 ++++++++- .../tangem/core/ui/test/AppSettingsScreenTestTags.kt | 1 + 7 files changed, 35 insertions(+), 7 deletions(-) diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/GaslessScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/GaslessScenarios.kt index 90ef47eb41..7e86edb967 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/GaslessScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/GaslessScenarios.kt @@ -57,6 +57,17 @@ fun BaseTestCase.selectStablecoinAsFeeToken(coinName: String, tokenName: String) step("Select '$tokenName' as the fee-paying token") { onSendFeeSelectorBottomSheet { feeTokenItem(tokenName).performClick() } } + step("Wait until the '$tokenName' fee is loaded and 'Apply' is enabled") { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { + runCatching { + onSendFeeSelectorBottomSheet { + networkFeeTitle.assertIsDisplayed() + feeTokenItem(tokenName).assertIsDisplayed() + applyButton.assertIsEnabled() + } + }.isSuccess + } + } } /** diff --git a/app/src/androidTest/kotlin/com/tangem/screens/AppSettingsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/AppSettingsPageObject.kt index 240f3470e9..3ad8fbea66 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/AppSettingsPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/AppSettingsPageObject.kt @@ -14,6 +14,10 @@ class AppSettingsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider hasTestTag(AppSettingsScreenTestTags.CURRENCY_BUTTON) useUnmergedTree = true } + + val backButton: KNode = child { + hasTestTag(AppSettingsScreenTestTags.BACK_BUTTON) + } } internal fun BaseTestCase.onAppSettingsScreen(function: AppSettingsPageObject.() -> Unit) = diff --git a/app/src/androidTest/kotlin/com/tangem/screens/DetailsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/DetailsPageObject.kt index ff937e632d..8e39f04196 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/DetailsPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/DetailsPageObject.kt @@ -14,6 +14,10 @@ import androidx.compose.ui.test.hasText as withText class DetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : ComposeScreen(semanticsProvider = semanticsProvider) { + val screenContainer: KNode = child { + hasTestTag(DetailsScreenTestTags.SCREEN_CONTAINER) + } + val topAppBarBackButton: KNode = child { hasTestTag(TopAppBarTestTags.CLOSE_BUTTON) } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/AppCurrencyTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/AppCurrencyTest.kt index e484f38fd4..990110ee5d 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/AppCurrencyTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/AppCurrencyTest.kt @@ -59,15 +59,15 @@ class AppCurrencyTest : BaseTestCase() { onAppSettingsScreen { currencyButton.assertIsDisplayed() } } } - step("Return to 'Details' screen") { - waitForIdle() - device.uiDevice.pressBack() + step("Return to 'Details' screen via 'Back' button") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onAppSettingsScreen { backButton.clickWithAssertion() } + onDetailsScreen { screenContainer.assertIsDisplayed() } + } } step("Return to 'Main' screen via 'Back' button") { - onDetailsScreen { topAppBarBackButton.clickWithAssertion() } - } - step("Assert 'Main' screen is opened") { flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onDetailsScreen { topAppBarBackButton.clickWithAssertion() } onMainScreen { screenContainer.assertIsDisplayed() } } } diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/AppSettingsScreen.kt b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/AppSettingsScreen.kt index a7d5a8890a..57e355faae 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/AppSettingsScreen.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/appsettings/AppSettingsScreen.kt @@ -31,6 +31,7 @@ internal fun AppSettingsScreen(state: AppSettingsScreenState, onBackClick: () -> modifier = modifier, titleRes = R.string.app_settings_title, addBottomInsets = false, + backButtonTestTag = AppSettingsScreenTestTags.BACK_BUTTON, content = { when (state) { is AppSettingsScreenState.Content -> AppSettings(state = state) diff --git a/app/src/main/java/com/tangem/tap/features/details/ui/common/DetailsComposeElements.kt b/app/src/main/java/com/tangem/tap/features/details/ui/common/DetailsComposeElements.kt index 19afbf0689..6b1b95bed3 100644 --- a/app/src/main/java/com/tangem/tap/features/details/ui/common/DetailsComposeElements.kt +++ b/app/src/main/java/com/tangem/tap/features/details/ui/common/DetailsComposeElements.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import com.tangem.core.ui.components.PrimaryButtonIconEnd @@ -23,6 +24,7 @@ internal fun SettingsScreensScaffold( @StringRes titleRes: Int? = null, addBottomInsets: Boolean = true, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, + backButtonTestTag: String? = null, content: @Composable () -> Unit, fab: @Composable () -> Unit = {}, ) { @@ -35,6 +37,7 @@ internal fun SettingsScreensScaffold( modifier = Modifier.statusBarsPadding(), onBackClick = onBackClick, backgroundColor = backgroundColor, + backButtonTestTag = backButtonTestTag, ) }, modifier = modifier, @@ -91,13 +94,17 @@ internal fun EmptyTopBarWithNavigation( onBackClick: () -> Unit, modifier: Modifier = Modifier, backgroundColor: Color = TangemTheme.colors.background.primary, + backButtonTestTag: String? = null, ) { TopAppBar( modifier = modifier, title = { }, navigationIcon = { - IconButton(onClick = onBackClick) { + IconButton( + onClick = onBackClick, + modifier = if (backButtonTestTag != null) Modifier.testTag(backButtonTestTag) else Modifier, + ) { Icon( painter = painterResource(id = R.drawable.ic_back_24), contentDescription = null, diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/AppSettingsScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/AppSettingsScreenTestTags.kt index c8ed037e0b..4d30428c3d 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/test/AppSettingsScreenTestTags.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/test/AppSettingsScreenTestTags.kt @@ -2,4 +2,5 @@ package com.tangem.core.ui.test object AppSettingsScreenTestTags { const val CURRENCY_BUTTON = "APP_SETTINGS_SCREEN_CURRENCY_BUTTON" + const val BACK_BUTTON = "APP_SETTINGS_SCREEN_BACK_BUTTON" } \ No newline at end of file From 4b4555100fa833c77041e0594649111865f99eb9 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 26 Jun 2026 19:24:21 +0300 Subject: [PATCH 064/210] Updated on 2026-08-14 --- .../api/PromoBannersBlockComponent.kt | 1 + .../promobanners/impl/ui/PromoBannersBlock.kt | 5 ++++- .../tangempay/details/impl/build.gradle.kts | 1 + ...DefaultTangemPayDetailsContainerComponent.kt | 3 +++ .../components/TangemPayDetailsComponent.kt | 17 +++++++++++++++++ .../tangempay/ui/TangemPayDetailsScreenV2.kt | 9 +++++++++ 6 files changed, 35 insertions(+), 1 deletion(-) diff --git a/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/PromoBannersBlockComponent.kt b/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/PromoBannersBlockComponent.kt index d2eb78c1ae..c61fca07be 100644 --- a/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/PromoBannersBlockComponent.kt +++ b/features/promo-banners/api/src/main/kotlin/com/tangem/features/promobanners/api/PromoBannersBlockComponent.kt @@ -20,6 +20,7 @@ interface PromoBannersBlockComponent { enum class Placeholder(val value: String) { MAIN("main"), FEED("shtorka"), + PAYMENT_ACCOUNT_MAIN("payment_account_main"), } interface Factory : ComponentFactory diff --git a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/ui/PromoBannersBlock.kt b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/ui/PromoBannersBlock.kt index 1d13b403b0..c495daf13c 100644 --- a/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/ui/PromoBannersBlock.kt +++ b/features/promo-banners/impl/src/main/kotlin/com/tangem/features/promobanners/impl/ui/PromoBannersBlock.kt @@ -71,10 +71,13 @@ private fun bannerContainerColor(placeholder: Placeholder): Color = if (LocalRed when (placeholder) { Placeholder.MAIN -> TangemTheme.colors2.surface.level1 Placeholder.FEED -> TangemTheme.colors2.surface.level3 + Placeholder.PAYMENT_ACCOUNT_MAIN -> TangemTheme.colors3.bg.opaque.primary } } else { when (placeholder) { - Placeholder.MAIN -> TangemTheme.colors.background.primary + Placeholder.MAIN, + Placeholder.PAYMENT_ACCOUNT_MAIN, + -> TangemTheme.colors.background.primary Placeholder.FEED -> TangemTheme.colors.background.action } } diff --git a/features/tangempay/details/impl/build.gradle.kts b/features/tangempay/details/impl/build.gradle.kts index 29e3faebb5..0f0485f42f 100644 --- a/features/tangempay/details/impl/build.gradle.kts +++ b/features/tangempay/details/impl/build.gradle.kts @@ -31,6 +31,7 @@ dependencies { implementation(projects.features.tokenRecieve.api) implementation(projects.features.txhistory.api) implementation(projects.features.tokendetails.api) + implementation(projects.features.promoBanners.api) /** Domain */ implementation(projects.domain.balanceHiding) diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt index ab9078108e..de4ad91bc2 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/DefaultTangemPayDetailsContainerComponent.kt @@ -15,6 +15,7 @@ import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.navigation.inner.InnerRouter import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.promobanners.api.PromoBannersBlockComponent import com.tangem.features.tangempay.navigation.TangemPayAccountDetailsInnerRoute import com.tangem.features.tangempay.utils.userWalletId import com.tangem.features.tokendetails.ExpressTransactionsComponent @@ -29,6 +30,7 @@ internal class DefaultTangemPayDetailsContainerComponent @AssistedInject constru private val tangemPayCardPageFactory: TangemPayCardPageComponent.Factory, private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory, private val expressTransactionsComponentFactory: ExpressTransactionsComponent.Factory, + private val promoBannersBlockComponentFactory: PromoBannersBlockComponent.Factory, ) : AppComponentContext by appComponentContext, TangemPayDetailsContainerComponent { private val stackNavigation = StackNavigation() @@ -65,6 +67,7 @@ internal class DefaultTangemPayDetailsContainerComponent @AssistedInject constru params = params, tokenReceiveComponentFactory = tokenReceiveComponentFactory, expressTransactionsComponentFactory = expressTransactionsComponentFactory, + promoBannersBlockComponentFactory = promoBannersBlockComponentFactory, ) is TangemPayAccountDetailsInnerRoute.CardDetails -> tangemPayCardPageFactory.create( context = childByContext(componentContext = componentContext, router = innerRouter), diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt index 26e12e9976..9d5070cf82 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/components/TangemPayDetailsComponent.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.extensions.compose.subscribeAsState @@ -18,6 +19,7 @@ import com.tangem.core.ui.components.NavigationBar3ButtonsScrim import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.res.LocalVisaRedesignEnabled +import com.tangem.features.promobanners.api.PromoBannersBlockComponent import com.tangem.features.tangempay.components.txHistory.DefaultTangemPayTxHistoryComponent import com.tangem.features.tangempay.components.txHistory.TangemPayTxHistoryDetailsComponent import com.tangem.features.tangempay.entity.TangemPayDetailsNavigation @@ -34,10 +36,20 @@ internal class TangemPayDetailsComponent( private val params: TangemPayDetailsContainerComponent.Params, private val tokenReceiveComponentFactory: TokenReceiveComponent.Factory, private val expressTransactionsComponentFactory: ExpressTransactionsComponent.Factory, + private val promoBannersBlockComponentFactory: PromoBannersBlockComponent.Factory, ) : AppComponentContext by appComponentContext, ComposableContentComponent { private val model: TangemPayDetailsModel = getOrCreateModel(params = params) + private val promoBannersBlockComponent: PromoBannersBlockComponent by lazy { + promoBannersBlockComponentFactory.create( + context = child("promoBannersBlockComponent"), + params = PromoBannersBlockComponent.Params( + placeholder = PromoBannersBlockComponent.Placeholder.PAYMENT_ACCOUNT_MAIN, + ), + ) + } + private val bottomSheetSlot = childSlot( source = model.bottomSheetNavigation, serializer = TangemPayDetailsNavigation.serializer(), @@ -63,6 +75,7 @@ internal class TangemPayDetailsComponent( } init { + promoBannersBlockComponent.setVisibleOnScreen(true) lifecycle.subscribe( onPause = model::onPause, onResume = model::onResume, @@ -73,6 +86,9 @@ internal class TangemPayDetailsComponent( override fun Content(modifier: Modifier) { val state by model.uiState.collectAsStateWithLifecycle() val bottomSheet by bottomSheetSlot.subscribeAsState() + val promoBannersBlock = ComposableContentComponent { promoModifier -> + promoBannersBlockComponent.ContentWithPadding(modifier = promoModifier, horizontalItemPadding = 16.dp) + } CompositionLocalProvider(LocalVisaRedesignEnabled provides model.isRedesignEnabled()) { NavigationBar3ButtonsScrim() if (LocalVisaRedesignEnabled.current) { @@ -80,6 +96,7 @@ internal class TangemPayDetailsComponent( state = state, txHistoryComponent = txHistoryComponent, expressTransactionsComponent = expressTransactionsComponent, + promoBannersBlockComponent = promoBannersBlock, modifier = modifier, ) } else { diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreenV2.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreenV2.kt index 98fe7af06f..ef167d1fd5 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreenV2.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreenV2.kt @@ -37,6 +37,7 @@ import com.tangem.core.ui.components.containers.pullToRefresh.TangemPullToRefres import com.tangem.core.ui.components.notifications.NotificationConfig import com.tangem.core.ui.components.text.applyBladeBrush import com.tangem.core.ui.components.topFade +import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.ds.button.* import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.ds.message.TangemMessage @@ -77,6 +78,7 @@ internal fun TangemPayDetailsScreenV2( state: TangemPayDetailsUM, txHistoryComponent: TangemPayTxHistoryComponent, expressTransactionsComponent: ExpressTransactionsComponent, + promoBannersBlockComponent: ComposableContentComponent, modifier: Modifier = Modifier, ) { val listState = rememberLazyListState() @@ -116,6 +118,11 @@ internal fun TangemPayDetailsScreenV2( ), ) { payDetailsBody(state) + item("promoBannersBlock") { + promoBannersBlockComponent.Content( + modifier = Modifier.padding(vertical = 12.dp), + ) + } with(expressTransactionsComponent) { expressTransactionsContent( state = expressState.transactionsToDisplay, @@ -455,6 +462,7 @@ private fun TangemPayDetailsScreenPreview( txHistoryUM = PreviewTangemPayTxHistoryComponent.contentUM, ), expressTransactionsComponent = PreviewEmptyExpressTransactionsComponent(), + promoBannersBlockComponent = ComposableContentComponent.EMPTY, ) } } @@ -469,6 +477,7 @@ private fun TangemPayDetailsTxHistoryScreenPreview( state = TangemPayDetailsUMProvider().values.first(), txHistoryComponent = PreviewTangemPayTxHistoryComponent(txHistoryUM = state), expressTransactionsComponent = PreviewEmptyExpressTransactionsComponent(), + promoBannersBlockComponent = ComposableContentComponent.EMPTY, ) } } From 5ef3837bd597951593396140494d2344e929f2d1 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 22 Jun 2026 16:14:23 +0500 Subject: [PATCH 065/210] Updated on 2026-08-14 --- .../multi/DefaultMultiNetworkStatusFetcher.kt | 2 +- .../DefaultSingleNetworkStatusFetcher.kt | 1 + .../entity/DefaultTangemPayCurrencyFactory.kt | 24 ++++- .../DefaultVirtualAccountStatusFetcher.kt | 74 +++++++++++++ .../DefaultVirtualAccountStatusFetcherTest.kt | 100 ++++++++++++++++++ .../domain/card/common/visa/VisaUtilities.kt | 1 + .../multi/MultiNetworkStatusFetcher.kt | 14 ++- .../single/SingleNetworkStatusFetcher.kt | 10 +- .../domain/pay/TangemPayCurrencyFactory.kt | 1 + 9 files changed, 219 insertions(+), 8 deletions(-) create mode 100644 data/visa/src/test/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusFetcherTest.kt diff --git a/data/networks/src/main/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcher.kt b/data/networks/src/main/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcher.kt index 1f3e0860a0..7db5617464 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcher.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/multi/DefaultMultiNetworkStatusFetcher.kt @@ -67,7 +67,7 @@ internal class DefaultMultiNetworkStatusFetcher @Inject constructor( commonNetworkStatusFetcher.fetch( userWalletId = params.userWalletId, network = network, - networkCurrencies = networksCurrencies[network].orEmpty().toSet(), + networkCurrencies = networksCurrencies[network].orEmpty().toSet() + params.extraTokens, xpub = xpubByNetwork[network], ) } diff --git a/data/networks/src/main/java/com/tangem/data/networks/single/DefaultSingleNetworkStatusFetcher.kt b/data/networks/src/main/java/com/tangem/data/networks/single/DefaultSingleNetworkStatusFetcher.kt index c89ba87fe5..0ace80ea40 100644 --- a/data/networks/src/main/java/com/tangem/data/networks/single/DefaultSingleNetworkStatusFetcher.kt +++ b/data/networks/src/main/java/com/tangem/data/networks/single/DefaultSingleNetworkStatusFetcher.kt @@ -21,6 +21,7 @@ internal class DefaultSingleNetworkStatusFetcher @Inject constructor( params = MultiNetworkStatusFetcher.Params( userWalletId = params.userWalletId, networks = setOf(params.network), + extraTokens = params.extraTokens, ), ) } diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/entity/DefaultTangemPayCurrencyFactory.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/entity/DefaultTangemPayCurrencyFactory.kt index 711c82bc5f..bb95aa5384 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/entity/DefaultTangemPayCurrencyFactory.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/entity/DefaultTangemPayCurrencyFactory.kt @@ -5,8 +5,9 @@ import com.tangem.data.common.currency.CryptoCurrencyFactory import com.tangem.data.common.network.NetworkFactory import com.tangem.domain.card.common.visa.VisaUtilities import com.tangem.domain.common.wallets.UserWalletsListRepository -import com.tangem.domain.common.wallets.requireUserWalletsSync +import com.tangem.domain.common.wallets.getSyncStrict import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.TangemPayCurrencyFactory import javax.inject.Inject @@ -23,9 +24,7 @@ internal class DefaultTangemPayCurrencyFactory @Inject constructor( } override fun create(userWalletId: UserWalletId): CryptoCurrency.Token { - val userWallet = userWalletsListRepository.requireUserWalletsSync() - .firstOrNull { it.walletId == userWalletId } - ?: error("User wallet with id $userWalletId not found") + val userWallet = userWalletsListRepository.getSyncStrict(userWalletId) val network = networkFactory.create( blockchain = VisaUtilities.visaBlockchain, userWallet = userWallet, @@ -40,4 +39,21 @@ internal class DefaultTangemPayCurrencyFactory @Inject constructor( decimals = TangemPayCurrencyFactory.TOKEN_DECIMALS, ) } + + override fun createVirtualAccountToken(userWalletId: UserWalletId): CryptoCurrency.Token { + val userWallet = userWalletsListRepository.getSyncStrict(userWalletId) + val network = networkFactory.create( + blockchain = VisaUtilities.visaBlockchain, + derivationPath = Network.DerivationPath.Custom(VisaUtilities.virtualAccountDerivationPath.rawPath), + userWallet = userWallet, + ) + return cryptoCurrencyFactory.createToken( + network = requireNotNull(network), + rawId = TangemPayCurrencyFactory.TOKEN_ID, + name = TangemPayCurrencyFactory.TOKEN_NAME, + symbol = TangemPayCurrencyFactory.TOKEN_NAME, + contractAddress = TangemPayCurrencyFactory.TOKEN_CONTRACT_ADDRESS, + decimals = TangemPayCurrencyFactory.TOKEN_DECIMALS, + ) + } } \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusFetcher.kt b/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusFetcher.kt index 8a1643d193..6c0249af86 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusFetcher.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusFetcher.kt @@ -1,19 +1,41 @@ package com.tangem.data.virtualaccount.flow import arrow.core.Either +import arrow.core.left +import arrow.core.right +import com.tangem.data.common.network.NetworkFactory import com.tangem.data.virtualaccount.store.VirtualAccountStatusesStore +import com.tangem.domain.card.common.visa.VisaUtilities +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.common.wallets.getSyncStrict import com.tangem.domain.core.utils.catchOn import com.tangem.domain.models.StatusSource import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.VirtualAccountStatusValue +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.network.NetworkStatus +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.networks.single.SingleNetworkStatusFetcher +import com.tangem.domain.networks.single.SingleNetworkStatusProducer +import com.tangem.domain.networks.single.SingleNetworkStatusSupplier +import com.tangem.domain.pay.TangemPayCurrencyFactory import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusFetcher +import com.tangem.domain.wallets.extension.hasDerivation import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import com.tangem.utils.logging.TangemLogger +import java.math.BigDecimal import javax.inject.Inject +@Suppress("LongParameterList") internal class DefaultVirtualAccountStatusFetcher @Inject constructor( private val virtualAccountStatusesStore: VirtualAccountStatusesStore, private val dispatchers: CoroutineDispatcherProvider, + private val networkFactory: NetworkFactory, + private val tangemPayCurrencyFactory: TangemPayCurrencyFactory, + private val userWalletsListRepository: UserWalletsListRepository, + private val singleNetworkStatusFetcher: SingleNetworkStatusFetcher, + private val singleNetworkStatusSupplier: SingleNetworkStatusSupplier, ) : VirtualAccountStatusFetcher { override suspend fun invoke(params: VirtualAccountStatusFetcher.Params) = Either.catchOn(dispatchers.default) { @@ -21,6 +43,7 @@ internal class DefaultVirtualAccountStatusFetcher @Inject constructor( // TODO([REDACTED_TASK_KEY]): Replace with the real VA status fetch (provisioning state, balance and banking // details) from the backend once Virtual Account status endpoints are available. Until then the // account is surfaced as NotCreated so the entity flows through the app end-to-end. + getBalance(params.userWalletId) virtualAccountStatusesStore.store( userWalletId = params.userWalletId, status = AccountStatus.Virtual(account = account, value = VirtualAccountStatusValue.NotCreated), @@ -31,4 +54,55 @@ internal class DefaultVirtualAccountStatusFetcher @Inject constructor( source = StatusSource.ONLY_CACHE, ) } + + private suspend fun getBalance(userWalletId: UserWalletId): Either { + val userWallet = userWalletsListRepository.getSyncStrict(userWalletId) + + val hasVirtualAccountDerivation = userWallet.hasDerivation( + blockchain = VisaUtilities.visaBlockchain, + derivationPath = VisaUtilities.virtualAccountDerivationPath.rawPath, + ) + if (!hasVirtualAccountDerivation) { + // TODO: Doston(VA) Derive will be implemented in [REDACTED_TASK_KEY] + TangemLogger.withTag(TAG).d("Virtual account is not derived") + return VirtualAccountStatusValue.Error.NotSynced.left() + } + + val network = networkFactory.create( + blockchain = VisaUtilities.visaBlockchain, + derivationPath = + Network.DerivationPath.Custom(VisaUtilities.virtualAccountDerivationPath.rawPath), + userWallet = userWallet, + ) + if (network == null) { + TangemLogger.withTag(TAG).d("Can not create network for Virtual account") + return VirtualAccountStatusValue.Error.Unavailable.left() + } + val token = tangemPayCurrencyFactory.createVirtualAccountToken(userWalletId) + + singleNetworkStatusFetcher( + SingleNetworkStatusFetcher.Params( + userWalletId = userWalletId, + network = network, + extraTokens = setOf(token), + ), + ) + + val verifiedStatus = singleNetworkStatusSupplier + .getSyncOrNull(SingleNetworkStatusProducer.Params(userWalletId, network)) + ?.value as? NetworkStatus.Verified + val balance = (verifiedStatus?.amounts?.get(token.id) as? NetworkStatus.Amount.Loaded)?.value + + return if (balance != null) { + TangemLogger.withTag(TAG).d("VA on-chain balance = $balance") + balance.right() + } else { + TangemLogger.withTag(TAG).d("Can not get VA balance") + VirtualAccountStatusValue.Error.Unavailable.left() + } + } + + private companion object { + private const val TAG = "VirtualAccountStatusFetcher" + } } \ No newline at end of file diff --git a/data/visa/src/test/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusFetcherTest.kt b/data/visa/src/test/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusFetcherTest.kt new file mode 100644 index 0000000000..2960eaa74b --- /dev/null +++ b/data/visa/src/test/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusFetcherTest.kt @@ -0,0 +1,100 @@ +package com.tangem.data.virtualaccount.flow + +import arrow.core.right +import com.tangem.blockchain.common.Blockchain +import com.tangem.data.common.network.NetworkFactory +import com.tangem.data.virtualaccount.store.VirtualAccountStatusesStore +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.networks.single.SingleNetworkStatusFetcher +import com.tangem.domain.networks.single.SingleNetworkStatusSupplier +import com.tangem.domain.pay.TangemPayCurrencyFactory +import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusFetcher +import com.tangem.domain.wallets.extension.hasDerivation +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +private const val USER_WALLET_EXTENSIONS = "com.tangem.domain.wallets.extension.UserWalletExtensionsKt" + +@OptIn(ExperimentalCoroutinesApi::class) +internal class DefaultVirtualAccountStatusFetcherTest { + + private val virtualAccountStatusesStore: VirtualAccountStatusesStore = mockk(relaxed = true) + private val dispatchers = TestingCoroutineDispatcherProvider() + private val networkFactory: NetworkFactory = mockk() + private val tangemPayCurrencyFactory: TangemPayCurrencyFactory = mockk() + private val userWalletsListRepository: UserWalletsListRepository = mockk() + private val singleNetworkStatusFetcher: SingleNetworkStatusFetcher = mockk() + private val singleNetworkStatusSupplier: SingleNetworkStatusSupplier = mockk(relaxed = true) + + private val fetcher = DefaultVirtualAccountStatusFetcher( + virtualAccountStatusesStore = virtualAccountStatusesStore, + dispatchers = dispatchers, + networkFactory = networkFactory, + tangemPayCurrencyFactory = tangemPayCurrencyFactory, + userWalletsListRepository = userWalletsListRepository, + singleNetworkStatusFetcher = singleNetworkStatusFetcher, + singleNetworkStatusSupplier = singleNetworkStatusSupplier, + ) + + private val userWalletId = UserWalletId("011") + private val userWallet: UserWallet = mockk { every { walletId } returns userWalletId } + private val network: Network = mockk() + private val token: CryptoCurrency.Token = mockk() + + @BeforeEach + fun setUp() { + mockkStatic(USER_WALLET_EXTENSIONS) + clearMocks(networkFactory, tangemPayCurrencyFactory, singleNetworkStatusFetcher, userWalletsListRepository) + every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet)) + every { + networkFactory.create(any(), any(), any()) + } returns network + every { tangemPayCurrencyFactory.createVirtualAccountToken(userWalletId) } returns token + coEvery { singleNetworkStatusFetcher(any()) } returns Unit.right() + } + + @AfterEach + fun tearDown() { + unmockkStatic(USER_WALLET_EXTENSIONS) + } + + @Test + fun `GIVEN VA derivation missing WHEN invoke THEN on-chain fetch skipped`() = runTest { + // Arrange + every { userWallet.hasDerivation(any(), any()) } returns false + + // Act + fetcher.invoke(VirtualAccountStatusFetcher.Params(userWalletId)) + + // Assert + coVerify(exactly = 0) { singleNetworkStatusFetcher(any()) } + } + + @Test + fun `GIVEN VA derivation present WHEN invoke THEN on-chain fetch performed`() = runTest { + // Arrange + every { userWallet.hasDerivation(any(), any()) } returns true + + // Act + fetcher.invoke(VirtualAccountStatusFetcher.Params(userWalletId)) + + // Assert + coVerify(exactly = 1) { singleNetworkStatusFetcher(any()) } + } +} \ No newline at end of file diff --git a/domain/card/src/main/kotlin/com/tangem/domain/card/common/visa/VisaUtilities.kt b/domain/card/src/main/kotlin/com/tangem/domain/card/common/visa/VisaUtilities.kt index ef0f7ffdf6..6b0800e209 100644 --- a/domain/card/src/main/kotlin/com/tangem/domain/card/common/visa/VisaUtilities.kt +++ b/domain/card/src/main/kotlin/com/tangem/domain/card/common/visa/VisaUtilities.kt @@ -22,6 +22,7 @@ object VisaUtilities { val visaDefaultDerivationPath get() = visaBlockchain.derivationPath(DerivationStyle.V3) val customDerivationPath = DerivationPath("m/44'/60'/999999'/0/0") + val virtualAccountDerivationPath = DerivationPath("m/44'/60'/999998'/0/0") val curve = EllipticCurve.Secp256k1 fun signWithNonceMessage(nonce: String): String { diff --git a/domain/networks/src/main/java/com/tangem/domain/networks/multi/MultiNetworkStatusFetcher.kt b/domain/networks/src/main/java/com/tangem/domain/networks/multi/MultiNetworkStatusFetcher.kt index dcd68ceb1b..7eeee62750 100644 --- a/domain/networks/src/main/java/com/tangem/domain/networks/multi/MultiNetworkStatusFetcher.kt +++ b/domain/networks/src/main/java/com/tangem/domain/networks/multi/MultiNetworkStatusFetcher.kt @@ -1,6 +1,7 @@ package com.tangem.domain.networks.multi import com.tangem.domain.core.flow.FlowFetcher +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId @@ -11,5 +12,16 @@ import com.tangem.domain.models.wallet.UserWalletId */ interface MultiNetworkStatusFetcher : FlowFetcher { - data class Params(val userWalletId: UserWalletId, val networks: Set) + /** + * Params + * + * @property userWalletId user wallet id + * @property networks networks whose statuses are fetched + * @property extraTokens additional tokens to fetch balances for, beyond the wallet's added currencies + */ + data class Params( + val userWalletId: UserWalletId, + val networks: Set, + val extraTokens: Set = emptySet(), + ) } \ No newline at end of file diff --git a/domain/networks/src/main/java/com/tangem/domain/networks/single/SingleNetworkStatusFetcher.kt b/domain/networks/src/main/java/com/tangem/domain/networks/single/SingleNetworkStatusFetcher.kt index 7c638d12f4..5923c9d977 100644 --- a/domain/networks/src/main/java/com/tangem/domain/networks/single/SingleNetworkStatusFetcher.kt +++ b/domain/networks/src/main/java/com/tangem/domain/networks/single/SingleNetworkStatusFetcher.kt @@ -1,6 +1,7 @@ package com.tangem.domain.networks.single import com.tangem.domain.core.flow.FlowFetcher +import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.Network import com.tangem.domain.models.wallet.UserWalletId @@ -15,7 +16,12 @@ interface SingleNetworkStatusFetcher : FlowFetcher = emptySet(), + ) } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/TangemPayCurrencyFactory.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/TangemPayCurrencyFactory.kt index 39bdae4191..fa37a30d68 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/TangemPayCurrencyFactory.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/TangemPayCurrencyFactory.kt @@ -17,6 +17,7 @@ interface TangemPayCurrencyFactory { * @throws IllegalStateException if no wallet with [userWalletId] is currently loaded. */ fun create(userWalletId: UserWalletId): CryptoCurrency.Token + fun createVirtualAccountToken(userWalletId: UserWalletId): CryptoCurrency.Token /** Hardcoded token metadata for the Tangem Pay currency (USDC on Polygon). */ companion object { From 8d3c2b1cd82ac3512e7c4f74b5d4d4669bd59a6d Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 22 Jun 2026 20:31:03 +0500 Subject: [PATCH 066/210] Updated on 2026-08-14 --- .../tangem/tap/di/TangemSdkManagerModule.kt | 3 + .../sdk/impl/DefaultTangemSdkManager.kt | 20 +++++ .../domain/sdk/impl/MockTangemSdkManager.kt | 7 ++ ...gemPayGenerateVirtualAccountAddressTask.kt | 80 +++++++++++++++++++ .../DefaultTangemPayAuthDataSource.kt | 13 +++ .../pay/datasource/TangemPayHotSdkManager.kt | 31 +++++++ .../di/VirtualAccountDataModule.kt | 17 ++++ .../DefaultVirtualAccountStatusFetcher.kt | 15 +--- ...faultVirtualAccountActivationRepository.kt | 38 +++++++++ .../DefaultVirtualAccountStatusFetcherTest.kt | 57 ++++++------- ...tVirtualAccountActivationRepositoryTest.kt | 79 ++++++++++++++++++ .../DefaultColdMapDerivationsRepository.kt | 5 ++ .../DefaultDerivationsRepository.kt | 18 +++++ .../hot/DefaultHotMapDerivationsRepository.kt | 5 ++ domain/visa/models/build.gradle.kts | 3 + .../model/VirtualAccountActivationData.kt | 16 ++++ .../pay/datasource/TangemPayAuthDataSource.kt | 3 + .../VirtualAccountActivationRepository.kt | 13 +++ .../usecase/ActivateVirtualAccountUseCase.kt | 17 ++++ .../ColdMapDerivationsRepository.kt | 3 + .../derivations/DerivationsRepository.kt | 8 ++ .../HotMapDerivationsRepository.kt | 3 + .../com/tangem/sdk/api/TangemSdkManager.kt | 5 ++ 23 files changed, 413 insertions(+), 46 deletions(-) create mode 100644 app/src/main/java/com/tangem/tap/domain/tasks/visa/TangemPayGenerateVirtualAccountAddressTask.kt create mode 100644 data/visa/src/main/kotlin/com/tangem/data/virtualaccount/repository/DefaultVirtualAccountActivationRepository.kt create mode 100644 data/visa/src/test/kotlin/com/tangem/data/virtualaccount/repository/DefaultVirtualAccountActivationRepositoryTest.kt create mode 100644 domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VirtualAccountActivationData.kt create mode 100644 domain/visa/src/main/kotlin/com/tangem/domain/virtualaccount/repository/VirtualAccountActivationRepository.kt create mode 100644 domain/visa/src/main/kotlin/com/tangem/domain/virtualaccount/usecase/ActivateVirtualAccountUseCase.kt diff --git a/app/src/main/java/com/tangem/tap/di/TangemSdkManagerModule.kt b/app/src/main/java/com/tangem/tap/di/TangemSdkManagerModule.kt index 37929d7d54..e24a130c14 100644 --- a/app/src/main/java/com/tangem/tap/di/TangemSdkManagerModule.kt +++ b/app/src/main/java/com/tangem/tap/di/TangemSdkManagerModule.kt @@ -10,6 +10,7 @@ import com.tangem.sdk.api.TangemSdkManager import com.tangem.tap.domain.sdk.impl.DefaultTangemSdkManager import com.tangem.tap.domain.sdk.impl.MockTangemSdkManager import com.tangem.tap.domain.tasks.visa.TangemPayGenerateAddressAndSignChallengeTask +import com.tangem.tap.domain.tasks.visa.TangemPayGenerateVirtualAccountAddressTask import com.tangem.tap.domain.tasks.visa.VisaCardActivationTask import com.tangem.tap.domain.visa.VisaCardScanHandler import dagger.Module @@ -31,6 +32,7 @@ internal class TangemSdkManagerModule { visaCardScanHandler: VisaCardScanHandler, visaCardActivationTaskFactory: VisaCardActivationTask.Factory, tangemPayChallengeTaskFactory: TangemPayGenerateAddressAndSignChallengeTask.Factory, + tangemPayVirtualAccountTaskFactory: TangemPayGenerateVirtualAccountAddressTask.Factory, onboardingV2FeatureToggles: OnboardingV2FeatureToggles, analyticsErrorHandler: AnalyticsErrorHandler, cardRepository: CardRepository, @@ -44,6 +46,7 @@ internal class TangemSdkManagerModule { visaCardScanHandler = visaCardScanHandler, visaCardActivationTaskFactory = visaCardActivationTaskFactory, tangemPayChallengeTaskFactory = tangemPayChallengeTaskFactory, + tangemPayVirtualAccountTaskFactory = tangemPayVirtualAccountTaskFactory, onboardingV2FeatureToggles = onboardingV2FeatureToggles, analyticsErrorHandler = analyticsErrorHandler, cardRepository = cardRepository, diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt b/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt index 3d5616cc54..0a6e31fb97 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/impl/DefaultTangemSdkManager.kt @@ -50,6 +50,7 @@ import com.tangem.tap.common.analytics.events.TangemSdkErrorEvent import com.tangem.tap.common.analytics.paramsInterceptor.CardContextInterceptor import com.tangem.tap.domain.tasks.product.* import com.tangem.tap.domain.tasks.visa.TangemPayGenerateAddressAndSignChallengeTask +import com.tangem.tap.domain.tasks.visa.TangemPayGenerateVirtualAccountAddressTask import com.tangem.tap.domain.tasks.visa.TangemPaySignWithdrawalHashTask import com.tangem.tap.domain.tasks.visa.VisaCardActivationTask import com.tangem.tap.domain.tasks.visa.VisaCustomerWalletApproveTask @@ -72,6 +73,7 @@ internal class DefaultTangemSdkManager( private val visaCardScanHandler: VisaCardScanHandler, private val visaCardActivationTaskFactory: VisaCardActivationTask.Factory, private val tangemPayChallengeTaskFactory: TangemPayGenerateAddressAndSignChallengeTask.Factory, + private val tangemPayVirtualAccountTaskFactory: TangemPayGenerateVirtualAccountAddressTask.Factory, private val onboardingV2FeatureToggles: OnboardingV2FeatureToggles, private val analyticsErrorHandler: AnalyticsErrorHandler, private val cardRepository: CardRepository, @@ -531,6 +533,24 @@ internal class DefaultTangemSdkManager( } } + override suspend fun tangemPayProduceVirtualAccountData( + preflightReadFilter: PreflightReadFilter, + ): Either { + return coroutineScope { + val result = runTaskAsyncReturnOnMain( + runnable = tangemPayVirtualAccountTaskFactory.create(coroutineScope = this), + cardId = null, + initialMessage = Message(resources.getStringSafe(R.string.initial_message_tap_header)), + preflightReadFilter = preflightReadFilter, + ) + + return@coroutineScope when (result) { + is CompletionResult.Failure<*> -> result.error.left() + is CompletionResult.Success -> result.data.right() + } + } + } + override suspend fun getWithdrawalSignature( hash: String, preflightReadFilter: PreflightReadFilter, diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/impl/MockTangemSdkManager.kt b/app/src/main/java/com/tangem/tap/domain/sdk/impl/MockTangemSdkManager.kt index 46568885f1..fa901dd6fd 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/impl/MockTangemSdkManager.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/impl/MockTangemSdkManager.kt @@ -23,6 +23,7 @@ import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.WithdrawalSignatureResult import com.tangem.domain.visa.model.TangemPayInitialCredentials +import com.tangem.domain.visa.model.VirtualAccountActivationData import com.tangem.domain.visa.model.VisaActivationInput import com.tangem.domain.visa.model.VisaDataForApprove import com.tangem.domain.visa.model.VisaSignedDataByCustomerWallet @@ -241,6 +242,12 @@ class MockTangemSdkManager( error("Not implemented") } + override suspend fun tangemPayProduceVirtualAccountData( + preflightReadFilter: PreflightReadFilter, + ): Either { + error("Not implemented") + } + override suspend fun getWithdrawalSignature( hash: String, preflightReadFilter: PreflightReadFilter, diff --git a/app/src/main/java/com/tangem/tap/domain/tasks/visa/TangemPayGenerateVirtualAccountAddressTask.kt b/app/src/main/java/com/tangem/tap/domain/tasks/visa/TangemPayGenerateVirtualAccountAddressTask.kt new file mode 100644 index 0000000000..cbc6dce5f6 --- /dev/null +++ b/app/src/main/java/com/tangem/tap/domain/tasks/visa/TangemPayGenerateVirtualAccountAddressTask.kt @@ -0,0 +1,80 @@ +package com.tangem.tap.domain.tasks.visa + +import com.tangem.common.CompletionResult +import com.tangem.common.card.CardWallet +import com.tangem.common.core.CardSession +import com.tangem.common.core.CardSessionRunnable +import com.tangem.common.core.CompletionCallback +import com.tangem.common.core.TangemSdkError +import com.tangem.common.extensions.toMapKey +import com.tangem.core.error.ext.tangemError +import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey +import com.tangem.domain.card.common.visa.VisaUtilities +import com.tangem.domain.visa.error.VisaActivationError +import com.tangem.domain.visa.model.VirtualAccountActivationData +import com.tangem.operations.derivation.DeriveWalletPublicKeyTask +import com.tangem.operations.derivation.ExtendedPublicKeysMap +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * Derives the Virtual Account key ([VisaUtilities.virtualAccountDerivationPath]) on the card and + * generates its deposit address. The derived key is returned (keyed by the seed wallet public key) + * so the caller can persist it via `DerivationsRepository.storeDerivedKeys` — no second tap needed. + */ +class TangemPayGenerateVirtualAccountAddressTask @AssistedInject constructor( + @Assisted private val coroutineScope: CoroutineScope, +) : CardSessionRunnable { + + override fun run(session: CardSession, callback: CompletionCallback) { + coroutineScope.launch { + callback(runSuspend(session = session)) + } + } + + private suspend fun runSuspend(session: CardSession): CompletionResult { + val card = session.environment.card ?: return CompletionResult.Failure(TangemSdkError.MissingPreflightRead()) + val wallet = card.wallets.firstOrNull { it.curve == VisaUtilities.curve } + ?: return CompletionResult.Failure(VisaActivationError.MissingWallet.tangemError) + + val extendedPublicKey = when (val derivationResult = runDerivationTask(session, wallet)) { + is CompletionResult.Failure<*> -> return CompletionResult.Failure(derivationResult.error) + is CompletionResult.Success -> derivationResult.data + } + + val address = VisaUtilities.generateAddressFromExtendedKey(extendedPublicKey = extendedPublicKey) + + val derivedKeys = mapOf( + wallet.publicKey.toMapKey() to ExtendedPublicKeysMap( + mapOf(VisaUtilities.virtualAccountDerivationPath to extendedPublicKey), + ), + ) + + return CompletionResult.Success( + data = VirtualAccountActivationData(address = address, derivedKeys = derivedKeys), + ) + } + + private suspend fun runDerivationTask( + session: CardSession, + wallet: CardWallet, + ): CompletionResult { + val deferred = CompletableDeferred>() + val derivationTask = DeriveWalletPublicKeyTask( + walletPublicKey = wallet.publicKey, + derivationPath = VisaUtilities.virtualAccountDerivationPath, + ) + + derivationTask.run(session = session, callback = deferred::complete) + return deferred.await() + } + + @AssistedFactory + interface Factory { + fun create(coroutineScope: CoroutineScope): TangemPayGenerateVirtualAccountAddressTask + } +} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/datasource/DefaultTangemPayAuthDataSource.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/datasource/DefaultTangemPayAuthDataSource.kt index 8198c181ce..f69787ee69 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/datasource/DefaultTangemPayAuthDataSource.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/datasource/DefaultTangemPayAuthDataSource.kt @@ -6,6 +6,7 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.pay.WithdrawalSignatureResult import com.tangem.domain.pay.datasource.TangemPayAuthDataSource import com.tangem.domain.visa.model.TangemPayInitialCredentials +import com.tangem.domain.visa.model.VirtualAccountActivationData import com.tangem.sdk.api.TangemSdkManager import javax.inject.Inject @@ -26,6 +27,18 @@ internal class DefaultTangemPayAuthDataSource @Inject constructor( } } + override suspend fun produceVirtualAccountData( + userWallet: UserWallet, + ): Either { + return when (userWallet) { + is UserWallet.Cold -> { + val preflightReadFilter = UserWalletIdPreflightReadFilter(userWallet.walletId) + tangemSdkManager.tangemPayProduceVirtualAccountData(preflightReadFilter = preflightReadFilter) + } + is UserWallet.Hot -> tangemPayHotSdkManager.produceVirtualAccountData(userWallet) + } + } + override suspend fun getWithdrawalSignature( userWallet: UserWallet, hash: String, diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/datasource/TangemPayHotSdkManager.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/datasource/TangemPayHotSdkManager.kt index 6c2eac2122..6e79a073b5 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/datasource/TangemPayHotSdkManager.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/datasource/TangemPayHotSdkManager.kt @@ -5,6 +5,7 @@ import arrow.core.getOrElse import arrow.core.raise.Raise import arrow.core.raise.either import com.tangem.common.extensions.hexToBytes +import com.tangem.common.extensions.toMapKey import com.tangem.core.error.ext.tangemError import com.tangem.crypto.hdWallet.bip32.ExtendedPublicKey import com.tangem.domain.card.common.visa.VisaUtilities @@ -14,11 +15,13 @@ import com.tangem.domain.visa.datasource.TangemPayRemoteDataSource import com.tangem.domain.visa.error.VisaActivationError import com.tangem.domain.visa.error.VisaCardScanError import com.tangem.domain.visa.model.TangemPayInitialCredentials +import com.tangem.domain.visa.model.VirtualAccountActivationData import com.tangem.domain.wallets.hot.HotWalletAccessor import com.tangem.hot.sdk.TangemHotSdk import com.tangem.hot.sdk.model.DataToSign import com.tangem.hot.sdk.model.DeriveWalletRequest import com.tangem.hot.sdk.model.UnlockHotWallet +import com.tangem.operations.derivation.ExtendedPublicKeysMap import javax.inject.Inject internal class TangemPayHotSdkManager @Inject constructor( @@ -56,6 +59,34 @@ internal class TangemPayHotSdkManager @Inject constructor( ) } + suspend fun produceVirtualAccountData(hotWallet: UserWallet.Hot): Either = + withUnlockedHotWallet(hotWallet) { unlockHotWallet -> + val response = tangemHotSdk.derivePublicKey( + unlockHotWallet = unlockHotWallet, + request = DeriveWalletRequest( + requests = listOf( + DeriveWalletRequest.Request( + curve = VisaUtilities.curve, + paths = listOf(VisaUtilities.virtualAccountDerivationPath), + ), + ), + ), + ) + val curveResponse = response.responses.firstOrNull { it.curve == VisaUtilities.curve } + ?: raise(VisaActivationError.MissingWallet.tangemError) + val extendedPublicKey = curveResponse.publicKeys[VisaUtilities.virtualAccountDerivationPath] + ?: raise(VisaActivationError.MissingWallet.tangemError) + + VirtualAccountActivationData( + address = VisaUtilities.generateAddressFromExtendedKey(extendedPublicKey), + derivedKeys = mapOf( + curveResponse.seedKey.publicKey.toMapKey() to ExtendedPublicKeysMap( + mapOf(VisaUtilities.virtualAccountDerivationPath to extendedPublicKey), + ), + ), + ) + } + suspend fun getWithdrawalSignature( hotWallet: UserWallet.Hot, hash: String, diff --git a/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/di/VirtualAccountDataModule.kt b/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/di/VirtualAccountDataModule.kt index 117dff144b..a85c3e8c6d 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/di/VirtualAccountDataModule.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/di/VirtualAccountDataModule.kt @@ -8,6 +8,7 @@ import com.squareup.moshi.Moshi import com.tangem.data.virtualaccount.converter.VirtualAccountStatusValueDMConverter import com.tangem.data.virtualaccount.flow.DefaultVirtualAccountStatusFetcher import com.tangem.data.virtualaccount.flow.DefaultVirtualAccountStatusProducer +import com.tangem.data.virtualaccount.repository.DefaultVirtualAccountActivationRepository import com.tangem.data.virtualaccount.store.VirtualAccountStatusesStore import com.tangem.datasource.di.NetworkMoshi import com.tangem.datasource.local.datastore.RuntimeSharedStore @@ -17,6 +18,8 @@ import com.tangem.datasource.utils.mapWithStringKeyTypes import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusFetcher import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusProducer import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusSupplier +import com.tangem.domain.virtualaccount.repository.VirtualAccountActivationRepository +import com.tangem.domain.virtualaccount.usecase.ActivateVirtualAccountUseCase import com.tangem.utils.coroutines.AppCoroutineScope import dagger.Binds import dagger.Module @@ -40,6 +43,12 @@ internal interface VirtualAccountDataModule { @Singleton fun bindVirtualAccountStatusFetcher(impl: DefaultVirtualAccountStatusFetcher): VirtualAccountStatusFetcher + @Binds + @Singleton + fun bindVirtualAccountActivationRepository( + impl: DefaultVirtualAccountActivationRepository, + ): VirtualAccountActivationRepository + companion object { @Provides @@ -77,5 +86,13 @@ internal interface VirtualAccountDataModule { keyCreator = { "virtual_account_status_${it.userWalletId.stringValue}" }, ) {} } + + @Provides + @Singleton + fun provideActivateVirtualAccountUseCase( + repository: VirtualAccountActivationRepository, + ): ActivateVirtualAccountUseCase { + return ActivateVirtualAccountUseCase(repository = repository) + } } } \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusFetcher.kt b/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusFetcher.kt index 6c0249af86..6ebb61f4c5 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusFetcher.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusFetcher.kt @@ -21,7 +21,6 @@ import com.tangem.domain.networks.single.SingleNetworkStatusProducer import com.tangem.domain.networks.single.SingleNetworkStatusSupplier import com.tangem.domain.pay.TangemPayCurrencyFactory import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusFetcher -import com.tangem.domain.wallets.extension.hasDerivation import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.logging.TangemLogger import java.math.BigDecimal @@ -57,21 +56,9 @@ internal class DefaultVirtualAccountStatusFetcher @Inject constructor( private suspend fun getBalance(userWalletId: UserWalletId): Either { val userWallet = userWalletsListRepository.getSyncStrict(userWalletId) - - val hasVirtualAccountDerivation = userWallet.hasDerivation( - blockchain = VisaUtilities.visaBlockchain, - derivationPath = VisaUtilities.virtualAccountDerivationPath.rawPath, - ) - if (!hasVirtualAccountDerivation) { - // TODO: Doston(VA) Derive will be implemented in [REDACTED_TASK_KEY] - TangemLogger.withTag(TAG).d("Virtual account is not derived") - return VirtualAccountStatusValue.Error.NotSynced.left() - } - val network = networkFactory.create( blockchain = VisaUtilities.visaBlockchain, - derivationPath = - Network.DerivationPath.Custom(VisaUtilities.virtualAccountDerivationPath.rawPath), + derivationPath = Network.DerivationPath.Custom(VisaUtilities.virtualAccountDerivationPath.rawPath), userWallet = userWallet, ) if (network == null) { diff --git a/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/repository/DefaultVirtualAccountActivationRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/repository/DefaultVirtualAccountActivationRepository.kt new file mode 100644 index 0000000000..65845d940a --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/repository/DefaultVirtualAccountActivationRepository.kt @@ -0,0 +1,38 @@ +package com.tangem.data.virtualaccount.repository + +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.common.wallets.getSyncStrict +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.datasource.TangemPayAuthDataSource +import com.tangem.domain.virtualaccount.repository.VirtualAccountActivationRepository +import com.tangem.domain.wallets.derivations.DerivationsRepository +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.withContext +import javax.inject.Inject + +internal class DefaultVirtualAccountActivationRepository @Inject constructor( + private val authDataSource: TangemPayAuthDataSource, + private val derivationsRepository: DerivationsRepository, + private val userWalletsListRepository: UserWalletsListRepository, + private val dispatchers: CoroutineDispatcherProvider, +) : VirtualAccountActivationRepository { + + override suspend fun activateVirtualAccount(userWalletId: UserWalletId) { + withContext(dispatchers.io) { + val userWallet = userWalletsListRepository.getSyncStrict(userWalletId) + val activationData = authDataSource.produceVirtualAccountData(userWallet) + .fold( + ifLeft = { error("Can not activate virtual account: ${it.message}") }, + ifRight = { it }, + ) + + // Persist the derived VA key so the on-chain balance can be read without re-deriving (no extra tap). + derivationsRepository.storeDerivedKeys( + userWalletId = userWalletId, + derivedKeys = activationData.derivedKeys, + ) + + // TODO([REDACTED_TASK_KEY]): register activationData.address with the VA backend once the endpoint is available. + } + } +} \ No newline at end of file diff --git a/data/visa/src/test/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusFetcherTest.kt b/data/visa/src/test/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusFetcherTest.kt index 2960eaa74b..e578dcb5be 100644 --- a/data/visa/src/test/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusFetcherTest.kt +++ b/data/visa/src/test/kotlin/com/tangem/data/virtualaccount/flow/DefaultVirtualAccountStatusFetcherTest.kt @@ -13,24 +13,14 @@ import com.tangem.domain.networks.single.SingleNetworkStatusFetcher import com.tangem.domain.networks.single.SingleNetworkStatusSupplier import com.tangem.domain.pay.TangemPayCurrencyFactory import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusFetcher -import com.tangem.domain.wallets.extension.hasDerivation import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider -import io.mockk.clearMocks -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockk -import io.mockk.mockkStatic -import io.mockk.unmockkStatic +import io.mockk.* import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test -private const val USER_WALLET_EXTENSIONS = "com.tangem.domain.wallets.extension.UserWalletExtensionsKt" - @OptIn(ExperimentalCoroutinesApi::class) internal class DefaultVirtualAccountStatusFetcherTest { @@ -59,25 +49,40 @@ internal class DefaultVirtualAccountStatusFetcherTest { @BeforeEach fun setUp() { - mockkStatic(USER_WALLET_EXTENSIONS) clearMocks(networkFactory, tangemPayCurrencyFactory, singleNetworkStatusFetcher, userWalletsListRepository) every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet)) - every { - networkFactory.create(any(), any(), any()) - } returns network every { tangemPayCurrencyFactory.createVirtualAccountToken(userWalletId) } returns token coEvery { singleNetworkStatusFetcher(any()) } returns Unit.right() } - @AfterEach - fun tearDown() { - unmockkStatic(USER_WALLET_EXTENSIONS) + @Test + fun `GIVEN network created WHEN invoke THEN on-chain status fetched with VA token`() = runTest { + // Arrange + every { + networkFactory.create(any(), any(), any()) + } returns network + + // Act + fetcher.invoke(VirtualAccountStatusFetcher.Params(userWalletId)) + + // Assert + coVerify(exactly = 1) { + singleNetworkStatusFetcher( + SingleNetworkStatusFetcher.Params( + userWalletId = userWalletId, + network = network, + extraTokens = setOf(token), + ), + ) + } } @Test - fun `GIVEN VA derivation missing WHEN invoke THEN on-chain fetch skipped`() = runTest { + fun `GIVEN network cannot be created WHEN invoke THEN on-chain fetch skipped`() = runTest { // Arrange - every { userWallet.hasDerivation(any(), any()) } returns false + every { + networkFactory.create(any(), any(), any()) + } returns null // Act fetcher.invoke(VirtualAccountStatusFetcher.Params(userWalletId)) @@ -85,16 +90,4 @@ internal class DefaultVirtualAccountStatusFetcherTest { // Assert coVerify(exactly = 0) { singleNetworkStatusFetcher(any()) } } - - @Test - fun `GIVEN VA derivation present WHEN invoke THEN on-chain fetch performed`() = runTest { - // Arrange - every { userWallet.hasDerivation(any(), any()) } returns true - - // Act - fetcher.invoke(VirtualAccountStatusFetcher.Params(userWalletId)) - - // Assert - coVerify(exactly = 1) { singleNetworkStatusFetcher(any()) } - } } \ No newline at end of file diff --git a/data/visa/src/test/kotlin/com/tangem/data/virtualaccount/repository/DefaultVirtualAccountActivationRepositoryTest.kt b/data/visa/src/test/kotlin/com/tangem/data/virtualaccount/repository/DefaultVirtualAccountActivationRepositoryTest.kt new file mode 100644 index 0000000000..88f4fa2da5 --- /dev/null +++ b/data/visa/src/test/kotlin/com/tangem/data/virtualaccount/repository/DefaultVirtualAccountActivationRepositoryTest.kt @@ -0,0 +1,79 @@ +package com.tangem.data.virtualaccount.repository + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.common.extensions.ByteArrayKey +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.datasource.TangemPayAuthDataSource +import com.tangem.domain.visa.model.VirtualAccountActivationData +import com.tangem.domain.wallets.derivations.DerivationsRepository +import com.tangem.operations.derivation.ExtendedPublicKeysMap +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +internal class DefaultVirtualAccountActivationRepositoryTest { + + private val authDataSource: TangemPayAuthDataSource = mockk() + private val derivationsRepository: DerivationsRepository = mockk(relaxUnitFun = true) + private val userWalletsListRepository: UserWalletsListRepository = mockk() + private val dispatchers = TestingCoroutineDispatcherProvider() + + private val repository = DefaultVirtualAccountActivationRepository( + authDataSource = authDataSource, + derivationsRepository = derivationsRepository, + userWalletsListRepository = userWalletsListRepository, + dispatchers = dispatchers, + ) + + private val userWalletId = UserWalletId("011") + private val userWallet: UserWallet = mockk { every { walletId } returns userWalletId } + + private val derivedKeys: Map = mapOf( + ByteArrayKey(byteArrayOf(1, 2, 3)) to ExtendedPublicKeysMap(emptyMap()), + ) + private val activationData = VirtualAccountActivationData(address = "0xVA", derivedKeys = derivedKeys) + + @BeforeEach + fun setUp() { + clearMocks(authDataSource, derivationsRepository, userWalletsListRepository) + every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet)) + } + + @Test + fun `GIVEN datasource returns data WHEN activate THEN derived keys persisted`() = runTest { + // Arrange + coEvery { authDataSource.produceVirtualAccountData(userWallet) } returns activationData.right() + + // Act + repository.activateVirtualAccount(userWalletId) + + // Assert + coVerify(exactly = 1) { derivationsRepository.storeDerivedKeys(userWalletId, derivedKeys) } + } + + @Test + fun `GIVEN datasource returns error WHEN activate THEN throws AND nothing persisted`() = runTest { + // Arrange + coEvery { authDataSource.produceVirtualAccountData(userWallet) } returns IllegalStateException("nope").left() + + // Act + val error = runCatching { repository.activateVirtualAccount(userWalletId) }.exceptionOrNull() + + // Assert + assertThat(error).isInstanceOf(IllegalStateException::class.java) + coVerify(exactly = 0) { derivationsRepository.storeDerivedKeys(any(), any()) } + } +} \ No newline at end of file diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/cold/DefaultColdMapDerivationsRepository.kt b/data/wallets/src/main/java/com/tangem/data/wallets/cold/DefaultColdMapDerivationsRepository.kt index b141b3b5ca..8f8ab2cfaa 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/cold/DefaultColdMapDerivationsRepository.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/cold/DefaultColdMapDerivationsRepository.kt @@ -100,6 +100,11 @@ internal class DefaultColdMapDerivationsRepository @Inject constructor( } } + override fun mergeDerivedKeys( + userWallet: UserWallet.Cold, + keys: Map, + ): UserWallet.Cold = userWallet.updateDerivedKeys(keys) + override suspend fun hasMissedDerivations( userWallet: UserWallet.Cold, networksWithDerivationPath: Map, diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/derivations/DefaultDerivationsRepository.kt b/data/wallets/src/main/java/com/tangem/data/wallets/derivations/DefaultDerivationsRepository.kt index 3628a45990..37dc47defe 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/derivations/DefaultDerivationsRepository.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/derivations/DefaultDerivationsRepository.kt @@ -77,6 +77,24 @@ internal class DefaultDerivationsRepository @Inject constructor( } } + override suspend fun storeDerivedKeys( + userWalletId: UserWalletId, + derivedKeys: Map, + ) { + if (derivedKeys.isEmpty()) { + TangemLogger.d("Nothing to store") + return + } + + val userWallet = userWalletsListRepository.getSyncStrict(userWalletId) + val updatedUserWallet = when (userWallet) { + is UserWallet.Cold -> coldDerivationsRepository.mergeDerivedKeys(userWallet, derivedKeys) + is UserWallet.Hot -> hotDerivationsRepository.mergeDerivedKeys(userWallet, derivedKeys) + } + + userWallet.update(updatedUserWallet) + } + override suspend fun getExistingDerivedKeys( userWalletId: UserWalletId, seedKey: ByteArrayKey, diff --git a/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotMapDerivationsRepository.kt b/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotMapDerivationsRepository.kt index 3e0a758cab..91d94a7313 100644 --- a/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotMapDerivationsRepository.kt +++ b/data/wallets/src/main/java/com/tangem/data/wallets/hot/DefaultHotMapDerivationsRepository.kt @@ -101,6 +101,11 @@ internal class DefaultHotMapDerivationsRepository @Inject constructor( return updatedUserWallet.updateWithNewKeys(newKeys) to newKeys } + override fun mergeDerivedKeys( + userWallet: UserWallet.Hot, + keys: Map, + ): UserWallet.Hot = userWallet.updateWithNewKeys(keys) + override suspend fun hasMissedDerivations( userWallet: UserWallet.Hot, networksWithDerivationPath: Map, diff --git a/domain/visa/models/build.gradle.kts b/domain/visa/models/build.gradle.kts index e528d11260..9d9560e9a9 100644 --- a/domain/visa/models/build.gradle.kts +++ b/domain/visa/models/build.gradle.kts @@ -15,4 +15,7 @@ dependencies { /** Domain models */ implementation(projects.domain.models) + + /** Tangem libraries (derived public keys types for VA activation) */ + implementation(tangemDeps.card.core) } \ No newline at end of file diff --git a/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VirtualAccountActivationData.kt b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VirtualAccountActivationData.kt new file mode 100644 index 0000000000..01f01c9459 --- /dev/null +++ b/domain/visa/models/src/main/kotlin/com/tangem/domain/visa/model/VirtualAccountActivationData.kt @@ -0,0 +1,16 @@ +package com.tangem.domain.visa.model + +import com.tangem.common.extensions.ByteArrayKey +import com.tangem.operations.derivation.ExtendedPublicKeysMap + +/** + * Result of deriving the Virtual Account key on the card. + * + * @property address the VA deposit address generated from the derived key + * @property derivedKeys the derived extended public key(s) keyed by the seed wallet public key, + * ready to be persisted into the wallet (see `DerivationsRepository.storeDerivedKeys`) + */ +data class VirtualAccountActivationData( + val address: String, + val derivedKeys: Map, +) \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/datasource/TangemPayAuthDataSource.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/datasource/TangemPayAuthDataSource.kt index 4ab30e9ff7..f9e43dc3c8 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/datasource/TangemPayAuthDataSource.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/datasource/TangemPayAuthDataSource.kt @@ -4,11 +4,14 @@ import arrow.core.Either import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.pay.WithdrawalSignatureResult import com.tangem.domain.visa.model.TangemPayInitialCredentials +import com.tangem.domain.visa.model.VirtualAccountActivationData interface TangemPayAuthDataSource { suspend fun produceInitialCredentials(userWallet: UserWallet): Either + suspend fun produceVirtualAccountData(userWallet: UserWallet): Either + suspend fun getWithdrawalSignature( userWallet: UserWallet, hash: String, diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/virtualaccount/repository/VirtualAccountActivationRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/virtualaccount/repository/VirtualAccountActivationRepository.kt new file mode 100644 index 0000000000..4b1d46f487 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/virtualaccount/repository/VirtualAccountActivationRepository.kt @@ -0,0 +1,13 @@ +package com.tangem.domain.virtualaccount.repository + +import com.tangem.domain.models.wallet.UserWalletId + +interface VirtualAccountActivationRepository { + + /** + * Derives the Virtual Account key on the card (NFC) and persists it into the wallet, so the + * on-chain VA balance can later be fetched without re-deriving. Throws on failure. + */ + @Throws + suspend fun activateVirtualAccount(userWalletId: UserWalletId) +} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/virtualaccount/usecase/ActivateVirtualAccountUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/virtualaccount/usecase/ActivateVirtualAccountUseCase.kt new file mode 100644 index 0000000000..dc7db9d27b --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/virtualaccount/usecase/ActivateVirtualAccountUseCase.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.virtualaccount.usecase + +import arrow.core.Either +import arrow.core.Either.Companion.catch +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.virtualaccount.repository.VirtualAccountActivationRepository + +class ActivateVirtualAccountUseCase( + private val repository: VirtualAccountActivationRepository, +) { + + suspend operator fun invoke(userWalletId: UserWalletId): Either { + return catch { + repository.activateVirtualAccount(userWalletId) + } + } +} \ No newline at end of file diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/ColdMapDerivationsRepository.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/ColdMapDerivationsRepository.kt index 86da09c677..b33b71e5eb 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/ColdMapDerivationsRepository.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/ColdMapDerivationsRepository.kt @@ -27,6 +27,9 @@ interface ColdMapDerivationsRepository { derivations: Map>, ): Pair> + /** Merges already-derived [keys] into [userWallet]'s stored derivations without deriving on the card. */ + fun mergeDerivedKeys(userWallet: UserWallet.Cold, keys: Map): UserWallet.Cold + /** Check if user [userWallet] has missed derivations using map of [Network.ID] with extraDerivationPath */ suspend fun hasMissedDerivations( userWallet: UserWallet.Cold, diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/DerivationsRepository.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/DerivationsRepository.kt index a3ee510fde..8f9b139f46 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/DerivationsRepository.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/DerivationsRepository.kt @@ -29,6 +29,14 @@ interface DerivationsRepository { derivations: Map>, ): Map + /** + * Merges already-derived [derivedKeys] into the wallet's stored derivations and persists it. + * Does NOT derive on the card (no NFC): use it to save a key that was obtained by a dedicated + * card task. Keyed by the seed wallet public key ([ByteArrayKey]). + */ + @Throws + suspend fun storeDerivedKeys(userWalletId: UserWalletId, derivedKeys: Map) + /** Returns already derived extended public keys for the given [seedKey] */ suspend fun getExistingDerivedKeys(userWalletId: UserWalletId, seedKey: ByteArrayKey): ExtendedPublicKeysMap diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/HotMapDerivationsRepository.kt b/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/HotMapDerivationsRepository.kt index 27b260db1d..f97950bf8b 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/HotMapDerivationsRepository.kt +++ b/domain/wallets/src/main/java/com/tangem/domain/wallets/derivations/HotMapDerivationsRepository.kt @@ -29,6 +29,9 @@ interface HotMapDerivationsRepository { derivations: Map>, ): Pair> + /** Merges already-derived [keys] into [userWallet]'s stored derivations. */ + fun mergeDerivedKeys(userWallet: UserWallet.Hot, keys: Map): UserWallet.Hot + /** Check if user [userWallet] has missed derivations using map of [Network.ID] with extraDerivationPath */ suspend fun hasMissedDerivations( userWallet: UserWallet.Hot, diff --git a/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/TangemSdkManager.kt b/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/TangemSdkManager.kt index ba87204980..97b2ebe3a8 100644 --- a/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/TangemSdkManager.kt +++ b/libs/tangem-sdk-api/src/main/kotlin/com/tangem/sdk/api/TangemSdkManager.kt @@ -20,6 +20,7 @@ import com.tangem.domain.models.scan.ScanResponse import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.WithdrawalSignatureResult import com.tangem.domain.visa.model.TangemPayInitialCredentials +import com.tangem.domain.visa.model.VirtualAccountActivationData import com.tangem.domain.visa.model.VisaActivationInput import com.tangem.domain.visa.model.VisaDataForApprove import com.tangem.domain.visa.model.VisaSignedDataByCustomerWallet @@ -175,6 +176,10 @@ interface TangemSdkManager { preflightReadFilter: PreflightReadFilter, ): Either + suspend fun tangemPayProduceVirtualAccountData( + preflightReadFilter: PreflightReadFilter, + ): Either + suspend fun getWithdrawalSignature( hash: String, preflightReadFilter: PreflightReadFilter, From f84d6a1261ba63913247d8f3da0b02301701eb99 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 24 Jun 2026 01:21:15 -0700 Subject: [PATCH 067/210] Updated on 2026-08-14 --- .../details/api/build.gradle.kts | 9 + .../component/VirtualAccountMainComponent.kt | 14 ++ .../details/impl/build.gradle.kts | 20 +- .../common/ui/TangemBalanceHeader.kt | 115 +++++++++ .../common/ui/TangemBalanceHeaderState.kt | 18 ++ .../common/ui/TangemCircleActionButton.kt | 70 ++++++ .../common/ui/TangemEmptyState.kt | 73 ++++++ ...sModule.kt => VirtualAccountMainModule.kt} | 2 +- .../DefaultVirtualAccountMainComponent.kt | 34 +++ .../main/VirtualAccountMainModel.kt | 46 ++++ .../main/VirtualAccountMainScreen.kt | 229 ++++++++++++++++++ .../main/VirtualAccountMainUM.kt | 29 +++ .../di/VirtualAccountMainComponentModule.kt | 18 ++ .../main/di/VirtualAccountMainModelModule.kt | 20 ++ .../extension/BaseExtensionConfigurations.kt | 1 + 15 files changed, 694 insertions(+), 4 deletions(-) create mode 100644 features/virtual-accounts/details/api/src/main/kotlin/com/tangem/features/virtualaccount/details/component/VirtualAccountMainComponent.kt create mode 100644 features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/common/ui/TangemBalanceHeader.kt create mode 100644 features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/common/ui/TangemBalanceHeaderState.kt create mode 100644 features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/common/ui/TangemCircleActionButton.kt create mode 100644 features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/common/ui/TangemEmptyState.kt rename features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/di/{VirtualAccountDetailsModule.kt => VirtualAccountMainModule.kt} (94%) create mode 100644 features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/DefaultVirtualAccountMainComponent.kt create mode 100644 features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/VirtualAccountMainModel.kt create mode 100644 features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/VirtualAccountMainScreen.kt create mode 100644 features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/VirtualAccountMainUM.kt create mode 100644 features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/di/VirtualAccountMainComponentModule.kt create mode 100644 features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/di/VirtualAccountMainModelModule.kt diff --git a/features/virtual-accounts/details/api/build.gradle.kts b/features/virtual-accounts/details/api/build.gradle.kts index ccb34f0307..1f5657de2a 100644 --- a/features/virtual-accounts/details/api/build.gradle.kts +++ b/features/virtual-accounts/details/api/build.gradle.kts @@ -9,4 +9,13 @@ android { } dependencies { + /** Core */ + api(projects.core.decompose) + api(projects.core.ui) + + /** Domain */ + api(projects.domain.models) + + /** Compose */ + implementation(deps.compose.runtime) } \ No newline at end of file diff --git a/features/virtual-accounts/details/api/src/main/kotlin/com/tangem/features/virtualaccount/details/component/VirtualAccountMainComponent.kt b/features/virtual-accounts/details/api/src/main/kotlin/com/tangem/features/virtualaccount/details/component/VirtualAccountMainComponent.kt new file mode 100644 index 0000000000..cd452567a0 --- /dev/null +++ b/features/virtual-accounts/details/api/src/main/kotlin/com/tangem/features/virtualaccount/details/component/VirtualAccountMainComponent.kt @@ -0,0 +1,14 @@ +package com.tangem.features.virtualaccount.details.component + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.wallet.UserWalletId + +interface VirtualAccountMainComponent : ComposableContentComponent { + + data class Params( + val userWalletId: UserWalletId, + ) + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/virtual-accounts/details/impl/build.gradle.kts b/features/virtual-accounts/details/impl/build.gradle.kts index 3fec5d85d5..1d9448064a 100644 --- a/features/virtual-accounts/details/impl/build.gradle.kts +++ b/features/virtual-accounts/details/impl/build.gradle.kts @@ -11,11 +11,25 @@ android { } dependencies { + /** Core */ + implementation(projects.core.configToggles) + implementation(projects.core.decompose) + implementation(projects.core.res) + implementation(projects.core.ui) + implementation(projects.core.utils) + + /** Domain */ + implementation(projects.domain.models) + + /** Features */ implementation(projects.features.virtualAccounts.details.api) - implementation(projects.core.configToggles) - - implementation(deps.compose.runtime) + /** Compose */ + implementation(deps.compose.foundation) + implementation(deps.compose.material3) + implementation(deps.compose.ui) + implementation(deps.compose.ui.tooling) + implementation(deps.decompose.ext.compose) /** DI */ implementation(deps.hilt.android) diff --git a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/common/ui/TangemBalanceHeader.kt b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/common/ui/TangemBalanceHeader.kt new file mode 100644 index 0000000000..6ec531b3d2 --- /dev/null +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/common/ui/TangemBalanceHeader.kt @@ -0,0 +1,115 @@ +package com.tangem.features.virtualaccount.common.ui + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.TextAutoSize +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.text.applyBladeBrush +import com.tangem.core.ui.ds2.shimmers.TextShimmer +import com.tangem.core.ui.ds2.shimmers.TextShimmerStyle +import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.utils.StringsSigns.DASH_SIGN + +@Composable +fun TangemBalanceHeader( + state: TangemBalanceHeaderState, + label: TextReference, + modifier: Modifier = Modifier, + balanceModifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2), + ) { + AnimatedContent( + targetState = state, + label = "Updating the balance", + transitionSpec = { + fadeIn(animationSpec = tween(durationMillis = 220, delayMillis = 90)) togetherWith + fadeOut(animationSpec = tween(durationMillis = 90)) + }, + ) { animatedState -> + when (animatedState) { + is TangemBalanceHeaderState.Loading -> TextShimmer( + modifier = Modifier.size(width = 160.dp, height = 56.dp), + text = "1234.00", + style = TextShimmerStyle.HEADING_MEDIUM, + radius = TangemTheme.dimens2.x25, + ) + is TangemBalanceHeaderState.Content -> Text( + modifier = balanceModifier, + text = animatedState.balance + .orMaskWithStars(animatedState.isBalanceHidden) + .resolveAnnotatedReference(), + style = TangemTheme.typography3.display.medium.applyBladeBrush( + isEnabled = animatedState.isFlickering, + textColor = TangemTheme.colors3.text.primary, + ), + color = TangemTheme.colors3.text.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + autoSize = TextAutoSize.StepBased( + minFontSize = TangemTheme.typography3.heading.medium.fontSize, + maxFontSize = TangemTheme.typography3.display.medium.fontSize, + ), + ) + is TangemBalanceHeaderState.Error -> Text( + modifier = balanceModifier, + text = DASH_SIGN, + style = TangemTheme.typography3.display.medium, + color = TangemTheme.colors3.text.primary, + ) + } + } + Text( + modifier = Modifier.padding(vertical = TangemTheme.dimens2.x1), + text = label.resolveReference(), + color = TangemTheme.colors3.text.secondary, + style = TangemTheme.typography3.caption.medium, + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun TangemBalanceHeaderPreview() { + TangemThemePreviewRedesign { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + TangemBalanceHeader( + modifier = Modifier.fillMaxWidth(), + state = TangemBalanceHeaderState.Content( + balance = stringReference("$0.00"), + isBalanceHidden = false, + ), + label = stringReference("Total balance"), + ) + TangemBalanceHeader( + modifier = Modifier.fillMaxWidth(), + state = TangemBalanceHeaderState.Loading, + label = stringReference("Total balance"), + ) + TangemBalanceHeader( + modifier = Modifier.fillMaxWidth(), + state = TangemBalanceHeaderState.Error, + label = stringReference("Total balance"), + ) + } + } +} \ No newline at end of file diff --git a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/common/ui/TangemBalanceHeaderState.kt b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/common/ui/TangemBalanceHeaderState.kt new file mode 100644 index 0000000000..2bbe065b20 --- /dev/null +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/common/ui/TangemBalanceHeaderState.kt @@ -0,0 +1,18 @@ +package com.tangem.features.virtualaccount.common.ui + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.extensions.TextReference + +@Immutable +sealed interface TangemBalanceHeaderState { + + data object Loading : TangemBalanceHeaderState + + data class Content( + val balance: TextReference, + val isBalanceHidden: Boolean, + val isFlickering: Boolean = false, + ) : TangemBalanceHeaderState + + data object Error : TangemBalanceHeaderState +} \ No newline at end of file diff --git a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/common/ui/TangemCircleActionButton.kt b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/common/ui/TangemCircleActionButton.kt new file mode 100644 index 0000000000..62ad094981 --- /dev/null +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/common/ui/TangemCircleActionButton.kt @@ -0,0 +1,70 @@ +package com.tangem.features.virtualaccount.common.ui + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.text.TextAutoSize +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds2.button.TangemButton +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveAnnotatedReference +import com.tangem.core.ui.extensions.stringReference +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_arrow_down_24 + +@Composable +fun TangemCircleActionButton( + title: TextReference, + icon: TangemIconUM, + onClick: () -> Unit, + modifier: Modifier = Modifier, + isEnabled: Boolean = true, + isLoading: Boolean = false, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + TangemButton( + variant = TangemButton.Variant.Material, + size = TangemButton.Size.X14, + onClick = onClick, + iconStart = icon, + isLoading = isLoading, + isEnabled = isEnabled, + ) + Text( + text = title.resolveAnnotatedReference(), + style = TangemTheme.typography3.subheading.medium, + color = TangemTheme.colors3.text.primary, + autoSize = TextAutoSize.StepBased( + minFontSize = TangemTheme.typography3.caption.medium.fontSize, + maxFontSize = TangemTheme.typography3.subheading.medium.fontSize, + ), + maxLines = 1, + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun TangemCircleActionButtonPreview() { + TangemThemePreviewRedesign { + TangemCircleActionButton( + title = stringReference("Action"), + icon = TangemIconUM.Icon( + imageVector = Icons.ic_arrow_down_24, + tintReference = { TangemTheme.colors3.icon.primary }, + ), + onClick = {}, + ) + } +} \ No newline at end of file diff --git a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/common/ui/TangemEmptyState.kt b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/common/ui/TangemEmptyState.kt new file mode 100644 index 0000000000..672ee4004f --- /dev/null +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/common/ui/TangemEmptyState.kt @@ -0,0 +1,73 @@ +package com.tangem.features.virtualaccount.common.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringReference +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_binoculars_20 + +@Composable +fun TangemEmptyState( + icon: ImageVector, + text: TextReference, + modifier: Modifier = Modifier, + iconModifier: Modifier = Modifier, + textModifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x3, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + modifier = Modifier + .size(TangemTheme.dimens2.x10) + .background( + color = TangemTheme.colors3.bg.opaque.primary, + shape = CircleShape, + ) + .padding(10.dp) + .then(iconModifier), + imageVector = icon, + tint = TangemTheme.colors3.icon.secondary, + contentDescription = null, + ) + + Text( + modifier = textModifier, + textAlign = TextAlign.Center, + text = text.resolveReference(), + style = TangemTheme.typography3.caption.medium, + color = TangemTheme.colors3.text.secondary, + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun TangemEmptyStatePreview() { + TangemThemePreviewRedesign { + TangemEmptyState( + icon = Icons.ic_binoculars_20, + text = stringReference("No transactions yet\nStart spending and see history here"), + ) + } +} \ No newline at end of file diff --git a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/di/VirtualAccountDetailsModule.kt b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/di/VirtualAccountMainModule.kt similarity index 94% rename from features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/di/VirtualAccountDetailsModule.kt rename to features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/di/VirtualAccountMainModule.kt index 8e35f7eb24..d3a53a6e88 100644 --- a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/di/VirtualAccountDetailsModule.kt +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/di/VirtualAccountMainModule.kt @@ -11,7 +11,7 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -internal object VirtualAccountDetailsModule { +internal object VirtualAccountMainModule { @Provides @Singleton diff --git a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/DefaultVirtualAccountMainComponent.kt b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/DefaultVirtualAccountMainComponent.kt new file mode 100644 index 0000000000..7ad7ef7c21 --- /dev/null +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/DefaultVirtualAccountMainComponent.kt @@ -0,0 +1,34 @@ +package com.tangem.features.virtualaccount.main + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.features.virtualaccount.details.component.VirtualAccountMainComponent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultVirtualAccountMainComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted params: VirtualAccountMainComponent.Params, +) : VirtualAccountMainComponent, AppComponentContext by appComponentContext { + + private val model: VirtualAccountMainModel = getOrCreateModel(params = params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + VirtualAccountMainScreen(state = state, modifier = modifier) + } + + @AssistedFactory + interface Factory : VirtualAccountMainComponent.Factory { + override fun create( + context: AppComponentContext, + params: VirtualAccountMainComponent.Params, + ): DefaultVirtualAccountMainComponent + } +} \ No newline at end of file diff --git a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/VirtualAccountMainModel.kt b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/VirtualAccountMainModel.kt new file mode 100644 index 0000000000..65a9fd1e60 --- /dev/null +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/VirtualAccountMainModel.kt @@ -0,0 +1,46 @@ +package com.tangem.features.virtualaccount.main + +import androidx.compose.runtime.Stable +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.navigation.Router +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.features.virtualaccount.details.component.VirtualAccountMainComponent +import com.tangem.features.virtualaccount.details.impl.R +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +@Stable +@ModelScoped +internal class VirtualAccountMainModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, +) : Model() { + + @Suppress("UnusedPrivateProperty") + private val params = paramsContainer.require() + + val uiState: StateFlow + field = MutableStateFlow( + createInitialState(), + ) + + private fun createInitialState(): VirtualAccountMainUM = VirtualAccountMainUM( + title = resourceReference(R.string.virtual_account_title), + subtitle = resourceReference(R.string.tangempay_usdc_on_polygon_network), + balance = VirtualAccountBalanceBlockState.Content( + fiatBalance = stringReference("$0.00"), + isBalanceFlickering = false, + ), + isBalanceHidden = false, + onBackClick = { router.pop() }, + onMenuClick = {}, + onAddFundsClick = {}, + onSendClick = {}, + ) +} \ No newline at end of file diff --git a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/VirtualAccountMainScreen.kt b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/VirtualAccountMainScreen.kt new file mode 100644 index 0000000000..98d4289358 --- /dev/null +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/VirtualAccountMainScreen.kt @@ -0,0 +1,229 @@ +package com.tangem.features.virtualaccount.main + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.components.SpacerH24 +import com.tangem.core.ui.components.topFade +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds.topbar.TangemTopBar +import com.tangem.core.ui.ds2.button.TangemButton +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.core.ui.res.generated.icons.* +import com.tangem.features.virtualaccount.common.ui.TangemBalanceHeader +import com.tangem.features.virtualaccount.common.ui.TangemBalanceHeaderState +import com.tangem.features.virtualaccount.common.ui.TangemCircleActionButton +import com.tangem.features.virtualaccount.common.ui.TangemEmptyState +import com.tangem.features.virtualaccount.details.impl.R +import com.tangem.core.ui.R as CoreUiR + +private val InitialTopBarHeight: Dp = 64.dp +private const val TOP_FADE_MID_STOP = 0.8f +private const val TOP_FADE_MID_ALPHA = 0.8f + +@Composable +internal fun VirtualAccountMainScreen(state: VirtualAccountMainUM, modifier: Modifier = Modifier) { + val listState = rememberLazyListState() + val density = LocalDensity.current + val statusBarHeight = with(density) { WindowInsets.systemBars.getTop(this).toDp() } + val bottomBarHeight = with(density) { WindowInsets.systemBars.getBottom(this).toDp() } + var topBarTotalHeight by remember { mutableStateOf(InitialTopBarHeight + statusBarHeight) } + val rootBackground = TangemTheme.colors3.bg.primary + + Box( + modifier = modifier + .fillMaxSize() + .background(rootBackground), + ) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .topFade( + height = topBarTotalHeight, + 0f to rootBackground, + TOP_FADE_MID_STOP to rootBackground.copy(alpha = TOP_FADE_MID_ALPHA), + 1f to Color.Transparent, + ), + horizontalAlignment = Alignment.CenterHorizontally, + state = listState, + contentPadding = PaddingValues( + top = topBarTotalHeight, + bottom = TangemTheme.dimens2.x4 + bottomBarHeight, + ), + ) { + body( + state = state, + listState = listState, + ) + } + TopBar( + state = state, + onHeightChange = { measuredHeight -> + if (topBarTotalHeight != measuredHeight) topBarTotalHeight = measuredHeight + }, + ) + } +} + +private fun LazyListScope.body(state: VirtualAccountMainUM, listState: LazyListState) { + item("balanceBlock") { + BalanceBlock( + state = state.balance, + isBalanceHidden = state.isBalanceHidden, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens2.x4) + .padding(top = TangemTheme.dimens2.x12), + ) + } + item("actionButtonsBlock") { + SpacerH24() + ActionBlock(state = state) + } + item("emptyTransactions") { + SpacerH24() + TangemEmptyState( + icon = Icons.ic_binoculars_20, + text = resourceReference(R.string.virtual_account_transactions_empty), + modifier = Modifier + .heightIn(min = rememberRemainingViewportHeight(listState, "emptyTransactions")) + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens2.x4, vertical = TangemTheme.dimens2.x3), + ) + } +} + +@Composable +private fun BalanceBlock( + state: VirtualAccountBalanceBlockState, + isBalanceHidden: Boolean, + modifier: Modifier = Modifier, +) { + TangemBalanceHeader( + state = when (state) { + is VirtualAccountBalanceBlockState.Loading -> TangemBalanceHeaderState.Loading + is VirtualAccountBalanceBlockState.Content -> TangemBalanceHeaderState.Content( + balance = state.fiatBalance, + isFlickering = state.isBalanceFlickering, + isBalanceHidden = isBalanceHidden, + ) + is VirtualAccountBalanceBlockState.Error -> TangemBalanceHeaderState.Error + }, + label = resourceReference(R.string.token_details_balance_total), + modifier = modifier, + ) +} + +@Composable +private fun LazyItemScope.ActionBlock(state: VirtualAccountMainUM, modifier: Modifier = Modifier) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + TangemCircleActionButton( + modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x4), + title = resourceReference(R.string.common_add_funds), + icon = TangemIconUM.Icon( + imageVector = Icons.ic_arrow_down_24, + tintReference = { TangemTheme.colors3.icon.primary }, + ), + onClick = state.onAddFundsClick, + ) + TangemCircleActionButton( + modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x4), + title = resourceReference(R.string.common_send), + icon = TangemIconUM.Icon( + imageVector = Icons.ic_arrow_up_24, + tintReference = { TangemTheme.colors3.icon.primary }, + ), + onClick = state.onSendClick, + ) + } +} + +@Composable +private fun TopBar(state: VirtualAccountMainUM, onHeightChange: (Dp) -> Unit, modifier: Modifier = Modifier) { + val density = LocalDensity.current + TangemTopBar( + modifier = modifier + .onSizeChanged { size -> onHeightChange(with(density) { size.height.toDp() }) } + .statusBarsPadding(), + title = state.title, + subtitle = state.subtitle, + startContent = { + TangemButton( + iconStart = TangemIconUM.Icon(iconRes = CoreUiR.drawable.ic_arrow_back_28), + onClick = state.onBackClick, + size = TangemButton.Size.X11, + variant = TangemButton.Variant.Material, + ) + }, + endContent = { + TangemButton( + iconStart = TangemIconUM.Icon(imageVector = Icons.ic_dots_vertical_24), + onClick = state.onMenuClick, + size = TangemButton.Size.X11, + variant = TangemButton.Variant.Material, + ) + }, + ) +} + +/** + * Computes the height left between the top of the item identified by [itemKey] and the bottom of the + * list's viewport (excluding bottom content padding). Returns `0.dp` until the item has been laid out. + * + * The item's own height does not affect its offset (only the items above it do), so reading the offset + * back to size the item is stable and does not loop. + */ +@Composable +private fun rememberRemainingViewportHeight(listState: LazyListState, itemKey: Any): Dp { + val density = LocalDensity.current + val remainingPx by remember(listState, itemKey) { + derivedStateOf { + val info = listState.layoutInfo + val item = info.visibleItemsInfo.firstOrNull { it.key == itemKey } + ?: return@derivedStateOf 0 + (info.viewportEndOffset - info.afterContentPadding - item.offset).coerceAtLeast(minimumValue = 0) + } + } + return with(density) { remainingPx.toDp() } +} + +@Preview(device = Devices.PIXEL_7_PRO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.PIXEL_7_PRO) +@Composable +private fun VirtualAccountMainScreenPreview() { + TangemThemePreviewRedesign { + VirtualAccountMainScreen( + state = VirtualAccountMainUM( + title = resourceReference(R.string.virtual_account_title), + subtitle = resourceReference(R.string.tangempay_usdc_on_polygon_network), + balance = VirtualAccountBalanceBlockState.Content( + fiatBalance = stringReference("$0.00"), + isBalanceFlickering = false, + ), + isBalanceHidden = false, + onBackClick = {}, + onMenuClick = {}, + onAddFundsClick = {}, + onSendClick = {}, + ), + ) + } +} \ No newline at end of file diff --git a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/VirtualAccountMainUM.kt b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/VirtualAccountMainUM.kt new file mode 100644 index 0000000000..555fdd5601 --- /dev/null +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/VirtualAccountMainUM.kt @@ -0,0 +1,29 @@ +package com.tangem.features.virtualaccount.main + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.extensions.TextReference + +@Immutable +internal data class VirtualAccountMainUM( + val title: TextReference, + val subtitle: TextReference, + val balance: VirtualAccountBalanceBlockState, + val isBalanceHidden: Boolean, + val onBackClick: () -> Unit, + val onMenuClick: () -> Unit, + val onAddFundsClick: () -> Unit, + val onSendClick: () -> Unit, +) + +@Immutable +internal sealed class VirtualAccountBalanceBlockState { + + data object Loading : VirtualAccountBalanceBlockState() + + data class Content( + val fiatBalance: TextReference, + val isBalanceFlickering: Boolean, + ) : VirtualAccountBalanceBlockState() + + data object Error : VirtualAccountBalanceBlockState() +} \ No newline at end of file diff --git a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/di/VirtualAccountMainComponentModule.kt b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/di/VirtualAccountMainComponentModule.kt new file mode 100644 index 0000000000..3e3ca68186 --- /dev/null +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/di/VirtualAccountMainComponentModule.kt @@ -0,0 +1,18 @@ +package com.tangem.features.virtualaccount.main.di + +import com.tangem.features.virtualaccount.main.DefaultVirtualAccountMainComponent +import com.tangem.features.virtualaccount.details.component.VirtualAccountMainComponent +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +internal interface VirtualAccountMainComponentModule { + + @Binds + fun bindVirtualAccountMainComponentFactory( + factory: DefaultVirtualAccountMainComponent.Factory, + ): VirtualAccountMainComponent.Factory +} \ No newline at end of file diff --git a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/di/VirtualAccountMainModelModule.kt b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/di/VirtualAccountMainModelModule.kt new file mode 100644 index 0000000000..9c621bc6fc --- /dev/null +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/di/VirtualAccountMainModelModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.virtualaccount.main.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.virtualaccount.main.VirtualAccountMainModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(ModelComponent::class) +internal interface VirtualAccountMainModelModule { + + @Binds + @IntoMap + @ClassKey(VirtualAccountMainModel::class) + fun bindVirtualAccountMainModel(model: VirtualAccountMainModel): Model +} \ No newline at end of file diff --git a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/BaseExtensionConfigurations.kt b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/BaseExtensionConfigurations.kt index e0c1ec5b25..44920f24a3 100644 --- a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/BaseExtensionConfigurations.kt +++ b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/extension/BaseExtensionConfigurations.kt @@ -20,6 +20,7 @@ internal fun BaseExtension.configureCompilerOptions() { internal fun BaseExtension.configureCompose(project: Project) { val useCompose = with(project.path) { contains(":ui") || + contains(Regex(pattern = ":common-ui\$")) || // shared Composable UI component modules contains(":common:ui-charts") || contains(":features:onboarding") || // TODO: divide on api/impl after migrating all onboarding to module contains(Regex(pattern = ":presentation\$")) || From d9cf8d5aefb8acc53332f5841d7b613d72c8c2e0 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 24 Jun 2026 17:31:01 +0500 Subject: [PATCH 068/210] Updated on 2026-08-14 --- app/src/main/AndroidManifest.xml | 10 + .../tangem/tap/routing/utils/ChildFactory.kt | 19 ++ .../tap/routing/utils/DeepLinkFactory.kt | 3 + .../tap/routing/utils/DeepLinkFactoryTest.kt | 6 + .../com/tangem/common/routing/AppRoute.kt | 18 ++ .../tangem/common/routing/DeepLinkRoute.kt | 4 + .../pay/models/response/CustomerMeResponse.kt | 10 + .../onboarding/api/build.gradle.kts | 9 + .../VirtualAccountOnboardingComponent.kt | 21 ++ .../OnboardVirtualAccountsDeepLinkHandler.kt | 10 + .../onboarding/impl/build.gradle.kts | 19 +- ...efaultVirtualAccountOnboardingComponent.kt | 35 +++ ...ltOnboardVirtualAccountsDeepLinkHandler.kt | 35 +++ .../VirtualAccountOnboardingFeatureModule.kt | 25 ++ .../VirtualAccountOnboardingModelsModule.kt | 20 ++ .../model/VirtualAccountOnboardingModel.kt | 96 ++++++++ .../ui/VirtualAccountOnboardingScreen.kt | 213 ++++++++++++++++++ .../ui/VirtualAccountOnboardingUM.kt | 22 ++ .../bg_virtual_account_onboarding.webp | Bin 0 -> 53770 bytes 19 files changed, 573 insertions(+), 2 deletions(-) create mode 100644 features/virtual-accounts/onboarding/api/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/component/VirtualAccountOnboardingComponent.kt create mode 100644 features/virtual-accounts/onboarding/api/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/deeplink/OnboardVirtualAccountsDeepLinkHandler.kt create mode 100644 features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/component/DefaultVirtualAccountOnboardingComponent.kt create mode 100644 features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/deeplink/DefaultOnboardVirtualAccountsDeepLinkHandler.kt create mode 100644 features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/di/VirtualAccountOnboardingFeatureModule.kt create mode 100644 features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/di/VirtualAccountOnboardingModelsModule.kt create mode 100644 features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/model/VirtualAccountOnboardingModel.kt create mode 100644 features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/ui/VirtualAccountOnboardingScreen.kt create mode 100644 features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/ui/VirtualAccountOnboardingUM.kt create mode 100644 features/virtual-accounts/onboarding/impl/src/main/res/drawable/bg_virtual_account_onboarding.webp diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 36cca67394..71831c5102 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -313,6 +313,16 @@ + + + + + + + + diff --git a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index 559a3bef90..13e28e17e8 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt @@ -43,6 +43,7 @@ import com.tangem.features.tangempay.components.TangemPayHotWalletOnboardingComp import com.tangem.features.tangempay.components.TangemPayOnboardingComponent import com.tangem.features.tangempay.components.TangemPayOnboardingComponent.Params.* import com.tangem.features.tokendetails.TokenDetailsComponent +import com.tangem.features.virtualaccount.onboarding.component.VirtualAccountOnboardingComponent import com.tangem.features.wallet.WalletEntryComponent import com.tangem.features.walletconnect.components.WalletConnectEntryComponent import com.tangem.features.yield.supply.api.YieldSupplyEntryComponent @@ -113,6 +114,7 @@ internal class ChildFactory @Inject constructor( private val tangemPayDetailsContainerComponentFactory: TangemPayDetailsContainerComponent.Factory, private val tangemPayOnboardingComponentFactory: TangemPayOnboardingComponent.Factory, private val tangemPayWalletOnboardingComponentFactory: TangemPayHotWalletOnboardingComponent.Factory, + private val virtualAccountOnboardingComponentFactory: VirtualAccountOnboardingComponent.Factory, private val kycComponentFactory: KycComponent.Factory, private val surveyComponentFactory: SurveyComponent.Factory, private val yieldSupplyEntryComponentFactory: YieldSupplyEntryComponent.Factory, @@ -696,6 +698,23 @@ internal class ChildFactory @Inject constructor( componentFactory = tangemPayWalletOnboardingComponentFactory, ) } + is AppRoute.VirtualAccountOnboarding -> { + createComponentChild( + context = context, + params = when (val mode = route.mode) { + is AppRoute.VirtualAccountOnboarding.Mode.Deeplink -> + VirtualAccountOnboardingComponent.Params.Deeplink( + userWalletId = mode.userWalletId, + deeplink = mode.deeplink, + ) + is AppRoute.VirtualAccountOnboarding.Mode.FromMain -> + VirtualAccountOnboardingComponent.Params.FromMain(userWalletId = mode.userWalletId) + is AppRoute.VirtualAccountOnboarding.Mode.FromDetailsScreen -> + VirtualAccountOnboardingComponent.Params.FromDetailsScreen(userWalletId = mode.userWalletId) + }, + componentFactory = virtualAccountOnboardingComponentFactory, + ) + } is AppRoute.Kyc -> { createComponentChild( context = context, diff --git a/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt index 378e230a37..c30d377259 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/DeepLinkFactory.kt @@ -21,6 +21,7 @@ import com.tangem.features.send.api.deeplink.SellRedirectDeepLinkHandler import com.tangem.features.staking.api.deeplink.StakingDeepLinkHandler import com.tangem.features.survey.deeplink.SurveyDeepLinkHandler import com.tangem.features.tangempay.deeplink.OnboardVisaDeepLinkHandler +import com.tangem.features.virtualaccount.onboarding.deeplink.OnboardVirtualAccountsDeepLinkHandler import com.tangem.features.tangempay.deeplink.TangemPayMainDeepLinkHandler import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler import com.tangem.features.wallet.deeplink.PromoDeeplinkHandler @@ -57,6 +58,7 @@ internal class DeepLinkFactory @Inject constructor( private val swapDeepLink: SwapDeepLinkHandler.Factory, private val promoDeepLink: PromoDeeplinkHandler.Factory, private val onboardVisaDeepLink: OnboardVisaDeepLinkHandler.Factory, + private val onboardVirtualAccountsDeepLink: OnboardVirtualAccountsDeepLinkHandler.Factory, private val marketsTokenExchangesDeepLink: MarketsTokenExchangesDeepLinkHandler.Factory, private val tangemPayMainDeepLink: TangemPayMainDeepLinkHandler.Factory, private val newsDetailsDeepLink: NewsDetailsDeepLinkHandler.Factory, @@ -173,6 +175,7 @@ internal class DeepLinkFactory @Inject constructor( DeepLinkRoute.WalletConnect.host -> walletConnectDeepLink.create(deeplinkUri) DeepLinkRoute.Promo.host -> promoDeepLink.create(coroutineScope, queryParams) DeepLinkRoute.OnboardVisa.host -> onboardVisaDeepLink.create(deeplinkUri) + DeepLinkRoute.OnboardVirtualAccounts.host -> onboardVirtualAccountsDeepLink.create(deeplinkUri) DeepLinkRoute.News.host -> newsDeepLink.create(queryParams) DeepLinkRoute.Earn.host -> earnDeepLink.create(queryParams) DeepLinkRoute.Yield.host -> yieldDeepLink.create(coroutineScope, queryParams) diff --git a/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt b/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt index c6cfb8973c..c6744e1b77 100644 --- a/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt +++ b/app/src/test/kotlin/com/tangem/tap/routing/utils/DeepLinkFactoryTest.kt @@ -19,6 +19,7 @@ import com.tangem.features.onramp.deeplink.SwapDeepLinkHandler import com.tangem.features.send.api.deeplink.SellRedirectDeepLinkHandler import com.tangem.features.staking.api.deeplink.StakingDeepLinkHandler import com.tangem.features.tangempay.deeplink.OnboardVisaDeepLinkHandler +import com.tangem.features.virtualaccount.onboarding.deeplink.OnboardVirtualAccountsDeepLinkHandler import com.tangem.features.tangempay.deeplink.TangemPayMainDeepLinkHandler import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler import com.tangem.features.wallet.deeplink.PromoDeeplinkHandler @@ -84,6 +85,10 @@ class DeepLinkFactoryTest { every { create(any()) } returns mockk() } + private val onboardVirtualAccountsDeepLink = mockk(relaxed = true) { + every { create(any()) } returns mockk() + } + private val tangemPayMainDeepLink = mockk(relaxed = true) { every { create(any(), any()) } returns mockk() } @@ -140,6 +145,7 @@ class DeepLinkFactoryTest { swapDeepLink = swapDeepLinkFactory, promoDeepLink = promoDeepLinkFactory, onboardVisaDeepLink = onboardVisaDeepLink, + onboardVirtualAccountsDeepLink = onboardVirtualAccountsDeepLink, tangemPayMainDeepLink = tangemPayMainDeepLink, newsDetailsDeepLink = newsDeeplink, newsDeepLink = newsDeepLinkFactory, diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt index 90449eab97..93c4629c98 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt @@ -511,6 +511,24 @@ sealed class AppRoute(val path: String) : Route { @Serializable data class Kyc(val userWalletId: UserWalletId) : AppRoute(path = "/kyc") + @Serializable + data class VirtualAccountOnboarding( + val mode: Mode, + ) : AppRoute(path = "/virtual_account_onboarding/$mode") { + + @Serializable + sealed class Mode { + @Serializable + data class Deeplink(val userWalletId: UserWalletId, val deeplink: String) : Mode() + + @Serializable + data class FromMain(val userWalletId: UserWalletId) : Mode() + + @Serializable + data class FromDetailsScreen(val userWalletId: UserWalletId) : Mode() + } + } + @Serializable data class Survey(val token: String, val displayId: String? = null) : AppRoute(path = "/survey") diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt index e2e31a626c..9abdcf8b09 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/DeepLinkRoute.kt @@ -68,6 +68,10 @@ sealed class DeepLinkRoute { override val host: String = "onboard-visa" } + data object OnboardVirtualAccounts : DeepLinkRoute() { + override val host: String = "onboard-virtual-account" + } + data object PayApp : DeepLinkRoute() { override val host: String = "tangem.com" } diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt index da654f8d16..17de6c4cd0 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/CustomerMeResponse.kt @@ -35,7 +35,17 @@ data class CustomerMeResponse( @Json(name = "display_name") val displayName: String?, @Json(name = "actual_card_limit") val actualCardLimit: CardLimit?, @Json(name = "admin_card_limit") val adminCardLimit: CardLimit?, + @Json(name = "product_specification_data_type") val specificationDataType: SpecificationDataType, ) { + @JsonClass(generateAdapter = false) + enum class SpecificationDataType { + @Json(name = "ACCOUNT") + ACCOUNT, + + @Json(name = "CARD") + CARD, + } + @JsonClass(generateAdapter = false) enum class Status { @Json(name = "NEW") diff --git a/features/virtual-accounts/onboarding/api/build.gradle.kts b/features/virtual-accounts/onboarding/api/build.gradle.kts index bd895bec0a..a409f095d3 100644 --- a/features/virtual-accounts/onboarding/api/build.gradle.kts +++ b/features/virtual-accounts/onboarding/api/build.gradle.kts @@ -9,4 +9,13 @@ android { } dependencies { + /** Core */ + implementation(projects.core.decompose) + implementation(projects.core.ui) + + /** Domain */ + implementation(projects.domain.models) + + /** Compose */ + implementation(deps.compose.runtime) } \ No newline at end of file diff --git a/features/virtual-accounts/onboarding/api/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/component/VirtualAccountOnboardingComponent.kt b/features/virtual-accounts/onboarding/api/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/component/VirtualAccountOnboardingComponent.kt new file mode 100644 index 0000000000..5aac13f9ca --- /dev/null +++ b/features/virtual-accounts/onboarding/api/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/component/VirtualAccountOnboardingComponent.kt @@ -0,0 +1,21 @@ +package com.tangem.features.virtualaccount.onboarding.component + +import com.tangem.core.decompose.factory.ComponentFactory +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.domain.models.wallet.UserWalletId + +interface VirtualAccountOnboardingComponent : ComposableContentComponent { + + sealed class Params { + + abstract val userWalletId: UserWalletId + + data class Deeplink(override val userWalletId: UserWalletId, val deeplink: String) : Params() + + data class FromMain(override val userWalletId: UserWalletId) : Params() + + data class FromDetailsScreen(override val userWalletId: UserWalletId) : Params() + } + + interface Factory : ComponentFactory +} \ No newline at end of file diff --git a/features/virtual-accounts/onboarding/api/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/deeplink/OnboardVirtualAccountsDeepLinkHandler.kt b/features/virtual-accounts/onboarding/api/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/deeplink/OnboardVirtualAccountsDeepLinkHandler.kt new file mode 100644 index 0000000000..62350c86f4 --- /dev/null +++ b/features/virtual-accounts/onboarding/api/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/deeplink/OnboardVirtualAccountsDeepLinkHandler.kt @@ -0,0 +1,10 @@ +package com.tangem.features.virtualaccount.onboarding.deeplink + +import android.net.Uri + +interface OnboardVirtualAccountsDeepLinkHandler { + + interface Factory { + fun create(uri: Uri): OnboardVirtualAccountsDeepLinkHandler + } +} \ No newline at end of file diff --git a/features/virtual-accounts/onboarding/impl/build.gradle.kts b/features/virtual-accounts/onboarding/impl/build.gradle.kts index b187abb29a..8ea2dc7f4d 100644 --- a/features/virtual-accounts/onboarding/impl/build.gradle.kts +++ b/features/virtual-accounts/onboarding/impl/build.gradle.kts @@ -11,11 +11,23 @@ android { } dependencies { + /** Core */ + implementation(projects.core.decompose) + implementation(projects.core.error) + implementation(projects.core.ui) + implementation(projects.core.utils) + + /** Common */ + implementation(projects.common.routing) + implementation(projects.common.ui) + /** Api */ implementation(projects.features.virtualAccounts.onboarding.api) - /** Core modules */ - implementation(projects.core.configToggles) + /** Domain */ + implementation(projects.domain.common) + implementation(projects.domain.models) + implementation(projects.domain.visa) /** Compose */ implementation(deps.compose.foundation) @@ -27,4 +39,7 @@ dependencies { /** DI */ implementation(deps.hilt.android) kapt(deps.hilt.kapt) + + /** Other */ + implementation(deps.arrow.core) } \ No newline at end of file diff --git a/features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/component/DefaultVirtualAccountOnboardingComponent.kt b/features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/component/DefaultVirtualAccountOnboardingComponent.kt new file mode 100644 index 0000000000..253935756f --- /dev/null +++ b/features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/component/DefaultVirtualAccountOnboardingComponent.kt @@ -0,0 +1,35 @@ +package com.tangem.features.virtualaccount.onboarding.component + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.features.virtualaccount.onboarding.model.VirtualAccountOnboardingModel +import com.tangem.features.virtualaccount.onboarding.ui.VirtualAccountOnboardingScreen +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultVirtualAccountOnboardingComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted params: VirtualAccountOnboardingComponent.Params, +) : VirtualAccountOnboardingComponent, AppComponentContext by appComponentContext { + + private val model: VirtualAccountOnboardingModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.uiState.collectAsStateWithLifecycle() + VirtualAccountOnboardingScreen(modifier = modifier, state = state) + } + + @AssistedFactory + interface Factory : VirtualAccountOnboardingComponent.Factory { + override fun create( + context: AppComponentContext, + params: VirtualAccountOnboardingComponent.Params, + ): DefaultVirtualAccountOnboardingComponent + } +} \ No newline at end of file diff --git a/features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/deeplink/DefaultOnboardVirtualAccountsDeepLinkHandler.kt b/features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/deeplink/DefaultOnboardVirtualAccountsDeepLinkHandler.kt new file mode 100644 index 0000000000..87dcb0729b --- /dev/null +++ b/features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/deeplink/DefaultOnboardVirtualAccountsDeepLinkHandler.kt @@ -0,0 +1,35 @@ +package com.tangem.features.virtualaccount.onboarding.deeplink + +import android.net.Uri +import com.tangem.common.routing.AppRoute +import com.tangem.common.routing.AppRouter +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.utils.logging.TangemLogger +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +internal class DefaultOnboardVirtualAccountsDeepLinkHandler @AssistedInject constructor( + @Assisted uri: Uri, + appRouter: AppRouter, + userWalletsListRepository: UserWalletsListRepository, +) : OnboardVirtualAccountsDeepLinkHandler { + + init { + val userWalletId = userWalletsListRepository.selectedUserWallet.value?.walletId + if (userWalletId == null) { + TangemLogger.e("Can not open virtual account onboarding deeplink: no selected wallet") + } else { + val mode = AppRoute.VirtualAccountOnboarding.Mode.Deeplink( + userWalletId = userWalletId, + deeplink = uri.toString(), + ) + appRouter.push(AppRoute.VirtualAccountOnboarding(mode)) + } + } + + @AssistedFactory + interface Factory : OnboardVirtualAccountsDeepLinkHandler.Factory { + override fun create(uri: Uri): DefaultOnboardVirtualAccountsDeepLinkHandler + } +} \ No newline at end of file diff --git a/features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/di/VirtualAccountOnboardingFeatureModule.kt b/features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/di/VirtualAccountOnboardingFeatureModule.kt new file mode 100644 index 0000000000..3d1c743658 --- /dev/null +++ b/features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/di/VirtualAccountOnboardingFeatureModule.kt @@ -0,0 +1,25 @@ +package com.tangem.features.virtualaccount.onboarding.di + +import com.tangem.features.virtualaccount.onboarding.component.DefaultVirtualAccountOnboardingComponent +import com.tangem.features.virtualaccount.onboarding.component.VirtualAccountOnboardingComponent +import com.tangem.features.virtualaccount.onboarding.deeplink.DefaultOnboardVirtualAccountsDeepLinkHandler +import com.tangem.features.virtualaccount.onboarding.deeplink.OnboardVirtualAccountsDeepLinkHandler +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal interface VirtualAccountOnboardingFeatureModule { + + @Binds + fun bindFactory(impl: DefaultVirtualAccountOnboardingComponent.Factory): VirtualAccountOnboardingComponent.Factory + + @Binds + @Singleton + fun bindOnboardVirtualAccountsDeepLinkHandlerFactory( + impl: DefaultOnboardVirtualAccountsDeepLinkHandler.Factory, + ): OnboardVirtualAccountsDeepLinkHandler.Factory +} \ No newline at end of file diff --git a/features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/di/VirtualAccountOnboardingModelsModule.kt b/features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/di/VirtualAccountOnboardingModelsModule.kt new file mode 100644 index 0000000000..e14040c3ea --- /dev/null +++ b/features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/di/VirtualAccountOnboardingModelsModule.kt @@ -0,0 +1,20 @@ +package com.tangem.features.virtualaccount.onboarding.di + +import com.tangem.core.decompose.di.ModelComponent +import com.tangem.core.decompose.model.Model +import com.tangem.features.virtualaccount.onboarding.model.VirtualAccountOnboardingModel +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@Module +@InstallIn(ModelComponent::class) +internal interface VirtualAccountOnboardingModelsModule { + + @Binds + @IntoMap + @ClassKey(VirtualAccountOnboardingModel::class) + fun bindVirtualAccountOnboardingModel(model: VirtualAccountOnboardingModel): Model +} \ No newline at end of file diff --git a/features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/model/VirtualAccountOnboardingModel.kt b/features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/model/VirtualAccountOnboardingModel.kt new file mode 100644 index 0000000000..9c30c6911e --- /dev/null +++ b/features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/model/VirtualAccountOnboardingModel.kt @@ -0,0 +1,96 @@ +package com.tangem.features.virtualaccount.onboarding.model + +import androidx.compose.runtime.Stable +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.navigation.Router +import com.tangem.domain.pay.repository.OnboardingRepository +import com.tangem.features.virtualaccount.onboarding.component.VirtualAccountOnboardingComponent +import com.tangem.features.virtualaccount.onboarding.ui.VirtualAccountOnboardingUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@Stable +@ModelScoped +internal class VirtualAccountOnboardingModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val router: Router, + private val onboardingRepository: OnboardingRepository, +) : Model() { + + private val params = paramsContainer.require() + + val uiState: StateFlow + field = MutableStateFlow(VirtualAccountOnboardingUM.Loading(onBack = ::back)) + + init { + when (params) { + is VirtualAccountOnboardingComponent.Params.Deeplink -> validateDeeplinkAndShow(params.deeplink) + is VirtualAccountOnboardingComponent.Params.FromMain, + is VirtualAccountOnboardingComponent.Params.FromDetailsScreen, + -> showOnboarding() + } + } + + private fun validateDeeplinkAndShow(deeplink: String) { + modelScope.launch { + onboardingRepository.validateDeeplink(deeplink) + .onRight { isValid -> if (isValid) showOnboarding() else back() } + .onLeft { back() } + } + } + + private fun showOnboarding() { + uiState.update { + VirtualAccountOnboardingUM.Content( + onBack = ::back, + isLoading = false, + onGetCardClick = ::onGetCardClick, + onTermsClick = ::onTermsClick, + onPrivacyClick = ::onPrivacyClick, + ) + } + } + + private fun onTermsClick() { + // TODO([REDACTED_TASK_KEY]): open the provider Terms of Use link. + } + + private fun onPrivacyClick() { + // TODO([REDACTED_TASK_KEY]): open the provider Privacy Policy link. + } + + private fun onGetCardClick() { + modelScope.launch { + setLoading(isLoading = true) + delay(STUB_GET_CARD_DELAY_MS) + // TODO: create order and sign challenge [REDACTED_JIRA] + setLoading(isLoading = false) + } + } + + private fun setLoading(isLoading: Boolean) { + uiState.update { state -> + when (state) { + is VirtualAccountOnboardingUM.Content -> state.copy(isLoading = isLoading) + is VirtualAccountOnboardingUM.Loading -> state + } + } + } + + private fun back() { + router.pop() + } + + private companion object { + // TODO([REDACTED_TASK_KEY]): remove the stub delay once create-order + sign-challenge is implemented. + const val STUB_GET_CARD_DELAY_MS = 3000L + } +} \ No newline at end of file diff --git a/features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/ui/VirtualAccountOnboardingScreen.kt b/features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/ui/VirtualAccountOnboardingScreen.kt new file mode 100644 index 0000000000..537d302e29 --- /dev/null +++ b/features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/ui/VirtualAccountOnboardingScreen.kt @@ -0,0 +1,213 @@ +package com.tangem.features.virtualaccount.onboarding.ui + +import android.content.res.Configuration +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +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.graphics.Brush +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withLink +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds2.button.Close +import com.tangem.core.ui.ds2.button.TangemButton +import com.tangem.core.ui.extensions.annotatedReference +import com.tangem.core.ui.extensions.appendColored +import com.tangem.core.ui.extensions.resolveAnnotatedReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.features.virtualaccount.onboarding.impl.R + +private const val GRADIENT_TRANSPARENT_STOP = 0.45f +private const val GRADIENT_OPAQUE_STOP = 0.72f + +private const val TERMS_LINK_TAG = "VA_TERMS" +private const val PRIVACY_LINK_TAG = "VA_PRIVACY" + +@Composable +internal fun VirtualAccountOnboardingScreen(state: VirtualAccountOnboardingUM, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .fillMaxSize() + .background(TangemTheme.colors3.bg.primary), + ) { + Image( + painter = painterResource(id = R.drawable.bg_virtual_account_onboarding), + contentDescription = null, + contentScale = ContentScale.Crop, + alignment = Alignment.TopCenter, + modifier = Modifier.fillMaxSize(), + ) + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colorStops = arrayOf( + 0f to TangemTheme.colors3.bg.primary.copy(alpha = 0f), + GRADIENT_TRANSPARENT_STOP to TangemTheme.colors3.bg.primary.copy(alpha = 0f), + GRADIENT_OPAQUE_STOP to TangemTheme.colors3.bg.primary, + 1f to TangemTheme.colors3.bg.primary, + ), + ), + ), + ) + + when (state) { + is VirtualAccountOnboardingUM.Loading -> Loading(modifier = Modifier.fillMaxSize()) + is VirtualAccountOnboardingUM.Content -> Content(state = state) + } + + TangemButton.Close( + modifier = Modifier + .align(Alignment.TopEnd) + .statusBarsPadding() + .padding(top = 4.dp, end = 16.dp), + onClick = state.onBack, + ) + } +} + +@Composable +private fun Loading(modifier: Modifier = Modifier) { + Box(modifier = modifier, contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = TangemTheme.colors3.icon.primary) + } +} + +@Composable +private fun Content(state: VirtualAccountOnboardingUM.Content, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxSize() + .systemBarsPadding(), + ) { + Spacer(modifier = Modifier.weight(1f)) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = "Send USD from your bank. Receive USDC", + style = TangemTheme.typography3.heading.medium, + color = TangemTheme.colors3.text.primary, + ) + Text( + text = "A dedicated account with US banking details — no deposit or maintenance fees", + style = TangemTheme.typography3.subheading.medium, + color = TangemTheme.colors3.text.secondary, + ) + } + + TermsCard( + modifier = Modifier.padding(top = 24.dp, start = 8.dp, end = 8.dp), + state = state, + ) + } +} + +@Composable +private fun TermsCard(state: VirtualAccountOnboardingUM.Content, modifier: Modifier = Modifier) { + val shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp, bottomStart = 28.dp, bottomEnd = 28.dp) + Column( + modifier = modifier + .fillMaxWidth() + .clip(shape) + .background(TangemTheme.colors3.bg.opaque.primary) + .border(width = 1.dp, color = TangemTheme.colors3.border.secondary, shape = shape), + ) { + Text( + modifier = Modifier.padding(top = 12.dp, start = 16.dp, end = 16.dp), + text = buildTermsAndPolicy( + onTermsClick = state.onTermsClick, + onPrivacyClick = state.onPrivacyClick, + ).resolveAnnotatedReference(), + style = TangemTheme.typography3.caption.medium, + color = TangemTheme.colors3.text.secondary, + textAlign = TextAlign.Center, + ) + TangemButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 12.dp), + variant = TangemButton.Variant.Primary, + size = TangemButton.Size.X12, + text = stringReference("Open account"), + iconEnd = TangemIconUM.Icon(R.drawable.ic_tangem_24), + isLoading = state.isLoading, + onClick = state.onGetCardClick, + ) + } +} + +@Composable +private fun buildTermsAndPolicy(onTermsClick: () -> Unit, onPrivacyClick: () -> Unit) = annotatedReference { + val linkColor = TangemTheme.colors3.text.primary + append("By using service, you agree with provider ") + withLink( + link = LinkAnnotation.Clickable( + tag = TERMS_LINK_TAG, + linkInteractionListener = { onTermsClick() }, + ), + block = { appendColored(text = "Terms of Use", color = linkColor) }, + ) + append(" and ") + withLink( + link = LinkAnnotation.Clickable( + tag = PRIVACY_LINK_TAG, + linkInteractionListener = { onPrivacyClick() }, + ), + block = { appendColored(text = "Privacy Policy", color = linkColor) }, + ) +} + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun VirtualAccountOnboardingScreenPreview( + @PreviewParameter(VirtualAccountOnboardingStateProvider::class) + state: VirtualAccountOnboardingUM, +) { + TangemThemePreviewRedesign { + VirtualAccountOnboardingScreen(state = state, modifier = Modifier.fillMaxSize()) + } +} + +private class VirtualAccountOnboardingStateProvider : + CollectionPreviewParameterProvider( + listOf( + VirtualAccountOnboardingUM.Loading(onBack = {}), + VirtualAccountOnboardingUM.Content( + onBack = {}, + isLoading = false, + onGetCardClick = {}, + onTermsClick = {}, + onPrivacyClick = {}, + ), + VirtualAccountOnboardingUM.Content( + onBack = {}, + isLoading = true, + onGetCardClick = {}, + onTermsClick = {}, + onPrivacyClick = {}, + ), + ), + ) \ No newline at end of file diff --git a/features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/ui/VirtualAccountOnboardingUM.kt b/features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/ui/VirtualAccountOnboardingUM.kt new file mode 100644 index 0000000000..c5ab5b0a33 --- /dev/null +++ b/features/virtual-accounts/onboarding/impl/src/main/kotlin/com/tangem/features/virtualaccount/onboarding/ui/VirtualAccountOnboardingUM.kt @@ -0,0 +1,22 @@ +package com.tangem.features.virtualaccount.onboarding.ui + +import androidx.compose.runtime.Immutable + +/** + * UI model for the Virtual Account onboarding screen. + */ +@Immutable +internal sealed class VirtualAccountOnboardingUM { + + abstract val onBack: () -> Unit + + data class Loading(override val onBack: () -> Unit) : VirtualAccountOnboardingUM() + + data class Content( + override val onBack: () -> Unit, + val isLoading: Boolean, + val onGetCardClick: () -> Unit, + val onTermsClick: () -> Unit, + val onPrivacyClick: () -> Unit, + ) : VirtualAccountOnboardingUM() +} \ No newline at end of file diff --git a/features/virtual-accounts/onboarding/impl/src/main/res/drawable/bg_virtual_account_onboarding.webp b/features/virtual-accounts/onboarding/impl/src/main/res/drawable/bg_virtual_account_onboarding.webp new file mode 100644 index 0000000000000000000000000000000000000000..40030c0fd99abb4a820b1cc12414d0d2a6e0ed8e GIT binary patch literal 53770 zcmbSSRaX?;0v(2ip&N-Ax;uvMlrHHH7#c)6BpiC^?oR0tP(T`_yFpO8Q>5$q8}7q- zI!|Yzj$XZlk{X=hTM~ncvF18|YTCO6+5oX+Yn9~pCXUCipYGa8Fl=n={baaK zz9B9+RgxM1-6$YIn}-Haqx9FCstj%)01%nt*X|VhV*HFUj@>)ww_-2ISkXw}gX6!> zu@8Rltneqk`EIhkkJf#aU7h$^kOc7JFhLZpZZ@Khg%C1Yl9-B4GaUs zSbz4@8;At&g!@BGC?6j`g4~(?gY6utr1&{Y4z(+3H0guctVChkJ>%qMI1i=_S0c7SzR*HxGx{3rNt8~U)4UWu~ozQ=|vum(6??HQB zq+G7Bj~(-+66~7x%8RK5Cqfh2bz}~o&J@1UYREB|T#DEYkg{x$5FYJ0nG;W^kCZ2! zy!`QITEdK$P}&D0QF9`j#l0NER#71JbjL`>dk!YYTB4tH7UAm2QJhon>;_NAGrG>^ zCp4}I%Ts1p&v8Y5|AzyGAokudZk5MYRPkEd2Mp1Z_BKYNQo(Q7k(}KrIfVsjH7cda z7=N$9?3RQNCh;9zg`Sy*qlQkSBPro5Zr$>l{8WKs1(QWYt&^*cg&SAkgRsGG!Q7{K z(nQm05T_LG^#jv3T#>fjeUZc{GSb6e7VP;+s0gBW_?RH_P>6^fmmgtp7Z`shIap2D4u<5+SX@*pxONMu-$k;bhQ_TDED@n zcP)_wAViVg=l8I@FSnWG8efzy%C#$Lhm;E~GPUZ_X96LlQYPfYBGYLr+<-vZIPBF( z1I17S!fQ)GE`+MlCAJ}M`snl0C8M>wEHT=ezPc!ip&EVixBDjVg6*Gmv*Vr_9I%3G z$xzDZ=+KfsWda6au-($gsZ9D!cP5MrziH>)5_=2RJ(L*BcT^Qn^D^X882$=)w z_I(q8EA6gnXeKVEf^aX=lLFtuVkUdv)?Fd$ojNRL>EBoiCGqgiJPr3wzu?ae%?GP` za<2w^lgG{c=3Glmru|s~nT+9xQ~9AnEWUsewWEa+G9d9YCimf&4K+&-LilXPxH-9Z z_cE}N{F_YuzD5QNSF4x^O;iY8X;nu}{*mkoKc9*QsgVKvcl%eJduyRJAD5?}Bwh5B zXo;=mQ{*R~Ylh+BLQp$x(i%kagAB5xiqB--FQ~G!pic4S?K-SMo9ckYa3q8ol{|(% zBAj2MC%>1DcAwELKeeb0%~QA+aw2Wffdd?gRz;9h~B8+sxguMx?xh(otklZ&0n zIIBP;aHTDe@Y`gFz9vZWQc(EzGe|axMlQHJ!H5jk?bFnm8?-_kI6topO23ROdWvin zKc_R3ID^_3kMIzCuzmq5m0g9*C*kH?eNl(QP(O% z7&d3J>7G=+YjS^(Gp!^>DV*c=kpI~|i^Uq=SM6VLA#tg=bn~-wM=md{6bDJXPFLmN zdJKoO5F2kpLmY((ypH|e*mQ1&IBXd+i;LJt4|NRd>o`*g&oR@oTs6W8m(ntJaOsa= zzk2ATh;&HcFAmY*Z;Zh1jj2^?`il2a=zR(Yaiqk#leq*1yrcCb6wS2f?^(qEEaAK? z<0MixuTZV_>Y0=#M+Ny-VD_Me>Sy}{Q0I>@}UZ&A2__xjA2rm{jS8y?~RmXmBN%3w7vyHP$p@czdvqlW-gm! zCn^@$nJatmY}V3k?4t;eRxHhQfVC+H+HgmFMd@+yu>Om*!25NPiYlA@su^v17+0wqvw_Zus8&mo`s^CHFgiLZmC` z0r#0cN$%A$=6|ECHDWeNYv|o-`+)e7hR8gvDTm2%=-@;USfrRQ3yWgD8|sX%!~W4Zd)G(CyK<@4qBuG6{=;t!WnvB%fhUl*0BH1 zmE7;!Q%E^bHl6!0l}ja`YZ4~9ro=LWMBhJL%4{ShL)s(ntY&3r%Vj`T732b1)#Td& z;Mkj0EkhJyTfIL`JzlC8x4i)-5S8D|A{C?L0g@ZyAh*0u!e{O8L~(9(L^PBAG=+Zy zjgPi}t2leYB2xZbnLx;Wic?cs!#3+6PmiyVatJ|{2kwj3HK}(dz~b`K`c0F>?Ug>+ zx02X1_B-E#ko<%oA3nZ$Tr%_{Fj7rPb`hGWh%m}Bzl+yv-WzKmD+n^i(Vq0B;6oKh z*%8)D;raxXrp6&G^|xaK*}8b;I~7=`9tZ9pDApz1;Kp9!gfM4Cjc|qN*+f!kzIuAZ zewRLul7!Tj1jloUT6-vqSO%+Re{tbIhGhz+VkR%T5v6ivpMl>b3>V0Ft1VoV6@x}B zLIxMJ{YpNBvM2y9u88P;%~dnYefHgwRi(LoC$is$w<_@ijG9Y`Vi`k{w(<^xAtq9qNgi5{c=cG=YT8Wj$syc-@HQT zQr6p&YXLddR6kN{9F+v;Oe|z`nrZ!%- zsH6TX!=pmM1+f?WJQk4-@dZQGIFF^FvAzcRLL$DPmV?jBKV;-OLpErnM$Ft}!c}?H3?% zm(A8?ytiqbfE{UY5bd`Q$tZ-cD&MCpbVhwn3f7QNd2u33m<9T9Q$>}CG5eNjg617S zLs4xbI>gAwsRL1iUVhFRM?c)PHkdM96EWIj0&80Ui(UGKa=55feL_#73aSpbJqB5X zAbHEn#-3QZm5S`tF&0#i;1Y>17dJ|ph#drf;qpXyvY`3#7d>kBmU39t4`BSIp=xrt zmobK_I|m9_B;b1=9B3AK83vGkL%f#l>mY$9-t(hlDL>|eJSOi(KG)RxZ?eT(yw|g# zRs}51qv(R>1X7j;KEmc;-akaWMn_~9YSLs*g2gnID1==SmBPAUwJ_~iH2wKyw&xu0 zcj?QJxb6}i=M$v8MQ;=HNeJE?74f4SQ<_-B-;Kn|EqnMtfznPU?s_0~3DtJB(BE?N^ma!@ZMlf4{tHA*(4wMl=iV7_ zon*D1B|RxHZ|xrf+Y0;md#pZGp1H|{A$68+`#ykIK|nZ?=AHkdJEW>Fkq$@eR7z_bVdKhfb~WE}cCF%{)4BK-*reehJF1>sNF{D($3833( z%xprt$#GHdh8d3`>6g1ZqQ@1aRGB|kU)Z7brY^gQWDHmPUbSTD4_r#38s(MNe|G3$;uskBLVkT&EW|IE9pc(lJmK-KNHIz7=U=IJDRoK);))iMah9ys=GQ>* z>JTsEyZDcIkbsJW%M>c+fJT?NyWAf*nk9*X)A-mfP%e^y9Bn+!+q8<(Y- zEp6sgP{&4pP*d48JEB|%1?)tUcXIwTw3~%ui)~-sd-c-;R_yxg`>_iB)sO>?O!t)0 zG%5dag)2T87)rNyNHuKeE{c6Yo0yL;8Lj-kE|d!tV7@AQyHw`DYaEC zXf29~qZTS|()IF86>=5yUVExx`{QBfmzy>1X03x?mbqB}8A6)mFW^+;Bd*H*8);^? zVeV&_*c{ZFU~$XT6U`oS!!fC_(2$^6xRz`!O7z@39FLIXK$p}@5Jn|*vyFDajx@PF zi>ag@tE#v}5OS7J-#|>guihdLSwy@9)PD(tyX?oLG?7Af_=8dY48K<9JHcAUAdZYh zeuA_vk$HZ<9=a;PY0ssP+9KUlk1)0ICk7il-f8z_`uOZ^S02EW!ViCNWd|&%ffU~G z3brm5E)R6euCU@p9k*esSOQ5e`_lAWWF1J-Wxvjsz~D?lNJ6DE^4L~!kA@^Fp`n%$ zJh@Sq^ZnIs>?-!+U76SEjJxQkWW)vUS-?|#RvXbc;yyKOac?z;Ws`mkP{d8YC1Y)< zk7*>hn7ME?V>Kda@ynlb3;3r9XiwvDcf#G|Fd;Zeu3U^ZgBnxRSO&NU$XdJkRNDjy zU;h$I|LqA~!jFbL^Bm$G8Q>%mbRiDdM1D9R(WoIiJw{>VF4}x-OEGL-9{_X<{q|_-Ee-n0Fkvi&@T%d?&v5f{2`8=aJEh`>hp(-KF0#t^zA?$4 zPzbH3Hph37lJdR-_*)ZPLGP|dKGGy@AIGCJQLq)dopi53qn2YLj+3dPrR;qOw5r*| z?7z}}C$&wx;Xxtvb?_c=Lq=)E%@Sgb+Y*}RSbLIOCHFUc3~g=X3ZrCoL|r63iI5;dCF4ZuC=F7hg-i0LzS9uKj%(Nfuqrora!+yd?Cl~sndeHhfDED!T-=)zQ3+oEA{;uH5|iMH z5{AILEiM~ru1Y*R1g8rh%fqf_tOV8hH@`VFXVF2w-cW)>rB_#=wI;*Q&YvLdiDzyhQynQj%e1Y)Fbs6-idSgxSs)%_`XF zv+!vU&wQvaZ&7j#)>-HmpeW6H2o0RTllo{iDE&oOW^7i8Chbe0r-|Eq zB@XHtu+8ipjiu(CRnGsOF?+|xDi(x77xjB(SmM>?MNtm;1{$&?VnTZ*)%e@-d5!|;K+k?%{j07nb z+#LxN47dA&>YOy0b$vF2?&J*HlP=Jp_)ZhZ(jz zAczkRVg<1$yy-moW)lw1st>w3_N~wXcW7JUg0VxF{^)+O(57^P8ZBD>t-g=)hN<== z`%*A$zc5BeeFx@m$hjDl->d-Twhj#ZnUk%t+vC{Oqf&=us zzC7=ac=dlhKqIeszE#3#q$sCx9V;z7wM5}0v`D(1T>~yLVtwu14nDvDrMjez!6id%AsHFbNo8+qoIaw)3uxafxxGHb`E4zh5DI0m zxAMSc)Fc5oPm9+9C;w0}uDbnlEtnbLyft0wmfqB_loYdTA#2e(hO!R!{B@i13Tt^u0kf|!g>uIzKV?QR>8?Ak#%Jn2b|4M@JCvL!{_rOIOw zL?H)L|5YH0-#R{n&FG+Ev#I1Cf+FUSn5+Lb4y>XQkv>BU2}V4aQ^^xCioW)QxTB)w zpiFSKwAx)*l!Od(X35Xk*8mcYZdhdx%Lr?S%4kxr18R8V#N&S&kx_ z#mLmBEuDZ?Owo-#$^(g>QR+)xG3cdv9dglGM5(q@@2OnH-Y&3AIAFsFNnfDf=6&U^ zNPj`I*y4;mSn7U7&8PE=8S18on_6*Lz<7xScYu8B>J6qFA{j~2w^a$D%3J2DyodQ9+jxwTpCicbB;Ebn{ia{3$6Eo+oW zqKcGP{qLdv9&pR7IRjd7Zel>7S?$|kj`xHQBckbYHm^iL4Pq;D%M}`C!r$Na>-{>X z2YrEAC`igD<_M?{%aHJhX8?7j48Y;@E~QPFNxU*9@wBX}6FK=GH_6o}OzjEmS4Qr* z*nMV13X^8P7P`QyS1NsFGTE^Dd!P0!@j{zK}A_Yu%KfZ z`;*~VnfHM%tnP?*&qA!bE);7l7=vN}DHB}A?FAg1 zUhInhG^KoI@*IW-o(#pF;p8*hG{{^>rpq4|T8*$0GcCTV5V_(`2j+ioSO}3(4uhdL zw|HBra8qkDzxsLvSE=3vz!uRiVq*+MsSqV`tUE7dye1YC?#>~hAo8{vNTSxXSuCT1 z&O-}T<1ShPtJBWCz5B*h2fjv5l!r;=#E?TMuw%=9U-43vG;?CR{xR?*%)P997}Xx+ z*?nG(S)d?ryCmUleBnNBxk`gY)R1VF+J)KVHl`d#keZQI*iakn#B9>BE7+S2j70^j zs$cMYG(lcJ+!dGKzAAQfh)5p$+q&}mwZT3gV@Io9uMZ2?QgB%R$7P889^}A0rIyJr z?L-X=V1rBjnT*7&IrxkZx3igA0bfgW-tr3#crRMfaJyqt1ArQq*ru0=n+$}j`4`?u z8#qD&3Z~QxGW>$$?W84*Rg%|qXCN?zGQ%Rd}QM}3p=@wq-n2?0;}k?^o;|494P$V$|*6Iz|RPXE9;P{k07^^%xk z<>GJdLoQ(zq@$pZBhFT`hUe2c9b-@-mEJmdm)wLnze*Rsk7I6nyb(H3oLXj z!->~#Oa8XUi~(d16GYE^&ALS#ca@?u$=^$t9Y9Ba$PY>tDLkt#Ah6t{!q2ZrT-ONJ zCDaE`%@{hAMe^HBy7I~++VlAlglvtLK zEGYrM`Vzzqw~>8rML#|8nzoEMy9zp=;4(K&GHBsAqz0vRXoRu)g(nK5EzG>*PrP%p$AQWTNvZ5W? zEuGnB!+N9o=I4;9QorT{R8Xw8Q3q2*fb#|-nv~e2i$3)YIQ5*UqxRP{ZwKy2vGCu zf162MBjRH}Ce?{D&_c=l{kWCv{ct}pG+*42lhnw$SP`6#VyDz@C=S2>oIF4N&x-v0 zZW|duped_74Dl%Bl|!^Bjp6+GZpxbE8?8-EIAFcZEu*pAV|t=RO=X3~J8&CZFJnt( zLgW2xVEyBtgNl%n35N-+h-^Rpp`c)T(n%do;>Y7Bs>Ab}O8tn&uK3T9hY-;M;z16b z8GQr~fpml0mf!`dLb0FplhS;o0bKflcoP^L@>CdAJ7ORIQuD0wu0Y{d-XS~v@GQmi zXf^bYpNf|$+k-g=_xU~B#@+EMAp?b74=@$t>RHK&p^R1rwVxT1ak|c}At>KS)*>b0 zH|WI5^gRR8JVE6UklrjV2RD^|oguIIZSVg3*1JfvmLo%eR_B}0*6gT{6F%fA+qs2d z)PxnWUXYM=_BIe7GUi#sWxFALWwnDY(1c5vVab$zB4q8Pq?(gfbqRss?Z|2zWtofuxChlMgHY~`3 z3yeq1hk!EKbrmsswqSj6|DB?k%IYo_2=_@d51QTI{=wXM%-l>Q1LGH29xr^`*$wPC zciuMEfH0~fo{Nu{DamKXxzO_amn65zKfvm^CLvVnjkL4`le|b6I1&Jb}j% z61wgbX7z#egJ|129vPPV+b(yEd0+0KHwBV1Z!z>%BPKW3+T>>nKF90A=S8pGTeQW( zp%epfZuTTv{yfI~JbXai+-WLy3n5J3wmarHrA8?Gh91REtDlRVE6gJy2{ZHa8NEz9 z@x?LC_EADwZk#^~l8H0mo8&9GtNa(31`Da(*ZRdB*nI`sW~4O(zaJq2hCW40C|i+| zpOE`q4lgKVT7hG&F2(95SISZC6Xpy|iada+6nPl`n6NIK+RQfK||s7 zw!}D+aBu|hP;)g9#uK!*HY`^c;=&?PC4F<%7>l1zBEJTct+9c$&$2@ZswAghwqF-Q!8Nb{*3$o-%5W^SQK_t@rVYvjPKC$CY0!g1{&rXP#3{Zz2 zhp(BaXkLWb+0EQ=cQB(YT6NynbR30e7=&Q#!)jzHDaJS^*w}vVB<+au1@e$f;uRje zJUuKZnr0nWZBtPqhZ%jVT^$aMCtMQO!h6GP;6Zffb|JN?PKOrMd)Xdmh+jd@QL5~6 zpz&NP4*qgw<#*P&W`h}y6NXKyPGrBRvtxgtnYmeT8%R$czBLiC^gyp7v6D%9E?p5# z*S&l+JLEU5mZ#CK(eEKObl%w6*-OYiXO4`0ApG~(T428taGQqCi~Na1vf?Er8@oU$ z4lOy<+Wv4$w8p}Tx=)n8ewKS5CN~{eRxk`(>c{PIL}|vw`7GK$J01ugs51=yjZS;o zweLocR~I!@6OdCBkU1dlSm@q(0C0pHJM?E(F;3!~e`Q)PKs6ATV2fXqPEs;K4DPlrqhHTa52D>xLR3X?Y6U+l>>1W*eXnchi;gnm%*mS+Ry`o z!nGNCH%SM;tYN~h$`$549g~hUZ+QI#9lX!KQjPxiZrl06HwXc7`pQI&8u*+V9xWO{ z^cbOBqHv3u8*VR`XZLMIhLXEyFShdfsMrO*&`?rOG>6dtDnTSF`}aPsDpO!uxxO(X zaw`!Y`*47?t`fpV`d7l=)NsF8;3 zWot$tTv~p~Qv+J`4qogev)_7YN1NojU+Y+n315P4GwuleBr{7mxlUv$~!Db zm_#(i(Jxenq>fyB#RxtqDBE?TP~A3@jhMz+Dc3Hb=miA)9(k=>ml48Q35I4<`mn|7|!&XE#d}RABqg_V!X-Xaq|Hj z_=zj;cyS{pz6=xeDCoQNSga~Xe=Fp7)6*$590_9DfPMw>6(fkWB?+*G-J~Yd9%QUb zpA^2^sh+2nn4qyeiKvG#9Ck+qla-NdbFzp?X|;N6hWey;Z$!>1YBX-x*2P_8Z;W@hU>(KC z-M6nxm{9*Z*A9-p3d;09DPwCCz6nn4{P>hf2ok4rDjZoqSWV2AA;r>^U3vJfr(Tiz zWrrg7Y9t&eMSuQz>1zu8@Y7Fr%9qmh=eso55{%#pdvY_~SovWdZ6K1RXuP|ON&CPw z_uZ5DtUR=G;lr>6&=w|@x;L#vcrP1;lLm-FUT@;U;tb@7EjpS&kh?B1`kV_1GtnhD zFeh%Q*AsQ@e_iN|7z!D3&Le3p#wdO+{%sI3&ysubkCKZ^nvL5Gg(_HD#SyV?8}dN$F9n{hqx+W7GNzV3Ab0*7j3DV5a zxsN%|+K7NW-I{zqniJhF(HXx80`Y0dJW21=CNSZp3oPr)!s}ZtmSq89)5ASH18JTi zfw-#|7{OO0cg&}fJg>yr87BGP2UlWCj}3Qp(4#>|`j1lpF6`DTfaQ_xO{J)K6+WO);-7ENAwa9+~=5HLhN4eON zCjm^N0POASmGX~W_(h~aj--^z&wv6Iv6^z@G`Hd4&@3ehN}kjL%PF&CS*c_sRoIEV zRc9Q!-$U7lHN7V-q#f=h33-yt`CUz&=@f&eOg6FLUl4&v0vfq_$H#D{d(BmeMe0v4 zwO70&2u7UxH# zwB%#iUCd~v12r~6RxNv12o$uItta}ZhY%Rz|J;U2bkewgnKI^*lcl)3mAwMhkT6(C zzryFU?2UocRNR^#C54slNR3QoP8kJ+xzn^e~@9tgBh~WOZ4Y_Q( zHVfKP!4`jakp&mv<2~hJ%roQ}ukj-$SW8ND`aA>3KxuzGR`jKuZ@a)MSBPiC#asc4 zY0M-$ZnrH!PA&zkyRq1~E-r7WwN6QbFNIGf_b=Iq;P**5 zV1pQYob*FAcUxD5AqN6iaXig@F=bOG`-)vhlDCII4aCw9jN?nkH(x3HgZL0Jj4HS7 ze3Yt?|=BGV(c=bldbfI=j$dKz8-d|M>&9X+M@E&BJ zXej@eTg}z=a4do;`hgcDX|q-9i^?_VbdvRPBn0bbFcyKo&eJkb(r!tany!w$A{r@( z{GQ0}NpEm*u`nBD`$HWUhp5q$wg<(ndU3{9ncpN8XZZV)TOm!q zGH4Df;J1+M{VQcTfFvC^ZHzrx(^M3?~9X672f#=@fGjGj9*!XJ={5ianB^ zv5_XT(u=#%lpT77!2l&fW{gdvKEDG6Pyl6W-;hM>g{ksJJx*{7+cNhpYQi4n(RKmh z?Rb8_$1UmV$nfQ=YfLpmV4fqHtF*0Au^I9-XePsK9ahh3-TAy{hf zEU0=3Pb@Xu@u${TVy)mI()1Ze6BO@1ZVc;2U$2|3i*ep|VEOhBk|(A^C?E@0g2yld zk|J7d^c6=&m6pPvpuWOKK0t!ElEO=zsOn*(DfEuaC5{B$owN>(s*(}$(zXlt%Z2R7 zgt=%WCj#(&Ibw5_j-ptsy|HK4uIJE(v_8uxkY75D4<1yZ>m&U=#s>jCTWbFk`9cH& z37WgA6q{2`*iX@nT=xwHJ09$dUXmgH)pWGeF=j1O)jn!TTZq>uYr;$gq~EvnHcRV5 zfqj(v*+Z1j#zCq@C#{!OEF#Hx8SQTgxsH7Ajbs>&{y~xE8o$Rj5~Fv~ly+Uyu7s+Q z`_sn1=JT%1*7P~=Eg<`3BqMv5#az}6Iv-pPE-FrBCl8_)W3WF9B7~}me+dGAO~$cS z@0?72#$350J0AF55E5A~&O%Wsbb5i$dJXp7S*!B1<(BISbF;UmKCBB!x)44+%fKPy ztW#|o+#s+uqIcThA-Si)Tm6K{$LLg0-h%Qhjq#R}YdFoQjm|ACcv{~N@AT=Et`S2C z1^)?{P||5NF}ddCJYFFyQ$kH5aGExUc6;O`Bfmtd)K_9J=L;+vbyc~Q2Sh@*ObWm4 z*@xd!c0SsuhDykIhqG-;7umVr#=|MzO%J;v)pfFi_J+Uj9t6zCo3nc2x_xdC8yyBX zGz5e7zQ+%sIvZk6R*7K>)RKIKW`=7~V?=xg;}~p~BeC+hnIaOF?jORfA{LVa^JMt( z)RAJdgKoYayod&FWOZr$n=Qapsh*Bw?IPxvb}b&eUfJD?=Z`Doc@cejmEYKGG)}yx zzkHxRVvHqden(p@(5k`#iH+jfbd|ZqA>8S(SU%-&x=L;hAs7<3Z7mSgn;LPL4-Xr9 z@fXL0dAj!S>rJ3h;X}}o_b)6k?mTHME_o!me+1-BIEeeGduQ)=6Z?vxs9#C)0edUy zT!DqSgK>LX0XIA$Jcs+!Vw--sDE5qh`v=^PcBK!H`Mryg(WpZr+InBh*9+n(?LVEt z$g54uBNQKl8{7ma?pv?<_?piZZYx{H+d3UE`4OFsn(Bv>xiw(~;BKB{T zPFfS+ZdTaC?!BwXn@HG$jo zxPMgN&JEtLDBAz{L^XgJ+N)1u=rmr?XdChwMRA{?oHEe}3%pdw#!;u9YJB=j4D=|YCy z;IDl|XxN_ARnSPH#UFMS|IRc z*YDa2s}Lcj0oR$}QYpT>xV?f3xp|xsx;lj%B8cM#@J?eMyiojEk}}`Cfq0o4wR8BA z@7g~CF0k0kajg%QzD_5$$tws=iV-5EY@vhA2mghpubhcz%<177`wAQ}4j8z>7E;Z( zqMAIvsT8I<49+>$H%A!WDPYm*T2KQjrT1^{x;USUiU|%RLJlM|-FI&r>a*~byV=}t z;ee}P3p8TNkBRir##Z>XR?9rO;r^1W_T_G-4{`@YU>K3{Jm+z=bn|jV)S2SGfc>SD zxpa*nss@=tza7fx%f@W*)}>m%o=VWmNVAF_=wEAt?;ihAUc(q!^E z4vgaOAD9R7;SdWV?YbBGH-X~~LdiUrQ~@?oVJWk-%r!*Y_9)?#MU3u47)QH`6T~WX zV-|1U(opAr|JqBg#Ne4=7wH{0Zw&aef*`IvYpzg%lZ;gBWK$6EZj6qoWF4RSnk@f; zUAtL5%}u)#(x9QHHU(RZtnW-=E6fn^pjp1fb}4o9$5d2QSU1X3P!{DM%u&EkKt6_F z2bU5x(!Hm;69e*IW3I54?Ihn}cZ7F*nmpU04iDakS?H8?SBFjUPx*Y(LleG+u~C`# zq&b~mzHD||$>0ouiF4CbV?XplJ`hu2P|iP#BSE|c7SFfqSD(?Om;yl36yV~hV!N^b zTy3N|L9$`yV)zG}&Gg?{#~b@V2Lau`Psp{zV(BmJMRjs7x(3BgC)8G5&vM}e2>_R{ zWde+mE%q>us4J_PemCOwRCo1aN&NXCQry`2F~uR7q!NvvXIWt4)ZF~=&;LbFHzNP* zD{~m?;}rEh{=f-d&aYUuU~uSVV@0p|z5*)Yu0y{An@GoG4>5zlJIlsNCi?-IYchfmk@}M;1Us> zcW6GWVg^aPZqEaLuguj^eu-QZzmx7AM>DJhuB(i@0G1@DDn}C&+lw9KlQ#T=Y!goe zUy<&x(@jC#9J-a*VTB}I+)|3Lh>At1b~pLK=3u!P@`6T3stHiF(vTIkzy3PYJ zG<7Lst^MiUJ_Hwe4a6Orf#&)Nt62UKy2xed+VF^nv`*XdIItf3OZlT`lGWft6)Wf` zO=KjWyTf@AZ$^j~t=k7p9L_&I#I(8n=4rJIU${OY#f0wW34QHr!lt$0CETARUfp)w z$A;(RMObcC>h_&?linN@eS#nz2Ab*(y00t$sA!}T)(NY*Xf(6CKq-si_u3kv=Zhcn zusdA}L61=5wFo)Z`mfCHWELxAUwf_=UY)7+Q;7YqOdmK;(+ASFWw%c|NVH<7_BZhV~_;yF`QZ(m|O4@Kt8FlBdd0n1-X zX-7}@MX_GAPE(J=n>kuN=ffvPXFXldq28aj#YOtoEUo#)NuTkRe=7A4E%!Y<8Uh{u zI~oOs92LZ=eLy_NUimd#?YN0wdc|tv-$pUiI?5L1?`zP3-X;o{&wb-jjB0&X5E&=` zXMtQ2X^O_ETNYHv(iiH9F@e9=$wp`DCdukau9~>BrJNHzD%WW>9XKK@xR$?-G2ZHn%E`SVwN*8(}_@AV}e?1PGL z@%`bWW44(4RA=$!W!KrvXj?GdgW>cqEIy7tXNHgpVZ=g}L`@zoHzTJg2eFKD!Ed>J z-Aw1$ITjxIb;Tc6N-}`CHIm@^ZIZ3NSo!e};!u}Qj*;d=7xIRd$|yM$wnB$qu80KB zI&HfKL2y`V$iLMKfKG>l@{Hso2t+QE?;o%(G24z-P*)Ab&eU5smJ<=S2I}u8OOIY7 z5o2igHv){>qmcciNS}@Rd(HWep}w|@-{;v0n|e{#y_n}e8nUJ}IEv9j1o9EUDPCT# z;wYTG2Zoup=sC3NaFT>DexMHz-xO+!9pg@gab`2Z-*e96V;)VcsMa*KZRG+4@t$Y~ zmPNhIyFGc@V52PTU*xJvoWw|73PAb()1lz=kH9c79S)_7k9skGI^GVeKl*#)#h^cA zu^)j$-VThW|Es*HGXFO7QH{6DfPW)lulUAw6Vr~(d&r65YK~;IhqVzUz$9`I>S7LZ zzy4>MOBs1ewd|2Zq=MAnos0ZSShuzZ9#Gi7itTa{Th(Ru87nO zCQLnoIi#E!r!#kf5oI57mmh2%X(qX8j{dl0B0D5j=VWlqSn@^?0RiEA?!U;P+V#f4 zi7J$wLnzR~;(I8Srn$=xb&1={7h;=_?OhElIat7V_p~VhRH&teZk@LdMw}lE5B0ml zZoqrP$y*|{pB^Qkamfjoh`X}&Ce?o(NbB1sw;%Z<0r&GYQFM0E7Pj(oD;oJE*1{4( zseHyHhb|aOLv-!;qonBe_lf%TBJ@S!s>@OGvw`7a2cuUQbmUhHlr5Iyp0P(IXVS72 z@RMc`G#P9$5?DH-^mFK`&?0;o&#hu5fgd6szWitg;B7wk(jnH!cq_6hwz<5?$8 z=tC!!kIhr^pyujTUc*}B9(<&LL0e;KyXv1nN8(LAYJOj%dqYFiuX2O&OMvZqn;erE zM09iwZkMvUY~^KfFJy1}5AYyKN!E;48;e%UaMbT;il6pni>!f)Bki$%`|a>f7z?Pi zI@=SYAybjz!P{z4On&6z-m)O9?N01{68*cn)#;hShd|C>PBrHB`EDPMTFVmEk z5MOxBiLs~5{#mmlH33rmNbey20wmQ~TXzMZgWH&ExbpKD14mp@1-8kriE-RPne0CO8?a4okJkPyJJN8yxx3ZWUs1n~D497vM*JBkc9 zdGR^r#kcX^ix_>Go)$@v1vdAuR8+)J=z2_OMTGKPMh-6An+Y57{x_%f=qLLDqlf9= z1XkWOMJ{}U${O9>QD}hdS9Gh9YCIL~y&epzuLlpg&9P24? zyxkS?j~NpBWp)V)%N4B}Vx>U4QUkkwWQRv?Lo!6_5k;9lYXo2vx|waG%;CljD%9k1 zB<%aul*tA8z-Uv2c7F2oXWvj;bcRO_zt)lkzg`6*@-g5g=EoK`rth0=K>?Cq4gJ$b zXbMTLOq~XamA;~)o#mw*XWGXH$-iiSFF57UB!xD_@EF9AG!oMeC3#9SAuC)ky-);B z!9nwyQK2zPt_{*1ijw%rLkLsPX1ehIv3J&OQN3@pA7JPjdg!6Mk&dA|l}1`xy1Tm@ zq`Mml0qGWoMg$}ULFq2f{NBPjf1dA4*n8jCb3OZB>$6r$gn~odIn&>uzFzWOU^>9OW{=3&)g=Mg*j7;6YAzwwIHM&N#Z^ZZ{qq(8wIU zwkKla(vX}oURHj{wZ7Sfea)@57z#YwG&*;}o7rxP2T|iA_Q_D|BkX&=>0IJxUs)Mg zPyz=G)R!BP<|smA-GWg3$D%v4sPr8?{h_KbREtf=#t1D zkBg}R{Mnz})95cDX>+<%%(Kb^E&~^rcZzle=nT=zuON>2YT&HeYru?=ikR~o!#4HL ztmfgP?mt1}nK51e5Adu8zz-kw7RmM+%>-=A$j!O4`jl$ie_D==DJAsD1rfhSL-NG3 z?d)o{qDcKyPyooM+$!c2Pm)nQO@eF$5)~(f)Dg!si#lZ6gdHgdHJ4|Q#5Aq~22;;; z5~W=SriIrg3?3rQ4ue*|v&~a?h|YtVcz1Th{vas-tg4WBEf|p(v=(BK{7Mw&%A_nD zs{%eH8~XZ>+JX$fMC5QASI5~r8XZ7Pt?`-Xhr6Vyr>p4HQGZzS9m~3*5~D=MSAkmNEDuI-29m0`}7+;8-$;`3h*EM@vI3KDcB7OJ((&2{IY-kW%y`AT@@ z1Ab2LU*y6HTBv<`NI^V4{%)Z0}lB`@oen zBCme!pn6+IVeLs2;-?a5I%J|dgmr2=@>n2CPlnU{L=0dR-9LpCk6;@XzNO8JS04!C zPA!EGB<$>J{JXu8y)3FXcNoXf4;3sXsie&BG^+3UeG0ny4K?^Oq;qYgR#L7fqBcd5 z&_35wu_oD~aec>Jg*$$d@xyhQ%=W5j>^@9sXEfJV0W9ROz8Z62<@#Ct`5r5+zKw&z&h4-}3z6OphPb5@Ku};)LF~j zxL+pU?Ej1YRt5nzqS$UhB2rk1e_EXUu$7(h%_$dj$B2>Y%yxWP(cK#F=+5zK(h2qh$@#++eJORQDuPS76EbblXu zcqy%!>rRZQML8F=2j?Eo#I=iAl4Pq5*|)*T74!!BRguN?j1&`Wl$|W`|z@6_OVksByMOjD3sLY%`b{-s1TylSt zYLT?k?zU9K#mQw&LQ#D7l}-8$+gx>>V5fQ(N)m0UCQeKCz(ue@*qJbvoz4}IIoYAi znBL->;_0`SaX$V~Q)f;1#_C=SE4k~)<|F^I!ztW3)TT@%U_k?$gil~LCaV|{`)$@S z6^SeurXrG5g>Q)Ri|`{%0}^({Eis;3*(38KOQ9UapP6#M+Zt~Jv`X7Txuv;QjWj;nr)yw z=%OX)A1Omy3H-*R@auyr^LxYjp8N;A*issrHBlX36SI=jg7XKt>Z&#hZPXqyG_}sR zh5^lYmX|(vak|Bh% zlcoyZN}Vv$bu^bm8}eaPal!w&^0O=6bWt}UoDSelRV2Jd%3g2dkkh%-`QiApZ+jkTYgEZI^-2_>cH zV*C*O`h#ub8M=6|#% zdNL-qHS@v!4T55bDS>b*z8=ss&JW8T{g7mJUOxCbiH+)t5JqQFI2)S4P}K(bhwvMH zrwlCj6)6#{*adEQ6w&05V1L8wpG|fodq>xAC{vjZ8{nU4J2?3{MMQ+L*n? z<}gPohZ%WFIaYHXNyMuH#GM$-$BO31GWq^Np81gK>%hVVaEL&D@)ej6+VLzS#C*hr zjmEH~Tr8x9eGkzJmH42g$2VqfDd`0GioY$!S>UoXQf=iyNpo~6jHoj2y7f+l&?50@ z?vC{b>XBE!kgx-uv;=`7B>B1MxLZK?3q`3eexBadj@Mf5VZ&KGhW2-j9_q}i=@!Yc zkr@r`TM!Zllkje}Fb*oxx$(PR)U+wlU#JcUXivD(jDD8uBvrAjx7q{7)?t3%01=6Y z2v|k2r*x$c8sZ`-w!0)|GGuD)kd>}vom74yur=CnE?7gnm#i}_Vr(zoz}Y}M2o@xD z@jVLhlAR8vac#hTymq1MI)a_hKsA3Jtq(5hKJ|df5vZ}7Rx;Ccv_2g^P>kCouUyTa z9zjjOf*GI%5c;9Ew)LOujS{ni5D9?J=_C(TXiTRd*=NBmF@}<8WSMV6!~W>_2Cinq zyeoeU&;TsIo_q-_ZJ-9l3|x(`Z+p8^$|19qD9SXtVP=^_V|xHWZO+>+-dz^{U#|Z? zJPwi5<#rK^jToWf9LBB*PmYzQat$X_mbDIcT1)XMI$$rs*AcR{ueo%?DU$5kgW+!@ zn$ZN~O70TT}^5L!R=q|?l3j}&YJbV zt(WTA2wmkmhv*HFRiheHA+E8CatGX=mg!XLkSuq|Ga`!CUq0izsjBO^@ohitbb~&( zf!t9;=z?6DIQx@%qNn{MC{Ia@&*GG{5_Th~9qC1kcGgx=4fO=+lf(VK(>(6DrX;ct zf@yhh)HsGLk!)9~2-`w-mF+RG{zOnB3t{XyYzAMfHbx z!u_W{W@#meWaF>XiRm$Ywq2Z)CHLW8BnTZn(lORgEe__ocDOQ-&}?f33PEVdNs3+i z0<#nR&3`*9OKgemkc=Tek*-cHCFY}6mrH@x)JxiV_n5+>)6KQgtJCGgM-K;H$qkwE zTA1rc$Jk?ZQ8KL>k6O+bRQl&E6hr)?9Nb;FyKwwUjTl0MN}n`Y(RUE1ABM*`R~Fns z%cLUo|H4DPh<NIQr z1My>FN}=SLhr|a`pr4ppjmGR~FRaNtxBjuLa&KH6Ri$RoY}7fE%bbn08Kf)WXS@T| z&JqNaK8+F4;U~KQ?k;dZj?C3WHg7NuXSYcQJt#dS#lKO=2w42GiI=sK16$0!@e2g{a=9P2Y>*hnL~hEV*muiS zB}3*YV!cN#e>$oQMf?ie6(bbbVo!Cfh6f&Shq)u`et0GOcK(hr%Ly%SQqec#SrPK; z1Mi-PdGey%GnwbZO5n?tiocaZ0@YJhN)bFemp(ZWniD*F z@V>1EVgWIR_ksqp7=MSrh%ppMhoXrQW9~(wDn3Oih9=GGx4|5lq?zbmVov+xUZ>qS zy#OK3klDAxJ``WQQ^sTX97qQ-Zg}W8>lGs8VzUBX`97y8=$Gnddb!1ya8B^MQD+Fp zyK~LH1*3F+1Zn?7gehKHF<_naK6OEmO(sn$%U7|%9ZmZIp00$~B2D1Bu+*J-J%mVA!V z2XTNAM~%!zSTumJTOWrJ81+Tfg=z>Ep==>EzISOW6oZK)A0tJ5CO<237)d|Lx_vNU zmNqJp{P_W{8IZeFPQw6}q@XpSAZyUJz}! zxHz1ed$+=h^yKZ5j#PHD?5qG;&(BPpqtTy=kvQu^w~*>L@Tl9u z3C<2gK*vssWF5pC5)t+$oSJ^asD(tDa(u=sS%!uGi@33*OhoygkGj3D6$$=Z%m|^- z&oUb^V6+lPP}1blvnGCfPEezUDdNSuZHFzFkOKS-%s@P{Fbyg=3W_<R~=rZb8WtVDr@F|J5A3>vwBIo$bj9{V>>(u-Y+36Ggul_$DqU(&FR z>56$^4uB+bg^hiBsjGr8K&8ac611wavScPma!D&BQ&f(5gSD$Qyzz)+XT;Ecm^D@& zF5J!iVW4=8Pj*5AAbw{R>8AYn2+^l|q=;6(n?(PSQ`(vyMA3Z;^uv#%Jz@KCw0bJVh}9LK>(0`%;?0G+MnZ6ldAyNQ9!aJKx63uwn1N`R z4Anl6I}M=ith}?M0yfYu+m6#{{MnB~Zx7X=IFhv|B*B>iB9Z88F4m zuu)6C>mVirioj9c9u-PMeOUIb2(;XLZhE;e;S&@lL~2$ZP{R1+Auau7;SweGG|bJITBamQn7(c=kVd_CI*`KX~>(c=kVd_Ww_K7VA^LDe7Xa|7R>4 za)sKr$Smn6C&Uid@hP)G=t8G5neU(x4`(MVjA*^_uF2D6`7p^R zbeN8b93;P`(e#l?A?2Y{k01UY{&c~%BaespX`Z{M5*(WX-~J#Dc~BgLefEegF+G3q zEsu54uKSrX2%H%UV*XZ=On(`PPtJ;fkJ@ANp3v}7A1t%{3GOy;R5{)H=%gx3wWtmm z{ba)AGxxF1A3GU2`MU_4iyWTplib$^tQsw$9k{gI%=Zyf+fitElPizB=#!#+4N}w< z%R#**LTsSKSF5}%bO_mpZ$gCZ->i$1wNYgnBg_D0^q`RUt-yl~q6?Lg9B#R)5#oDN z>0BSp=#@8Z-U-_=knk@#(@CiDLnCBN+G8zd(ovZ0hvC08S$^GZecPsU&4_-g&lep@ zg-kGvsXtY7ux5^sffS3mGNy(z9;vXi4dZPmj1m@eSj__qsFztczF`U!s0h=se!($W zG1_ov0-6OCS;g7W?Cs(zTR8wB5}Zfu=ecXOLe!Sdy_-uq{=U1r%<`63q&Y!Vc(*% zOodkkcAg4McV(6Cc^d+Aw~=&}Y(lX>Kll}^$`unGrr3oRkeF1{1 z2Y`6&q)qK6fJU0*d5N7rrS0zR)1J6&L zkW+R*DdWn%c~_bQX~GSWh%NzpGMc04@T_Fl9=EwJ+cMIH-F@KUOV=+~rAsbz!r~aP z-bRmD@l*Z)a>sC38(my+^JHG6Xc8Wt&j|NWJ}x_%3^@XR=(auQ zKM11D9SHG-+hxnf6tanF(D567$a4rXGipR7hd&sMuuae=(-(a2f&C?+A4)$Tm>jmw z_vjE%sM zp!G9=Ki2EkS2oQ?TzZ9W7$D~ok=Wsw%M9D^%n+AcbpRrN9iPIQmE)x>lDYWE!?)p!aO3j9_nul!Pm zgi&h5Q3(c*PB#65UY4=AF`|3e%ikX3bha&IdKc$Y~(w#Kgz4j9{4d(Dvcm0YGoiw?wMU9yxg@i~1-tzfjo{2_qEsl+e}M z>a3{!3nme5p+d|HQb&bdbri~lJV zrX-Tt(*$ynl)414<4+KD8Y$vOSq5MSd)8%qwfFuR|87`q3ie{QB3E^I^PmqoxhRs) zh&%yQ4?}8ZsNFmc2b+=2t`#8+K?pGXcczs ze{YZ#FR2wJ?IK}Vh3Y{?or{Vc&}I%P)A$q`WU!xZ?YeA&5MEjK0>b7dn?^!N;1L+N zvplqZn&^#B4HO9>iC!Hv)E03CiwEEHGe;)-yXSMzrR;d+!_2wY#UTSeCJaSmUA7_X_ThG+l`g=D zH?{o9Z(J-Ah3i*~pIwO<11Q2vov4MEt=3z^lw09Ck@zu9$Tu9ku!e4lo}yWKVGGmO zfft&}>F|+~pO&Wx{NK~L-b`!tM(ny&BdJMA%hBj@KZ|%N!Zu#$U2=S^UZ9Q)T@6c!;OPx=2>s-y4M1`R5!6rl&-apXpIInMVk#iav zXIdu?(#^EWuMC*8UW!w-{{xBR`NgM>`X8e2|Z+u%rHb+-(%n}h0mWC8r{g4`xw%KkMh`QQz;w1v60&wK>H5-O0`sAruv2pS3E#oy9|kMG7+jI*l6+@eBps>?(-*5T(UV{9 zeDKGPwnJ>H^RR^=(l{uyJA+whtSWK+K2OYs-bthwa^qeaJRGXE~VVXme+I!|N=_5sUm-H6n%U3Sau%+0PZa1wL^xoAK`EU`ZQ;G0T zFHBLk^OV=&`VYF6uZ{8?2_#d1I!4;xp@zIkW+FaNMg@hlktpq*C%bYHoALXeVxw1A zM5aDJORVKgVfcZiGjZyI@S}%TdEz9M0`DG{IobZkK-U(9xLa4PoKpTd_T#B;hs)O= z&10dWSN$y%z5k5jHLlEsW3;L}yy&sVc2quK*YESjb|j*GXZy z=5Gdf4fk0vEZXa5ud z;TO^%+$nMmQ6TIXff$Z9n+svy1!%)m_qwj5VNB35#`bEX6{1kv7Hope#92Go?rLv{ zz5_-+XI#`ATE@#@1=8qVZd6U$bN-G=^IhTlDSULMiV6*Vrm=Z6i~<>z{Up4l>BKyiG})}d ztSm7RG4GGzJV}a2RC73g$!aCCW(vWPbn_iphM65@K=MB-CCtP=#|AylX<2q#EN zOcrwMLg(Hq2#^@gym zZtcAc2akr4B}p+0v1NjPhRJL(^Md%RpL`Le|Jc_595?NF!VXfs2Z!)NWeMcw;K52$ zA%@@xv_3BgY}ii0>uru@Bq{76=<9c$VlS}5_Pj^A&`w~Y_xh*Y_MHu31h}hW1FeM) zls2k|W`KVrSYnBKlvHET zTuUY$Jy{w;-RYP@kV%65P?wq`M_Q4Iy8}}mjiH_~Url~*wsJL(5EwsU;3PD0 zGWv8Rf(<6;<2sSLqfV%MXjS0vjq2xqk0A{}eb`eZB+jDU46u+N(Zr7mRTe7TXvjvN zZVG?jAkHcvM{9;BaKZXZYaRbc9e`S%!dUdL+?`rE4Bzp=t?@I4O=%f)WiM(#<^*#tBO7QW4-q4AVkjG-O8d!B)OcX&lQ zCt>dfo9I_+lb}hl8Jh$pjA3atxRwU-z=jjmyisZkd~i$fUU&SaDw=l!e7{s&v$!xf zadmq>G|kFTpmdR%fVr@@UkPPnZ(Zx(H*cHWQ6u)i-IuXnNc&Q+k7!2|Zgk#)Ka1nw ze%N2wlCk7OE|J*XDkL-94`q=Mitb}IQnGpv$E;ANML#-5+K@;LSkTWFu(Xda&yoNH zMX`rR-d08Y4CeSVFY`|tap9?}I^m!vHBQS?*r^=NV8`;y>^8bGiOumyx`Gu&_TlV$ z6EZGVuEhXwwWMEpZmog{7~4d!W}JsH>1p^zGVu7*RT?=xf1}(7%+4 z3|y8Z&)ot&kse-|9Zv4~?4E$jAg2UMhRYGs;&TPE;4FxZTWROASBrPc z{NDS8{&IVJftzKS?nrXiEyaCAR{9a&6@gmLpgJ9YeNjmhWkU~r=CBlruhFwr+~2`f zT4pgliE*PzVSvhRHbm2LwvkBE*qom}E*q~)IDJVwvCTs%XaamQjteK-AnM=7WwT}u zP&5|gOrCpyY`H%F2HQ0l%pE3nci-b*?ujk&UeA@7yQZfj%FSs4SUbxQnsI`}3@VMX zL}>LOK)noNe{72i@3UTUJ)f!fuF<2n4#(^{QLnyf)@C(d_L7&j6~jX=w6rjmB_N-< zQ7UyOs>0UAWTQg20iK#!6=E{ZDNks1aNFVC9aSD)i-?0gi;G2PwCiL*KNw5Lc}2%8 z#|nO!Pp3HDO7CPa)2fIlBA|J7E=p*`KcRV->nCmBR1+WgW~Y3*z57E&jC;+ew+WsBI}Z&-v8w^gJO;o!I=%7i}(Hyi6@E0f)QxD7qNAQXn?>@)eVNa*$du3{#U*k z+*xnT3Y&blOt4=GLsHOnsk1{ zBy<<3+?A_PVedxVC%@CZ7nZ4Ok3e%R0U}`Ir6eN)hRe`ErSac#5ZToj6^7U1)Wdpn z{uvY2=Rn=>q@MB4(;I6Xgv%NO0=Zej!I_w-)&eoM2`4{C<`3WE~!+S-#D)G*Bpps!MtquL%-^eIAc`$AG}_>cgN#A_{?C&r4uQS z;E`QdOU-?#p7mG2bs#^dEwv#jwxQBe^l}%3^}{HanXo?Vo#}XX$|SlL*hTNnZ;%;J zqT<%Cx%C&$pNFgtm0zq#pX%>Omo(o|P-Jpdw9rzpF^LC^QJ`n@NrlXXTeDG9N}U*i zW>R(4i+ot9Wn}LZ7JQzO+4H;pZ<=SR|3XC*#*UtdofIdNKQ4g|<+8@gcj++-?Zaf< zm=UFR3;)Wg2cEU!5SopU-`R2)PHtb`_vD9(tfM6716gmE+)`{# ztX4!Q-7PQOTdhh;+^Hggw$W``S|ogxzw%13P|bI76YH+NIJH{zSC-sfMQnjW)LsiW zW4jomF$RSvaQ2Tp6fHI(w6jy)AqKSX`K9=%s za|d(>KN+IKr|6(+i6dsD!QiRjnCUCdh2_1ZaK=0=8JoB!r~dZvygWQuI+VHy@uUi?gJcKZdudC zp=`gpR}xr~95JyrM4483<|$rcp3A%>e;5?9yO@!f*OReXut+4){SHw@iQ86t=6fte zAgwOKMd|%3;u;C$A9hz{An~d<91`m8Ae8lza;9FKi7d+RVK@ zcj!L}oIO5%-BgiSQc_$84eW_zMwoovFhl4$2b_+`m?16%+Ai+qB~^kfn5=p^G4=w~#~L*tQ9`hu3^oWFUAs*CYtRMycb!17O&CBG9B6 z%zx$g$J&4RL(#7*?Exu=CzZ=@jq|V(sK#|4E+i2=7;E9QjC;;*8&cCGD=5vGwtsOY zKQhm&L_**C(z(4!n=@!iLL)&B^oy2gMw~dYcdD_|uWtygNawx}F;ME{nWJ)*KSQLLpFRb(A!ZaKWsdA}Ng^8As5DT~U}< z*tc_G#K3n$f)&-6_Ln!fw{WM8zc&r6<&y| zZxP^{9_J4#>VVzJk)eY*B!8_gh?FcBCrFb*DeZL8Mpp8Lgm9+OR~-Z(GCh?1MaD3xZ!7WZ@h^J{wF!YPcQ8` zJ4ju-HH1R|V3#O#wETP+)PKC|l`$WPOnvqib~S&dE+4hNvkRr95Al4*w*+N6i~{(N zHJpBR4_Adm(pcrn32NV7sbyBYdp`)T7S7$9WC={TG{U|u{ls*`YFD2fH!4M?Ktlza zqH2hKZ^8FTiPpa~czQg_k4S{HA)s8VY-p&yH1xEG?pP?DRx$KuqvtUmD7vS(RIX0{ zFhTe`>HbR?Flbxq%ZeP*vJmFz7>0@8{gJwK3}9e%RPzF^q_O=8sCd?&S9L2-!)Cvg z31yT@aYxX}7{pxu$lerXJhb8qn7|3lL?uRo0a5!ZtZ4P0AZE03@PRD;d6?X`{COz` z=hO^n1nbNOtA|mrwaMi4*q)2H+qD;3Ge`2yh1JviPR0J>jRJBs5PwH84>D;bX@s{- zQZt}5s1J;3$t+A|R4|+CsG%R7AibEk14vyoRp7ve5jOHU5%g0NMba6m3B=isdvxGZ zJAC9lm4}eb{HE^3oHX>^CmJn%MGTD^hFebA6PJ-z?OW!P-wNY|hS**F5hm|?YW$1~ zzlNZ*FZRr}Jdymx36@NX?OrmhoqTe`>99>Igx?muiY@I3lM&8u+u0cM$7_5CO`s*E zY+N``6)%cu`Su7>p}(Ei3n~%(-xsmrAcAr0QOr>ieS{xGES@~{+Dk2AtxP-35uxG|{(u{0yhUWzGwa5TBA&4Q zcV~)9Q^~tw77QL8MIJD>Q*vXK_2G0l^*McqN6dHkH)>wUS&{IH$OPr57$;=maVewJ z$K7J+z#1Trs`{esN$BW`lv8@P=vIa|IxQ1VHuvJs3~|yiH=a~TG`pKo`V)KLF7?+8E$nRXRxJ&>3;!gfs>%;ZFX^JikjfpG z>j?R^Q0>DQS}E|4;bf7-9U&e;gE$Mbf;6G|F3#j{N`3k`7#lzW9DFJ7mZlEkf%Q%d zkBJhf&RUddl_JYusx{WPVWG_I62v)L^yE6}Zlo01pWkb?4^`xKpu2?)^pj?K~o~u6&pPw|o$-kje{UA^MR;gZvU*CUmHi z>Bbr(B9=RDzBrD|r~*#7OyP@`UwFTZOB5g2Sa+J97T!0{3%3ZKD0drAp`zf`p}2?dpd3*s@I~tK*1taXNegIZ596py>bMSQh7u;V#bhpwOU}{Uikd zjn(KJ@rJt=E7eAXRz`)rL(^x+Zln55;Des_5vI_U6efsTq z_2fj$FEJo2B`EhUeBZjFf9p$*!*uvu+7Z@LMu+FPHU7_Vp3UW;L(>Q~pdDQ|)heLp z-ZvFhvj4OZWrqoUKXu21N9yhFOs>H=j)31Oc!HWb*g{e?gjdl-)eHGBbGm`zGR*3= zxAA63`{#k;_#}U(YX5WH&r*!m*kQEyN)_lZkBPGVx%JIyd}@Y6b8g24kNJ6MsZqEE zuo&kM`HbDYp^m2r;V?lBLVzYlqK#RGOOdl9jYNc2Z00<8AO~{NZTCH8U1D~#{q%Zh zzi=}VH;y(_#ls#sQ)dEzhn!XJPidXeMBv`=1f}goaIG}p6}5Hk*b%ly2_svux;(;_ z;G#ZNi7yr|#45s)#N_|VH#v0D)w&@PcD&6a?$zmQMn=d-k`HTzu8{q1;LK_jl zm;$AGkX&~ESnnk)+d$9X^lSKUGvA!iPZFZ?)*=r{*28Se;Dp%RqVhj9D2D28BUIl{Av34$X0-ZkA{;n>++G@;M~11i_|)>{XGU2NrCE zYaoh0&sM75mAa1B&V_ph6vf9==;!msi2*%voTP25wfwpGC2Y418+qSA9?Q5T^e{=M z!u2=00ds4PP#K;dT-3ew4cE9f4Iw?}2-n7bG5AQ+94Z?V2EvSekaoNOPdrI&< z^oEbreiG{qsiCn@J8NAgL&N?E=Xb-)HQY(eQ10-Oc4{RI=IGo4HZKO~Wsv!kUb4xV z@cidp-lxf2!24Yg?L8ww-)=2ehw2xoqM(Re-;#u!LIA5Yi;NXo8}K*(>8Ap@C9Xp- zNN^=mlTMDqS|UFV5~^(g=Sn@k7UMDvmojQG#rwt0g}w;3tMglG>BlD;fEut^CynPV zcPg?2ibhiaH(0B`w#{59uVp)x5np8QO5WiQA!l7hC%&(*ID`nSb%qtJBjdA=+=qZA zemaDeRc;yWf?s^A#u+Xcf$$};#ENB5Mp7pVna9rz7YW1qN;}49fkc6~!0{E6a48$+ zXOX=e1$2J8H_3rF2|6|lDV=TS@U|sQTam27+jqvKZg4MZ z7e1+-pttE;Re8h5c0|o5zbLA#hU?st6cJ6DD209PF*h-`|INj6Uol;AuZuG3P|8Dh z_KHf(8|dMo%vXqF$>&$D-4-Df7|+mfC`2a^VIYpCOg^gt|6f@A3dqHlV7?$B(lWM0 zP6TnbN$z{KDU>;yKiP#UiZ)l}4XhBF|9}w2u~2B5NT_iwUR@qYhpqh5Z-pyEn~ld$ zu-_HDj8`|${F%rwPImDb?H2%O7k{sP#>w+6`BKEA++ec3lr~v&_DDEFEH7?^*^{O z+3IYOn&DZI=X)^o+AGmLA z*7~P9j)6?6J>RVxTE~A=6CfSGNTcvu(8Q9RmZiY+JoE;YUu0n1>_Af6C?yfkf}+?E!p68L zw7H~MH-DMkRyf=SxabLcx#oAr`W};8#DSkmWF8~_GzUnnQHJeZlQ!HLl%QCLnZaF$ z)Xv2&LUiXtQ%cYn+e9CrY^gU*8u7=nh7d>||Bwe7leSJ#6h<`Z`FZJh>uRar@saNW zEgn?^eUDx$HqxOa)-YkC(+}}qo0W87<|t|XoaUjU`g?0P7BRz^E0f^uq6k;%XbIX@ zi4}e){=PJ+JAwlC``nIQEkZlHHzr+&Hs%cn zuGEFUx6$||oORh^UK2_VsbE966_(?SMOoU^cJ9%NP&-ll`98X^TcI%Ca3vz-W?jPxVA`zrb`%emMA0KS1ZPYvU7 z{N6zxBhIX41S{0XjJU8K5f&v{^7+r$LLOm>T2LLUDqE5d zd>Nz@XFr@Ygh}gE6*QYwl`vcK9OL;B`P_j*iFqv83PwmBtTd;^FjCaP z-s+&cz+BYt6`s?WxabjdcTpP7ZWU1sInU}Bus~Lwql~tMVnE^=vy>Rr0~6#;q`{#G zu%y2%lJAF*SafGV-x;!&FjkIM$*+rd2zcQ>#xk z9|?6pk1F}?lAg!imFc1u*^yg_Q_x<)B(W8bN^l>I2x&4wuN?kYTywNE# z%ann`w&D3>I7-K3tI|J-&}S%IMvlCwFR|O=jGy!jbU2b`?Z#}aE7 zOjz{efJP+Gols&xB81uk3(6H3Y!WoL)Z(7O$6rA0$2pCy)VNN+0GjtpiyJJjx8UcM zQJNz_9v+lnDq2mL(W*mI+x; zKoV5Um6^p9{6uQgf{vXGcv?ddBP2mO0i^)C*q2`?x*uZslY9lgzwU!i;oQV7f`zl@ z7LhFs;;o?xMiK{u!ZFgwFa&Wt^xA!%i~1gHG6|17PmXaI8ENC(rF_VWu@w*90x*P6 zW)2q~@5ia10)(gqP$<$FXQjhv5aGc**&Pw=b(Hn#TLn)wjyrO*kMhQz69ue33 zoF`u58Syvm&RC!%c{yCf&4~MkX%8Hbe+)~cQVRjIg0Ka+ZGI#wcx4g)T5r8z{Uk25 zS4my{!&I7PejkIpKWlew9sv_Dtl1YBDs0yc0%In7HIyI(Aj3&&g&H4rae$x>P1Ntl zKX7u3CG-i7rL;8<36F)-5s>zqkaG{Lr_~k?Fqrn@R+8pL^yfiYNpCC>K7%5+<$v&Ns(5|$+L880h-d?n0eSF=c zJ-69Vs9_GxEk>_^uVip&j7C&Tg)wfEtRuzfix9f1f0(c7NZhW)1kw3e#3<1Hmh^&3 zqVE8ZRr7+zLM`&MK*V+x#A!MruKKFtz(2$eZJJ^jfOc#kJ)UWgd!!mRr~@{a?%Y^k zn0S$C1@?pP#C|U0AP8tYx-p~Cq^9=PqouU=R}F*a2;=omkRKN7Ttb-dv!nl0Dy;wE;ZiqLmH@;%H+E zxqoTQ7Fe?!o8TY=MlNdzBouTPV@Z*eel16Axxr(~8hJjN9QZshgwtl?+}2_9aoV5& zuQ=MdQM{4`X7l*#ejhubT&XE?0U86{@B3=PXA#Z)b&s7Eyc~0wwuiz5EMym@Z^CTc zG7@0~Zua*-ESe_* zIg~Dyu!2Ka4RTd+ydaTz2)fr~t4UrZJCg!HnM23vMMW2Fpu3DiFKtH=1e6YSn5+kg zxuOx)IIpu4Kmj3SE)}GMqyQk(9;iK#m0Aej=X2BNWnQ4;8IX@wk|g(3bjVmvXjp;ftEi>CxK_YmAy;NI6f479iSuxdoA3t476m{5sn31EY3YG z4hZo>UVSV?fuv)RqzSDA_j1gmK{QmrA@1Hs1qnDyA9it?g)*Kb1cb|JqK!cNOu&Oe zt31#ht58qtiiB7@9CUF5x)Q<;V_na4ipvbG@W5zFjY!5wBZcM>8K9@g+ByP90O`FN zm*C+M4hLkS`&Q9KuvTV$o_Y5Rq>gL!q65AYYdQ}@_CZEctrl4USvn5#bGvPU3C2+R zF*Q#Cwz*&`&r_>J7NvB(`IB~kRW*tT3NmE<(kQ*|;xd3x`lF6JUe|AqTKSP8CPo`j zDLRgFF_H04Xi>jXHKKxCV&VII1sr!ocTbaXdl!igSzV<`hUr4Se|Y~^;sxRRYT
o8#i|=PIKVXpk zY|zF$6viZVZJA5&SP*U4X$XmP7`M1WOx+AR!bl?5Mw$PZg}^Z_^_e4!A8|D zopg_v!jr-BxIL_|Sq8Z*y3Jx%ZFbb*FA!#KS&F^V#`ZDS(eRT#PR&khtW-h{O^bbq zP9)^))WP$uz;i#O6casHCplo=M0iMP=VSb&>mZ044J#5G#uJ8$TcRl3l48K2Z~Pb6 zJv`>r3qW+W#}$GM%=gMiA`qy1&eQ<%@VE(&lcC;l0tY*i0N9aN8?%^DKJu`@peK)7 zi$xZjs1b-I!P${677t+lsU$UX|EAsd)%XBF#FG#ba>yKL%n)Mj(Q-raE>}t|d`5-|zZ4~M z_(g-jy5R9K`2m_V01Ir6Xl`B>Z&wcQD z8!rA~SFJi0095bBAg*SjUh^HsSW@En zuZ=3s7SUM3@31Q3amD=4(?n6SJoktLeSRuz9&h)u-;E?4%;#{bXoVlcoJ50ApaMFs z4nltZ>%5}Z9;W@Y$^)4UP);-cSWy|uKuvXt`bj!KsZe*)&WK8270(`d=wj-sFG|P@ zJ1vaC8U3&^r(*&qW`LEJzYt6vgqbw>LZe(vZVDeEhdKsGfB0dS~>dGe0oq(V|H?{oZpaj0UB4w^N5T=BbmvQKpZ=G{*Vwto%DR<+#P!G6ND~e;dY%Av*-L#WBN;o9{Y(X zl4^)62)sU6GGJn-aJQ^qE2Lqa5B!7$tsn1U4!8+46i9#4?i+*g9us0M3#s)$Ua61fS19f4mw>c^eWoq(KHB^Y4+J%Ej3&@Y00|iAPV6gbs))c<{c51A5k4vzo0=ZZWNuyX-}~nn^T!m=UOx3(XPicbU8r^&@;p1 zP7p&B6*0>>42A1{f$09rbq2j3vnq9ft;I%C02#APw^;f3vdE73V;nkO+n=)yB%JUU+FG zh;Tl}Dx{II%+jo)vP~XIt|QY2gW>Z4itf*^Uv0#dxvo0Sp)o+1b=#_qJm@HK%>(tY zabh($fgcC|6W%0Z;1?XP+lU2$iorsP^!9?M;pP}o4YV9_*zW-UdI zhW>;Z{(0Xwf>9{mY2hG&!r0X<1_jCLn+uIu~u`=y7 zuz-F;zfMMTo(gbZc@Q)3SK))|KdHI!dDLhiIY@?r6Gx93^ay~W$-ERhz zI1l8qz{8fK12>M758nD)Vsu9dTIC~SAE|ssja;dRyJV)brHcUzpg=)*HmzT0M4#f? zK=(ld3pzWZLTYj=!RRRQF5lBYL5r)bfHamDu3MF9>o8 z41KbI?!=wKgswUQu%ehp%;~?Zlt^l04Kn;ZEbkr_R3l3L1&UoG1J6_?pq$cKb?pup z?IiIF{-E8X!)5?1G>sCVd@3ImpT=pc;9`Fi!zaW)P4W*I$WirA!SZw@IAT5d-d>E2 zf79*(4Tq^|3VwS(`U*6kNA+|4R6ZDNGtk}#nMI27NKi=Uej|cCJR{M;{W!Jd5OfIa zxm!{Rn3GHw3)O)^`W!}amU|2nA0I8**p9LG#aQt50n|cd*j1Z+Zo_~VYcqPnjk*V+ z5c57bj(d1?9D86qDP=<{!K4!Qe!?Y=4%d_Zfzx)Wc&z! z+3s5Na}BwBJ+Bmu*KiOqaMe=H@FP(A1n!Ux%tuep@k3^Ey8j^~7(*g!=biArPygIy zx|945!>+vWAyiEtJ6s>w;-S>6V9@oZ`OhnaQjiaaf`USJa#9ps-ST2ebB;#DU$r}N zU)Pj7q^Gr+DvJi8QjC9iMOpGQ^q6kH42iv|cKJD7+ujXdt>;X0DZf@XGT}!N;*IG3 zUD}xvZY?IRfFUfMeG~k*wUMGj2oYT_ik4^)BqX+;lAf>%p?N1Z*8GeWR%p|G!TS*8 zyh*^oi39y3LdP=&8BAeAwk$wU|3hRNKMpJsqdS5&3oND)afpBNy|@y8)9xp>+t}ym zAH9e9hOeodU_fmg#(+=(Sf<3>X@R^d)kGVg6(Tn>{=AVJF;ATkyhuBpf9xN$yCI*q z|6>$YvBIFyZZ&O(N0)HUlh>~9W6(8j(8pXakf&Q1_$y!<^bJ) z)K52@UqkFk@MvZrExe4j86*8uj1Y94iF{yxb^j98y9khqkJ%l2xlMEiy7-f-19TT6 zqLlEtvUx#Jq6OjnAh2A5^WE36O9>lGtE#f;dNxMAMH5F4nWUbf98^K`hF#)2-y#m|>;1d3U)l45P&bt=Uv|fTR2j$6dIqJ;7Da|7tTl`78mwR6n zRRl5rHkq+e5vGnaZq<&Ts_7w<8EKx~eirU)^fbZsymLKaW9uej#nK~A4W#XosPWv-|ATI*CuqH6~F%Xb%Lqea|bJwLHd~6NoHa7 z%0qvEYZFjKl1B=QJBLFjphW+C0Zgy`zdRu78UB!2GCA4yXqI@&BmZYck>XKjpfA3;xwnoh48iKmQ+X z`|t7B%Vf5ye!U0i{{PMY|M=^F|L)V*o#F)*#iY``PtuEb3-CX&k@49<@O_XyYdUXi z_&=zF*R*I1T(EWctzk8}5r65pauJl49&k71aC1?DTs3tCA;Gqsw!PU6> zI+jo)-nlFD+)bSWY9&1?N_fIkajro3XX##oV26;qNFTCK>ObhdO!akis3{m+ilLto zAGiEJQv1GJFe9Of9VqS&t3dY~gA}cl-2z!4Z3K`#8d1)Vy&UT~+NkKZMp~bgLPh!?tGa*3 z67a#G<3FSX-LIVNPZ)0%tpBKS|9R98*}u~M0m8LlqU^)|pKy5pPW#LS-LI8@>GSP> z7ViI1f8Eh#oVmgb*x&F!z`paru3%+B%7yL&V=>CAQ zn=!tjm&*TwRsJFVdy&|!m;9%K|HKJu%53@4g6>a#r0D-Ke}V2V(i_3jHoq7D11s9_ zuOI*H{K-|5=gfKFcGc>)Isvh;n@0wAem(hN69 zBJ7q2qrH*HAQLjM159X(a?^mVLtKJW@S9-`Of!0^s^EF^Vp{6er!GCfAoIMf6jl~`v7#u z;DUeX^gHms^1OrmZ}*@6bKm-o?EmfaCI7$p56BM@pZ9pH{`u`oUNyaZ^ZgGPA60z} ze?#(L#V_N(=|9qYfPWhPiT`8zudCn@DNT%*|UAnT{;kg(OYlpy@~?5a9Coxy8Q3C7hrRx;;MXAFMsi@~_> zoqBoY^9<5>+{Fn8h9AO^eY3}mf1ha%pmWF&fGrsp^z55On-#f>%Y(GA(vL8@Q6xKJ z3(Po)n8QbSz6Y}W4(c&V1(tsp=dyNZQ|<Rx|_iJUA#jAPTrR ze~Lk->A1T;@G>3~P+h)>6z6dCipwn5y0|20N}pkt^}v|Qwu=Ld+`Ai6{{57}uiwct zm;!5&U#GNZX^9tF+7t_aYKjv)gCxQKw9qWAmywN0VIpc%iTjdopgse01herqgKPi~Os@z@Ni|0adW39<58N62xb9GF7`k7L5C%`&?locY= zVQLg*n%V4E``HI`#yDY$pD@?6R-ObFGwC69_Q*$!z*kL-e^6dZ=BPvq8=~p*_yh#mn~=oG}+K4~N}2Z_#F{ z2ae?Yy1c5t!~)2QnV~mXGtjxuC1Y=v`e_r(aBsRxAd>{-v1Y7)_Uy2HlMzMiGsD8V5|P-{rv-}*#f;Ot1tuD1O7v>;$^1X2&^_yCgvfm*Vo zlCwcQPo4*BXrq`coF+im>x!Y##xw4?9Q=rqb zf0>H1$MQ7>XXRI`trcX}5eB^pJ^&dA3NBFJFNCsBSk;mT?S((CkpBqSJr zM>JGPP0^;6EQB`1_29tB{8mtxmL#n&`n@?K){lt%dvg9Zb1ZbT zhBIgst8a8-IXWuZt0VX^Kht<`y7`{{%o*9z@eXf=%pJ+#^#dl>QzHkoLeNN>`ngDV zAA=sv$IZReu|t9_J-O$(V}dE`tWMOlpO46 z9ohc{Xkq^+u#b&@87A4M{1^ji>MSTui@dTd!E+rY0<0>ignwsG-M{7@plr8tj`Du; zUE130%o^s~-I!@Y4SSKhXrR1P~M_ftX#?>(s_lUB>1i&7?T1tv&oD*8)>_2Gx0_oH}S? zdO3Kk%I=6GjwzkELZ5#_lN5DiT_tg#St^+uLpD2q`0nDlq=S{4tL8UaJtEUk_x`~r z|5Y`#imkH=U{~+0sJYyE49Z!@8^c|ZhMCf^jdxgU01z#wr)VT!{EUs--N({i6yHZ0 z%s>YOGB+vFUh0M|kZu)7T;kF8?yzB3eKG1!=x;#doe{qRnpf;Yt~{_x-$VI)Tn@li za&I~%f(#(!{&O?(_VGM1n2t)dT%3@^Tl137I?BUp+fyKH3?PLi$J$``F}-?O`z;zI z82H{er8!y|DD$(U3^Tsu0wf;613(XmaT$%RizN&s)@hhyF!LWJEFcy_fhHN($7L6q z1=5u$@yiUPEv4*g72*dQeRa|6W0}|PmtfDBu$6Gd?Fig`?n4DqgeCze@gG(#W#ACU!CAX9>2ftMwwRgc z2*j%uudpsm6Aoih7YGq0wl+aW1ehI=V>>gMwt0QN*P#`lll)^fpC`Z*`2YP)9Fi#! zpe>sN+$;=y3hQH+BzS1n{Hg{jm|GvaA5A9|CM~f+QeE)H1}@8W=7$i&H*>Ob@{)ZC zVsPL~*%+L>$+)-xh)Ch)JlvA&VAkwlk`=aj^!?}k_oN_IBc>bM^D6nn7khv3)Yq!D zZF&{*U`y$(h*7zes)+S!Jn)aft)WsUv!k*#nTvxADg$=6NGDrhTvm!>lvjY>bKvSW zhTyA3BhhxwAB+lBS@+|4SU&ns*{qJS(#ZJTCp6ucYZdo*exo0V zlE00FaSstqv_Z#38vHcTN~BCM6rTa=9RXg#JmN)S|1bZrvFbWI@KM`5b5buIdL1$+ z!1StNQ1s}MNIGa4*kW}l^2-KEMWY%k;^>!vs@3oJzp1ZqzBQS9^L($BXiQHk=pd8G z+2?eVX>;0xt5TJ>O2FzJoT%Y{RmZj^O^`(C^8hA&oy}OQ){DXot@_A$@I~gEX2N^( zD@a%DLn|91Nr}S{Y7dH6Xrz@802d;KASgcXVD&Xhy7#2&4#?c}$FT8e|AI)Ej?p}U zE;io5Pq?BvYyK~d!$Z467nOL5`+M)rc5(XjkQA3a`+-~b4V1px__b;UP8T&Svn&4y zONT>y22hXUomt&H?mcbZDp}eeh>@`gvb;-CGV+d=OgorHzsm?`D`eaU7+d!ad>Nyy zzP{Zq42Sd96XZpgC08y}r9DJ~f(VfW#cjz#*aZGJXi`&H7n4Oy}~m1{}$Z)hHf%A4be4Ly4^&An9{`^#BfYt@Gi zeeThRinn8PO|M!q{k~=1IX4Zb(6zPzfP(5;)kt}-6NV{4P=M9sYlo`1mIaBG`I_@W zdEGUINnXSL8-Dk@EkoK%A-tA?)R;XAV_S+tIf&;AYs}85KaeZPVYUbjI!Y4}IlR}6 zN02_bZr9by*%C!|#WEPM1D-LTIeBQU>^3j|TO$Ex(H>CAqaXI^Gcl7LVqvGoi6^s~ z_s^RGw_U?BIEs=g=%>r?&Fzb*)^I{G`|4*Pix|40Q2^&_A)r6Vfj;pZp3$e$vND0FF2QE6>wq^t_?`8(1djin7C zL~GN$_L}b$fBP=2eHC^bUPsZ`s%y?Y>5LQPJN`G8%}yYC3pe4=d+Jc|{hySHPzaJ3H14=F3 z&^*@*ZHe2*sM72RLl|Nqi5Te6uo?_7TEE{y^6 z%d7#pAhjS1@{i5A<(9$!|NSjdwlFY}g@U??qk+OfM`}8V5i?#4YN;JWhvChC{oD#` z5MvaWH<45Pn%DeJal`k`ahas~C53guN80C+HeoGbBOhE(|Nr)J4RF4LGq?I~1?D9} zd+FVL=3@<0l0&>>S2lzI8)m6+wB{Cc(DVQMX+j8Qdj(5Jf0ti#6m{fLyZ?d=vx& z_6VH9rAN$1{7zq^rebQh5^T&VtF?5zpJs9v8*z?5gz3`D$rLK&UTdW=u%fd~j}aD0 zPQYei~(dx_Pl+VYHUHncrN?u_{Y4B^$+zRe%>MYFme4mmo>(Q zz5!z(uI-~#Lqm~UXRVa?CcPpgj>=v4A9!J1+dD@f{~N*EiWzkOBN^h$Vn$O+c(wox zKm-vD|4LzngS0|#m<=n!JK?wxuNiB>6n|t$^tNq>$%U5wWsWX*&t@X zN<*FJ83bBH%+xdpk=no=H5hhfElfYFU~6JF4Qjf!yrk+!#s3Htj@njT7yZcw7@ckb zll*7I_aQXQ!`V}?+z}~U-ZGGTpUI|vMXiCZ9X||dldsxzO>Vocik-AULNoK&b0`BK z(aI~+1@NDEKFgKLXaA?2_m#xFhy%9Kw4NO9n|Crf=E9qa{!KvU+;9pYwZqc-w|<8{ zNvBi$Flm1VTg*`aMe8>kQwVu7^(jP+9aLLCPVcq++sbjxezz1ei$&KJfx*1oe2n@f zY>-x8QXhf8!v|MjYC_OMQR$VD^0U6rnjPjy%}XB4)%grP^h)7-HkEepA=K-lyb(Um zPO*c8)md2gb_h|RkR6|icP_KyA5IxH&0qZ84n6RcAu=O{8R=P2bR9h~cy z+~@Q&CQA>2DqtjRl(fqb{`v zGbQHbe9pr1nSK`yxeA%uy2+8L_Mh3YmDHb#o2v@s1+A-Q8C2=vyN&N}D?`*3*r%mc z;e?$>Nb_z~0*szDsK|7zBp~9nY~)*zMCVcd4&UZL%UNwvKN94a)!zpBN&h+X(NM z*1CmAofVS~^4Elr_K1xdd&!=K$}Ce6e6UrZ#y_&9$>QqfT~0a@A=zhow3y}K zyF}W)I~}(<6ZyGY0&W)u<~O;252UjFvbWlmTC764zG=oGNc^>u6{|n4i2UWXWh2Kg zezzgZr%OkV{X#Jhlju+aCKRoq{J-=Vj$E#`S+}nVU^q2k%xc;5%pX+;u8Y?8w`gNB z&3GRitT>zy?Rzp{nA46=LW9OwbF?FXPo2P#NyrUQ-38ivYJ#nB4j9g+LU>xqwMq{# zNvRp3!ui3_U$?zpZq*!v9!V@%A*3M9{`C_bUe-sJpz<9#qeayP+WT9nzt&Eo?V5_i zE3}9i4vt$o(Qo|=IHR;~s}tv9_Nh zH)NjRbRHfx0>%E_zoEhd;;E`c6>!Mz6J4T#NXTL?d-9(BUg+cVi*hzz4s^U#+d%Fv zNK;Y9YD|d+>T;s4_ygxQn?gFO!N$tJh^}WIJY%rUq$m=Y2JA>9e6Y}?Gv7i;kJ65h zw=l6|eoe=S5Lcb85!f>T1`du+rCEsiz4>HX2uy%Y-D z-p^1aB=xj9bpt>WackNyl#BuBiA!Z9(~SpQ-DB=ZMVgx1aT1Kgj$*OuQ4rS8BZ^JO`7A4CYS~d zG9As65SA4&x?XzOei<~9EPsp40oDJDXEp>pX-$Q`re<+HUYW??;A4jg78lXA-wegv zFwc!H5!k)}TzgoL57-Z>2CzWaBg2`-0BY9QQ5n2qwXIvq4$ahw49vPMt;Xy7DIVvHtDkk(*1wdmoi+6~Wb`>`5054;j-tUDeFkfh zLAnj%L7IzhKv>rlAu9zuie$znC|E4o3qgc4qMXV#ZUH&5>|{W)$1<+L8t72w#BoAM zR+{ze#VmA*OsM%059Hgb6jxe@xjQtIXWJJKLpn!_AuH!|cOGR05ZtJn#fLue@#-3h zMlJZ)k}WOlX)uHbq4xO+9R#mLd&V13mmnbYSK-;%DFtACIx{fzcc_n4b2tE|WPA@e zi4f+XBQqyG4sA4)xc5BO^0PjU>xWRk;!EmjRB$#mkY7hR-R{y4LFEQ;u)hYc+cy0K z=e~`fpv2p)!cHvcZe5HBa_RBuVy@IZFz%U(TXwEKPtu8WTT~dwQYqT2#1pPNC_xa% z`BI^u$&nN|aybY+q$lrq2rmKtDZ*&8J1F`Hc6+?V-jB)F9v% zfhgcxizOld(2F+rG8_M!HQ5z5Dj1MHfMBwag+y<^F}G^jfs$ZrEasLn#1a%4|Gu{T zg;u|T_n7GzX?n`0sP6QysI*Y07=8!PW-mdDLm-KXIo+7TlMo|8(eUQ&jeYiOO@`@1 zOA(fcHQgeY^EU^iNne6~xw6lSY_lhl2Jx}Vo9+I$T`jLl0?xdgyF#|Qz_W5iPtR2&JWq|0*@J4JsG%1EI{%feYy;m4(ExFXl7B<}nF zLv$q_kgd}g!8yr;_0B?|yMt$rz)$bv;q#`R8Dq#GV*yEeSw!{W=?m?Vpg0a{^?5^h zl0rm7i87f9cXsNA3J@E=RyGXkqod)w>`xYiiJ!ir51E$I`vU%WYmzp1rxaJsqSQ|p z8fFy_udine1lqZ?pI!k)BVzK>%s33@EN6-*wsrSuG6Rz|6fSQul$6Vs>-U*~h%Y+4 zIHw$tpC{<9h={iuwohr9Wut-tBVg_xD`&j@$*;xts`9Py|l!fS|)ha&}9jAodea>22@SlJIa8J$5IOy%g- zGWo11u)gOk=1&Rlx5i@-YXCTk!pogRy}J(-*hB>z5pdhjl6xwLF~+^55o;)S1#oKk)dR;@ zlQp*I#k$?pnDJ1MGj=4kz%_aFL##|#8t+AGCe4eV;}mI=J&sLOUav!Ua4;7QfL{{?kDz2dkk2$jw!fokcnT6gV@i_mp0JG$6F^rnod3~VyxyK6BelS=&IJ)!@!i{Ts@5RLIOdZ(6j8{ElkxzyAo1MiO_cgo zZU<}yx59Hem#e*26%t>fLTd z-BtB+>n?8g!M~|_C8V65M1eCTyI#}~@Mr6dEN$HLs&!da*HYJv%0X|(O1A1ZZ{GNZ zS(?n0DV1wbrxcCM_w^4Ol9&}`J;K!?g?|lj)}>9MJVN^@psGjgh}))x4uz$uUr$xz zlbvxL0Hch_QzdyQFkIyGb8@&ESm4h*s#MmW`LHGwGt`Hv$n z=MN@H>;DuziycEr651ib#6J9I^3_9(Eh^5+GE8EiSLR?04!;3`vc=0f!_0f=;DH1s-!|-{YKNh+%ou^NM<&DTLw0?ppwl#h zEKj-4DS>xVz4uaLg;(6B{`(Xr5*f6)-(D|1K*xuu^ZX7zX$ct62PR6EKW^`v?CSulZa%MBrk3rSdTg ze1)qou)_xBz^?*&?9&abE-3(?rj6DQCesmjf58zx9dcU1X#pJ_>Y4hh?Hx|KaX|Y| zhnr6QN73XG-{i4H0_oE1ejSH})R9U4OHBMr?4WQk%XdT3w4l=>I9!KXLRzB?soQ7t z;t)aC07cp;VIXAjwDbu=3&v+4nW}(Pb~&Abe%PGEuDhGgIPSq zq`*@wt?JyYG-KVU1eU-OqQyI>w!9%A9?NlFE`L4}xgq)=D9W7@ufSs%rPbY26kwl( z(&W}3%LfSFJis5M1OSe&_*)o*B6lPXX4c?v_nY;cVaPP?ji-mN@*rT3!rJ+>6ZfzezZqDC!2+_k&!1jMhm?bO< zK_BVjO3gB)@Q@W1LO2yP8atgL_$0LN=EEwU&b}|y)6MTC=-}IAFCwB|``CdMZ)llq zvtZ&`o=SVtoBZqZOBc3~sqLO_)jyCo-u06hGF|jbq1k(IaM^)KJwq{yJ6AoopD(iS zbzi2k>qaXXBKms^IU!=y$fSTV>_hlN(3D;VJvrso23ne0N*|j;VpSiAhx0lwRL%y| zwx>Xg1hQn{DGt$aTsglU__~6T0yA?-wh&NziF;oaV!c#>)Bw~DjQ8&CTxDvtr_(Zi z4a}`8pu`KYQ(Knhl2dEUMm&W5AIvU(~eeJz%m(FXv@2muw&9q^@Cyl3hyLOVp z@a4M7;rZJ#+;{dn_i_nwRYmwhm2)zFz?~XbaEJm$tetv#b}Y z((iz?v*e>{%A(q5vWP|E1XX*;rGdO)r#a7jpS?1MmV=Rbc~&Yf_nq`cR*X8PMyVtFRcn=g*e;eNBx0e8+5}58NdFOg<%V$N2OF1k~LgG zTY?oEGCASS`^d}~u8+B>>eDTq&G&j&?#VhOs<40sm9v>k?vcR2(ziK02PXT*bKGL4 zwtcf0`7Z%o5oyKu4%pC*{I>LC3=?na)s|^Eo3e;y7FN`_xgJW8vwV-Iyha25kQQ18saoRQFl!^~*FqaR}bFM#>;qZS5 zYPaR6?g95$v{vMW+jc)i2TxjRoTrKXv}qf*>|Hy(6NXs3$2j4J4baYuMJ*_Z0AnNH z$W%QfYVm8+BQzY3v+eCO#$Vg$u)GMv8&4xC`das@qWLzd1)mPfst$YJY`dRYCfa~P z;9tft46)(>kBr~I@D{(b=-sou{zALmK1=@#X=c6n;e)yU*Q6rZW7XjVOQjzw8_WnY z<{*_ObM40~K|NAAIWzbHj^rDW)HX^JOs7#_fBH~>!{ti>lg9`a94S&@b&@~)HP%e_ zmJl7{Rls!Vt$V}o)`L?jvh<^uM(Syo?Uo`Vr&465QLbrI;|GKX5=Z%Thx79bV7`oT zfS9(D-$<^d_jpDp}4U z^$yB4y4X}W0HeI1Vw*y4>lilwdkGy{f9>vfAH8R-sW~NZ&_bqTt1fj@<#WLeM^+Vc zG?7Ty^Uv#Lx7fbF>ek?cnq+h0!es(GXbA_w`u zzk^p1lk!`B)~2EhZi>h95itD>rrjML9DfT29|Zm=isd=)y;d8krHUEa2d$_8V6`S6 z#Bp3_kQQ}w%HQ9bBJ7~~7SCRoQewcgG}L|(6V+;OGWTBFVkP>lh1Weifu|6fniEvv z+uc-~La+tog=5MNk~+fJTg&^$(n<@DLS#BY#N3uEs$OHS_bbV(@F513d^SkS?S}l` z6u@)sTc;c=$QV_qi|`_R%+=BzC}dBU>>sIzWeIy|0c-fKIKi}}%gsT}Bp-AfI4u0; z*2BFB!*hpq$f&W8rWr7cMQex-er^i9dVN(o^3FbulJ5s8Hu52`h1$c%a(=>a6btp_ z7yAGS+v@^s8F7^3j`QcnhF?443r762(>2e=@xa!D5a#rxsujG3p@|+qwS+0&^{o=J z=GmDJx$}=Gm7ozcF>-;HHSSj32SWkxXf7FB!5&&pO9g!WTbm(UuEV(48i);ox_WC{ z7{gh{A-VWT*0D*JSOzjjXm!bAgFKmb-lWzMpQft`dJId=qAYAx6^X! zR9@hTu>O6yF7~~y$x8vG7>o|-DyOI*5CBz#rG6FBENCT-Syg7~%{$8h+tXq?Cop-- z+Z+#0EnnP#s0j2;d*QC&8xmFxnUK8`sHq& za1*c>ABGgiqURa5tb6F=k|h$)m`h1^4;oOD039Ks`BH!iOr! z2shfM%G_X<#*jzCnjU5rR;<}ANCqg;n_LddYjAM^?J{~O`s`zJbE2Py^|zPR8^(n2 z)VLX0cBrnKusl$}xY#x@S=itW)ZR=`4m~>v6tZCOF2bMyNNByavpOyj9p?d#+rU81 z=#%@e5CC*P<6M0Q{xf&w5HSytIciZk;%pxr-0ie<1&5vicjW^AfC@^nQ9ErpBkLyA zMIXpEN$C%&utHj32Vz!U4g7@gDi;Lq*9{H?E^AEUBz4IO8?)jGRph?{S*L%|9De@NuHv&oj4w%va-3DBuV>@Rr;$%ae5M}W+sv}z2z zqap@~b^g4gD~rukp-Ku+@$OsIt%ZVN?1wVGrzYyM>-z~W2j&ckZ^_!h5V8CGy5GNq zYMCR_5pz$5Mu}zM7i#HzmX}?xwFbpNz6ALN@h9Oe6}2S4{+lPFv;gu0p}h_gUNxM) zprjMe;cV?-(b`zhv1o>K{83qi)jWM%t{D+W_0r1HDBuvQFz9b_6M!{fDgnq*a3tjv zJQiC!zu&pF+aJcO3rtxnv2;19v+stBAN8L$5uZ*l9sXJ418AfH-pBOo;Q+NaN6rZC z*5ltJZ<(v_H+&-?j9U^w?n5HgG8agt2g8)Q)gZBO1|;DFIBaml+vUE85ZSfIEGuPJ zcO&W(-PF|aHuh!b2b@^`npS-y%FjZ?7=E++4tv*lNX~!?JU>8Fzz-lT!xLmyhGiDG z_w#oFkr~D11WiUa;Z1&#>dw;NJiPikuH7-@{GPzcIYbysmRvj(v=4T= zgFnvQx`43H*L5iqoO4G@HrX%|JiQRj$!T3mr3A!O0m;-&goS>{6?t;wmcbo-db{dB z(z=hH;;tqtAXVdGFUQ`pK)MZM*H3DqVDvxHY&RMu(ghI&k;=lr0nVZkQYRw2584$` z(DBQk(VS?Hl+v@#ZHvG0sK=laewo;$q9ue?G>wiZd;@QNd1^EF7hXH;8nQq>JMnk= z$R)F}VFwU+AY(GOBlA!nJztc6OtU%4^wkOnIjx`p9~SqORqJ(CiBelLS2iO%DMdGNa00B6a0 zrv_CHc@c2 zZ|mhl`PQK4+4Os@QL=*X zuJ)g*|2EShqyXfxld#6b$Vh*BT~d1^M^+U1_e!vNSLw&P4Bu#jShu`W)p*%rY523y zspjOaAg~F4{~a|79#Ki+Me9MyeWw4UE^D~XefZIS(1i}5_qHw$bEvrv1L~YLbwTSy z{$|783Rc6c)NM;DvR(Rj0wD4ZW;fkV$Y#!06r+D*Z$-bLi;R%Rs7T>nKqM9hQ(juL z%!)2@qL_lvhWQji!)h14fhVafCN@ffY|Ez2l3JWZ=c~Gbx&l=~zp;4xGXP4FM}S@xfRiZBh;2v7D-YXOx-kO2UQ0NW#A<^xamX>wNh%~17WesKHmDjpiI SH3wiS4mt~UaJXuC00018KS4YI literal 0 HcmV?d00001 From 0c7586106e5f6c996b375fad5759b1cabc68cb8d Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 25 Jun 2026 03:20:02 -0700 Subject: [PATCH 069/210] Updated on 2026-08-14 --- .../local/preferences/PreferencesKeys.kt | 2 +- data/visa/build.gradle.kts | 1 + .../pay/DefaultTangemPayEligibilityManager.kt | 13 +- .../di/VirtualAccountDataModule.kt | 26 +++ .../models/pay/TangemPayEligibilityType.kt | 36 +++- .../tangem/domain/models/wallet/UserWallet.kt | 9 +- domain/virtual-account/build.gradle.kts | 20 ++ .../virtual-account/models/build.gradle.kts | 1 + .../model/VirtualAccountEligibility.kt | 12 ++ .../model/VirtualAccountEntryPoint.kt | 7 + .../GetVirtualAccountEligibilityUseCase.kt | 69 +++++++ ...GetVirtualAccountSuitableWalletsUseCase.kt | 17 ++ ...GetVirtualAccountEligibilityUseCaseTest.kt | 174 ++++++++++++++++++ ...irtualAccountSuitableWalletsUseCaseTest.kt | 52 ++++++ features/details/impl/build.gradle.kts | 1 + .../features/details/model/DetailsModel.kt | 32 ++++ .../features/details/utils/ItemsBuilder.kt | 34 ++++ 17 files changed, 491 insertions(+), 15 deletions(-) create mode 100644 domain/virtual-account/models/src/main/kotlin/com/tangem/domain/virtualaccount/model/VirtualAccountEligibility.kt create mode 100644 domain/virtual-account/models/src/main/kotlin/com/tangem/domain/virtualaccount/model/VirtualAccountEntryPoint.kt create mode 100644 domain/virtual-account/src/main/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountEligibilityUseCase.kt create mode 100644 domain/virtual-account/src/main/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountSuitableWalletsUseCase.kt create mode 100644 domain/virtual-account/src/test/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountEligibilityUseCaseTest.kt create mode 100644 domain/virtual-account/src/test/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountSuitableWalletsUseCaseTest.kt diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt index 32568b5de7..84dc5ebd31 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/preferences/PreferencesKeys.kt @@ -156,7 +156,7 @@ object PreferencesKeys { val TANGEM_PAY_ACTIVE_WITHDRAW_ORDERS_KEY by lazy { stringPreferencesKey(name = "tangemPayActiveWithdrawOrdersKey") } - val TANGEM_PAY_ELIGIBILITY_KEY by lazy { stringSetPreferencesKey(name = "tangemPayEligibilityList") } + val TANGEM_PAY_ELIGIBILITY_KEY by lazy { stringSetPreferencesKey(name = "tangemPayEligibilityListV2") } fun getShouldShowNotificationKey(key: String) = booleanPreferencesKey("showShowNotificationUM_$key") // endregion diff --git a/data/visa/build.gradle.kts b/data/visa/build.gradle.kts index 5a5ccd9dec..2cc54a86b8 100644 --- a/data/visa/build.gradle.kts +++ b/data/visa/build.gradle.kts @@ -34,6 +34,7 @@ dependencies { /** Project - Domain */ implementation(projects.domain.visa) + implementation(projects.domain.virtualAccount) implementation(projects.domain.card) implementation(projects.domain.wallets) implementation(projects.domain.legacy) diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt index 8ca4cbc613..6f439a26bd 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/DefaultTangemPayEligibilityManager.kt @@ -1,17 +1,17 @@ package com.tangem.data.pay -import com.tangem.common.card.FirmwareVersion import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.models.pay.TangemPayEligibilityType +import com.tangem.domain.models.pay.isTangemPayType import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isLocked import com.tangem.domain.models.wallet.isMultiCurrency +import com.tangem.domain.models.wallet.isTangemPayCompatible import com.tangem.domain.pay.TangemPayEligibilityManager import com.tangem.domain.pay.model.TangemPayEntryPoint import com.tangem.domain.pay.repository.OnboardingRepository -import com.tangem.hot.sdk.model.HotWalletId import kotlinx.coroutines.* import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.sync.Mutex @@ -85,7 +85,7 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor( val wallets = userWalletsListRepository.userWallets.value ?: return emptyList() val candidates = wallets.filter { wallet -> - wallet.isMultiCurrency && !wallet.isLocked && wallet.isCompatible() && + wallet.isMultiCurrency && !wallet.isLocked && wallet.isTangemPayCompatible && !onboardingRepository.isTangemPayDeactivated(wallet.walletId) } @@ -98,11 +98,6 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor( return candidates } - private fun UserWallet.isCompatible(): Boolean = when (this) { - is UserWallet.Cold -> scanResponse.card.firmwareVersion >= FirmwareVersion.HDWalletAvailable - is UserWallet.Hot -> hotWalletId.authType != HotWalletId.AuthType.NoPassword - } - private suspend fun List.addPaeraCustomersData(): List { if (isEmpty()) return emptyList() @@ -139,7 +134,7 @@ internal class DefaultTangemPayEligibilityManager @Inject constructor( onboardingRepository.checkCustomerEligibility() } return if (entryPoint == null) { - eligibility.isNotEmpty() + eligibility.any { it.isTangemPayType } } else { eligibility.any { it == entryPoint.toEligibilityType() } } diff --git a/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/di/VirtualAccountDataModule.kt b/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/di/VirtualAccountDataModule.kt index a85c3e8c6d..89d3b807c2 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/di/VirtualAccountDataModule.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/virtualaccount/di/VirtualAccountDataModule.kt @@ -18,8 +18,13 @@ import com.tangem.datasource.utils.mapWithStringKeyTypes import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusFetcher import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusProducer import com.tangem.domain.virtualaccount.flow.VirtualAccountStatusSupplier +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.pay.repository.OnboardingRepository import com.tangem.domain.virtualaccount.repository.VirtualAccountActivationRepository import com.tangem.domain.virtualaccount.usecase.ActivateVirtualAccountUseCase +import com.tangem.domain.virtualaccount.usecase.GetVirtualAccountEligibilityUseCase +import com.tangem.domain.virtualaccount.usecase.GetVirtualAccountSuitableWalletsUseCase +import com.tangem.security.DeviceSecurityInfoProvider import com.tangem.utils.coroutines.AppCoroutineScope import dagger.Binds import dagger.Module @@ -94,5 +99,26 @@ internal interface VirtualAccountDataModule { ): ActivateVirtualAccountUseCase { return ActivateVirtualAccountUseCase(repository = repository) } + + @Provides + @Singleton + fun provideGetVirtualAccountSuitableWalletsUseCase( + userWalletsListRepository: UserWalletsListRepository, + ): GetVirtualAccountSuitableWalletsUseCase { + return GetVirtualAccountSuitableWalletsUseCase(userWalletsListRepository = userWalletsListRepository) + } + + @Provides + fun provideGetVirtualAccountEligibilityUseCase( + getVirtualAccountSuitableWalletsUseCase: GetVirtualAccountSuitableWalletsUseCase, + onboardingRepository: OnboardingRepository, + deviceSecurityInfoProvider: DeviceSecurityInfoProvider, + ): GetVirtualAccountEligibilityUseCase { + return GetVirtualAccountEligibilityUseCase( + getVirtualAccountSuitableWalletsUseCase = getVirtualAccountSuitableWalletsUseCase, + onboardingRepository = onboardingRepository, + deviceSecurityInfoProvider = deviceSecurityInfoProvider, + ) + } } } \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayEligibilityType.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayEligibilityType.kt index 2431867555..8c2bf08afa 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayEligibilityType.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayEligibilityType.kt @@ -4,14 +4,42 @@ enum class TangemPayEligibilityType { BANNER, DETAILS, + DEEPLINK, + + BANNER_VIRTUAL_ACCOUNT, + DETAILS_VIRTUAL_ACCOUNT, + DEEPLINK_VIRTUAL_ACCOUNT, + UNKNOWN, ; companion object { - fun fromString(value: String): TangemPayEligibilityType = when (value.lowercase()) { - "banner" -> BANNER - "details" -> DETAILS + fun fromString(value: String): TangemPayEligibilityType = when (value.uppercase()) { + "BANNER" -> BANNER + "DETAILS" -> DETAILS + "DEEPLINK" -> DEEPLINK + "BANNER_VIRTUAL_ACCOUNT" -> BANNER_VIRTUAL_ACCOUNT + "DETAILS_VIRTUAL_ACCOUNT" -> DETAILS_VIRTUAL_ACCOUNT + "DEEPLINK_VIRTUAL_ACCOUNT" -> DEEPLINK_VIRTUAL_ACCOUNT else -> UNKNOWN } } -} \ No newline at end of file +} + +val TangemPayEligibilityType.isVirtualAccountType: Boolean + get() = this in VIRTUAL_ACCOUNT_TYPES + +val TangemPayEligibilityType.isTangemPayType: Boolean + get() = this in TANGEM_PAY_TYPES + +private val VIRTUAL_ACCOUNT_TYPES = setOf( + TangemPayEligibilityType.BANNER_VIRTUAL_ACCOUNT, + TangemPayEligibilityType.DETAILS_VIRTUAL_ACCOUNT, + TangemPayEligibilityType.DEEPLINK_VIRTUAL_ACCOUNT, +) + +private val TANGEM_PAY_TYPES = setOf( + TangemPayEligibilityType.BANNER, + TangemPayEligibilityType.DETAILS, + TangemPayEligibilityType.DEEPLINK, +) \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/wallet/UserWallet.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/wallet/UserWallet.kt index acc0333fe9..2fea13cb92 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/wallet/UserWallet.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/wallet/UserWallet.kt @@ -1,5 +1,6 @@ package com.tangem.domain.models.wallet +import com.tangem.common.card.FirmwareVersion import com.tangem.domain.models.MobileWallet import com.tangem.domain.models.scan.CardDTO import com.tangem.domain.models.scan.ScanResponse @@ -118,4 +119,10 @@ val UserWallet.isLocked } inline val UserWallet.isHotWallet get() = this is UserWallet.Hot -inline val UserWallet.isColdWallet get() = this is UserWallet.Cold \ No newline at end of file +inline val UserWallet.isColdWallet get() = this is UserWallet.Cold + +val UserWallet.isTangemPayCompatible: Boolean + get() = when (this) { + is UserWallet.Cold -> scanResponse.card.firmwareVersion >= FirmwareVersion.HDWalletAvailable + is UserWallet.Hot -> hotWalletId.authType != HotWalletId.AuthType.NoPassword + } \ No newline at end of file diff --git a/domain/virtual-account/build.gradle.kts b/domain/virtual-account/build.gradle.kts index ff053920b6..618b957012 100644 --- a/domain/virtual-account/build.gradle.kts +++ b/domain/virtual-account/build.gradle.kts @@ -10,4 +10,24 @@ android { } dependencies { + /** Project - Domain */ + api(projects.domain.models) + api(projects.domain.virtualAccount.models) + implementation(projects.domain.common) + implementation(projects.domain.visa) + + /** Project - Core */ + implementation(projects.core.security) + + /** Coroutines */ + implementation(deps.kotlin.coroutines) + + /** Tests */ + testImplementation(deps.test.junit5) + testImplementation(deps.test.mockk) + testImplementation(deps.test.coroutine) + testImplementation(deps.test.truth) + testImplementation(projects.test.core) + testImplementation(projects.common.test) + testImplementation(projects.domain.card) } \ No newline at end of file diff --git a/domain/virtual-account/models/build.gradle.kts b/domain/virtual-account/models/build.gradle.kts index d587d7c152..0604c48d68 100644 --- a/domain/virtual-account/models/build.gradle.kts +++ b/domain/virtual-account/models/build.gradle.kts @@ -10,4 +10,5 @@ android { } dependencies { + api(projects.domain.models) } \ No newline at end of file diff --git a/domain/virtual-account/models/src/main/kotlin/com/tangem/domain/virtualaccount/model/VirtualAccountEligibility.kt b/domain/virtual-account/models/src/main/kotlin/com/tangem/domain/virtualaccount/model/VirtualAccountEligibility.kt new file mode 100644 index 0000000000..23d9bf4583 --- /dev/null +++ b/domain/virtual-account/models/src/main/kotlin/com/tangem/domain/virtualaccount/model/VirtualAccountEligibility.kt @@ -0,0 +1,12 @@ +package com.tangem.domain.virtualaccount.model + +import com.tangem.domain.models.wallet.UserWallet + +sealed interface VirtualAccountEligibility { + + data class Available( + val wallets: List, + ) : VirtualAccountEligibility + + data object NotAvailable : VirtualAccountEligibility +} \ No newline at end of file diff --git a/domain/virtual-account/models/src/main/kotlin/com/tangem/domain/virtualaccount/model/VirtualAccountEntryPoint.kt b/domain/virtual-account/models/src/main/kotlin/com/tangem/domain/virtualaccount/model/VirtualAccountEntryPoint.kt new file mode 100644 index 0000000000..fdc50dcb4a --- /dev/null +++ b/domain/virtual-account/models/src/main/kotlin/com/tangem/domain/virtualaccount/model/VirtualAccountEntryPoint.kt @@ -0,0 +1,7 @@ +package com.tangem.domain.virtualaccount.model + +enum class VirtualAccountEntryPoint { + BANNER, + DETAILS, + DEEPLINK, +} \ No newline at end of file diff --git a/domain/virtual-account/src/main/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountEligibilityUseCase.kt b/domain/virtual-account/src/main/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountEligibilityUseCase.kt new file mode 100644 index 0000000000..1d4a854342 --- /dev/null +++ b/domain/virtual-account/src/main/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountEligibilityUseCase.kt @@ -0,0 +1,69 @@ +package com.tangem.domain.virtualaccount.usecase + +import com.tangem.domain.models.pay.TangemPayEligibilityType +import com.tangem.domain.models.pay.isVirtualAccountType +import com.tangem.domain.pay.repository.OnboardingRepository +import com.tangem.domain.virtualaccount.model.VirtualAccountEligibility +import com.tangem.domain.virtualaccount.model.VirtualAccountEntryPoint +import com.tangem.security.DeviceSecurityInfoProvider +import com.tangem.security.isSecurityExposed +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope + +class GetVirtualAccountEligibilityUseCase( + private val getVirtualAccountSuitableWalletsUseCase: GetVirtualAccountSuitableWalletsUseCase, + private val onboardingRepository: OnboardingRepository, + private val deviceSecurityInfoProvider: DeviceSecurityInfoProvider, +) { + suspend operator fun invoke(entryPoint: VirtualAccountEntryPoint?): VirtualAccountEligibility { + if (deviceSecurityInfoProvider.isSecurityExposed()) { + return VirtualAccountEligibility.NotAvailable + } + + val suitableWallets = getVirtualAccountSuitableWalletsUseCase() + if (suitableWallets.isEmpty()) { + return VirtualAccountEligibility.NotAvailable + } + + val isEligible = checkEligibility(entryPoint) + if (isEligible) { + return VirtualAccountEligibility.Available(suitableWallets) + } + + val eligibleWallets = coroutineScope { + suitableWallets + .map { wallet -> + async { + val isExistingCustomer = onboardingRepository.hasTangemPayInWallet(wallet.walletId).getOrNull() + wallet.takeIf { isExistingCustomer == true } + } + } + .awaitAll() + .filterNotNull() + } + + return if (eligibleWallets.isEmpty()) { + VirtualAccountEligibility.NotAvailable + } else { + VirtualAccountEligibility.Available(eligibleWallets) + } + } + + private suspend fun checkEligibility(entryPoint: VirtualAccountEntryPoint?): Boolean { + val eligibility = onboardingRepository.getCustomerEligibility().ifEmpty { + onboardingRepository.checkCustomerEligibility() + } + return if (entryPoint == null) { + eligibility.any { it.isVirtualAccountType } + } else { + eligibility.contains(entryPoint.toEligibilityType()) + } + } + + private fun VirtualAccountEntryPoint.toEligibilityType(): TangemPayEligibilityType = when (this) { + VirtualAccountEntryPoint.BANNER -> TangemPayEligibilityType.BANNER_VIRTUAL_ACCOUNT + VirtualAccountEntryPoint.DETAILS -> TangemPayEligibilityType.DETAILS_VIRTUAL_ACCOUNT + VirtualAccountEntryPoint.DEEPLINK -> TangemPayEligibilityType.DEEPLINK_VIRTUAL_ACCOUNT + } +} \ No newline at end of file diff --git a/domain/virtual-account/src/main/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountSuitableWalletsUseCase.kt b/domain/virtual-account/src/main/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountSuitableWalletsUseCase.kt new file mode 100644 index 0000000000..8a14d69c48 --- /dev/null +++ b/domain/virtual-account/src/main/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountSuitableWalletsUseCase.kt @@ -0,0 +1,17 @@ +package com.tangem.domain.virtualaccount.usecase + +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.isLocked +import com.tangem.domain.models.wallet.isMultiCurrency +import com.tangem.domain.models.wallet.isTangemPayCompatible + +class GetVirtualAccountSuitableWalletsUseCase( + private val userWalletsListRepository: UserWalletsListRepository, +) { + operator fun invoke(): List { + return userWalletsListRepository.userWallets.value + .orEmpty() + .filter { it.isMultiCurrency && !it.isLocked && it.isTangemPayCompatible } + } +} \ No newline at end of file diff --git a/domain/virtual-account/src/test/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountEligibilityUseCaseTest.kt b/domain/virtual-account/src/test/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountEligibilityUseCaseTest.kt new file mode 100644 index 0000000000..2e83b585b0 --- /dev/null +++ b/domain/virtual-account/src/test/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountEligibilityUseCaseTest.kt @@ -0,0 +1,174 @@ +package com.tangem.domain.virtualaccount.usecase + +import arrow.core.left +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.models.pay.TangemPayEligibilityType +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.pay.repository.OnboardingRepository +import com.tangem.domain.virtualaccount.model.VirtualAccountEligibility +import com.tangem.domain.virtualaccount.model.VirtualAccountEntryPoint +import com.tangem.domain.visa.error.VisaApiError +import com.tangem.security.DeviceSecurityInfoProvider +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.every +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) +internal class GetVirtualAccountEligibilityUseCaseTest { + + private val getVirtualAccountSuitableWalletsUseCase: GetVirtualAccountSuitableWalletsUseCase = mockk() + private val onboardingRepository: OnboardingRepository = mockk() + private val deviceSecurityInfoProvider: DeviceSecurityInfoProvider = mockk() + + private val useCase = GetVirtualAccountEligibilityUseCase( + getVirtualAccountSuitableWalletsUseCase = getVirtualAccountSuitableWalletsUseCase, + onboardingRepository = onboardingRepository, + deviceSecurityInfoProvider = deviceSecurityInfoProvider, + ) + + @BeforeEach + fun setup() { + clearMocks(getVirtualAccountSuitableWalletsUseCase, onboardingRepository, deviceSecurityInfoProvider) + every { deviceSecurityInfoProvider.isRooted } returns false + every { deviceSecurityInfoProvider.isBootloaderUnlocked } returns false + every { deviceSecurityInfoProvider.isXposed } returns false + } + + @Test + fun `GIVEN device is rooted WHEN invoke THEN returns NotAvailable`() = runTest { + // GIVEN + every { deviceSecurityInfoProvider.isRooted } returns true + + // WHEN + val result = useCase(VirtualAccountEntryPoint.BANNER) + + // THEN + assertThat(result).isEqualTo(VirtualAccountEligibility.NotAvailable) + } + + @Test + fun `GIVEN no suitable wallets WHEN invoke THEN returns NotAvailable`() = runTest { + // GIVEN + every { getVirtualAccountSuitableWalletsUseCase() } returns emptyList() + + // WHEN + val result = useCase(VirtualAccountEntryPoint.BANNER) + + // THEN + assertThat(result).isEqualTo(VirtualAccountEligibility.NotAvailable) + } + + @Test + fun `GIVEN entry point eligibility passes WHEN invoke THEN returns Available with all suitable wallets`() = runTest { + // GIVEN + val wallets = listOf(mockWallet(), mockWallet()) + every { getVirtualAccountSuitableWalletsUseCase() } returns wallets + coEvery { + onboardingRepository.getCustomerEligibility() + } returns listOf(TangemPayEligibilityType.BANNER_VIRTUAL_ACCOUNT) + + // WHEN + val result = useCase(VirtualAccountEntryPoint.BANNER) + + // THEN + assertThat(result).isEqualTo(VirtualAccountEligibility.Available(wallets)) + } + + @Test + fun `GIVEN null entry point AND any VA eligibility present WHEN invoke THEN returns Available`() = runTest { + // GIVEN + val wallets = listOf(mockWallet()) + every { getVirtualAccountSuitableWalletsUseCase() } returns wallets + coEvery { + onboardingRepository.getCustomerEligibility() + } returns listOf(TangemPayEligibilityType.DEEPLINK_VIRTUAL_ACCOUNT) + + // WHEN + val result = useCase(entryPoint = null) + + // THEN + assertThat(result).isEqualTo(VirtualAccountEligibility.Available(wallets)) + } + + @Test + fun `GIVEN cached eligibility empty WHEN invoke THEN falls back to fetched eligibility`() = runTest { + // GIVEN + val wallets = listOf(mockWallet()) + every { getVirtualAccountSuitableWalletsUseCase() } returns wallets + coEvery { onboardingRepository.getCustomerEligibility() } returns emptyList() + coEvery { + onboardingRepository.checkCustomerEligibility() + } returns listOf(TangemPayEligibilityType.BANNER_VIRTUAL_ACCOUNT) + + // WHEN + val result = useCase(VirtualAccountEntryPoint.BANNER) + + // THEN + assertThat(result).isEqualTo(VirtualAccountEligibility.Available(wallets)) + } + + @Test + fun `GIVEN eligibility fails AND wallet is existing customer WHEN invoke THEN returns Available with wallet`() = + runTest { + // GIVEN + val wallet = mockWallet() + every { getVirtualAccountSuitableWalletsUseCase() } returns listOf(wallet) + coEvery { onboardingRepository.getCustomerEligibility() } returns listOf(TangemPayEligibilityType.BANNER) + coEvery { onboardingRepository.hasTangemPayInWallet(wallet.walletId) } returns true.right() + + // WHEN + val result = useCase(VirtualAccountEntryPoint.BANNER) + + // THEN + assertThat(result).isEqualTo(VirtualAccountEligibility.Available(listOf(wallet))) + } + + @Test + fun `GIVEN eligibility fails AND wallet is not a customer WHEN invoke THEN returns NotAvailable`() = runTest { + // GIVEN + val wallet = mockWallet() + every { getVirtualAccountSuitableWalletsUseCase() } returns listOf(wallet) + coEvery { onboardingRepository.getCustomerEligibility() } returns listOf(TangemPayEligibilityType.BANNER) + coEvery { + onboardingRepository.hasTangemPayInWallet(wallet.walletId) + } returns VisaApiError.NotPaeraCustomer.left() + + // WHEN + val result = useCase(VirtualAccountEntryPoint.BANNER) + + // THEN + assertThat(result).isEqualTo(VirtualAccountEligibility.NotAvailable) + } + + @Test + fun `GIVEN eligibility fails AND only some wallets are customers WHEN invoke THEN returns Available with customers`() = + runTest { + // GIVEN + val customerWallet = mockWallet() + val nonCustomerWallet = mockWallet() + every { + getVirtualAccountSuitableWalletsUseCase() + } returns listOf(customerWallet, nonCustomerWallet) + coEvery { onboardingRepository.getCustomerEligibility() } returns listOf(TangemPayEligibilityType.BANNER) + coEvery { onboardingRepository.hasTangemPayInWallet(customerWallet.walletId) } returns true.right() + coEvery { onboardingRepository.hasTangemPayInWallet(nonCustomerWallet.walletId) } returns false.right() + + // WHEN + val result = useCase(VirtualAccountEntryPoint.BANNER) + + // THEN + assertThat(result).isEqualTo(VirtualAccountEligibility.Available(listOf(customerWallet))) + } + + private fun mockWallet(): UserWallet { + val id = mockk() + return mockk { every { walletId } returns id } + } +} \ No newline at end of file diff --git a/domain/virtual-account/src/test/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountSuitableWalletsUseCaseTest.kt b/domain/virtual-account/src/test/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountSuitableWalletsUseCaseTest.kt new file mode 100644 index 0000000000..40883c03ce --- /dev/null +++ b/domain/virtual-account/src/test/kotlin/com/tangem/domain/virtualaccount/usecase/GetVirtualAccountSuitableWalletsUseCaseTest.kt @@ -0,0 +1,52 @@ +package com.tangem.domain.virtualaccount.usecase + +import com.google.common.truth.Truth.assertThat +import com.tangem.common.test.domain.card.MockScanResponseFactory +import com.tangem.common.test.domain.wallet.MockUserWalletFactory +import com.tangem.domain.card.configs.GenericCardConfig +import com.tangem.domain.card.configs.Wallet2CardConfig +import com.tangem.domain.common.wallets.UserWalletsListRepository +import com.tangem.domain.models.wallet.UserWallet +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.jupiter.api.Test + +internal class GetVirtualAccountSuitableWalletsUseCaseTest { + + private val userWalletsListRepository: UserWalletsListRepository = mockk() + + private val useCase = GetVirtualAccountSuitableWalletsUseCase(userWalletsListRepository = userWalletsListRepository) + + @Test + fun `GIVEN compatible, single-currency and outdated wallets WHEN invoke THEN returns only the compatible one`() { + // GIVEN + val compatible = MockUserWalletFactory.create( + MockScanResponseFactory.create(cardConfig = Wallet2CardConfig, derivedKeys = emptyMap()), + ) + val singleCurrency = MockUserWalletFactory.createSingleWalletWithToken() + val outdatedFirmware = MockUserWalletFactory.create( + MockScanResponseFactory.create(cardConfig = GenericCardConfig(maxWalletCount = 2), derivedKeys = emptyMap()), + ) + every { userWalletsListRepository.userWallets } returns + MutableStateFlow(listOf(compatible, singleCurrency, outdatedFirmware)) + + // WHEN + val result = useCase() + + // THEN + assertThat(result).containsExactly(compatible) + } + + @Test + fun `GIVEN no wallets WHEN invoke THEN returns empty list`() { + // GIVEN + every { userWalletsListRepository.userWallets } returns MutableStateFlow(null) + + // WHEN + val result = useCase() + + // THEN + assertThat(result).isEmpty() + } +} \ No newline at end of file diff --git a/features/details/impl/build.gradle.kts b/features/details/impl/build.gradle.kts index 3b6e587c78..51324ed3de 100644 --- a/features/details/impl/build.gradle.kts +++ b/features/details/impl/build.gradle.kts @@ -50,6 +50,7 @@ dependencies { implementation(projects.domain.legacy) implementation(projects.domain.settings) implementation(projects.domain.visa) + implementation(projects.domain.virtualAccount) /* SDK */ // TODO: For TangemError model, should be removed after card domain scanning refactoring diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt index 6e8d03041f..9562662f33 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt @@ -22,6 +22,9 @@ import com.tangem.domain.pay.TangemPayEligibilityManager import com.tangem.domain.pay.model.TangemPayEntryPoint import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase import com.tangem.domain.tangempay.TangemPayAnalyticsEvents +import com.tangem.domain.virtualaccount.model.VirtualAccountEligibility +import com.tangem.domain.virtualaccount.model.VirtualAccountEntryPoint +import com.tangem.domain.virtualaccount.usecase.GetVirtualAccountEligibilityUseCase import com.tangem.domain.walletconnect.CheckIsWalletConnectAvailableUseCase import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase import com.tangem.domain.wallets.analytics.Settings @@ -69,6 +72,7 @@ internal class DetailsModel @Inject constructor( private val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase, private val analyticsEventHandler: AnalyticsEventHandler, private val tangemPayEligibilityManager: TangemPayEligibilityManager, + private val getVirtualAccountEligibilityUseCase: GetVirtualAccountEligibilityUseCase, ) : Model() { private val params: DetailsComponent.Params = paramsContainer.require() @@ -101,6 +105,7 @@ internal class DetailsModel @Inject constructor( ) addTangemPayItemIfEligible() + addVirtualAccountItemIfEligible() state = MutableStateFlow( value = DetailsUM( @@ -328,5 +333,32 @@ internal class DetailsModel @Inject constructor( } } + private fun addVirtualAccountItemIfEligible() { + modelScope.launch { + val eligibility = getVirtualAccountEligibilityUseCase(VirtualAccountEntryPoint.DETAILS) + if (eligibility is VirtualAccountEligibility.Available) { + items.update { items -> + itemsBuilder.addVirtualAccountItem( + items = items, + onClick = ::onVirtualAccountItemClicked, + ) + } + } + } + } + + private fun onVirtualAccountItemClicked() { + modelScope.launch { + when (val eligibility = getVirtualAccountEligibilityUseCase(VirtualAccountEntryPoint.DETAILS)) { + is VirtualAccountEligibility.Available -> router.push( + AppRoute.VirtualAccountOnboarding( + AppRoute.VirtualAccountOnboarding.Mode.FromDetailsScreen(eligibility.wallets.first().walletId), + ), + ) + VirtualAccountEligibility.NotAvailable -> items.update { itemsBuilder.removeVirtualAccountItem(it) } + } + } + } + private fun getAppVersion(): String = "${appInfoProvider.appVersion} (${appInfoProvider.appVersionCode})" } \ No newline at end of file diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/ItemsBuilder.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/ItemsBuilder.kt index 856b5f8dab..6aca30759b 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/ItemsBuilder.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/utils/ItemsBuilder.kt @@ -16,6 +16,7 @@ import kotlinx.collections.immutable.toPersistentList import javax.inject.Inject private const val TANGEM_PAY_ITEM_ID = "get_tangem_pay" +private const val VIRTUAL_ACCOUNT_ITEM_ID = "get_virtual_account" @ModelScoped internal class ItemsBuilder @Inject constructor( @@ -75,6 +76,30 @@ internal class ItemsBuilder @Inject constructor( }.toImmutableList() } + fun addVirtualAccountItem(items: ImmutableList, onClick: () -> Unit): ImmutableList { + return items.map { block -> + if (block.id == "shop" && block is DetailsItemUM.Basic) { + val newItems = block + .items + .toMutableList() + .apply { add(getVirtualAccountItem(onClick = onClick)) } + block.copy(items = newItems.toImmutableList()) + } else { + block + } + }.toImmutableList() + } + + fun removeVirtualAccountItem(items: ImmutableList): ImmutableList { + return items.map { block -> + if (block is DetailsItemUM.Basic && block.items.any { it.id == VIRTUAL_ACCOUNT_ITEM_ID }) { + block.copy(items = block.items.filter { it.id != VIRTUAL_ACCOUNT_ITEM_ID }.toImmutableList()) + } else { + block + } + }.toImmutableList() + } + private fun buildWalletConnectBlock(isWalletConnectAvailable: Boolean, userWalletId: UserWalletId): DetailsItemUM? { return if (isWalletConnectAvailable) { DetailsItemUM.WalletConnect( @@ -173,4 +198,13 @@ internal class ItemsBuilder @Inject constructor( onClick = onClick, ), ) + + private fun getVirtualAccountItem(onClick: () -> Unit): DetailsItemUM.Basic.Item = DetailsItemUM.Basic.Item( + id = VIRTUAL_ACCOUNT_ITEM_ID, + block = BlockUM( + text = resourceReference(R.string.virtual_account_title), + iconRes = R.drawable.ic_tangem_pay_24, + onClick = onClick, + ), + ) } \ No newline at end of file From 94ded5dcca9564c151eeaadf4a61a8c6327a0cdb Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 26 Jun 2026 04:31:35 -0700 Subject: [PATCH 070/210] Updated on 2026-08-14 --- core/res/src/main/res/values/strings.xml | 11 + .../details/model/DetailsModelTestBase.kt | 12 +- .../details/impl/build.gradle.kts | 8 + .../DefaultVirtualAccountMainComponent.kt | 34 +- .../main/VirtualAccountMainModel.kt | 71 +++- ...lAccountMainNavigationBottomSheetConfig.kt | 12 + .../VirtualAccountAddFundsBottomSheet.kt | 328 ++++++++++++++++++ ...tualAccountAddFundsBottomSheetComponent.kt | 45 +++ .../addfunds/VirtualAccountAddFundsModel.kt | 70 ++++ .../main/addfunds/VirtualAccountAddFundsUM.kt | 33 ++ .../main/di/VirtualAccountMainModelModule.kt | 6 + 11 files changed, 621 insertions(+), 9 deletions(-) create mode 100644 features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/VirtualAccountMainNavigationBottomSheetConfig.kt create mode 100644 features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/addfunds/VirtualAccountAddFundsBottomSheet.kt create mode 100644 features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/addfunds/VirtualAccountAddFundsBottomSheetComponent.kt create mode 100644 features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/addfunds/VirtualAccountAddFundsModel.kt create mode 100644 features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/addfunds/VirtualAccountAddFundsUM.kt diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index e644cefa16..c0d7a0dfb5 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -115,10 +115,17 @@ Enter address Invalid address Keep editing + You can not create more than 20 addresses. Delete one to add new. + Can\'t add new address + Contact name is required + Contact name contains invalid characters + Contact name must not exceed 50 characters + That name is already taken on this wallet New contact No contacts yet Contacts added will appear here Remove address + Save to Wallet This contact will be linked to this wallet’s address book. Select network Address book @@ -352,6 +359,7 @@ Get token Go to provider Go to token + Go to verification Got it Hide Hold to %s @@ -732,6 +740,8 @@ Key Generation All cryptographic operations happen inside the secure chip, certified against cloning and physical tampering. Hardware-Level Security + Network activity is high. You can continue now or try again later when fees may be lower. + Network fee is higher than usual Add Existing Wallet Create New Wallet Order Tangem @@ -1402,6 +1412,7 @@ Memo Check your network connection Network fee info unreachable + from %1$s in %2$s You send From %s Gas limit diff --git a/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelTestBase.kt b/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelTestBase.kt index d40b5d6c0a..072a256769 100644 --- a/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelTestBase.kt +++ b/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelTestBase.kt @@ -16,6 +16,8 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.TangemPayEligibilityManager import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase +import com.tangem.domain.virtualaccount.model.VirtualAccountEligibility +import com.tangem.domain.virtualaccount.usecase.GetVirtualAccountEligibilityUseCase import com.tangem.domain.walletconnect.CheckIsWalletConnectAvailableUseCase import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase @@ -27,12 +29,7 @@ import com.tangem.features.details.utils.ItemsBuilder import com.tangem.features.details.utils.SocialsBuilder import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import com.tangem.utils.info.AppInfoProvider -import io.mockk.coEvery -import io.mockk.every -import io.mockk.mockk -import io.mockk.mockkObject -import io.mockk.slot -import io.mockk.unmockkObject +import io.mockk.* import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -66,6 +63,7 @@ internal abstract class DetailsModelTestBase { protected val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase = mockk() protected val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxUnitFun = true) protected val tangemPayEligibilityManager: TangemPayEligibilityManager = mockk() + protected val getVirtualAccountEligibilityUseCase: GetVirtualAccountEligibilityUseCase = mockk() // Captured from itemsBuilder.buildAll(...) so the feature buttons can be driven. protected val wcSlot = slot() @@ -89,6 +87,7 @@ internal abstract class DetailsModelTestBase { every { appInfoProvider.appVersion } returns "1.2.3" every { appInfoProvider.appVersionCode } returns 456 coEvery { tangemPayEligibilityManager.getEligibleWallets(any(), any()) } returns emptyList() + coEvery { getVirtualAccountEligibilityUseCase(any()) } returns VirtualAccountEligibility.NotAvailable every { itemsBuilder.buildAll( @@ -128,6 +127,7 @@ internal abstract class DetailsModelTestBase { generateBuyTangemCardLinkUseCase = generateBuyTangemCardLinkUseCase, analyticsEventHandler = analyticsEventHandler, tangemPayEligibilityManager = tangemPayEligibilityManager, + getVirtualAccountEligibilityUseCase = getVirtualAccountEligibilityUseCase, ) protected fun stubBuildAllReturns(list: ImmutableList) { diff --git a/features/virtual-accounts/details/impl/build.gradle.kts b/features/virtual-accounts/details/impl/build.gradle.kts index 1d9448064a..0993a9f074 100644 --- a/features/virtual-accounts/details/impl/build.gradle.kts +++ b/features/virtual-accounts/details/impl/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(deps.plugins.android.library) alias(deps.plugins.kotlin.android) + alias(deps.plugins.kotlin.serialization) alias(deps.plugins.kotlin.kapt) alias(deps.plugins.hilt.android) id("configuration") @@ -14,12 +15,15 @@ dependencies { /** Core */ implementation(projects.core.configToggles) implementation(projects.core.decompose) + implementation(projects.core.navigation) implementation(projects.core.res) implementation(projects.core.ui) implementation(projects.core.utils) /** Domain */ implementation(projects.domain.models) + implementation(projects.domain.feedback) + implementation(projects.domain.feedback.models) /** Features */ implementation(projects.features.virtualAccounts.details.api) @@ -31,6 +35,10 @@ dependencies { implementation(deps.compose.ui.tooling) implementation(deps.decompose.ext.compose) + /** Other */ + implementation(deps.arrow.core) + implementation(deps.kotlin.immutable.collections) + /** DI */ implementation(deps.hilt.android) kapt(deps.hilt.kapt) diff --git a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/DefaultVirtualAccountMainComponent.kt b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/DefaultVirtualAccountMainComponent.kt index 7ad7ef7c21..50ce82e412 100644 --- a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/DefaultVirtualAccountMainComponent.kt +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/DefaultVirtualAccountMainComponent.kt @@ -4,24 +4,56 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.decompose.router.slot.childSlot import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableBottomSheetComponent import com.tangem.features.virtualaccount.details.component.VirtualAccountMainComponent +import com.tangem.features.virtualaccount.main.addfunds.VirtualAccountAddFundsBottomSheetComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject internal class DefaultVirtualAccountMainComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, - @Assisted params: VirtualAccountMainComponent.Params, + @Assisted private val params: VirtualAccountMainComponent.Params, ) : VirtualAccountMainComponent, AppComponentContext by appComponentContext { private val model: VirtualAccountMainModel = getOrCreateModel(params = params) + private val bottomSheetSlot = childSlot( + source = model.bottomSheetNavigation, + serializer = VirtualAccountMainNavigationBottomSheetConfig.serializer(), + handleBackButton = false, + childFactory = ::bottomSheetChild, + ) + @Composable override fun Content(modifier: Modifier) { val state by model.uiState.collectAsStateWithLifecycle() + val bottomSheet by bottomSheetSlot.subscribeAsState() VirtualAccountMainScreen(state = state, modifier = modifier) + bottomSheet.child?.instance?.BottomSheet() + } + + private fun bottomSheetChild( + config: VirtualAccountMainNavigationBottomSheetConfig, + componentContext: ComponentContext, + ): ComposableBottomSheetComponent { + return when (config) { + is VirtualAccountMainNavigationBottomSheetConfig.AddFunds -> VirtualAccountAddFundsBottomSheetComponent( + appComponentContext = childByContext(componentContext), + params = VirtualAccountAddFundsBottomSheetComponent.Params( + userWalletId = params.userWalletId, + listener = model, + requisites = config.requisites, + dailyDepositLimit = config.dailyDepositLimit, + ), + ) + } } @AssistedFactory diff --git a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/VirtualAccountMainModel.kt b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/VirtualAccountMainModel.kt index 65a9fd1e60..a05c327e16 100644 --- a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/VirtualAccountMainModel.kt +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/VirtualAccountMainModel.kt @@ -1,6 +1,9 @@ package com.tangem.features.virtualaccount.main import androidx.compose.runtime.Stable +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.arkivanov.decompose.router.slot.dismiss import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer @@ -9,6 +12,8 @@ import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.stringReference import com.tangem.features.virtualaccount.details.component.VirtualAccountMainComponent import com.tangem.features.virtualaccount.details.impl.R +import com.tangem.features.virtualaccount.main.addfunds.VirtualAccountAddFundsBottomSheetComponent +import com.tangem.features.virtualaccount.main.addfunds.VirtualAccountAddFundsListener import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -20,16 +25,22 @@ internal class VirtualAccountMainModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, private val router: Router, -) : Model() { +) : Model(), VirtualAccountAddFundsListener { @Suppress("UnusedPrivateProperty") private val params = paramsContainer.require() + val bottomSheetNavigation: SlotNavigation = SlotNavigation() + val uiState: StateFlow field = MutableStateFlow( createInitialState(), ) + override fun onAddFundsDismiss() { + bottomSheetNavigation.dismiss() + } + private fun createInitialState(): VirtualAccountMainUM = VirtualAccountMainUM( title = resourceReference(R.string.virtual_account_title), subtitle = resourceReference(R.string.tangempay_usdc_on_polygon_network), @@ -40,7 +51,63 @@ internal class VirtualAccountMainModel @Inject constructor( isBalanceHidden = false, onBackClick = { router.pop() }, onMenuClick = {}, - onAddFundsClick = {}, + onAddFundsClick = ::onAddFundsClick, onSendClick = {}, ) + + private fun buildRequisites(details: VirtualAccountDepositDetails) = listOf( + VirtualAccountAddFundsBottomSheetComponent.RequisitesRow( + title = stringReference("Beneficiary name and address"), + titleForShare = "Beneficiary name and address", + value = "${details.beneficiaryName}\n${details.beneficiaryAddress}", + ), + VirtualAccountAddFundsBottomSheetComponent.RequisitesRow( + title = stringReference("Bank name and address"), + titleForShare = "Bank name and address", + value = "${details.bankName}\n${details.bankAddress}", + ), + VirtualAccountAddFundsBottomSheetComponent.RequisitesRow( + title = stringReference("Account number"), + titleForShare = "Account number", + value = details.accountNumber, + ), + VirtualAccountAddFundsBottomSheetComponent.RequisitesRow( + title = stringReference("Routing number"), + titleForShare = "Routing number", + value = details.routingNumber, + ), + ) + + private fun onAddFundsClick() { + val details = getDepositDetails() + bottomSheetNavigation.activate( + VirtualAccountMainNavigationBottomSheetConfig.AddFunds( + requisites = buildRequisites(details), + dailyDepositLimit = details.dailyDepositLimit, + ), + ) + } + + // TODO v_rodionov: HARDCODE - get this data from backend + private fun getDepositDetails(): VirtualAccountDepositDetails { + return VirtualAccountDepositDetails( + beneficiaryName = "Ivan Ivanov", + beneficiaryAddress = "18, Rue Rubens 20, Paris, Ile-de-France 75013, US", + bankName = "SSB Bank", + bankAddress = "8700 Perry Highway, Pittsburgh, PA 15237, US", + accountNumber = "707613210122", + routingNumber = "043087080", + dailyDepositLimit = "$10,000", + ) + } + + private data class VirtualAccountDepositDetails( + val beneficiaryName: String, + val beneficiaryAddress: String, + val bankName: String, + val bankAddress: String, + val accountNumber: String, + val routingNumber: String, + val dailyDepositLimit: String, + ) } \ No newline at end of file diff --git a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/VirtualAccountMainNavigationBottomSheetConfig.kt b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/VirtualAccountMainNavigationBottomSheetConfig.kt new file mode 100644 index 0000000000..f0de59ec19 --- /dev/null +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/VirtualAccountMainNavigationBottomSheetConfig.kt @@ -0,0 +1,12 @@ +package com.tangem.features.virtualaccount.main + +import com.tangem.features.virtualaccount.main.addfunds.VirtualAccountAddFundsBottomSheetComponent.RequisitesRow +import kotlinx.serialization.Serializable + +@Serializable +internal sealed interface VirtualAccountMainNavigationBottomSheetConfig { + data class AddFunds( + val requisites: List, + val dailyDepositLimit: String, + ) : VirtualAccountMainNavigationBottomSheetConfig +} \ No newline at end of file diff --git a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/addfunds/VirtualAccountAddFundsBottomSheet.kt b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/addfunds/VirtualAccountAddFundsBottomSheet.kt new file mode 100644 index 0000000000..4537376d28 --- /dev/null +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/addfunds/VirtualAccountAddFundsBottomSheet.kt @@ -0,0 +1,328 @@ +package com.tangem.features.virtualaccount.main.addfunds + +import android.content.res.Configuration +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +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.TangemIconUM +import com.tangem.core.ui.ds.topbar.TangemTopBar +import com.tangem.core.ui.ds.topbar.TangemTopBarType +import com.tangem.core.ui.ds2.button.Close +import com.tangem.core.ui.ds2.button.TangemButton +import com.tangem.core.ui.ds2.row.* +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +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_24 +import com.tangem.core.ui.res.generated.icons.ic_info_24 +import com.tangem.core.ui.res.generated.icons.ic_sign_usd_32 +import com.tangem.features.virtualaccount.details.impl.R +import kotlinx.collections.immutable.persistentListOf +import com.tangem.core.ui.R as CoreUiR + +@Composable +internal fun VirtualAccountAddFundsBottomSheet(state: VirtualAccountAddFundsUM) { + val title = stringReference("Account details") + .takeIf { state.content is VirtualAccountAddFundsUM.Content.Details } + + TangemBottomSheet( + config = TangemBottomSheetConfig( + isShown = true, + onDismissRequest = state.onDismiss, + content = TangemBottomSheetConfigContent.Empty, + ), + type = TangemBottomSheetType.Modal, + containerColor = TangemTheme.colors3.bg.secondary, + title = { + TangemTopBar( + type = TangemTopBarType.BottomSheet, + title = title, + endContent = { TangemButton.Close(onClick = state.onDismiss) }, + ) + }, + content = { _ -> + when (val content = state.content) { + is VirtualAccountAddFundsUM.Content.Intro -> IntroContent(content) + is VirtualAccountAddFundsUM.Content.Details -> DetailsContent(content) + } + }, + ) +} + +@Composable +private fun IntroContent(content: VirtualAccountAddFundsUM.Content.Intro, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens2.x4) + .padding(bottom = TangemTheme.dimens2.x4), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + IntroIcons(modifier = Modifier.padding(top = TangemTheme.dimens2.x4)) + TitleText( + text = stringReference("Received USD will be converted to USDC by 1:1 rate"), + modifier = Modifier.padding(top = TangemTheme.dimens2.x8), + ) + SubtitleText( + text = stringReference("It might take 1-3 days to receive the money"), + modifier = Modifier.padding(top = TangemTheme.dimens2.x2), + ) + InfoNotification( + title = stringReference("Only ACH and domestic wire transfers are available"), + subtitle = stringReference("SWIFT won't pass"), + modifier = Modifier.padding(top = TangemTheme.dimens2.x6), + ) + TangemButton( + modifier = Modifier + .fillMaxWidth() + .padding(top = TangemTheme.dimens2.x4), + text = stringReference("Show details"), + variant = TangemButton.Variant.Primary, + size = TangemButton.Size.X12, + onClick = content.onShowDetailsClick, + ) + } +} + +@Composable +private fun DetailsContent(content: VirtualAccountAddFundsUM.Content.Details, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(bottom = TangemTheme.dimens2.x4), + ) { + content.items.forEachIndexed { index, item -> + CopyableRow( + item = item, + divider = index != content.items.lastIndex, + ) + } + InfoNotification( + title = stringReference("Available to deposit per day: ${content.dailyLimit}"), + subtitle = stringReference("Limit is resetting every day"), + modifier = Modifier + .padding(horizontal = TangemTheme.dimens2.x4) + .padding(top = TangemTheme.dimens2.x3), + ) + TangemButton( + text = resourceReference(R.string.common_share), + variant = TangemButton.Variant.Primary, + size = TangemButton.Size.X12, + onClick = content.onShareClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = TangemTheme.dimens2.x4) + .padding(top = TangemTheme.dimens2.x4), + ) + } +} + +@Composable +private fun CopyableRow(item: VirtualAccountAddFundsUM.DetailItem, divider: Boolean, modifier: Modifier = Modifier) { + TangemRow( + modifier = modifier, + divider = divider, + contentLead = TangemRowContentLead.Start, + verticalAlignment = TangemRowVerticalAlignment.Center, + titleSlot = { + TangemRowText( + text = item.label, + role = TangemRowTextRole.Subtitle, + ) + }, + subtitleSlot = { + TangemRowText( + text = item.value, + role = TangemRowTextRole.Title, + maxLines = Int.MAX_VALUE, + ) + }, + endSlot = { + TangemButton( + iconStart = TangemIconUM.Icon(imageVector = Icons.ic_copy_24), + onClick = item.onCopyClick, + size = TangemButton.Size.X9, + variant = TangemButton.Variant.Ghost, + contentDescription = item.label.resolveReference(), + ) + }, + ) +} + +@Composable +private fun InfoNotification(title: TextReference, subtitle: TextReference, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(TangemTheme.dimens2.x4)) + .background(TangemTheme.colors3.bg.status.infoSubtle) + .padding(TangemTheme.dimens2.x4), + horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2), + ) { + Icon( + modifier = Modifier.size(TangemTheme.dimens2.x5), + imageVector = Icons.ic_info_24, + contentDescription = null, + tint = TangemTheme.colors3.icon.status.info, + ) + Column(verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x0_5)) { + Text( + text = title.resolveReference(), + style = TangemTheme.typography3.subheading.medium, + color = TangemTheme.colors3.text.primary, + ) + Text( + text = subtitle.resolveReference(), + style = TangemTheme.typography3.caption.medium, + color = TangemTheme.colors3.text.secondary, + ) + } + } +} + +@Composable +private fun IntroIcons(modifier: Modifier = Modifier) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(-TangemTheme.dimens2.x4), + ) { + UsdIcon() + UsdcIcon() + } +} + +@Composable +private fun UsdIcon(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .size(TangemTheme.dimens2.x20) + .clip(CircleShape) + .background(TangemTheme.colors3.bg.opaque.primary) + .border(width = 1.dp, color = TangemTheme.colors3.border.secondary, shape = CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.size(TangemTheme.dimens2.x8), + imageVector = Icons.ic_sign_usd_32, + contentDescription = null, + tint = TangemTheme.colors3.icon.primary, + ) + } +} + +@Composable +private fun UsdcIcon(modifier: Modifier = Modifier) { + Box(modifier = modifier.size(TangemTheme.dimens2.x20)) { + Image( + modifier = Modifier + .fillMaxSize() + .clip(CircleShape) + .border(width = 1.dp, color = TangemTheme.colors3.border.secondary, shape = CircleShape), + painter = painterResource(CoreUiR.drawable.img_usdc_16), + contentDescription = null, + ) + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .size(TangemTheme.dimens2.x6) + .background(color = TangemTheme.colors3.bg.accent.violet, shape = CircleShape) + .border( + width = TangemTheme.dimens2.x0_5, + color = TangemTheme.colors3.bg.secondary, + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.size(TangemTheme.dimens2.x4), + painter = painterResource(CoreUiR.drawable.ic_polygon_22), + contentDescription = null, + tint = TangemTheme.colors3.icon.inverse, + ) + } + } +} + +@Composable +private fun TitleText(text: TextReference, modifier: Modifier = Modifier) { + Text( + modifier = modifier.fillMaxWidth(), + text = text.resolveReference(), + style = TangemTheme.typography3.heading.small, + color = TangemTheme.colors3.text.primary, + textAlign = TextAlign.Center, + ) +} + +@Composable +private fun SubtitleText(text: TextReference, modifier: Modifier = Modifier) { + Text( + modifier = modifier.fillMaxWidth(), + text = text.resolveReference(), + style = TangemTheme.typography3.subheading.medium, + color = TangemTheme.colors3.text.secondary, + textAlign = TextAlign.Center, + ) +} + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun VirtualAccountAddFundsIntroPreview() { + TangemThemePreviewRedesign { + IntroContent( + content = VirtualAccountAddFundsUM.Content.Intro( + onShowDetailsClick = {}, + ), + modifier = Modifier.background(TangemTheme.colors3.bg.secondary), + ) + } +} + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun VirtualAccountAddFundsDetailsPreview() { + TangemThemePreviewRedesign { + DetailsContent( + content = VirtualAccountAddFundsUM.Content.Details( + items = persistentListOf( + VirtualAccountAddFundsUM.DetailItem( + label = stringReference("Beneficiary name and address"), + value = "Ivan Ivanov\n18, Rue Rubens 20, Paris, Ile-de-France 75013, US", + onCopyClick = {}, + ), + VirtualAccountAddFundsUM.DetailItem( + label = stringReference("Account number"), + value = "707613210122", + onCopyClick = {}, + ), + ), + dailyLimit = "$10,000", + onShareClick = {}, + ), + modifier = Modifier.background(TangemTheme.colors3.bg.secondary), + ) + } +} \ No newline at end of file diff --git a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/addfunds/VirtualAccountAddFundsBottomSheetComponent.kt b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/addfunds/VirtualAccountAddFundsBottomSheetComponent.kt new file mode 100644 index 0000000000..4faaffcba5 --- /dev/null +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/addfunds/VirtualAccountAddFundsBottomSheetComponent.kt @@ -0,0 +1,45 @@ +package com.tangem.features.virtualaccount.main.addfunds + +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.core.ui.extensions.TextReference +import com.tangem.domain.models.wallet.UserWalletId + +internal class VirtualAccountAddFundsBottomSheetComponent( + appComponentContext: AppComponentContext, + params: Params, +) : ComposableBottomSheetComponent, AppComponentContext by appComponentContext { + + private val model: VirtualAccountAddFundsModel = getOrCreateModel(params = params) + + override fun dismiss() { + model.onDismiss() + } + + @Composable + override fun BottomSheet() { + val state by model.uiState.collectAsStateWithLifecycle() + VirtualAccountAddFundsBottomSheet(state = state) + } + + data class Params( + val userWalletId: UserWalletId, + val requisites: List, + val dailyDepositLimit: String, + val listener: VirtualAccountAddFundsListener, + ) + + data class RequisitesRow( + val title: TextReference, + val titleForShare: String, + val value: String, + ) +} + +internal interface VirtualAccountAddFundsListener { + fun onAddFundsDismiss() +} \ No newline at end of file diff --git a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/addfunds/VirtualAccountAddFundsModel.kt b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/addfunds/VirtualAccountAddFundsModel.kt new file mode 100644 index 0000000000..d78ef65a65 --- /dev/null +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/addfunds/VirtualAccountAddFundsModel.kt @@ -0,0 +1,70 @@ +package com.tangem.features.virtualaccount.main.addfunds + +import androidx.compose.runtime.Stable +import androidx.compose.ui.util.fastForEach +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.navigation.share.ShareManager +import com.tangem.core.ui.clipboard.ClipboardManager +import com.tangem.core.ui.extensions.TextReference +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject + +@Stable +@ModelScoped +internal class VirtualAccountAddFundsModel @Inject constructor( + paramsContainer: ParamsContainer, + override val dispatchers: CoroutineDispatcherProvider, + private val clipboardManager: ClipboardManager, + private val shareManager: ShareManager, +) : Model() { + + private val params = paramsContainer.require() + + val uiState: StateFlow + field = MutableStateFlow( + VirtualAccountAddFundsUM( + onDismiss = ::onDismiss, + content = VirtualAccountAddFundsUM.Content.Intro( + onShowDetailsClick = { showDetailsContent() }, + ), + ), + ) + + fun onDismiss() { + params.listener.onAddFundsDismiss() + } + + private fun showDetailsContent() { + uiState.update { state -> + state.copy( + content = VirtualAccountAddFundsUM.Content.Details( + items = params.requisites + .map { detailItem(label = it.title, value = it.value) } + .toImmutableList(), + dailyLimit = params.dailyDepositLimit, + onShareClick = { shareManager.shareText(buildShareText()) }, + ), + ) + } + } + + private fun detailItem(label: TextReference, value: String) = VirtualAccountAddFundsUM.DetailItem( + label = label, + value = value, + onCopyClick = { clipboardManager.setText(text = value, isSensitive = true) }, + ) + + private fun buildShareText(): String { + return buildString { + params.requisites.fastForEach { item -> + appendLine("${item.titleForShare}: ${item.value}") + } + } + } +} \ No newline at end of file diff --git a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/addfunds/VirtualAccountAddFundsUM.kt b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/addfunds/VirtualAccountAddFundsUM.kt new file mode 100644 index 0000000000..4665eddc8f --- /dev/null +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/addfunds/VirtualAccountAddFundsUM.kt @@ -0,0 +1,33 @@ +package com.tangem.features.virtualaccount.main.addfunds + +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.extensions.TextReference +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal data class VirtualAccountAddFundsUM( + val onDismiss: () -> Unit, + val content: Content, +) { + + @Immutable + sealed interface Content { + + data class Intro( + val onShowDetailsClick: () -> Unit, + ) : Content + + data class Details( + val items: ImmutableList, + val dailyLimit: String, + val onShareClick: () -> Unit, + ) : Content + } + + @Immutable + data class DetailItem( + val label: TextReference, + val value: String, + val onCopyClick: () -> Unit, + ) +} \ No newline at end of file diff --git a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/di/VirtualAccountMainModelModule.kt b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/di/VirtualAccountMainModelModule.kt index 9c621bc6fc..4b85e1f7d4 100644 --- a/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/di/VirtualAccountMainModelModule.kt +++ b/features/virtual-accounts/details/impl/src/main/kotlin/com/tangem/features/virtualaccount/main/di/VirtualAccountMainModelModule.kt @@ -3,6 +3,7 @@ package com.tangem.features.virtualaccount.main.di import com.tangem.core.decompose.di.ModelComponent import com.tangem.core.decompose.model.Model import com.tangem.features.virtualaccount.main.VirtualAccountMainModel +import com.tangem.features.virtualaccount.main.addfunds.VirtualAccountAddFundsModel import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -17,4 +18,9 @@ internal interface VirtualAccountMainModelModule { @IntoMap @ClassKey(VirtualAccountMainModel::class) fun bindVirtualAccountMainModel(model: VirtualAccountMainModel): Model + + @Binds + @IntoMap + @ClassKey(VirtualAccountAddFundsModel::class) + fun bindVirtualAccountAddFundsModel(model: VirtualAccountAddFundsModel): Model } \ No newline at end of file From d9092549aa176e9efe199c003354312a67dac289 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 24 Jun 2026 14:58:52 +0300 Subject: [PATCH 071/210] Updated on 2026-08-14 --- .../domain/token/MockCryptoCurrencyFactory.kt | 1 + core/res/src/main/res/values/strings.xml | 1 + .../converter/ExpressTxHistoryConverter.kt | 2 + .../express/models/ExchangeTransaction.kt | 3 + .../express/models/OnrampTransaction.kt | 3 + .../domain/txhistory/model/TxHistoryInfo.kt | 8 + ...istoryInfoToTxHistoryDetailsUMConverter.kt | 143 +++++++++++++-- .../txhistory/entity/TxHistoryDetailsUM.kt | 24 ++- .../txhistory/model/TxHistoryDetailsModel.kt | 3 + .../txhistory/ui/TxHistoryDetailsContent.kt | 16 ++ .../txhistory/ui/TxHistoryDetailsInfoRows.kt | 3 +- ...TxHistoryDetailsModalBottomSheetContent.kt | 62 +++++++ ...ryInfoToTxHistoryDetailsUMConverterTest.kt | 172 +++++++++++++++++- 13 files changed, 413 insertions(+), 28 deletions(-) diff --git a/common/test/src/main/java/com/tangem/common/test/domain/token/MockCryptoCurrencyFactory.kt b/common/test/src/main/java/com/tangem/common/test/domain/token/MockCryptoCurrencyFactory.kt index 70dbb5ec33..ca6fa0bed6 100644 --- a/common/test/src/main/java/com/tangem/common/test/domain/token/MockCryptoCurrencyFactory.kt +++ b/common/test/src/main/java/com/tangem/common/test/domain/token/MockCryptoCurrencyFactory.kt @@ -22,6 +22,7 @@ class MockCryptoCurrencyFactory(private val userWallet: UserWallet.Cold = defaul private val factory = CryptoCurrencyFactory(excludedBlockchains = ExcludedBlockchains()) + val bitcoin by lazy { createCoin(Blockchain.Bitcoin) } val cardano by lazy { createCoin(blockchain = Blockchain.Cardano) } val chia by lazy { createCoin(Blockchain.Chia) } val ethereum by lazy { createCoin(Blockchain.Ethereum) } diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index a0ebd7f24c..0f66cd5146 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -418,6 +418,7 @@ Privacy Policy %1$s-%2$s %1$s — %2$s + Rate Read more Receive Received diff --git a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverter.kt b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverter.kt index d911aa525b..8d001e036c 100644 --- a/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverter.kt +++ b/data/txhistory/src/main/kotlin/com/tangem/data/txhistory/repository/converter/ExpressTxHistoryConverter.kt @@ -66,6 +66,7 @@ internal class ExpressOnrampConverter : Converter Unit, - /** Own deposit addresses for this currency's network — used to label own-transfers as "Transfer". */ + private val onGoToProvider: (String) -> Unit, private val ownAddresses: Set = emptySet(), ) : Converter { @@ -159,7 +162,8 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter( isFaded = status is Status.Failed, ), statusBanner = swap.tx.status.toStatusBannerUM(), - rows = swap.toInfoRows(), + rows = swap.toInfoRows(onProviderClick = swap.providerClick(), rateRow = swap.tx.swapRateRow()), + providerButton = providerButton(swap.externalTxUrl, swap.tx.status.providerButtonLabel()), ) } @@ -185,7 +189,19 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverter( isFaded = status is Status.Failed, ), statusBanner = onramp.tx.status.toStatusBannerUM(), - rows = onramp.toInfoRows(), + rows = onramp.toInfoRows(onProviderClick = onramp.providerClick(), rateRow = onramp.tx.onrampRateRow()), + providerButton = providerButton(onramp.externalTxUrl, onramp.tx.status.providerButtonLabel()), + ) + } + + /** Opens the deal's provider page on tap; `null` when the deal has no provider link. */ + private fun ExpressTx.providerClick(): (() -> Unit)? = externalTxUrl?.let { url -> { onGoToProvider(url) } } + + private fun providerButton(url: String?, @StringRes label: Int?): TxHistoryDetailsUM.ProviderButtonUM? { + if (url == null || label == null) return null + return TxHistoryDetailsUM.ProviderButtonUM( + text = resourceReference(label), + onClick = { onGoToProvider(url) }, ) } @@ -285,6 +301,32 @@ private fun ExpressOnrampStatus.toStatusBannerUM(): TxHistoryDetailsUM.StatusBan ExpressOnrampStatus.Unknown -> null } +/** + * Label of the bottom CTA for an express swap, or `null` for statuses that need no provider action. The KYC + * [Verifying][ExpressExchangeStatus.Verifying] state sends the user to verification; the failure terminals send them + * to the provider (to track / refund). Mirrors the failed/verification banners (the existing express block uses the + * same per-tx link for both). + */ +@StringRes +private fun ExpressExchangeStatus.providerButtonLabel(): Int? = when (this) { + ExpressExchangeStatus.Verifying -> R.string.common_go_to_verification + ExpressExchangeStatus.Failed, + ExpressExchangeStatus.TxFailed, + ExpressExchangeStatus.Expired, + -> R.string.common_go_to_provider + else -> null +} + +/** Label of the bottom CTA for an express onramp, or `null` for statuses that need no provider action. */ +@StringRes +private fun ExpressOnrampStatus.providerButtonLabel(): Int? = when (this) { + ExpressOnrampStatus.Verifying -> R.string.common_go_to_verification + ExpressOnrampStatus.Failed, + ExpressOnrampStatus.Expired, + -> R.string.common_go_to_provider + else -> null +} + private fun loadingBanner(@StringRes title: Int) = TxHistoryDetailsUM.StatusBannerUM( severity = Severity.Info, title = resourceReference(title), @@ -333,26 +375,34 @@ private fun Status.statusAwareTitle(@StringRes pending: Int, @StringRes confirme // endregion -// region Info rows (network fee) +// region Info rows (provider / rate / network fee) /** Detail rows of an on-chain tx: the network-fee row when a fee with a value is present (rate is not surfaced). */ private fun TxInfo.toInfoRows(): ImmutableList = listOfNotNull(feeRow()).toImmutableList() /** - * Detail rows of an express op: the [provider] row (its name) followed by the network-fee row from the matched on-chain - * leg. The provider row is dropped while the provider is unresolved; the fee row while no on-chain leg / fee is present. - * (Rate is not surfaced yet — no data.) + * Detail rows of an express op, in order: the [provider] row (its name), the effective-[rateRow] row, then the + * network-fee row from the matched on-chain leg. Each is dropped when its data is absent — the provider while it is + * unresolved, the rate while an amount is missing / non-positive (see [swapRateRow] / [onrampRateRow]), the fee while + * no on-chain leg / fee is present. */ -private fun ExpressTx.toInfoRows(): ImmutableList = buildList { - provider?.let { add(it.providerRow()) } +private fun ExpressTx.toInfoRows( + onProviderClick: (() -> Unit)?, + rateRow: TxHistoryDetailsUM.InfoRowUM?, +): ImmutableList = buildList { + provider?.let { add(it.providerRow(onProviderClick)) } + rateRow?.let { add(it) } addAll(txInfo.toInfoRows()) }.toImmutableList() -private fun ExpressProvider.providerRow(): TxHistoryDetailsUM.InfoRowUM = TxHistoryDetailsUM.InfoRowUM( - label = resourceReference(R.string.express_provider), - value = stringReference(name), - trailingIconRes = R.drawable.ic_arrow_top_right_24, -) +private fun ExpressProvider.providerRow(onClick: (() -> Unit)?): TxHistoryDetailsUM.InfoRowUM = + TxHistoryDetailsUM.InfoRowUM( + label = resourceReference(R.string.express_provider), + value = stringReference(name), + // The arrow link affordance is shown only when the row opens the provider page. + trailingIconRes = onClick?.let { R.drawable.ic_arrow_top_right_24 }, + onClick = onClick, + ) /** Detail rows pulled from the matched on-chain leg of an express op; empty while the leg has not loaded. */ private fun OnChainTx?.toInfoRows(): ImmutableList = @@ -371,6 +421,71 @@ private fun TxInfo.feeRow(): TxHistoryDetailsUM.InfoRowUM? { // endregion +// region Rate row + +private const val RATE_MAX_DECIMALS = 8 +private const val RATE_IF_ZERO_DECIMALS = 2 + +/** + * Effective swap rate row `1 {from} ≈ {x} {to}`, computed on the fly as `x = toAmount / fromAmount` (`toAmount` is + * already the actual-or-expected payout — the data layer coalesces `actualAmount ?: amount`). Hidden (`null`) when an + * amount is missing or non-positive — there is then no rate to show and division by zero is avoided. + */ +private fun ExchangeTransaction.swapRateRow(): TxHistoryDetailsUM.InfoRowUM? { + val fromAmount = fromAsset.amount.takeIfPositive() ?: return null + val toAmount = toAsset.amount.takeIfPositive() ?: return null + val rate = toAmount.divide(fromAmount, rateScale(toAsset.decimals), RoundingMode.HALF_UP) + val baseSymbol = fromAsset.cryptoCurrency?.symbol ?: fromAsset.id.networkId + val quoteSymbol = toAsset.cryptoCurrency?.symbol ?: toAsset.id.networkId + val value = rateText( + base = oneOf(baseSymbol), + quote = rate.format { crypto(symbol = quoteSymbol, decimals = toAsset.decimals, ignoreSymbolPosition = true) }, + ) + return rateRowUM(value) +} + +/** + * Effective onramp rate row `1 {crypto} ≈ {x} {fiat}`, computed on the fly as `x = fiatPaid / cryptoReceived`. The API's + * nominal `rate` / `rate_usd` are intentionally ignored to avoid UI drift from hidden fees. Hidden (`null`) when an + * amount is missing or non-positive. + */ +private fun OnrampTransaction.onrampRateRow(): TxHistoryDetailsUM.InfoRowUM? { + val fiatPaid = fromFiat.value.takeIfPositive() ?: return null + val cryptoReceived = toAsset.amount.takeIfPositive() ?: return null + // Divide at full precision; the fiat formatter then rounds the rate to the currency's display scale. + val rate = fiatPaid.divide(cryptoReceived, RATE_MAX_DECIMALS, RoundingMode.HALF_UP) + val cryptoSymbol = toAsset.cryptoCurrency?.symbol ?: toAsset.id.networkId + val fiatCode = (fromFiat.type as? AmountType.FiatType)?.code ?: fromFiat.currencySymbol + val value = rateText( + base = oneOf(cryptoSymbol), + quote = rate.format { fiat(fiatCurrencyCode = fiatCode, fiatCurrencySymbol = fromFiat.currencySymbol) }, + ) + return rateRowUM(value) +} + +private fun rateRowUM(value: String): TxHistoryDetailsUM.InfoRowUM = TxHistoryDetailsUM.InfoRowUM( + label = resourceReference(R.string.common_rate), + value = stringReference(value), +) + +/** Division scale: the quote's decimals, capped at [RATE_MAX_DECIMALS]; a zero-decimal quote still shows two. */ +private fun rateScale(quoteDecimals: Int): Int = + (if (quoteDecimals == 0) RATE_IF_ZERO_DECIMALS else quoteDecimals).coerceAtMost(RATE_MAX_DECIMALS) + +/** + * Leading `1 {symbol}` of the rate, e.g. `1 POL` — number-first, matching the amount legs (the crypto formatter forces a + * two-decimal minimum, so the literal `1` is built directly rather than via [crypto]). + */ +private fun oneOf(symbol: String): String = "1${StringsSigns.NON_BREAKING_SPACE}$symbol" + +private fun rateText(base: String, quote: String): String { + return "${base.trim()} ${StringsSigns.APPROXIMATE} ${quote.trim()}" +} + +private fun BigDecimal?.takeIfPositive(): BigDecimal? = this?.takeIf { it > BigDecimal.ZERO } + +// endregion + // region Amount building helpers /** Leading sign of the pay-in / "You send" leg: `−` while in flight or settled, dropped on a failed deal. */ diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt index 1a6e454f79..179cd1d3c6 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/entity/TxHistoryDetailsUM.kt @@ -38,9 +38,10 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent { * * [from] ("You send") → [to] ("You receive") exchange block. Both are nullable: when a leg cannot be built (e.g. a * future express variant with no asset data) the card falls back to a header-only placeholder. [statusBanner] is - * the express status plaque under the block, `null` until status is known. [rows] carries the provider row (its - * name) followed by the network-fee row pulled from the matched on-chain leg (`ExpressTx.txInfo`); each is dropped - * when its data is unavailable (rate is not surfaced yet — no data). + * the express status plaque under the block, `null` until status is known. [rows] carries, in order, the provider + * row (its name), the effective-rate row, and the network-fee row pulled from the matched on-chain leg + * (`ExpressTx.txInfo`); each is dropped when its data is unavailable. [providerButton] is the bottom "Go to + * provider" / "Go to verification" CTA, `null` unless the deal is on a provider-actionable terminal with a link. */ data class TwoAssets( override val header: HeaderUM, @@ -48,6 +49,7 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent { val to: AssetUM? = null, val statusBanner: StatusBannerUM? = null, val rows: ImmutableList = persistentListOf(), + val providerButton: ProviderButtonUM? = null, ) : TxHistoryDetailsUM /** @@ -69,6 +71,19 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent { enum class Severity { Info, Success, Error, Warning } } + /** + * Bottom call-to-action of the two-asset card, shown only on the provider-actionable terminals of an express deal + * (failed / expired → "Go to provider"; KYC verification → "Go to verification") and only when the deal carries a + * provider link. [onClick] opens that link (`ExpressTx.externalTxUrl`). + * + * @property text Button label ("Go to provider" / "Go to verification"). + * @property onClick Opens the provider's page for this deal. + */ + data class ProviderButtonUM( + val text: TextReference, + val onClick: () -> Unit, + ) + /** * One side of the two-asset block: the [label] over the signed [amount], with the [currencyIcon] on the trailing * side. [owner] `null` → plain label ("You send"); non-null → "From"/"To" prefix plus the resolved own account / @@ -133,11 +148,14 @@ internal sealed interface TxHistoryDetailsUM : TangemBottomSheetConfigContent { * * [trailingIconRes] is an optional glyph drawn after the [value] (e.g. the arrow-up-right link affordance on the * provider row); `null` leaves the trailing slot text-only. + * + * [onClick] makes the row tappable (e.g. the provider row opens the provider page); `null` makes it non-interactive. */ data class InfoRowUM( val label: TextReference, val value: TextReference, @DrawableRes val trailingIconRes: Int? = null, + val onClick: (() -> Unit)? = null, ) /** diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryDetailsModel.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryDetailsModel.kt index 52c3732eab..9837280310 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryDetailsModel.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/model/TxHistoryDetailsModel.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Stable 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.navigation.url.UrlOpener import com.tangem.core.ui.clipboard.ClipboardManager import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier import com.tangem.features.txhistory.component.TxHistoryDetailsComponent @@ -25,6 +26,7 @@ import javax.inject.Inject internal class TxHistoryDetailsModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val clipboardManager: ClipboardManager, + private val urlOpener: UrlOpener, multiAccountStatusListSupplier: MultiAccountStatusListSupplier, paramsContainer: ParamsContainer, ) : Model() { @@ -43,6 +45,7 @@ internal class TxHistoryDetailsModel @Inject constructor( TxHistoryInfoToTxHistoryDetailsUMConverter( currency = params.currency, onCopyAddress = ::onCopyAddress, + onGoToProvider = urlOpener::openUrl, ownAddresses = ownAddresses, ).convert(txInfo) } diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsContent.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsContent.kt index 14ed97a642..203f473ed4 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsContent.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsContent.kt @@ -11,8 +11,12 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds2.button.TangemButton import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.generated.icons.Icons +import com.tangem.core.ui.res.generated.icons.ic_chevron_right_20 import com.tangem.features.txhistory.entity.TxHistoryDetailsUM @Composable @@ -76,6 +80,18 @@ private fun TwoAssetsContent(state: TxHistoryDetailsUM.TwoAssets, modifier: Modi .fillMaxWidth() .padding(start = 16.dp, end = 16.dp, top = 16.dp), ) + // Bottom "Go to provider" / "Go to verification" CTA — only on a provider-actionable terminal with a link. + state.providerButton?.let { providerButton -> + TangemButton( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 16.dp), + variant = TangemButton.Variant.Primary, + text = providerButton.text, + iconEnd = TangemIconUM.Icon(Icons.ic_chevron_right_20), + onClick = providerButton.onClick, + ) + } } } diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsInfoRows.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsInfoRows.kt index 3341e7358b..66e5ebf47f 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsInfoRows.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsInfoRows.kt @@ -54,7 +54,8 @@ internal fun TxHistoryDetailsInfoRows(rows: ImmutableList, modifier: rows.forEachIndexed { index, row -> TangemRow( divider = index < lastIndex, - contentLead = TangemRowContentLead.Start, + contentLead = TangemRowContentLead.End, + onClick = row.onClick, titleSlot = { TangemRowText(text = row.label, role = TangemRowTextRole.Title) }, valueSlot = { Row( diff --git a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsModalBottomSheetContent.kt b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsModalBottomSheetContent.kt index bf58d7e39c..ce6000cd73 100644 --- a/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsModalBottomSheetContent.kt +++ b/features/txhistory/impl/src/main/kotlin/com/tangem/features/txhistory/ui/TxHistoryDetailsModalBottomSheetContent.kt @@ -53,6 +53,15 @@ private fun TxHistoryDetailsModalBottomSheetContentPreview() { } } +@Preview(showBackground = true, device = Devices.PIXEL_7_PRO) +@Preview(showBackground = true, device = Devices.PIXEL_7_PRO, uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun TxHistoryDetailsModalBottomSheetContentTwoAssetsPreview() { + TangemThemePreviewRedesign { + TxHistoryDetailsModalBottomSheetContent(state = previewTwoAssets(), onDismiss = {}) + } +} + /** Fully-populated single-asset state exercising every sub-view: header, amount block, counterparty and info rows. */ private fun previewSingleAsset() = TxHistoryDetailsUM.SingleAsset( header = TxHistoryDetailsUM.HeaderUM( @@ -85,4 +94,57 @@ private fun previewSingleAsset() = TxHistoryDetailsUM.SingleAsset( ), ) +/** Failed swap exercising the two-asset body: both legs, the error status banner, provider link row and the CTA. */ +private fun previewTwoAssets() = TxHistoryDetailsUM.TwoAssets( + header = TxHistoryDetailsUM.HeaderUM( + iconRes = R.drawable.ic_exchange_vertical_24, + status = Status.Failed, + title = stringReference("Swap"), + subtitle = stringReference("Jan 20 2026, 9:24 PM"), + ), + from = TxHistoryDetailsUM.AssetUM( + label = stringReference("You send"), + owner = null, + amount = stringReference("- 1.5 ETH"), + currencyIcon = CurrencyIconState.CoinIcon( + url = null, + fallbackResId = R.drawable.img_eth_22, + isGrayscale = false, + shouldShowCustomBadge = false, + ), + isFaded = true, + ), + to = TxHistoryDetailsUM.AssetUM( + label = stringReference("You receive"), + owner = null, + amount = stringReference("+ 0.001 BTC"), + currencyIcon = CurrencyIconState.CoinIcon( + url = null, + fallbackResId = R.drawable.img_btc_22, + isGrayscale = false, + shouldShowCustomBadge = false, + ), + isFaded = true, + ), + statusBanner = TxHistoryDetailsUM.StatusBannerUM( + severity = TxHistoryDetailsUM.StatusBannerUM.Severity.Error, + title = stringReference("Failed"), + subtitle = stringReference("Funds will be refunded by the provider"), + isLoading = false, + ), + rows = persistentListOf( + TxHistoryDetailsUM.InfoRowUM( + label = stringReference("Provider"), + value = stringReference("Changelly"), + trailingIconRes = R.drawable.ic_arrow_top_right_24, + onClick = {}, + ), + TxHistoryDetailsUM.InfoRowUM(label = stringReference("Network fee"), value = stringReference("0.00056 ETH")), + ), + providerButton = TxHistoryDetailsUM.ProviderButtonUM( + text = stringReference("Go to provider"), + onClick = {}, + ), +) + // endregion \ No newline at end of file diff --git a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverterTest.kt b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverterTest.kt index 505744835a..e92ab06cf2 100644 --- a/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverterTest.kt +++ b/features/txhistory/impl/src/test/kotlin/com/tangem/features/txhistory/converter/TxHistoryInfoToTxHistoryDetailsUMConverterTest.kt @@ -38,15 +38,23 @@ import java.math.BigDecimal @TestInstance(TestInstance.Lifecycle.PER_CLASS) internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest { - private val currency = MockCryptoCurrencyFactory().ethereum + private val mockCurrencyFactory = MockCryptoCurrencyFactory() + private val currency = mockCurrencyFactory.ethereum + + // The express payout leg: a real Bitcoin coin so the resolved symbol (BTC) matches the "bitcoin" network id. + private val bitcoin = mockCurrencyFactory.bitcoin private val copiedAddresses = mutableListOf() + private val openedUrls = mutableListOf() private val converter = TxHistoryInfoToTxHistoryDetailsUMConverter( currency = currency, onCopyAddress = copiedAddresses::add, + onGoToProvider = openedUrls::add, ) @BeforeEach fun setUp() { + copiedAddresses.clear() + openedUrls.clear() // The header subtitle formats the date via DateTimeFormatters -> DateFormat.getBestDateTimePattern, // which is an Android stub on the JVM. Mirror the DateTimeFormattersTest mock so convert() runs. mockkStatic(DateFormat::class) @@ -136,6 +144,7 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest { val ownConverter = TxHistoryInfoToTxHistoryDetailsUMConverter( currency = currency, onCopyAddress = copiedAddresses::add, + onGoToProvider = openedUrls::add, ownAddresses = setOf(USER_ADDRESS), ) val tx = onChain( @@ -157,6 +166,7 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest { val ownConverter = TxHistoryInfoToTxHistoryDetailsUMConverter( currency = currency, onCopyAddress = copiedAddresses::add, + onGoToProvider = openedUrls::add, ownAddresses = setOf(USER_ADDRESS), ) val tx = onChain( @@ -472,24 +482,49 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest { // Act val result = converter.convert(expressSwap(status = ExpressExchangeStatus.Finished, txInfo = leg)) as TxHistoryDetailsUM.TwoAssets - // Assert - assertThat(result.rows).hasSize(1) - assertThat(result.rows.first().label).isEqualTo(resourceReference(R.string.common_network_fee_title)) + // Assert — no provider in the fixture, so rate then the on-chain leg's network fee. + assertThat(result.rows.map { it.label }).containsExactly( + resourceReference(R.string.common_rate), + resourceReference(R.string.common_network_fee_title), + ).inOrder() } @Test - fun `GIVEN express swap with provider WHEN convert THEN provider row with its name and link icon`() { + fun `GIVEN express swap with provider and url WHEN convert THEN provider row links to the url`() { // Act + val result = converter.convert( + expressSwap( + status = ExpressExchangeStatus.Finished, + provider = provider(name = "Mercuryo"), + externalTxUrl = EXTERNAL_URL, + ), + ) as TxHistoryDetailsUM.TwoAssets + + // Assert — provider then rate (no on-chain leg, so no fee row). + assertThat(result.rows.map { it.label }).containsExactly( + resourceReference(R.string.express_provider), + resourceReference(R.string.common_rate), + ).inOrder() + val providerRow = result.rows.first() + assertThat(providerRow.label).isEqualTo(resourceReference(R.string.express_provider)) + assertThat(providerRow.value.resolveString()).isEqualTo("Mercuryo") + assertThat(providerRow.trailingIconRes).isEqualTo(R.drawable.ic_arrow_top_right_24) + providerRow.onClick?.invoke() + assertThat(openedUrls).containsExactly(EXTERNAL_URL) + } + + @Test + fun `GIVEN express swap with provider but no url WHEN convert THEN provider row has no link`() { + // Act — the provider supplies no link (e.g. DEX), so the row is plain text. val result = converter.convert( expressSwap(status = ExpressExchangeStatus.Finished, provider = provider(name = "Mercuryo")), ) as TxHistoryDetailsUM.TwoAssets // Assert - assertThat(result.rows).hasSize(1) val providerRow = result.rows.first() - assertThat(providerRow.label).isEqualTo(resourceReference(R.string.express_provider)) assertThat(providerRow.value.resolveString()).isEqualTo("Mercuryo") - assertThat(providerRow.trailingIconRes).isEqualTo(R.drawable.ic_arrow_top_right_24) + assertThat(providerRow.trailingIconRes).isNull() + assertThat(providerRow.onClick).isNull() } @Test @@ -508,10 +543,61 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest { // Assert assertThat(result.rows.map { it.label }).containsExactly( resourceReference(R.string.express_provider), + resourceReference(R.string.common_rate), resourceReference(R.string.common_network_fee_title), ).inOrder() } + @Test + fun `GIVEN express swap with both amounts WHEN convert THEN rate row 1 from approx to follows provider`() { + // Act — no on-chain leg, so the rows are provider then rate. + val result = converter.convert( + expressSwap(status = ExpressExchangeStatus.Finished, provider = provider(name = "Changelly")), + ) as TxHistoryDetailsUM.TwoAssets + + // Assert + assertThat(result.rows.map { it.label }).containsExactly( + resourceReference(R.string.express_provider), + resourceReference(R.string.common_rate), + ).inOrder() + val rate = result.rows[1].value.resolveString() + // 0.001 BTC / 1.5 ETH ≈ 0.00066667; base falls back to the unresolved from-leg network id, quote to BTC. + assertThat(rate).startsWith("1") + assertThat(rate).contains("≈") + assertThat(rate).contains("ethereum") + assertThat(rate).contains("BTC") + } + + @Test + fun `GIVEN express swap with non-positive amount WHEN convert THEN no rate row`() { + // Arrange — a zero pay-in makes the rate undefined; the row is dropped (division-by-zero guard). + val base = expressSwap(status = ExpressExchangeStatus.Finished, provider = provider(name = "Changelly")) + val swap = base.copy(tx = base.tx.copy(fromAsset = base.tx.fromAsset.copy(amount = BigDecimal.ZERO))) + + // Act + val result = converter.convert(swap) as TxHistoryDetailsUM.TwoAssets + + // Assert — only the provider row remains. + assertThat(result.rows.map { it.label }).containsExactly(resourceReference(R.string.express_provider)) + } + + @Test + fun `GIVEN express onramp with both amounts WHEN convert THEN rate row 1 crypto approx fiat`() { + // Act + val result = converter.convert( + expressOnramp(status = ExpressOnrampStatus.Finished), + ) as TxHistoryDetailsUM.TwoAssets + + // Assert — onramp has no provider in the fixture, so the only row is the rate. + assertThat(result.rows.map { it.label }).containsExactly(resourceReference(R.string.common_rate)) + val rate = result.rows.first().value.resolveString() + // 100 SEK / 0.006 BTC ≈ 16,666.67 SEK; base is the resolved crypto symbol (BTC). + assertThat(rate).startsWith("1") + assertThat(rate).contains("≈") + assertThat(rate).contains("BTC") + assertThat(rate).contains("SEK") + } + @Test fun `GIVEN finished express onramp WHEN convert THEN paid fiat is unsigned and topped-up crypto is plus`() { // Act @@ -587,6 +673,67 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest { assertThat(result.statusBanner).isNull() } + @Test + fun `GIVEN failed express swap with url WHEN convert THEN go-to-provider button opening the url`() { + // Act + val result = converter.convert( + expressSwap(status = ExpressExchangeStatus.Failed, externalTxUrl = EXTERNAL_URL), + ) as TxHistoryDetailsUM.TwoAssets + + // Assert + val button = result.providerButton + assertThat(button?.text).isEqualTo(resourceReference(R.string.common_go_to_provider)) + button?.onClick?.invoke() + assertThat(openedUrls).containsExactly(EXTERNAL_URL) + } + + @Test + fun `GIVEN verifying express swap with url WHEN convert THEN go-to-verification button`() { + // Act + val result = converter.convert( + expressSwap(status = ExpressExchangeStatus.Verifying, externalTxUrl = EXTERNAL_URL), + ) as TxHistoryDetailsUM.TwoAssets + + // Assert + assertThat(result.providerButton?.text).isEqualTo(resourceReference(R.string.common_go_to_verification)) + } + + @Test + fun `GIVEN verifying express onramp with url WHEN convert THEN go-to-verification button opening the url`() { + // Act + val result = converter.convert( + expressOnramp(status = ExpressOnrampStatus.Verifying, externalTxUrl = EXTERNAL_URL), + ) as TxHistoryDetailsUM.TwoAssets + + // Assert + val button = result.providerButton + assertThat(button?.text).isEqualTo(resourceReference(R.string.common_go_to_verification)) + button?.onClick?.invoke() + assertThat(openedUrls).containsExactly(EXTERNAL_URL) + } + + @Test + fun `GIVEN failed express swap without url WHEN convert THEN no provider button`() { + // Act — the provider supplies no link (e.g. DEX), so there is nowhere to send the user. + val result = converter.convert( + expressSwap(status = ExpressExchangeStatus.Failed, externalTxUrl = null), + ) as TxHistoryDetailsUM.TwoAssets + + // Assert + assertThat(result.providerButton).isNull() + } + + @Test + fun `GIVEN finished express swap with url WHEN convert THEN no provider button`() { + // Act — a settled success needs no provider action even when a link exists. + val result = converter.convert( + expressSwap(status = ExpressExchangeStatus.Finished, externalTxUrl = EXTERNAL_URL), + ) as TxHistoryDetailsUM.TwoAssets + + // Assert + assertThat(result.providerButton).isNull() + } + // endregion private fun onChain( @@ -626,6 +773,7 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest { isOutgoing: Boolean = true, txInfo: OnChainTx? = null, provider: ExpressProvider? = null, + externalTxUrl: String? = null, ): ExpressTx.Swap = ExpressTx.Swap( tx = ExchangeTransaction( txId = "swap-1", @@ -639,8 +787,9 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest { networkId = "bitcoin", amount = BigDecimal("0.001"), decimals = 8, - cryptoCurrency = currency, + cryptoCurrency = bitcoin, ), + externalTxUrl = externalTxUrl, ), isOutgoing = isOutgoing, txInfo = txInfo, @@ -649,6 +798,7 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest { private fun expressOnramp( status: ExpressOnrampStatus, txInfo: OnChainTx? = null, + externalTxUrl: String? = null, ): ExpressTx.Onramp = ExpressTx.Onramp( tx = OnrampTransaction( txId = "onramp-1", @@ -656,6 +806,7 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest { createdAtMillis = TIMESTAMP, provider = null, payoutHash = null, + externalTxUrl = externalTxUrl, fromFiat = Amount( currencySymbol = "SEK", value = BigDecimal("100"), @@ -666,7 +817,7 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest { networkId = "bitcoin", amount = BigDecimal("0.006"), decimals = 8, - cryptoCurrency = currency, + cryptoCurrency = bitcoin, ), ), txInfo = txInfo, @@ -692,5 +843,6 @@ internal class TxHistoryInfoToTxHistoryDetailsUMConverterTest { const val TIMESTAMP = 1_700_000_000_000L const val USER_ADDRESS = "0x1234567890abcdef1234" const val VALIDATOR_ADDRESS = "0xvalidator" + const val EXTERNAL_URL = "https://provider.example/tx/swap-1" } } \ No newline at end of file From 78c4581372ebbd01b151d190e56b40856018b4bc Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 29 Jun 2026 13:41:06 +0300 Subject: [PATCH 072/210] Updated on 2026-08-14 --- .../customerio/CustomerIoAnalyticsHandler.kt | 17 ++++---------- .../customerio/CustomerIoLogClient.kt | 23 ------------------- 2 files changed, 5 insertions(+), 35 deletions(-) delete mode 100644 app/src/main/java/com/tangem/tap/common/analytics/handlers/customerio/CustomerIoLogClient.kt diff --git a/app/src/main/java/com/tangem/tap/common/analytics/handlers/customerio/CustomerIoAnalyticsHandler.kt b/app/src/main/java/com/tangem/tap/common/analytics/handlers/customerio/CustomerIoAnalyticsHandler.kt index a86b651af3..39e9f3072c 100644 --- a/app/src/main/java/com/tangem/tap/common/analytics/handlers/customerio/CustomerIoAnalyticsHandler.kt +++ b/app/src/main/java/com/tangem/tap/common/analytics/handlers/customerio/CustomerIoAnalyticsHandler.kt @@ -34,18 +34,11 @@ class CustomerIoAnalyticsHandler( class Builder : AnalyticsHandlerBuilder { override fun build(data: AnalyticsHandlerBuilder.Data): AnalyticsHandler? { val cdpApiKey = data.config.customerIoCdpApiKey - return if (data.logConfig.isCustomerIoLogEnabled) { - CustomerIoAnalyticsHandler(client = CustomerIoLogClient()) - } else if (!cdpApiKey.isNullOrBlank()) { - CustomerIoAnalyticsHandler( - client = CustomerIoClient( - application = data.application, - cdpApiKey = cdpApiKey, - ), - ) - } else { - null - } + if (cdpApiKey.isNullOrBlank()) return null + + return CustomerIoAnalyticsHandler( + client = CustomerIoClient(application = data.application, cdpApiKey = cdpApiKey), + ) } } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/common/analytics/handlers/customerio/CustomerIoLogClient.kt b/app/src/main/java/com/tangem/tap/common/analytics/handlers/customerio/CustomerIoLogClient.kt deleted file mode 100644 index 6773cce899..0000000000 --- a/app/src/main/java/com/tangem/tap/common/analytics/handlers/customerio/CustomerIoLogClient.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.tangem.tap.common.analytics.handlers.customerio - -import com.tangem.utils.logging.TangemLogger - -/** - * Log client for Customer.io (used in debug mode). - * - * Logs all operations to Timber instead of sending them to Customer.io. - */ -internal class CustomerIoLogClient : CustomerIoAnalyticsClient { - - private var userId: String? = null - - override fun setUserId(userId: String) { - this.userId = userId - TangemLogger.withTag(CustomerIoAnalyticsHandler.ID).d("identify: userId=$userId") - } - - override fun clearUserId() { - TangemLogger.withTag(CustomerIoAnalyticsHandler.ID).d("clearIdentify: previous userId=$userId") - this.userId = null - } -} \ No newline at end of file From 3069b8d32dfd7f01fd43fddb1adc84b5ab85f581 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 29 Jun 2026 15:20:33 +0400 Subject: [PATCH 073/210] Updated on 2026-08-14 --- .../common/ui/notifications/NotificationUM.kt | 5 ++ .../configs/feature_toggles_config.json | 4 + .../tangem/data/quotes/di/QuotesDataModule.kt | 9 ++ domain/quotes/build.gradle.kts | 2 + .../domain/quotes/IsHighNetworkFeeUseCase.kt | 27 ++++++ .../quotes/IsHighNetworkFeeUseCaseTest.kt | 87 +++++++++++++++++++ .../features/send/api/SendFeatureToggles.kt | 4 +- .../send/DefaultSendFeatureToggles.kt | 12 ++- .../send/confirm/model/SendConfirmModel.kt | 15 +++- ...dConfirmationNotificationsTransformerV2.kt | 8 ++ .../features/send/send/SendModelTestBase.kt | 6 ++ ...firmationNotificationsTransformerV2Test.kt | 31 +++++++ .../swap/v2/api/SwapFeatureToggles.kt | 1 + .../swap/v2/impl/DefaultSwapFeatureToggles.kt | 3 + .../confirm/model/SendWithSwapConfirmModel.kt | 13 ++- ...wapConfirmationNotificationsTransformer.kt | 11 ++- .../features/swap/SwapFeatureToggles.kt | 1 + .../feature/swap/DefaultSwapFeatureToggles.kt | 5 ++ .../tangem/feature/swap/model/SwapModel.kt | 45 ++++++---- .../swap/model/SwapNotificationsFactory.kt | 8 ++ .../tangem/feature/swap/ui/StateBuilder.kt | 2 + .../swap/StateBuilderSwapButtonTest.kt | 5 ++ .../feature/swap/model/SwapModelTestBase.kt | 3 + 23 files changed, 287 insertions(+), 20 deletions(-) create mode 100644 domain/quotes/src/main/java/com/tangem/domain/quotes/IsHighNetworkFeeUseCase.kt create mode 100644 domain/quotes/src/test/kotlin/com/tangem/domain/quotes/IsHighNetworkFeeUseCaseTest.kt diff --git a/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationUM.kt b/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationUM.kt index b7fc3cc35a..7272171df2 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationUM.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/notifications/NotificationUM.kt @@ -235,6 +235,11 @@ sealed class NotificationUM(val config: NotificationConfig) { subtitle = resourceReference(id = R.string.send_notification_fee_too_high_text, wrappedList(value)), ) + data object HighNetworkFee : Warning( + title = resourceReference(id = R.string.high_fee_warning_title), + subtitle = resourceReference(id = R.string.high_fee_warning_description), + ) + data class NetworkFeeUnreachable(val onRefresh: () -> Unit) : Warning( title = resourceReference(R.string.send_fee_unreachable_error_title), subtitle = resourceReference(R.string.send_fee_unreachable_error_text), diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index 97f8587ac4..4504deb187 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -179,6 +179,10 @@ "name": "TWI_1469_FOR_YOU_ENABLED", "version": "undefined" }, + { + "name": "TWI_1367_HIGH_FEE_WARNING_ENABLED", + "version": "undefined" + }, { "name": "TWI_1638_VA_MVP0_ENABLED", "version": "6.1" diff --git a/data/quotes/src/main/java/com/tangem/data/quotes/di/QuotesDataModule.kt b/data/quotes/src/main/java/com/tangem/data/quotes/di/QuotesDataModule.kt index ffd7ce636e..937d609175 100644 --- a/data/quotes/src/main/java/com/tangem/data/quotes/di/QuotesDataModule.kt +++ b/data/quotes/src/main/java/com/tangem/data/quotes/di/QuotesDataModule.kt @@ -15,6 +15,7 @@ import com.tangem.datasource.local.datastore.RuntimeSharedStore import com.tangem.datasource.utils.MoshiDataStoreSerializer import com.tangem.datasource.utils.mapWithStringKeyTypes import com.tangem.domain.quotes.GetCurrencyUSDQuoteUseCase +import com.tangem.domain.quotes.IsHighNetworkFeeUseCase import com.tangem.domain.quotes.QuotesRepository import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher import com.tangem.domain.quotes.multi.MultiQuoteUpdater @@ -79,4 +80,12 @@ internal object QuotesDataModule { fun provideGetCurrencyUSDQuoteUseCase(quotesRepository: QuotesRepository): GetCurrencyUSDQuoteUseCase { return GetCurrencyUSDQuoteUseCase(quotesRepository) } + + @Singleton + @Provides + fun provideIsHighNetworkFeeUseCase( + getCurrencyUSDQuoteUseCase: GetCurrencyUSDQuoteUseCase, + ): IsHighNetworkFeeUseCase { + return IsHighNetworkFeeUseCase(getCurrencyUSDQuoteUseCase) + } } \ No newline at end of file diff --git a/domain/quotes/build.gradle.kts b/domain/quotes/build.gradle.kts index 27f51399d9..e6946d8787 100644 --- a/domain/quotes/build.gradle.kts +++ b/domain/quotes/build.gradle.kts @@ -6,4 +6,6 @@ plugins { dependencies { api(projects.domain.core) api(projects.domain.models) + + testImplementation(projects.test.core) } \ No newline at end of file diff --git a/domain/quotes/src/main/java/com/tangem/domain/quotes/IsHighNetworkFeeUseCase.kt b/domain/quotes/src/main/java/com/tangem/domain/quotes/IsHighNetworkFeeUseCase.kt new file mode 100644 index 0000000000..b37ff91417 --- /dev/null +++ b/domain/quotes/src/main/java/com/tangem/domain/quotes/IsHighNetworkFeeUseCase.kt @@ -0,0 +1,27 @@ +package com.tangem.domain.quotes + +import com.tangem.domain.models.currency.CryptoCurrency +import java.math.BigDecimal + +/** + * Checks whether a network fee is higher than a single hardcoded USD threshold, applied uniformly + * across all networks. The fee USD value is computed from the fee currency's USD quote + * ([GetCurrencyUSDQuoteUseCase]), independent of the user's selected app currency. + * + * Returns `false` when there is no USD quote or no raw currency id — never warn without pricing data. + */ +class IsHighNetworkFeeUseCase( + private val getCurrencyUSDQuoteUseCase: GetCurrencyUSDQuoteUseCase, +) { + + suspend operator fun invoke(feeCurrency: CryptoCurrency, feeAmount: BigDecimal): Boolean { + val rawCurrencyId = feeCurrency.id.rawCurrencyId ?: return false + val usdRate = getCurrencyUSDQuoteUseCase(rawCurrencyId) ?: return false + + return feeAmount.multiply(usdRate) > HIGH_FEE_USD_THRESHOLD + } + + private companion object { + val HIGH_FEE_USD_THRESHOLD = BigDecimal("10") + } +} \ No newline at end of file diff --git a/domain/quotes/src/test/kotlin/com/tangem/domain/quotes/IsHighNetworkFeeUseCaseTest.kt b/domain/quotes/src/test/kotlin/com/tangem/domain/quotes/IsHighNetworkFeeUseCaseTest.kt new file mode 100644 index 0000000000..72310339ef --- /dev/null +++ b/domain/quotes/src/test/kotlin/com/tangem/domain/quotes/IsHighNetworkFeeUseCaseTest.kt @@ -0,0 +1,87 @@ +package com.tangem.domain.quotes + +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.models.currency.CryptoCurrency +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.math.BigDecimal + +internal class IsHighNetworkFeeUseCaseTest { + + private val getCurrencyUSDQuoteUseCase: GetCurrencyUSDQuoteUseCase = mockk() + private val feeCurrency: CryptoCurrency = mockk() + private val rawCurrencyId = CryptoCurrency.RawID("bitcoin") + + private val useCase = IsHighNetworkFeeUseCase(getCurrencyUSDQuoteUseCase) + + @BeforeEach + fun setup() { + clearMocks(getCurrencyUSDQuoteUseCase, feeCurrency) + every { feeCurrency.id.rawCurrencyId } returns rawCurrencyId + } + + @Test + fun `GIVEN fee usd value above threshold WHEN invoke THEN returns true`() = runTest { + // Arrange — 0.5 coin * 25 USD = 12.5 USD > 10 + coEvery { getCurrencyUSDQuoteUseCase(rawCurrencyId) } returns BigDecimal("25") + + // Act + val result = useCase(feeCurrency, BigDecimal("0.5")) + + // Assert + assertThat(result).isTrue() + } + + @Test + fun `GIVEN fee usd value below threshold WHEN invoke THEN returns false`() = runTest { + // Arrange — 0.2 coin * 25 USD = 5 USD < 10 + coEvery { getCurrencyUSDQuoteUseCase(rawCurrencyId) } returns BigDecimal("25") + + // Act + val result = useCase(feeCurrency, BigDecimal("0.2")) + + // Assert + assertThat(result).isFalse() + } + + @Test + fun `GIVEN fee usd value equal to threshold WHEN invoke THEN returns false`() = runTest { + // Arrange — 0.4 coin * 25 USD = 10 USD, not strictly above threshold + coEvery { getCurrencyUSDQuoteUseCase(rawCurrencyId) } returns BigDecimal("25") + + // Act + val result = useCase(feeCurrency, BigDecimal("0.4")) + + // Assert + assertThat(result).isFalse() + } + + @Test + fun `GIVEN no usd quote WHEN invoke THEN returns false`() = runTest { + // Arrange + coEvery { getCurrencyUSDQuoteUseCase(rawCurrencyId) } returns null + + // Act + val result = useCase(feeCurrency, BigDecimal("100")) + + // Assert + assertThat(result).isFalse() + } + + @Test + fun `GIVEN no raw currency id WHEN invoke THEN returns false`() = runTest { + // Arrange + every { feeCurrency.id.rawCurrencyId } returns null + + // Act + val result = useCase(feeCurrency, BigDecimal("100")) + + // Assert + assertThat(result).isFalse() + } +} \ No newline at end of file diff --git a/features/send/api/src/main/java/com/tangem/features/send/api/SendFeatureToggles.kt b/features/send/api/src/main/java/com/tangem/features/send/api/SendFeatureToggles.kt index 38ac439890..87b31ad6d7 100644 --- a/features/send/api/src/main/java/com/tangem/features/send/api/SendFeatureToggles.kt +++ b/features/send/api/src/main/java/com/tangem/features/send/api/SendFeatureToggles.kt @@ -1,3 +1,5 @@ package com.tangem.features.send.api -interface SendFeatureToggles \ No newline at end of file +interface SendFeatureToggles { + val isHighFeeWarningEnabled: Boolean +} \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/DefaultSendFeatureToggles.kt b/features/send/impl/src/main/java/com/tangem/features/send/DefaultSendFeatureToggles.kt index 6c5e2ee2bf..20a4eb7415 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/DefaultSendFeatureToggles.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/DefaultSendFeatureToggles.kt @@ -1,6 +1,16 @@ package com.tangem.features.send +import com.tangem.core.configtoggle.FeatureToggles +import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.features.send.api.SendFeatureToggles import javax.inject.Inject -internal class DefaultSendFeatureToggles @Inject constructor() : SendFeatureToggles \ No newline at end of file +internal class DefaultSendFeatureToggles @Inject constructor( + private val featureTogglesManager: FeatureTogglesManager, +) : SendFeatureToggles { + + override val isHighFeeWarningEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled( + toggle = FeatureToggles.TWI_1367_HIGH_FEE_WARNING_ENABLED, + ) +} \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/SendConfirmModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/SendConfirmModel.kt index bf529097bc..48dabef006 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/SendConfirmModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/SendConfirmModel.kt @@ -34,6 +34,7 @@ import com.tangem.domain.feedback.models.FeedbackEmailType import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.isHotWallet +import com.tangem.domain.quotes.IsHighNetworkFeeUseCase import com.tangem.domain.settings.IsSendTapHelpEnabledUseCase import com.tangem.domain.settings.NeverShowTapHelpUseCase import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase @@ -43,6 +44,7 @@ import com.tangem.domain.transaction.usecase.SendTransactionUseCase import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.domain.utils.convertToSdkAmount +import com.tangem.features.send.api.SendFeatureToggles import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents.SendScreenSource import com.tangem.features.send.api.subcomponents.amount.SendAmountReduceTrigger @@ -113,6 +115,8 @@ internal class SendConfirmModel @Inject constructor( private val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase, private val currenciesRepository: CurrenciesRepository, private val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase, + private val isHighNetworkFeeUseCase: IsHighNetworkFeeUseCase, + private val sendFeatureToggles: SendFeatureToggles, sendBalanceUpdaterFactory: SendBalanceUpdater.Factory, ) : Model(), SendConfirmClickIntents, FeeSelectorModelCallback, SendNotificationsComponent.ModelCallback { @@ -503,6 +507,7 @@ internal class SendConfirmModel @Inject constructor( private fun updateConfirmNotifications() { modelScope.launch { + val feeCryptoCurrencyStatus = getCurrencyStatusForFeePayment() notificationsUpdateTrigger.triggerUpdate( data = NotificationData( destinationAddress = confirmData.enteredDestination.orEmpty(), @@ -512,9 +517,10 @@ internal class SendConfirmModel @Inject constructor( isIgnoreReduce = confirmData.isIgnoreReduce, fee = confirmData.fee, feeError = confirmData.feeError, - feeCryptoCurrencyStatus = getCurrencyStatusForFeePayment(), + feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, ), ) + val isHighNetworkFee = isHighNetworkFee(feeCryptoCurrencyStatus.currency) _uiState.update { state -> state.copy( confirmUM = SendConfirmationNotificationsTransformerV2( @@ -524,12 +530,19 @@ internal class SendConfirmModel @Inject constructor( cryptoCurrency = cryptoCurrencyStatus.currency, appCurrency = appCurrency, analyticsCategoryName = params.analyticsCategoryName, + isHighNetworkFee = isHighNetworkFee, ).transform(uiState.value.confirmUM), ) } } } + private suspend fun isHighNetworkFee(feeCurrency: CryptoCurrency): Boolean { + if (!sendFeatureToggles.isHighFeeWarningEnabled) return false + val feeAmount = confirmData.fee?.amount?.value ?: return false + return isHighNetworkFeeUseCase(feeCurrency, feeAmount) + } + @Suppress("LongMethod") private fun configConfirmNavigation() { combine( diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2.kt index f6e8b1f7e8..e240d132d3 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2.kt @@ -29,6 +29,7 @@ internal class SendConfirmationNotificationsTransformerV2( private val cryptoCurrency: CryptoCurrency, private val appCurrency: AppCurrency, private val analyticsCategoryName: String, + private val isHighNetworkFee: Boolean = false, ) : Transformer { override fun transform(prevState: ConfirmUM): ConfirmUM { val state = prevState as? ConfirmUM.Content ?: return prevState @@ -38,10 +39,17 @@ internal class SendConfirmationNotificationsTransformerV2( notifications = buildList { addTooHighNotification(feeSelectorUM) addTooLowNotification(feeSelectorUM) + addHighNetworkFeeNotification() }.toPersistentList(), ) } + private fun MutableList.addHighNetworkFeeNotification() { + if (isHighNetworkFee) { + add(NotificationUM.Warning.HighNetworkFee) + } + } + private fun MutableList.addTooLowNotification(feeSelectorUM: FeeSelectorUM.Content) { if (FeeCalculationUtils.checkIfCustomFeeTooLow(feeSelectorUM)) { add(NotificationUM.Warning.FeeTooLow) diff --git a/features/send/impl/src/test/java/com/tangem/features/send/send/SendModelTestBase.kt b/features/send/impl/src/test/java/com/tangem/features/send/send/SendModelTestBase.kt index db5b2e13d6..8658c5b023 100644 --- a/features/send/impl/src/test/java/com/tangem/features/send/send/SendModelTestBase.kt +++ b/features/send/impl/src/test/java/com/tangem/features/send/send/SendModelTestBase.kt @@ -23,6 +23,7 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase +import com.tangem.domain.quotes.IsHighNetworkFeeUseCase import com.tangem.domain.transaction.usecase.CreateTransferTransactionUseCase import com.tangem.domain.transaction.usecase.GetFeeUseCase import com.tangem.domain.transaction.usecase.SendTransactionUseCase @@ -30,6 +31,7 @@ import com.tangem.domain.transaction.usecase.gasless.GetFeeForGaslessUseCase import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.send.api.SendComponent +import com.tangem.features.send.api.SendFeatureToggles import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.entity.PredefinedValues import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorCheckReloadListener @@ -122,6 +124,8 @@ internal abstract class SendModelTestBase { protected val manageCryptoCurrenciesUseCase: ManageCryptoCurrenciesUseCase = mockk(relaxed = true) protected val currenciesRepository: CurrenciesRepository = mockk(relaxed = true) protected val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase = mockk(relaxed = true) + protected val isHighNetworkFeeUseCase: IsHighNetworkFeeUseCase = mockk(relaxed = true) + protected val sendFeatureToggles: SendFeatureToggles = mockk(relaxed = true) protected val sendAnalyticHelper: SendAnalyticHelper = mockk(relaxed = true) protected val sendBalanceUpdaterFactory: SendBalanceUpdater.Factory = mockk(relaxed = true) @@ -233,6 +237,8 @@ internal abstract class SendModelTestBase { manageCryptoCurrenciesUseCase = manageCryptoCurrenciesUseCase, currenciesRepository = currenciesRepository, createAndSendGaslessTransactionUseCase = createAndSendGaslessTransactionUseCase, + isHighNetworkFeeUseCase = isHighNetworkFeeUseCase, + sendFeatureToggles = sendFeatureToggles, sendBalanceUpdaterFactory = sendBalanceUpdaterFactory, ) } diff --git a/features/send/impl/src/test/java/com/tangem/features/send/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2Test.kt b/features/send/impl/src/test/java/com/tangem/features/send/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2Test.kt index 05cd13af6c..8c82cb540d 100644 --- a/features/send/impl/src/test/java/com/tangem/features/send/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2Test.kt +++ b/features/send/impl/src/test/java/com/tangem/features/send/send/confirm/model/transformers/SendConfirmationNotificationsTransformerV2Test.kt @@ -76,6 +76,7 @@ class SendConfirmationNotificationsTransformerV2Test { cryptoCurrency = cryptoCurrency, appCurrency = appCurrency, analyticsCategoryName = analyticsCategoryName, + isHighNetworkFee = false, ) val initialState: ConfirmUM = ConfirmUM.Empty @@ -98,6 +99,7 @@ class SendConfirmationNotificationsTransformerV2Test { cryptoCurrency = cryptoCurrency, appCurrency = appCurrency, analyticsCategoryName = analyticsCategoryName, + isHighNetworkFee = false, ) val initialState = createTestConfirmUM() @@ -120,6 +122,7 @@ class SendConfirmationNotificationsTransformerV2Test { cryptoCurrency = cryptoCurrency, appCurrency = appCurrency, analyticsCategoryName = analyticsCategoryName, + isHighNetworkFee = false, ) val initialState = createTestConfirmUM() @@ -145,6 +148,7 @@ class SendConfirmationNotificationsTransformerV2Test { cryptoCurrency = cryptoCurrency, appCurrency = appCurrency, analyticsCategoryName = analyticsCategoryName, + isHighNetworkFee = false, ) val initialState = createTestConfirmUM() @@ -158,6 +162,31 @@ class SendConfirmationNotificationsTransformerV2Test { assertThat(content.notifications.first()).isInstanceOf(NotificationUM.Warning.TooHigh::class.java) } + @Test + fun `GIVEN high network fee WHEN transform THEN returns state with high network fee notification`() = runTest { + // GIVEN + val feeSelectorUM = createNormalFeeSelectorUM() + val amountUM = createTestAmountUM() + val transformer = SendConfirmationNotificationsTransformerV2( + feeSelectorUM = feeSelectorUM, + amountUM = amountUM, + analyticsEventHandler = analyticsEventHandler, + cryptoCurrency = cryptoCurrency, + appCurrency = appCurrency, + analyticsCategoryName = analyticsCategoryName, + isHighNetworkFee = true, + ) + val initialState = createTestConfirmUM() + + // WHEN + val result = transformer.transform(initialState) + + // THEN + assertThat(result).isInstanceOf(ConfirmUM.Content::class.java) + val content = result as ConfirmUM.Content + assertThat(content.notifications).containsExactly(NotificationUM.Warning.HighNetworkFee) + } + @Test fun `GIVEN fee too low WHEN transform THEN returns state with too low notification`() = runTest { // GIVEN @@ -170,6 +199,7 @@ class SendConfirmationNotificationsTransformerV2Test { cryptoCurrency = cryptoCurrency, appCurrency = appCurrency, analyticsCategoryName = analyticsCategoryName, + isHighNetworkFee = false, ) val initialState = createTestConfirmUM() @@ -196,6 +226,7 @@ class SendConfirmationNotificationsTransformerV2Test { cryptoCurrency = cryptoCurrency, appCurrency = appCurrency, analyticsCategoryName = analyticsCategoryName, + isHighNetworkFee = false, ) val initialState = createTestConfirmUM() diff --git a/features/swap-v2/api/src/main/java/com/tangem/features/swap/v2/api/SwapFeatureToggles.kt b/features/swap-v2/api/src/main/java/com/tangem/features/swap/v2/api/SwapFeatureToggles.kt index b0fb0a7b2c..7c26a78310 100644 --- a/features/swap-v2/api/src/main/java/com/tangem/features/swap/v2/api/SwapFeatureToggles.kt +++ b/features/swap-v2/api/src/main/java/com/tangem/features/swap/v2/api/SwapFeatureToggles.kt @@ -2,4 +2,5 @@ package com.tangem.features.swap.v2.api interface SwapFeatureToggles { val isSwapProviderFilterEnabled: Boolean + val isHighFeeWarningEnabled: Boolean } \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/DefaultSwapFeatureToggles.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/DefaultSwapFeatureToggles.kt index cf73e0fed6..4104bbaab3 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/DefaultSwapFeatureToggles.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/DefaultSwapFeatureToggles.kt @@ -10,4 +10,7 @@ internal class DefaultSwapFeatureToggles @Inject constructor( ) : SwapFeatureToggles { override val isSwapProviderFilterEnabled: Boolean = featureToggles.isFeatureEnabled(FeatureToggles.AND_15009_SWAP_PROVIDER_FILTER_ENABLED) + + override val isHighFeeWarningEnabled: Boolean = + featureToggles.isFeatureEnabled(FeatureToggles.TWI_1367_HIGH_FEE_WARNING_ENABLED) } \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt index 7ef6c2aa13..9c63c30efb 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt @@ -27,6 +27,7 @@ import com.tangem.domain.models.account.derivationIndex import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.isHotWallet +import com.tangem.domain.quotes.IsHighNetworkFeeUseCase import com.tangem.domain.settings.IsSendTapHelpEnabledUseCase import com.tangem.domain.swap.models.SwapAmountType import com.tangem.domain.swap.models.SwapDirection.Companion.withSwapDirection @@ -47,6 +48,7 @@ import com.tangem.features.send.api.subcomponents.destination.entity.Destination import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateListener import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateTrigger +import com.tangem.features.swap.v2.api.SwapFeatureToggles import com.tangem.features.swap.v2.api.subcomponents.SwapAmountUpdateTrigger import com.tangem.features.swap.v2.impl.R import com.tangem.features.swap.v2.impl.amount.SwapAmountReduceTrigger @@ -89,6 +91,8 @@ internal class SendWithSwapConfirmModel @Inject constructor( private val estimateFeeForTokenUseCase: EstimateFeeForTokenUseCase, private val estimateFeeForGaslessTxUseCase: EstimateFeeForGaslessTxUseCase, private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase, + private val isHighNetworkFeeUseCase: IsHighNetworkFeeUseCase, + private val swapFeatureToggles: SwapFeatureToggles, private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase, private val sendNotificationsUpdateTrigger: SendNotificationsUpdateTrigger, private val swapNotificationsUpdateTrigger: SwapNotificationsUpdateTrigger, @@ -467,12 +471,19 @@ internal class SendWithSwapConfirmModel @Inject constructor( feeValue = confirmData.fee?.amount?.value, ), ) + val isHighNetworkFee = isHighNetworkFee(feeCryptoCurrencyStatus.currency) uiState.transformerUpdate( - SendWithSwapConfirmationNotificationsTransformer(), + SendWithSwapConfirmationNotificationsTransformer(isHighNetworkFee = isHighNetworkFee), ) } } + private suspend fun isHighNetworkFee(feeCurrency: CryptoCurrency): Boolean { + if (!swapFeatureToggles.isHighFeeWarningEnabled) return false + val feeAmount = confirmData.fee?.amount?.value ?: return false + return isHighNetworkFeeUseCase(feeCurrency, feeAmount) + } + private fun subscribeOnNotificationUpdates() { combine( flow = sendNotificationsUpdateListener.hasErrorFlow, diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/transformers/SendWithSwapConfirmationNotificationsTransformer.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/transformers/SendWithSwapConfirmationNotificationsTransformer.kt index c62ecc15db..5f7e8286c0 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/transformers/SendWithSwapConfirmationNotificationsTransformer.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/transformers/SendWithSwapConfirmationNotificationsTransformer.kt @@ -22,7 +22,9 @@ import com.tangem.features.swap.v2.impl.sendviaswap.entity.SendWithSwapUM import com.tangem.utils.transformer.Transformer import kotlinx.collections.immutable.toPersistentList -internal class SendWithSwapConfirmationNotificationsTransformer : Transformer { +internal class SendWithSwapConfirmationNotificationsTransformer( + private val isHighNetworkFee: Boolean, +) : Transformer { override fun transform(prevState: SendWithSwapUM): SendWithSwapUM { val confirmUM = prevState.confirmUM as? ConfirmUM.Content ?: return prevState val feeSelectorUM = prevState.feeSelectorUM as? FeeSelectorUM.Content ?: return prevState @@ -34,11 +36,18 @@ internal class SendWithSwapConfirmationNotificationsTransformer : Transformer.addHighNetworkFeeNotification() { + if (isHighNetworkFee) { + add(NotificationUM.Warning.HighNetworkFee) + } + } + private fun MutableList.addTooLowNotification(feeSelectorUM: FeeSelectorUM.Content) { if (checkIfCustomFeeTooLow(feeSelectorUM = feeSelectorUM)) { add(NotificationUM.Warning.FeeTooLow) diff --git a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt index 91abcfd896..033fbb8f15 100644 --- a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt +++ b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapFeatureToggles.kt @@ -10,4 +10,5 @@ interface SwapFeatureToggles { val isSwapPredefinedButtonsEnabled: Boolean val isExpressShareButtonEnabled: Boolean val isSwapBestDexRateEnabled: Boolean + val isHighFeeWarningEnabled: Boolean } \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt index 1babbc9152..41d5567f61 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapFeatureToggles.kt @@ -52,4 +52,9 @@ internal class DefaultSwapFeatureToggles @Inject constructor( get() = featureTogglesManager.isFeatureEnabled( toggle = FeatureToggles.AND_15715_SWAP_BEST_DEX_RATE_ENABLED, ) && isSwapIntegratedApproveEnabled + + override val isHighFeeWarningEnabled: Boolean + get() = featureTogglesManager.isFeatureEnabled( + toggle = FeatureToggles.TWI_1367_HIGH_FEE_WARNING_ENABLED, + ) } \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index d46ad20a15..bd36406530 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -65,6 +65,7 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.isHotWallet import com.tangem.domain.pay.WithdrawalResult import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase +import com.tangem.domain.quotes.IsHighNetworkFeeUseCase import com.tangem.domain.settings.usercountry.GetUserCountryUseCase import com.tangem.domain.settings.usercountry.models.UserCountry import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions @@ -173,6 +174,7 @@ internal class SwapModel @Inject constructor( private val getSwapUiModeUseCase: GetSwapUiModeUseCase, private val setSwapUiModeUseCase: SetSwapUiModeUseCase, private val calculateAmountUseCase: CalculateAmountUseCase, + private val isHighNetworkFeeUseCase: IsHighNetworkFeeUseCase, ) : Model() { private val params = paramsContainer.require() @@ -1103,7 +1105,7 @@ internal class SwapModel @Inject constructor( ) } - private fun setupLoadedState( + private suspend fun setupLoadedState( provider: SwapProvider, state: SwapState, fromSwapCurrencyStatus: SwapCurrencyStatus, @@ -1127,7 +1129,7 @@ internal class SwapModel @Inject constructor( } } - private fun setupQuotesLoadedUiState(provider: SwapProvider, state: SwapState.QuotesLoadedState) { + private suspend fun setupQuotesLoadedUiState(provider: SwapProvider, state: SwapState.QuotesLoadedState) { val loadedStates = dataState.getLastLoadedSuccessStates() val additionalBadge = SwapProviderResolver.resolveBadge( provider = provider, @@ -1136,17 +1138,26 @@ internal class SwapModel @Inject constructor( state = state, isSwapBestDexRateEnabled = swapFeatureToggles.isSwapBestDexRateEnabled, ) + val swapFee = getSelectedSwapFee() uiState = stateBuilder.createQuotesLoadedState( uiStateHolder = uiState, quoteModel = state, feeCryptoCurrencyStatus = dataState.feePaidCryptoCurrency, swapProvider = provider, additionalBadge = additionalBadge, - swapFee = getSelectedSwapFee(), + swapFee = swapFee, feeError = feeSelectorRepository.state.value as? FeeSelectorUM.Error, + isHighNetworkFee = isHighNetworkFee(swapFee), ) } + private suspend fun isHighNetworkFee(swapFee: SwapFee?): Boolean { + if (!swapFeatureToggles.isHighFeeWarningEnabled) return false + swapFee ?: return false + val feeAmount = swapFee.fee.amount.value ?: return false + return isHighNetworkFeeUseCase(swapFee.selectedFeeToken.currency, feeAmount) + } + private fun sendAnalyticsForNotifications( provider: SwapProvider, fromToken: CryptoCurrencyStatus, @@ -2077,12 +2088,14 @@ internal class SwapModel @Inject constructor( } analyticsEventHandler.send(SwapEvents.ProviderChosen(provider)) uiState = stateBuilder.dismissBottomSheet(uiState) - setupLoadedState( - provider = provider, - state = swapState, - fromSwapCurrencyStatus = fromSwapCurrencyStatus, - toSwapCurrencyStatus = toSwapCurrencyStatus, - ) + modelScope.launch { + setupLoadedState( + provider = provider, + state = swapState, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + ) + } } }, onProviderFilterSelect = { filterType -> @@ -2823,12 +2836,14 @@ internal class SwapModel @Inject constructor( } }, ) - setupLoadedState( - provider = provider, - state = swapState, - fromSwapCurrencyStatus = fromSwapCurrencyStatus, - toSwapCurrencyStatus = toSwapCurrencyStatus, - ) + modelScope.launch { + setupLoadedState( + provider = provider, + state = swapState, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + ) + } } else { TangemLogger.e("loadFee: ${feeError.error}, isHidden = true") refreshTransferUIStateIfNeeded() diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt index 1d2ca68bb9..7f9c144c0f 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapNotificationsFactory.kt @@ -124,6 +124,7 @@ internal class SwapNotificationsFactory( swapFee: SwapFee?, feeError: GetFeeError?, appRouter: AppRouter, + isHighNetworkFee: Boolean = false, ): ImmutableList { val warnings = buildList { maybeAddFeeErrorNotification(feeCryptoCurrencyStatus, quoteModel, feeError) @@ -135,10 +136,17 @@ internal class SwapNotificationsFactory( maybeAddUnableCoverFeeWarning(quoteModel, feeCryptoCurrencyStatus, appRouter) maybeAddTransactionInProgressWarning(quoteModel) maybeAddPriceImpactNotification(quoteModel.priceImpact) + maybeAddHighNetworkFeeWarning(isHighNetworkFee) } return warnings.toPersistentList() } + private fun MutableList.maybeAddHighNetworkFeeWarning(isHighNetworkFee: Boolean) { + if (isHighNetworkFee) { + add(NotificationUM.Warning.HighNetworkFee) + } + } + private fun MutableList.maybeAddRentExemptionError(quoteModel: SwapState.QuotesLoadedState) { quoteModel.currencyCheck?.rentWarning?.let { add(NotificationUM.Solana.RentInfo(it)) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index 1875936424..fc355d82e7 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -577,6 +577,7 @@ internal class StateBuilder( additionalBadge: ProviderState.AdditionalBadge, swapFee: SwapFee?, feeError: FeeSelectorUM.Error?, + isHighNetworkFee: Boolean, ): SwapStateHolder { if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder @@ -590,6 +591,7 @@ internal class StateBuilder( swapFee = swapFee, feeError = feeError?.error, appRouter = appRouter, + isHighNetworkFee = isHighNetworkFee, ) val fromAccountTitleUM = when { diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderSwapButtonTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderSwapButtonTest.kt index e608eb2f17..86ac7453d5 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderSwapButtonTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderSwapButtonTest.kt @@ -109,6 +109,7 @@ internal class StateBuilderSwapButtonTest { additionalBadge = ProviderState.AdditionalBadge.Empty, swapFee = null, feeError = null, + isHighNetworkFee = false, ) assertThat(result.swapButton.isEnabled).isTrue() @@ -134,6 +135,7 @@ internal class StateBuilderSwapButtonTest { additionalBadge = ProviderState.AdditionalBadge.Empty, swapFee = null, feeError = null, + isHighNetworkFee = false, ) assertThat(result.swapButton.isEnabled).isFalse() @@ -163,6 +165,7 @@ internal class StateBuilderSwapButtonTest { additionalBadge = ProviderState.AdditionalBadge.Empty, swapFee = null, feeError = null, + isHighNetworkFee = false, ) assertThat(result.swapButton.isEnabled).isFalse() @@ -187,6 +190,7 @@ internal class StateBuilderSwapButtonTest { additionalBadge = ProviderState.AdditionalBadge.Empty, swapFee = buildSwapFee(), feeError = null, + isHighNetworkFee = false, ) assertThat(result.swapButton.isEnabled).isTrue() @@ -215,6 +219,7 @@ internal class StateBuilderSwapButtonTest { additionalBadge = ProviderState.AdditionalBadge.Empty, swapFee = buildSwapFee(), feeError = null, + isHighNetworkFee = false, ) assertThat(result.swapButton.isEnabled).isFalse() diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/model/SwapModelTestBase.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/model/SwapModelTestBase.kt index 6a80346c00..23510180fa 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/model/SwapModelTestBase.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/model/SwapModelTestBase.kt @@ -26,6 +26,7 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase +import com.tangem.domain.quotes.IsHighNetworkFeeUseCase import com.tangem.domain.settings.usercountry.GetUserCountryUseCase import com.tangem.domain.settings.usercountry.models.UserCountry import com.tangem.domain.stories.ShouldShowStoriesUseCase @@ -103,6 +104,7 @@ internal abstract class SwapModelTestBase { protected val getSwapUiModeUseCase: GetSwapUiModeUseCase = mockk(relaxed = true) protected val setSwapUiModeUseCase: SetSwapUiModeUseCase = mockk(relaxed = true) protected val calculateAmountUseCase: CalculateAmountUseCase = mockk(relaxed = true) + protected val isHighNetworkFeeUseCase: IsHighNetworkFeeUseCase = mockk(relaxed = true) protected val isWalletBackupProblematicUseCase: IsWalletBackupProblematicUseCase = mockk(relaxed = true) protected val sendBackupProblemEmailUseCase: SendBackupProblemEmailUseCase = mockk(relaxed = true) @@ -175,6 +177,7 @@ internal abstract class SwapModelTestBase { getSwapUiModeUseCase = getSwapUiModeUseCase, setSwapUiModeUseCase = setSwapUiModeUseCase, calculateAmountUseCase = calculateAmountUseCase, + isHighNetworkFeeUseCase = isHighNetworkFeeUseCase, isWalletBackupProblematicUseCase = isWalletBackupProblematicUseCase, sendBackupProblemEmailUseCase = sendBackupProblemEmailUseCase, ) From f5e14a9ab4361a31a43e79fc38b9839904152eed Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 25 Jun 2026 11:59:59 +0500 Subject: [PATCH 074/210] Updated on 2026-08-14 --- .../NavigationModelCallback.kt | 7 +- .../api/subcomponents/amount/AmountRoute.kt | 4 +- .../amount/SendAmountComponent.kt | 4 +- .../amount/SendAmountComponentParams.kt | 3 +- .../destination/DestinationRoute.kt | 4 +- .../destination/SendDestinationComponent.kt | 4 +- .../SendDestinationComponentParams.kt | 3 +- .../features/send/common/CommonSendRoute.kt | 2 +- .../amount/DefaultSendAmountComponent.kt | 50 +++++++++++ .../amount/model/SendAmountModel.kt | 48 +--------- .../DefaultSendDestinationComponent.kt | 51 +++++++++++ .../destination/model/SendDestinationModel.kt | 89 +++++-------------- .../amount/model/SendAmountNavigationTest.kt | 76 ++++++++-------- 13 files changed, 179 insertions(+), 166 deletions(-) diff --git a/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationModelCallback.kt b/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationModelCallback.kt index 2e84e796a8..e21434050b 100644 --- a/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationModelCallback.kt +++ b/common/ui/src/main/java/com/tangem/common/ui/navigationButtons/NavigationModelCallback.kt @@ -1,7 +1,8 @@ package com.tangem.common.ui.navigationButtons +import com.tangem.core.decompose.navigation.Route + interface NavigationModelCallback { - fun onNavigationResult(navigationUM: NavigationUM) - fun onBackClick() - fun onNextClick() + fun onBackClick(currentRoute: Route) + fun onNextClick(currentRoute: Route) } \ No newline at end of file diff --git a/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/amount/AmountRoute.kt b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/amount/AmountRoute.kt index afcdebbae2..a4a4ce0301 100644 --- a/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/amount/AmountRoute.kt +++ b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/amount/AmountRoute.kt @@ -1,8 +1,10 @@ package com.tangem.features.send.api.subcomponents.amount +import com.tangem.core.decompose.navigation.Route + /** * Common route for amount */ -interface AmountRoute { +interface AmountRoute : Route { val isEditMode: Boolean } \ No newline at end of file diff --git a/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/amount/SendAmountComponent.kt b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/amount/SendAmountComponent.kt index 79e3d44a57..759d7edb35 100644 --- a/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/amount/SendAmountComponent.kt +++ b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/amount/SendAmountComponent.kt @@ -3,10 +3,10 @@ package com.tangem.features.send.api.subcomponents.amount import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.navigationButtons.NavigationModelCallback import com.tangem.core.decompose.factory.ComponentFactory -import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.decompose.ComposableModularContentComponent import com.tangem.domain.wallets.models.errors.GetUserWalletError -interface SendAmountComponent : ComposableContentComponent { +interface SendAmountComponent : ComposableModularContentComponent { fun updateState(amountUM: AmountState) diff --git a/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/amount/SendAmountComponentParams.kt b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/amount/SendAmountComponentParams.kt index bda588bfee..e976dbb5ca 100644 --- a/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/amount/SendAmountComponentParams.kt +++ b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/amount/SendAmountComponentParams.kt @@ -9,7 +9,6 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.entity.PredefinedValues -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow sealed class SendAmountComponentParams { @@ -39,7 +38,7 @@ sealed class SendAmountComponentParams { override val accountFlow: StateFlow, override val isAccountModeFlow: StateFlow, val callback: SendAmountComponent.ModelCallback, - val currentRoute: Flow, + val route: AmountRoute, ) : SendAmountComponentParams() data class AmountBlockParams( diff --git a/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/destination/DestinationRoute.kt b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/destination/DestinationRoute.kt index c83ce3ebf0..79ac0df1d3 100644 --- a/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/destination/DestinationRoute.kt +++ b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/destination/DestinationRoute.kt @@ -1,8 +1,10 @@ package com.tangem.features.send.api.subcomponents.destination +import com.tangem.core.decompose.navigation.Route + /** * Common route for destination */ -interface DestinationRoute { +interface DestinationRoute : Route { val isEditMode: Boolean } \ No newline at end of file diff --git a/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/destination/SendDestinationComponent.kt b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/destination/SendDestinationComponent.kt index 6a323c5bf3..4eb849925a 100644 --- a/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/destination/SendDestinationComponent.kt +++ b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/destination/SendDestinationComponent.kt @@ -2,10 +2,10 @@ package com.tangem.features.send.api.subcomponents.destination import com.tangem.common.ui.navigationButtons.NavigationModelCallback import com.tangem.core.decompose.factory.ComponentFactory -import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.decompose.ComposableModularContentComponent import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM -interface SendDestinationComponent : ComposableContentComponent { +interface SendDestinationComponent : ComposableModularContentComponent { fun updateState(destinationUM: DestinationUM) diff --git a/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/destination/SendDestinationComponentParams.kt b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/destination/SendDestinationComponentParams.kt index ead4f35164..7399b00475 100644 --- a/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/destination/SendDestinationComponentParams.kt +++ b/features/send/api/src/main/java/com/tangem/features/send/api/subcomponents/destination/SendDestinationComponentParams.kt @@ -6,7 +6,6 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.entity.PredefinedValues import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow sealed class SendDestinationComponentParams { @@ -26,7 +25,7 @@ sealed class SendDestinationComponentParams { override val userWalletId: UserWalletId, val title: TextReference, val isBalanceHidingFlow: StateFlow, - val currentRoute: Flow, + val route: DestinationRoute, val callback: SendDestinationComponent.ModelCallback, override val isAllowSelfSend: Boolean = false, ) : SendDestinationComponentParams() diff --git a/features/send/impl/src/main/java/com/tangem/features/send/common/CommonSendRoute.kt b/features/send/impl/src/main/java/com/tangem/features/send/common/CommonSendRoute.kt index c098d9b414..9483285003 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/common/CommonSendRoute.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/common/CommonSendRoute.kt @@ -16,7 +16,7 @@ internal sealed class CommonSendRoute : Route { @Serializable data object Confirm : CommonSendRoute() { - override val isEditMode: Boolean = true + override val isEditMode: Boolean = false } @Serializable diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/DefaultSendAmountComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/DefaultSendAmountComponent.kt index 0732ec6492..2546c2eb3a 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/DefaultSendAmountComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/DefaultSendAmountComponent.kt @@ -1,14 +1,23 @@ package com.tangem.features.send.subcomponents.amount +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme import com.tangem.features.send.api.subcomponents.amount.SendAmountComponent import com.tangem.features.send.api.subcomponents.amount.SendAmountComponentParams +import com.tangem.features.send.impl.R import com.tangem.features.send.subcomponents.amount.model.SendAmountModel import com.tangem.features.send.subcomponents.amount.ui.SendAmountContent import dagger.assisted.Assisted @@ -24,6 +33,23 @@ internal class DefaultSendAmountComponent @AssistedInject constructor( override fun updateState(amountUM: AmountState) = model.updateState(amountUM) + @Composable + override fun Title() { + AppBarWithBackButtonAndIcon( + text = stringResourceSafe(R.string.send_amount_label), + onBackClick = { + params.callback.onBackClick(params.route) + }, + backIconRes = if (params.route.isEditMode) { + R.drawable.ic_back_24 + } else { + R.drawable.ic_close_24 + }, + backgroundColor = TangemTheme.colors.background.tertiary, + modifier = Modifier.height(TangemTheme.dimens.size56), + ) + } + @Composable override fun Content(modifier: Modifier) { val state by model.uiState.collectAsStateWithLifecycle() @@ -37,6 +63,30 @@ internal class DefaultSendAmountComponent @AssistedInject constructor( ) } + @Composable + override fun Footer() { + val state by model.uiState.collectAsStateWithLifecycle() + PrimaryButton( + text = if (params.route.isEditMode) { + stringResourceSafe(R.string.common_continue) + } else { + stringResourceSafe(R.string.common_next) + }, + enabled = state.isPrimaryButtonEnabled, + onClick = { + model.onAmountNext() + params.callback.onNextClick(params.route) + }, + modifier = Modifier + .fillMaxWidth() + .padding( + start = 16.dp, + end = 16.dp, + bottom = 16.dp, + ), + ) + } + @AssistedFactory interface Factory : SendAmountComponent.Factory { override fun create( diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/model/SendAmountModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/model/SendAmountModel.kt index 0b2b8231da..2d505312ed 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/model/SendAmountModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/model/SendAmountModel.kt @@ -10,8 +10,6 @@ import com.tangem.common.ui.amountScreen.models.AmountParameters import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter -import com.tangem.common.ui.navigationButtons.NavigationButton -import com.tangem.common.ui.navigationButtons.NavigationUM import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model @@ -29,14 +27,12 @@ import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.features.send.api.entity.PredefinedValues import com.tangem.features.send.api.entity.isFromMainScreenQr -import com.tangem.features.send.api.subcomponents.amount.AmountRoute import com.tangem.features.send.api.subcomponents.amount.SendAmountComponentParams import com.tangem.features.send.api.subcomponents.amount.SendAmountReduceListener import com.tangem.features.send.api.subcomponents.amount.SendAmountUpdateListener import com.tangem.features.send.api.subcomponents.amount.analytics.CommonSendAmountAnalyticEvents import com.tangem.features.send.api.subcomponents.amount.analytics.CommonSendAmountAnalyticEvents.SelectedCurrencyType import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger -import com.tangem.features.send.common.CommonSendRoute import com.tangem.features.send.impl.R import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.extensions.orZero @@ -84,7 +80,6 @@ internal class SendAmountModel @Inject constructor( private var maxAmountBoundary: EnterAmountBoundary by Delegates.notNull() init { - configAmountNavigation() initAppCurrency() subscribeOnCryptoCurrencyStatusFlow() subscribeOnAmountReduceByTriggerUpdates() @@ -104,6 +99,7 @@ internal class SendAmountModel @Inject constructor( }, ifRight = { wallet -> userWallet = wallet + setSendWithSwapAvailability() }, ) }.launchIn(modelScope) @@ -274,9 +270,7 @@ internal class SendAmountModel @Inject constructor( override fun onConvertToAnotherToken() { val amountParams = params as? SendAmountComponentParams.AmountParams ?: return modelScope.launch { - var isEditMode = false - amountParams.currentRoute.collect { route -> isEditMode = route.isEditMode } - if (isEditMode) { + if (amountParams.route.isEditMode) { sendAmountAlertFactory.showResetSendingAlert { params.callback.resetSendNavigation() confirmConvertToToken() @@ -363,44 +357,6 @@ internal class SendAmountModel @Inject constructor( ) } - private fun configAmountNavigation() { - val params = params as? SendAmountComponentParams.AmountParams ?: return - combine( - flow = uiState, - // Filter on the public AmountRoute interface (not the internal CommonSendRoute.Amount) so an - // external host (e.g. staking) that supplies its own AmountRoute is not silently dropped here. - flow2 = params.currentRoute.filterIsInstance(), - transform = { state, route -> state to route }, - ).onEach { (state, route) -> - setSendWithSwapAvailability() - params.callback.onNavigationResult( - NavigationUM.Content( - source = CommonSendRoute.Amount::class.java.simpleName, - title = resourceReference(R.string.send_amount_label), - subtitle = null, - backIconRes = if (route.isEditMode) { - R.drawable.ic_back_24 - } else { - R.drawable.ic_close_24 - }, - backIconClick = params.callback::onBackClick, - primaryButton = NavigationButton( - textReference = if (route.isEditMode) { - resourceReference(R.string.common_continue) - } else { - resourceReference(R.string.common_next) - }, - isEnabled = state.isPrimaryButtonEnabled, - onClick = { - onAmountNext() - params.callback.onNextClick() - }, - ), - ), - ) - }.launchIn(modelScope) - } - private fun setSendWithSwapAvailability() { // Allowed only in multicurrency wallets val isMultiCurrency = userWallet?.isMultiCurrency == true diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/DefaultSendDestinationComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/DefaultSendDestinationComponent.kt index e2f98cccea..648d4e5a93 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/DefaultSendDestinationComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/DefaultSendDestinationComponent.kt @@ -1,8 +1,13 @@ package com.tangem.features.send.subcomponents.destination +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.arkivanov.decompose.router.slot.childSlot @@ -14,9 +19,15 @@ import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.features.addressbook.AddressBookContactsBlockComponent import com.tangem.features.addressbook.AddressBookFeatureToggles import com.tangem.features.addressbook.AddressSelectorComponent +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponent import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM +import com.tangem.features.send.impl.R import com.tangem.features.send.subcomponents.destination.model.SendDestinationModel import com.tangem.features.send.subcomponents.destination.ui.SendDestinationContent import dagger.assisted.Assisted @@ -68,6 +79,22 @@ internal class DefaultSendDestinationComponent @AssistedInject constructor( override fun updateState(destinationUM: DestinationUM) = model.updateState(destinationUM) + @Composable + override fun Title() { + BackHandler(onBack = model::onBackClick) + AppBarWithBackButtonAndIcon( + text = params.title.resolveReference(), + onBackClick = model::onBackClick, + backIconRes = if (params.route.isEditMode) { + R.drawable.ic_back_24 + } else { + R.drawable.ic_close_24 + }, + backgroundColor = TangemTheme.colors.background.tertiary, + modifier = Modifier.height(TangemTheme.dimens.size56), + ) + } + @Composable override fun Content(modifier: Modifier) { val state by model.uiState.collectAsStateWithLifecycle() @@ -83,6 +110,30 @@ internal class DefaultSendDestinationComponent @AssistedInject constructor( selector.child?.instance?.BottomSheet() } + @Composable + override fun Footer() { + val state by model.uiState.collectAsStateWithLifecycle() + PrimaryButton( + text = if (params.route.isEditMode) { + stringResourceSafe(R.string.common_continue) + } else { + stringResourceSafe(R.string.common_next) + }, + enabled = state.isPrimaryButtonEnabled, + onClick = { + model.saveResult() + params.callback.onNextClick(params.route) + }, + modifier = Modifier + .fillMaxWidth() + .padding( + start = 16.dp, + end = 16.dp, + bottom = 16.dp, + ), + ) + } + @AssistedFactory interface Factory : SendDestinationComponent.Factory { override fun create( diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt index d493255dca..055470d142 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModel.kt @@ -8,14 +8,11 @@ import com.arkivanov.decompose.router.slot.activate import com.arkivanov.decompose.router.slot.dismiss import com.tangem.common.routing.AppRoute import com.tangem.common.routing.entity.AddressBookOpenMode -import com.tangem.common.ui.navigationButtons.NavigationButton -import com.tangem.common.ui.navigationButtons.NavigationUM import com.tangem.core.analytics.api.AnalyticsEventHandler 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.navigation.Router -import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier import com.tangem.domain.account.status.usecase.GetBackupProblematicWalletForAddressUseCase import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase @@ -49,8 +46,6 @@ import com.tangem.features.send.api.entity.PredefinedValues import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams.DestinationBlockParams import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM -import com.tangem.features.send.common.CommonSendRoute -import com.tangem.features.send.impl.R import com.tangem.features.send.subcomponents.destination.SendDestinationAlertFactory import com.tangem.features.send.subcomponents.destination.analytics.EnterAddressSource import com.tangem.features.send.subcomponents.destination.analytics.SendDestinationAnalyticEvents @@ -142,7 +137,6 @@ internal class SendDestinationModel @Inject constructor( private val backupProblematicWalletCache = AtomicReference?>(null) init { - configDestinationNavigation() subscribeOnQRScannerResult() initialState() resetContactOnEdit() @@ -153,15 +147,12 @@ internal class SendDestinationModel @Inject constructor( private fun resetContactOnEdit() { val params = params as? SendDestinationComponentParams.DestinationParams ?: return - params.currentRoute - .filter { it.isEditMode } - .onEach { - val content = uiState.value as? DestinationUM.Content ?: return@onEach - if (content.addressTextField.contactName != null) { - _uiState.update(SendDestinationContactTransformer(contact = null)) - } + if (params.route.isEditMode) { + val content = uiState.value as? DestinationUM.Content ?: return + if (content.addressTextField.contactName != null) { + _uiState.update(SendDestinationContactTransformer(contact = null)) } - .launchIn(modelScope) + } } fun onContactClick(contact: MatchedContact) { @@ -259,7 +250,23 @@ internal class SendDestinationModel @Inject constructor( ) } - private fun saveResult() { + fun onBackClick() { + val params = params as? SendDestinationComponentParams.DestinationParams ?: return + if (!params.route.isEditMode) { + analyticsEventHandler.send( + CommonSendAnalyticEvents.CloseButtonClicked( + categoryName = params.analyticsCategoryName, + source = SendScreenSource.Address, + isFromSummary = false, + isValid = uiState.value.isPrimaryButtonEnabled, + ), + ) + saveResult() + } + params.callback.onBackClick(params.route) + } + + fun saveResult() { val params = params as? SendDestinationComponentParams.DestinationParams ?: return params.callback.onDestinationResult(uiState.value) } @@ -490,60 +497,10 @@ internal class SendDestinationModel @Inject constructor( private fun autoNextFromRecipient(type: EnterAddressSource, isValidAddress: Boolean, isValidMemo: Boolean) { if (type.isAutoNext && isValidAddress && isValidMemo) { saveResult() - (params as? SendDestinationComponentParams.DestinationParams)?.callback?.onNextClick() + (params as? SendDestinationComponentParams.DestinationParams)?.callback?.onNextClick(params.route) } } - @Suppress("LongMethod") - private fun configDestinationNavigation() { - val params = params as? SendDestinationComponentParams.DestinationParams ?: return - combine( - flow = uiState, - flow2 = params.currentRoute, - transform = { state, route -> state to route }, - ).onEach { (state, route) -> - params.callback.onNavigationResult( - NavigationUM.Content( - source = CommonSendRoute.Destination::class.java.simpleName, - title = params.title, - subtitle = null, - backIconRes = if (route.isEditMode) { - R.drawable.ic_back_24 - } else { - R.drawable.ic_close_24 - }, - backIconClick = { - if (!route.isEditMode) { - analyticsEventHandler.send( - CommonSendAnalyticEvents.CloseButtonClicked( - categoryName = params.analyticsCategoryName, - source = SendScreenSource.Address, - isFromSummary = false, - isValid = state.isPrimaryButtonEnabled, - ), - ) - saveResult() - } - params.callback.onBackClick() - }, - primaryButton = NavigationButton( - textReference = if (route.isEditMode) { - resourceReference(R.string.common_continue) - } else { - resourceReference(R.string.common_next) - }, - isEnabled = state.isPrimaryButtonEnabled, - onClick = { - saveResult() - params.callback.onNextClick() - }, - ), - secondaryPairButtonsUM = null, - ), - ) - }.launchIn(modelScope) - } - private companion object { const val RECENT_TX_SIZE = 100 const val RECENT_LOAD_DELAY = 500L diff --git a/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/amount/model/SendAmountNavigationTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/amount/model/SendAmountNavigationTest.kt index 837e9d2ee3..57a55fc5b8 100644 --- a/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/amount/model/SendAmountNavigationTest.kt +++ b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/amount/model/SendAmountNavigationTest.kt @@ -1,14 +1,11 @@ package com.tangem.features.send.subcomponents.amount.model import arrow.core.right -import com.google.common.truth.Truth.assertThat import com.tangem.blockchain.common.Blockchain import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory import com.tangem.common.ui.amountScreen.models.AmountState -import com.tangem.common.ui.navigationButtons.NavigationUM import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.model.MutableParamsContainer -import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.account.Account @@ -25,13 +22,11 @@ import com.tangem.features.send.api.subcomponents.amount.SendAmountComponentPara import com.tangem.features.send.api.subcomponents.amount.SendAmountReduceListener import com.tangem.features.send.api.subcomponents.amount.SendAmountUpdateListener import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger -import com.tangem.features.send.impl.R import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.clearMocks import io.mockk.coEvery import io.mockk.every import io.mockk.mockk -import io.mockk.slot import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -46,12 +41,14 @@ import org.junit.jupiter.api.Test import java.math.BigDecimal /** - * Guards the route-decoupling fix: `SendAmountModel.configAmountNavigation()` filters its route flow on - * the public `AmountRoute` interface, not the `internal CommonSendRoute.Amount`. The test feeds a - * foreign `AmountRoute` (which is NOT a `CommonSendRoute.Amount`) and asserts the navigation result is - * still produced — before the fix the `combine`'s `filterIsInstance()` dropped - * it and `onNavigationResult` never fired, leaving an external host (e.g. staking) with a dead Next - * button. Also checks the `isEditMode` → back-icon / primary-button mapping is unaffected. + * Guards the route-decoupling fix at the model level. The amount step is now hostable by foreign flows + * (e.g. staking / send-with-swap) that supply their own [AmountRoute] implementation rather than the + * `internal CommonSendRoute.Amount`. The navigation chrome (back icon / Next vs Continue) moved out of + * the model into `DefaultSendAmountComponent`, but the model still makes a route-driven decision in + * [SendAmountModel.onConvertToAnotherToken]: it reads `params.route.isEditMode` *directly* (previously + * it collected the route flow). This test feeds a foreign [AmountRoute] and verifies that edit-mode + * branches to the "reset sending" alert while a fresh (non-edit) route converts immediately — both via + * the public interface, not `CommonSendRoute`. */ @OptIn(ExperimentalCoroutinesApi::class) internal class SendAmountNavigationTest { @@ -79,10 +76,11 @@ internal class SendAmountNavigationTest { sendAmountUpdateListener, getSelectedAppCurrencyUseCase, getUserWalletUseCase, + sendAmountAlertFactory, callback, ) // No wallet → the model stays on AmountState.Empty (the heavy AmountStateConverter path is skipped), - // which is all the navigation block needs to emit. + // which is all these route-driven assertions need. every { getUserWalletUseCase.invokeFlow(any()) } returns emptyFlow() every { sendAmountReduceListener.reduceToTriggerFlow } returns emptyFlow() every { sendAmountReduceListener.reduceByTriggerFlow } returns emptyFlow() @@ -94,43 +92,41 @@ internal class SendAmountNavigationTest { @AfterEach fun tearDown() { - // Cancel modelScope so the long-lived navigation/status collectors stop between tests. + // Cancel modelScope so the long-lived reduce/status collectors stop between tests. model?.onDestroy() model = null } @Test - fun `GIVEN a foreign AmountRoute WHEN model created THEN navigation produced with close icon and next button`() = - runTest { - // Arrange — a route that is NOT CommonSendRoute.Amount (the impl type the model used to filter on). - val navSlot = slot() - - // Act - createModel(testScope = this, route = TestAmountRoute(isEditMode = false)) - advanceUntilIdle() - - // Assert — before the fix this never fired for a non-CommonSendRoute.Amount route. - verify(atLeast = 1) { callback.onNavigationResult(capture(navSlot)) } - val content = navSlot.captured as NavigationUM.Content - assertThat(content.backIconRes).isEqualTo(R.drawable.ic_close_24) - assertThat(content.primaryButton.textReference).isEqualTo(resourceReference(R.string.common_next)) - } - - @Test - fun `GIVEN a foreign AmountRoute in edit mode WHEN model created THEN navigation has back icon and continue button`() = + fun `GIVEN foreign AmountRoute in edit mode WHEN onConvertToAnotherToken THEN reset sending alert shown`() = runTest { // Arrange - val navSlot = slot() - - // Act createModel(testScope = this, route = TestAmountRoute(isEditMode = true)) advanceUntilIdle() - // Assert - verify(atLeast = 1) { callback.onNavigationResult(capture(navSlot)) } - val content = navSlot.captured as NavigationUM.Content - assertThat(content.backIconRes).isEqualTo(R.drawable.ic_back_24) - assertThat(content.primaryButton.textReference).isEqualTo(resourceReference(R.string.common_continue)) + // Act + model?.onConvertToAnotherToken() + advanceUntilIdle() + + // Assert — edit mode must guard the conversion behind the reset-sending confirmation. + verify(exactly = 1) { sendAmountAlertFactory.showResetSendingAlert(any()) } + verify(exactly = 0) { callback.onConvertToAnotherToken(any(), any()) } + } + + @Test + fun `GIVEN foreign AmountRoute not in edit mode WHEN onConvertToAnotherToken THEN converts without alert`() = + runTest { + // Arrange + createModel(testScope = this, route = TestAmountRoute(isEditMode = false)) + advanceUntilIdle() + + // Act + model?.onConvertToAnotherToken() + advanceUntilIdle() + + // Assert — a fresh route converts immediately, with no reset-sending alert. + verify(exactly = 0) { sendAmountAlertFactory.showResetSendingAlert(any()) } + verify(exactly = 1) { callback.onConvertToAnotherToken(any(), any()) } } private fun createModel(testScope: TestScope, route: AmountRoute): SendAmountModel { @@ -147,7 +143,7 @@ internal class SendAmountNavigationTest { accountFlow = MutableStateFlow(null), isAccountModeFlow = MutableStateFlow(false), callback = callback, - currentRoute = MutableStateFlow(route), + route = route, ) return SendAmountModel( paramsContainer = MutableParamsContainer(value = params), From b9a5220984885e44c9291699184a0bdc151ffa6b Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 25 Jun 2026 12:00:16 +0500 Subject: [PATCH 075/210] Updated on 2026-08-14 --- .../features/send/common/ui/SendContent.kt | 99 --------- .../send/common/ui/SendModularContent.kt | 68 ++++++ .../send/common/utils/SendRouteUtils.kt | 28 --- .../send/send/DefaultSendComponent.kt | 199 +++++++----------- .../send/send/confirm/SendConfirmComponent.kt | 57 ++++- .../send/confirm/model/SendConfirmModel.kt | 66 ++---- .../features/send/send/model/SendModel.kt | 32 ++- .../success/SendConfirmSuccessComponent.kt | 107 +++++++++- .../success/model/SendConfirmSuccessModel.kt | 84 +------- .../success/ui/SendConfirmSuccessContent.kt | 9 - .../features/send/send/ui/state/SendUM.kt | 4 +- .../send/model/SendModelNavigationTest.kt | 198 +++++++++++++++++ 12 files changed, 526 insertions(+), 425 deletions(-) delete mode 100644 features/send/impl/src/main/java/com/tangem/features/send/common/ui/SendContent.kt create mode 100644 features/send/impl/src/main/java/com/tangem/features/send/common/ui/SendModularContent.kt delete mode 100644 features/send/impl/src/main/java/com/tangem/features/send/common/utils/SendRouteUtils.kt create mode 100644 features/send/impl/src/test/java/com/tangem/features/send/send/model/SendModelNavigationTest.kt diff --git a/features/send/impl/src/main/java/com/tangem/features/send/common/ui/SendContent.kt b/features/send/impl/src/main/java/com/tangem/features/send/common/ui/SendContent.kt deleted file mode 100644 index a0c00f2d22..0000000000 --- a/features/send/impl/src/main/java/com/tangem/features/send/common/ui/SendContent.kt +++ /dev/null @@ -1,99 +0,0 @@ -package com.tangem.features.send.common.ui - -import androidx.compose.animation.* -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.arkivanov.decompose.extensions.compose.stack.Children -import com.arkivanov.decompose.extensions.compose.stack.animation.fade -import com.arkivanov.decompose.extensions.compose.stack.animation.slide -import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation -import com.arkivanov.decompose.router.stack.ChildStack -import com.tangem.common.ui.footers.SendingText -import com.tangem.common.ui.navigationButtons.NavigationButtonsBlockV2 -import com.tangem.common.ui.navigationButtons.NavigationUM -import com.tangem.core.ui.components.Fade -import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon -import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.resolveReference -import com.tangem.core.ui.res.TangemTheme -import com.tangem.features.send.common.CommonSendRoute -import com.tangem.features.send.common.ui.state.ConfirmUM - -@Composable -internal fun SendContent( - navigationUM: NavigationUM, - confirmUM: ConfirmUM, - stackState: ChildStack, -) { - Column( - modifier = Modifier - .background(color = TangemTheme.colors.background.tertiary) - .fillMaxSize() - .imePadding() - .systemBarsPadding(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - SendAppBar(navigationUM = navigationUM) - Children( - stack = stackState, - animation = stackAnimation { child -> - when (child.configuration) { - is CommonSendRoute.ConfirmSuccess -> fade(minAlpha = 1.0f) - is CommonSendRoute.Confirm -> fade() - else -> slide() - } - }, - modifier = Modifier.weight(1f), - ) { - Box(modifier = Modifier.fillMaxHeight()) { - it.instance.Content(Modifier.fillMaxSize(1f)) - - if (stackState.active.configuration != CommonSendRoute.ConfirmSuccess) { - Fade( - backgroundColor = TangemTheme.colors.background.tertiary, - modifier = Modifier.align(Alignment.BottomCenter), - ) - } - } - } - if (stackState.active.configuration != CommonSendRoute.ConfirmSuccess) { - Column { - AnimatedVisibility( - visible = stackState.active.configuration == CommonSendRoute.Confirm, - enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(), - ) { - SendingText(footerText = (confirmUM as? ConfirmUM.Content)?.sendingFooter ?: TextReference.EMPTY) - } - NavigationButtonsBlockV2( - navigationUM = navigationUM, - modifier = Modifier.padding( - start = 16.dp, - end = 16.dp, - bottom = 16.dp, - ), - ) - } - } - } -} - -@Composable -private fun SendAppBar(navigationUM: NavigationUM) { - val navigationUMContent = navigationUM as? NavigationUM.Content ?: return - AppBarWithBackButtonAndIcon( - text = navigationUMContent.title.resolveReference(), - subtitle = navigationUMContent.subtitle?.resolveReference(), - onBackClick = navigationUMContent.backIconClick, - onIconClick = navigationUMContent.additionalIconClick, - backIconRes = navigationUMContent.backIconRes, - iconRes = navigationUMContent.additionalIconRes, - backgroundColor = TangemTheme.colors.background.tertiary, - modifier = Modifier.height(TangemTheme.dimens.size56), - ) -} \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/common/ui/SendModularContent.kt b/features/send/impl/src/main/java/com/tangem/features/send/common/ui/SendModularContent.kt new file mode 100644 index 0000000000..7d5efc908c --- /dev/null +++ b/features/send/impl/src/main/java/com/tangem/features/send/common/ui/SendModularContent.kt @@ -0,0 +1,68 @@ +package com.tangem.features.send.common.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.arkivanov.decompose.extensions.compose.stack.Children +import com.arkivanov.decompose.extensions.compose.stack.animation.fade +import com.arkivanov.decompose.extensions.compose.stack.animation.slide +import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation +import com.arkivanov.decompose.router.stack.ChildStack +import com.tangem.core.ui.components.Fade +import com.tangem.core.ui.decompose.ComposableModularContentComponent +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.send.common.CommonSendRoute + +/** + * Shared pull-based host for the regular Send and NFT Send flows (both over [CommonSendRoute]). Renders the + * ACTIVE child's [ComposableModularContentComponent.Title] / [ComposableModularContentComponent.Footer] slots + * in place (matching the previous in-place app-bar/footer behavior), while keeping the per-route slide/fade + * Decompose [Children] animation for the Content region (and the bottom `Fade` gradient, hidden on + * `ConfirmSuccess`, exactly as the previous `SendContent`). + * + * Each step's `Footer()` owns its own bottom block (Confirm reveals `SendingText`; Success/Empty render + * nothing), so the host no longer special-cases routes for the footer. + */ +@Composable +internal fun SendModularContent(stackState: ChildStack) { + Column( + modifier = Modifier + .background(color = TangemTheme.colors.background.tertiary) + .fillMaxSize() + .imePadding() + .systemBarsPadding(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + stackState.active.instance.Title() + Children( + stack = stackState, + animation = stackAnimation { child -> + when (child.configuration) { + is CommonSendRoute.ConfirmSuccess -> fade(minAlpha = 1.0f) + is CommonSendRoute.Confirm -> fade() + else -> slide() + } + }, + modifier = Modifier.weight(1f), + ) { + Box(modifier = Modifier.fillMaxHeight()) { + it.instance.Content(Modifier.fillMaxSize(1f)) + + if (stackState.active.configuration != CommonSendRoute.ConfirmSuccess) { + Fade( + backgroundColor = TangemTheme.colors.background.tertiary, + modifier = Modifier.align(Alignment.BottomCenter), + ) + } + } + } + stackState.active.instance.Footer() + } +} \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/common/utils/SendRouteUtils.kt b/features/send/impl/src/main/java/com/tangem/features/send/common/utils/SendRouteUtils.kt deleted file mode 100644 index 8e1878cadc..0000000000 --- a/features/send/impl/src/main/java/com/tangem/features/send/common/utils/SendRouteUtils.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.tangem.features.send.common.utils - -import com.arkivanov.decompose.router.stack.ChildStack -import com.arkivanov.decompose.value.Value -import com.tangem.core.decompose.navigation.Router -import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.features.send.common.CommonSendRoute - -/** - * Workaround to try fix duplicate route crash - */ -internal fun Router.safeNextClick( - currentRoute: CommonSendRoute, - nextRoute: CommonSendRoute, - childStack: Value>, - popBack: () -> Unit, -) { - if (currentRoute.isEditMode) { - popBack() - } else { - val isAlreadyInStack = childStack.value.items.any { it.configuration == nextRoute } - if (isAlreadyInStack) { - popTo(nextRoute) - } else { - push(nextRoute) - } - } -} \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/DefaultSendComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/DefaultSendComponent.kt index a44f29903c..b4fe92d902 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/DefaultSendComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/DefaultSendComponent.kt @@ -1,15 +1,9 @@ package com.tangem.features.send.send import androidx.activity.compose.BackHandler -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.arkivanov.decompose.router.stack.StackNavigation import com.arkivanov.decompose.router.stack.childStack @@ -17,49 +11,45 @@ import com.arkivanov.decompose.router.stack.pop import com.arkivanov.decompose.value.ObserveLifecycleMode import com.arkivanov.decompose.value.subscribe import com.tangem.common.ui.amountScreen.models.AmountState -import com.tangem.common.ui.navigationButtons.NavigationUM import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.context.AppComponentContext -import com.tangem.core.decompose.context.child import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.decompose.navigation.inner.InnerRouter -import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.decompose.ComposableModularContentComponent +import com.tangem.core.ui.decompose.EmptyComposableBottomSheetComponent import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.models.account.derivationIndex import com.tangem.features.send.api.SendComponent import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents +import com.tangem.features.send.api.subcomponents.amount.AmountRoute import com.tangem.features.send.api.subcomponents.amount.SendAmountComponent import com.tangem.features.send.api.subcomponents.amount.SendAmountComponentParams +import com.tangem.features.send.api.subcomponents.destination.DestinationRoute import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponent import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockComponent import com.tangem.features.send.common.CommonSendRoute -import com.tangem.features.send.common.ui.SendContent +import com.tangem.features.send.common.ui.SendModularContent import com.tangem.features.send.common.ui.state.ConfirmUM import com.tangem.features.send.impl.R import com.tangem.features.send.send.confirm.SendConfirmComponent import com.tangem.features.send.send.model.SendModel import com.tangem.features.send.send.success.SendConfirmSuccessComponent -import com.tangem.features.send.subcomponents.amount.DefaultSendAmountComponent -import com.tangem.features.send.subcomponents.destination.DefaultSendDestinationBlockComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.launch -@Suppress("LargeClass") +@Suppress("LongParameterList") internal class DefaultSendComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, @Assisted private val params: SendComponent.Params, private val analyticsEventHandler: AnalyticsEventHandler, private val amountComponentFactory: SendAmountComponent.Factory, + private val destinationComponentFactory: SendDestinationComponent.Factory, + private val sendConfirmSuccessComponent: SendConfirmSuccessComponent.Factory, private val feeSelectorComponentFactory: FeeSelectorBlockComponent.Factory, - private val sendDestinationComponentFactory: SendDestinationComponent.Factory, ) : SendComponent, AppComponentContext by appComponentContext { private val stackNavigation = StackNavigation() @@ -93,48 +83,45 @@ internal class DefaultSendComponent @AssistedInject constructor( lifecycle = lifecycle, mode = ObserveLifecycleMode.CREATE_DESTROY, ) { stack -> - componentScope.launch { - when (val activeComponent = stack.active.instance) { - is SendConfirmComponent -> { - val fromCurrency = params.currency - val fromDerivationIndex = model.accountFlow.value?.derivationIndex?.value - .takeIf { model.isAccountModeFlow.value } - analyticsEventHandler.send( - CommonSendAnalyticEvents.ConfirmationScreenOpened( - categoryName = model.analyticCategoryName, - source = model.analyticsSendSource, - sendBlockchain = fromCurrency.network.name, - sendToken = fromCurrency.symbol, - fromDerivationIndex = fromDerivationIndex, - toDerivationIndex = null, - type = model.consumeEntryType(), - ), - ) - if (model.currentRoute.value.isEditMode) { - activeComponent.updateState(model.uiState.value) - } - } - is DefaultSendAmountComponent -> { - analyticsEventHandler.send( - CommonSendAnalyticEvents.AmountScreenOpened( - categoryName = model.analyticCategoryName, - source = model.analyticsSendSource, - type = model.consumeEntryType(), - ), - ) - activeComponent.updateState(model.uiState.value.amountUM) - } - is SendDestinationComponent -> { - analyticsEventHandler.send( - CommonSendAnalyticEvents.AddressScreenOpened( - categoryName = model.analyticCategoryName, - source = model.analyticsSendSource, - ), - ) - activeComponent.updateState(model.uiState.value.destinationUM) + when (val activeComponent = stack.active.instance) { + is SendConfirmComponent -> { + val fromCurrency = params.currency + val fromDerivationIndex = model.accountFlow.value?.derivationIndex?.value + .takeIf { model.isAccountModeFlow.value } + analyticsEventHandler.send( + CommonSendAnalyticEvents.ConfirmationScreenOpened( + categoryName = model.analyticCategoryName, + source = model.analyticsSendSource, + sendBlockchain = fromCurrency.network.name, + sendToken = fromCurrency.symbol, + fromDerivationIndex = fromDerivationIndex, + toDerivationIndex = null, + type = model.consumeEntryType(), + ), + ) + if (childStack.value.active.configuration.isEditMode) { + activeComponent.updateState(model.uiState.value) } } - model.currentRoute.emit(stack.active.configuration) + is SendAmountComponent -> { + analyticsEventHandler.send( + CommonSendAnalyticEvents.AmountScreenOpened( + categoryName = model.analyticCategoryName, + source = model.analyticsSendSource, + type = model.consumeEntryType(), + ), + ) + activeComponent.updateState(model.uiState.value.amountUM) + } + is SendDestinationComponent -> { + analyticsEventHandler.send( + CommonSendAnalyticEvents.AddressScreenOpened( + categoryName = model.analyticCategoryName, + source = model.analyticsSendSource, + ), + ) + activeComponent.updateState(model.uiState.value.destinationUM) + } } } } @@ -142,34 +129,31 @@ internal class DefaultSendComponent @AssistedInject constructor( @Composable override fun Content(modifier: Modifier) { val stackState by childStack.subscribeAsState() - val state by model.uiState.collectAsStateWithLifecycle() - BackHandler( - onBack = { - (state.navigationUM as? NavigationUM.Content)?.backIconClick() ?: onChildBack() - }, - ) - SendContent( - navigationUM = state.navigationUM, - confirmUM = state.confirmUM, - stackState = stackState, - ) + BackHandler(onBack = ::onChildBack) + SendModularContent(stackState = stackState) } - private fun createChild(route: CommonSendRoute, factoryContext: AppComponentContext) = when (route) { - CommonSendRoute.Empty -> getStubComponent() - is CommonSendRoute.Destination -> getDestinationComponent(factoryContext) - is CommonSendRoute.Amount -> getAmountComponent(factoryContext) + private fun createChild( + route: CommonSendRoute, + factoryContext: AppComponentContext, + ): ComposableModularContentComponent = when (route) { + CommonSendRoute.Empty -> ComposableModularContentComponent.EMPTY + is CommonSendRoute.Destination -> getDestinationComponent(route, factoryContext) + is CommonSendRoute.Amount -> getAmountComponent(route, factoryContext) is CommonSendRoute.Confirm -> getConfirmComponent(factoryContext) is CommonSendRoute.ConfirmSuccess -> getConfirmSuccessComponent(factoryContext) } - private fun getDestinationComponent(factoryContext: AppComponentContext): SendDestinationComponent = - sendDestinationComponentFactory.create( + private fun getDestinationComponent( + route: DestinationRoute, + factoryContext: AppComponentContext, + ): ComposableModularContentComponent { + return destinationComponentFactory.create( context = factoryContext, params = SendDestinationComponentParams.DestinationParams( state = model.uiState.value.destinationUM, - currentRoute = model.currentRoute.filterIsInstance(), + route = route, isBalanceHidingFlow = model.isBalanceHiddenFlow, analyticsCategoryName = model.analyticCategoryName, analyticsSendSource = model.analyticsSendSource, @@ -179,13 +163,17 @@ internal class DefaultSendComponent @AssistedInject constructor( callback = model, ), ) + } - private fun getAmountComponent(factoryContext: AppComponentContext): ComposableContentComponent { + private fun getAmountComponent( + route: AmountRoute, + factoryContext: AppComponentContext, + ): ComposableModularContentComponent { return amountComponentFactory.create( context = factoryContext, params = SendAmountComponentParams.AmountParams( state = model.uiState.value.amountUM, - currentRoute = model.currentRoute.filterIsInstance(), + route = route, isBalanceHidingFlow = model.isBalanceHiddenFlow, analyticsCategoryName = model.analyticCategoryName, appCurrency = model.appCurrency, @@ -201,7 +189,7 @@ internal class DefaultSendComponent @AssistedInject constructor( ) } - private fun getConfirmComponent(factoryContext: AppComponentContext): ComposableContentComponent { + private fun getConfirmComponent(factoryContext: AppComponentContext): ComposableModularContentComponent { return if (model.isAvailableForSend) { val cryptoCurrencyStatus = model.cryptoCurrencyStatusFlow.value val feeCryptoCurrencyStatus = model.feeCryptoCurrencyStatusFlow.value @@ -210,7 +198,6 @@ internal class DefaultSendComponent @AssistedInject constructor( params = SendConfirmComponent.Params( state = model.uiState.value, userWallet = model.userWallet, - currentRoute = model.currentRoute, isBalanceHidingFlow = model.isBalanceHiddenFlow, analyticsCategoryName = model.analyticCategoryName, cryptoCurrencyStatus = cryptoCurrencyStatus, @@ -233,79 +220,43 @@ internal class DefaultSendComponent @AssistedInject constructor( ) } else { model.showAlertError() - getStubComponent() + ComposableModularContentComponent.EMPTY } } - private fun getConfirmSuccessComponent(factoryContext: AppComponentContext): ComposableContentComponent { + private fun getConfirmSuccessComponent(factoryContext: AppComponentContext): ComposableModularContentComponent { val state = model.uiState.value val sendAmount = (state.amountUM as? AmountState.Data)?.amountTextField?.cryptoAmount?.value val destinationAddress = (state.destinationUM as? DestinationUM.Content)?.addressTextField?.value val txUrl = (state.confirmUM as? ConfirmUM.Success)?.txUrl - val cryptoCurrencyStatus = model.cryptoCurrencyStatusFlow.value if (sendAmount == null || destinationAddress == null || txUrl == null ) { model.showAlertError() - return getStubComponent() + return ComposableModularContentComponent.EMPTY } - val destinationBlockComponent = - DefaultSendDestinationBlockComponent( - appComponentContext = child("sendConfirmDestinationBlock"), - params = SendDestinationComponentParams.DestinationBlockParams( - state = model.uiState.value.destinationUM, - analyticsCategoryName = model.analyticCategoryName, - analyticsSendSource = model.analyticsSendSource, - userWalletId = model.userWallet.walletId, - cryptoCurrency = cryptoCurrencyStatus.currency, - blockClickEnableFlow = MutableStateFlow(true), - predefinedValues = model.predefinedValues, - isAddContactAvailable = true, - ), - onResult = { }, - onClick = {}, - ) - - return SendConfirmSuccessComponent( + return sendConfirmSuccessComponent.create( appComponentContext = factoryContext, params = SendConfirmSuccessComponent.Params( sendUMFlow = model.uiState, - destinationBlockComponent = destinationBlockComponent, + userWalletId = params.userWalletId, + cryptoCurrency = params.currency, + predefinedValues = model.predefinedValues, analyticsCategoryName = model.analyticCategoryName, - currentRoute = model.currentRoute, txUrl = txUrl, callback = model, ), ) } - private fun getStubComponent() = StubComponent() - - class StubComponent : ComposableContentComponent { - @Composable - override fun Content(modifier: Modifier) { - Box( - modifier = Modifier - .fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator( - modifier = Modifier.padding(TangemTheme.dimens.spacing12), - color = TangemTheme.colors.icon.primary1, - strokeWidth = TangemTheme.dimens.size2, - ) - } - } - } - private fun onChildBack() { val isEmptyRoute = childStack.value.active.configuration == CommonSendRoute.Empty val isEmptyStack = childStack.value.backStack.isEmpty() val isSuccess = model.uiState.value.confirmUM is ConfirmUM.Success - val isStubComponent = childStack.value.active.instance is StubComponent + val isStubComponent = childStack.value.active.instance == EmptyComposableBottomSheetComponent val isSendingInProgress = (model.uiState.value.confirmUM as? ConfirmUM.Content)?.isSending == true val isPopSend = isEmptyRoute || isEmptyStack || isSuccess || isStubComponent diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/SendConfirmComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/SendConfirmComponent.kt index 6e369f2e78..d13114d87c 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/SendConfirmComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/SendConfirmComponent.kt @@ -1,15 +1,26 @@ package com.tangem.features.send.send.confirm +import androidx.compose.animation.* +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import arrow.core.Either import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.common.ui.footers.SendingText +import com.tangem.common.ui.navigationButtons.NavigationPrimaryButton import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.child import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon +import com.tangem.core.ui.decompose.ComposableModularContentComponent +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus @@ -18,17 +29,17 @@ import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.entity.PredefinedValues +import com.tangem.features.send.api.subcomponents.amount.SendAmountComponentParams import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams.DestinationBlockParams import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockComponent import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent -import com.tangem.features.send.common.CommonSendRoute import com.tangem.features.send.common.ui.state.ConfirmUM +import com.tangem.features.send.impl.R import com.tangem.features.send.send.confirm.model.SendConfirmModel import com.tangem.features.send.send.confirm.ui.SendConfirmContent import com.tangem.features.send.send.ui.state.SendUM import com.tangem.features.send.subcomponents.amount.DefaultSendAmountBlockComponent -import com.tangem.features.send.api.subcomponents.amount.SendAmountComponentParams import com.tangem.features.send.subcomponents.destination.DefaultSendDestinationBlockComponent import com.tangem.features.send.subcomponents.notifications.DefaultSendNotificationsComponent import com.tangem.utils.extensions.orZero @@ -38,7 +49,7 @@ internal class SendConfirmComponent( appComponentContext: AppComponentContext, params: Params, feeSelectorComponentFactory: FeeSelectorBlockComponent.Factory, -) : ComposableContentComponent, AppComponentContext by appComponentContext { +) : ComposableModularContentComponent, AppComponentContext by appComponentContext { private val model: SendConfirmModel = getOrCreateModel(params = params) @@ -133,6 +144,20 @@ internal class SendConfirmComponent( model.updateState(state) } + @Composable + override fun Title() { + AppBarWithBackButtonAndIcon( + text = stringResourceSafe(R.string.common_send), + onBackClick = { + model.onBackClick() + router.pop() + }, + backIconRes = R.drawable.ic_back_24, + backgroundColor = TangemTheme.colors.background.tertiary, + modifier = Modifier.height(TangemTheme.dimens.size56), + ) + } + @Composable override fun Content(modifier: Modifier) { val state by model.uiState.collectAsStateWithLifecycle() @@ -148,6 +173,29 @@ internal class SendConfirmComponent( ) } + @Composable + override fun Footer() { + val state by model.uiState.collectAsStateWithLifecycle() + Column { + val sendingFooter = (state.confirmUM as? ConfirmUM.Content)?.sendingFooter + AnimatedVisibility( + visible = sendingFooter != null, + enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(), + ) { + SendingText(footerText = sendingFooter ?: TextReference.EMPTY) + } + NavigationPrimaryButton( + primaryButton = model.primaryButtonUM(state.confirmUM), + modifier = Modifier.padding( + start = 16.dp, + end = 16.dp, + bottom = 16.dp, + ), + ) + } + } + data class Params( val state: SendUM, val analyticsCategoryName: String, @@ -161,7 +209,6 @@ internal class SendConfirmComponent( val isAccountModeFlow: StateFlow, val appCurrency: AppCurrency, val callback: ModelCallback, - val currentRoute: Flow, val isBalanceHidingFlow: StateFlow, val predefinedValues: PredefinedValues, val onLoadFee: suspend () -> Either, diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/SendConfirmModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/SendConfirmModel.kt index 48dabef006..d30b1aaa76 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/SendConfirmModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/confirm/model/SendConfirmModel.kt @@ -10,7 +10,6 @@ import com.tangem.common.routing.AppRouter import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.navigationButtons.NavigationButton -import com.tangem.common.ui.navigationButtons.NavigationUM import com.tangem.common.ui.userwallet.ext.walletInterationIcon import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsParam @@ -160,7 +159,6 @@ internal class SendConfirmModel @Inject constructor( init { updateAmountSubtractAvailability() - configConfirmNavigation() subscribeOnNotificationsUpdateTrigger() subscribeOnCheckFeeResultUpdates() initialState() @@ -303,6 +301,17 @@ internal class SendConfirmModel @Inject constructor( } } + fun onBackClick() { + analyticsEventHandler.send( + CommonSendAnalyticEvents.CloseButtonClicked( + categoryName = analyticsCategoryName, + source = SendScreenSource.Confirm, + isFromSummary = true, + isValid = uiState.value.confirmUM.isPrimaryButtonEnabled, + ), + ) + } + private fun initialState() { val confirmUM = uiState.value.confirmUM @@ -543,58 +552,7 @@ internal class SendConfirmModel @Inject constructor( return isHighNetworkFeeUseCase(feeCurrency, feeAmount) } - @Suppress("LongMethod") - private fun configConfirmNavigation() { - combine( - flow = uiState, - flow2 = params.currentRoute, - transform = { state, route -> state to route }, - ).filter { - it.second is CommonSendRoute.Confirm - }.onEach { (state, _) -> - val confirmUM = state.confirmUM - params.callback.onResult( - state.copy( - navigationUM = NavigationUM.Content( - source = CommonSendRoute.Confirm.javaClass.simpleName, - title = resourceReference(id = R.string.common_send), - subtitle = null, - backIconRes = when (confirmUM) { - is ConfirmUM.Success -> R.drawable.ic_close_24 - else -> R.drawable.ic_back_24 - }, - backIconClick = { - analyticsEventHandler.send( - CommonSendAnalyticEvents.CloseButtonClicked( - categoryName = analyticsCategoryName, - source = SendScreenSource.Confirm, - isFromSummary = true, - isValid = confirmUM.isPrimaryButtonEnabled, - ), - ) - router.pop() - }, - primaryButton = primaryButtonUM(), - prevButton = null, - secondaryPairButtonsUM = ( - NavigationButton( - textReference = resourceReference(R.string.common_explore), - iconRes = R.drawable.ic_web_24, - onClick = ::onExploreClick, - ) to NavigationButton( - textReference = resourceReference(R.string.common_share), - iconRes = R.drawable.ic_share_24, - onClick = ::onShareClick, - ) - ).takeIf { confirmUM is ConfirmUM.Success }, - ), - ), - ) - }.launchIn(modelScope) - } - - private fun primaryButtonUM(): NavigationButton { - val confirmUM = uiState.value.confirmUM + fun primaryButtonUM(confirmUM: ConfirmUM): NavigationButton { val isContent = confirmUM is ConfirmUM.Content val isReadyToSend = isContent && !confirmUM.isSending val isHoldToConfirm = userWallet.isHotWallet && isContent diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/model/SendModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/model/SendModel.kt index 643b2cfae1..29bbb27bd4 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/model/SendModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/model/SendModel.kt @@ -7,13 +7,13 @@ import arrow.core.left import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.common.ui.amountScreen.models.AmountState -import com.tangem.common.ui.navigationButtons.NavigationUM import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.Basic 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.navigation.Route import com.tangem.core.decompose.navigation.Router import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.core.ui.utils.parseBigDecimalOrNull @@ -123,8 +123,6 @@ internal class SendModel @Inject constructor( CommonSendRoute.Empty } - val currentRoute = MutableStateFlow(initialRoute) - val cryptoCurrencyStatusFlow: StateFlow field = MutableStateFlow( CryptoCurrencyStatus( @@ -149,7 +147,7 @@ internal class SendModel @Inject constructor( return cryptoCurrencyStatus.isAvailableForSend() && feeCryptoCurrencyStatus.isAvailableForSend() } - val isUnavailableForSend: Boolean + private val isUnavailableForSend: Boolean get() { val cryptoCurrencyStatus = cryptoCurrencyStatusFlow.value val feeCryptoCurrencyStatus = feeCryptoCurrencyStatusFlow.value @@ -179,10 +177,6 @@ internal class SendModel @Inject constructor( initAppCurrency() } - override fun onNavigationResult(navigationUM: NavigationUM) { - uiState.update { it.copy(navigationUM = navigationUM) } - } - override fun onDestinationResult(destinationUM: DestinationUM) { uiState.update { it.copy(destinationUM = destinationUM) } } @@ -196,9 +190,9 @@ internal class SendModel @Inject constructor( uiState.update { sendUM } } - override fun onBackClick() { - when (val route = currentRoute.value) { - is CommonSendRoute.Amount -> if (!route.isEditMode) { + override fun onBackClick(currentRoute: Route) { + when (currentRoute) { + is CommonSendRoute.Amount -> if (!currentRoute.isEditMode) { analyticsEventHandler.send( CommonSendAnalyticEvents.CloseButtonClicked( categoryName = analyticCategoryName, @@ -208,7 +202,7 @@ internal class SendModel @Inject constructor( ), ) } - is CommonSendRoute.Destination -> if (!route.isEditMode) { + is CommonSendRoute.Destination -> if (!currentRoute.isEditMode) { analyticsEventHandler.send( CommonSendAnalyticEvents.CloseButtonClicked( categoryName = analyticCategoryName, @@ -224,11 +218,11 @@ internal class SendModel @Inject constructor( router.pop() } - override fun onNextClick() { - if (currentRoute.value.isEditMode) { - onBackClick() + override fun onNextClick(currentRoute: Route) { + if ((currentRoute as? CommonSendRoute)?.isEditMode == true) { + onBackClick(currentRoute) } else { - when (currentRoute.value) { + when (currentRoute) { is CommonSendRoute.Amount -> { val nextRoute = if (predefinedValues.isFromMainScreenQr) { CommonSendRoute.Confirm @@ -239,7 +233,7 @@ internal class SendModel @Inject constructor( } is CommonSendRoute.Destination -> router.push(CommonSendRoute.Confirm) CommonSendRoute.Confirm -> router.push(CommonSendRoute.ConfirmSuccess) - else -> onBackClick() + else -> router.pop() } } } @@ -263,7 +257,6 @@ internal class SendModel @Inject constructor( feeSelectorUM = FeeSelectorUM.Loading, confirmUM = ConfirmUM.Empty, confirmData = null, - navigationUM = NavigationUM.Empty, ) } router.popTo(CommonSendRoute.Amount(isEditMode = false)) @@ -442,7 +435,7 @@ internal class SendModel @Inject constructor( cryptoCurrencyStatusFlow, feeCryptoCurrencyStatusFlow, ) { cryptoCurrencyStatus, _ -> - if (!isAvailableForSend || currentRoute.value != initialRoute) { + if (!isAvailableForSend) { if (isUnavailableForSend) showAlertError() return@combine } @@ -548,7 +541,6 @@ internal class SendModel @Inject constructor( cryptoCurrency = cryptoCurrency, ).transform(DestinationUM.Empty()), confirmUM = ConfirmUM.Empty, - navigationUM = NavigationUM.Empty, confirmData = null, feeSelectorUM = FeeSelectorUM.Loading, ) diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/success/SendConfirmSuccessComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/success/SendConfirmSuccessComponent.kt index 314345341d..6890d5f4fd 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/success/SendConfirmSuccessComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/success/SendConfirmSuccessComponent.kt @@ -1,27 +1,77 @@ package com.tangem.features.send.send.success +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.tangem.common.ui.navigationButtons.DoneButtons +import com.tangem.common.ui.navigationButtons.NavigationButton import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.context.child import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon +import com.tangem.core.ui.decompose.ComposableModularContentComponent +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents +import com.tangem.features.send.api.entity.PredefinedValues import com.tangem.features.send.api.subcomponents.destination.SendDestinationBlockComponent -import com.tangem.features.send.common.CommonSendRoute +import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams +import com.tangem.features.send.impl.R import com.tangem.features.send.send.success.model.SendConfirmSuccessModel import com.tangem.features.send.send.success.ui.SendConfirmSuccessContent import com.tangem.features.send.send.ui.state.SendUM -import kotlinx.coroutines.flow.Flow +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -internal class SendConfirmSuccessComponent( - appComponentContext: AppComponentContext, - params: Params, -) : ComposableContentComponent, AppComponentContext by appComponentContext { +internal class SendConfirmSuccessComponent @AssistedInject constructor( + @Assisted appComponentContext: AppComponentContext, + @Assisted private val params: Params, + destinationBlockComponentFactory: SendDestinationBlockComponent.Factory, +) : ComposableModularContentComponent, AppComponentContext by appComponentContext { private val model: SendConfirmSuccessModel = getOrCreateModel(params = params) - private val destinationBlockComponent: SendDestinationBlockComponent = params.destinationBlockComponent + private val destinationBlockComponent: SendDestinationBlockComponent = destinationBlockComponentFactory.create( + context = child("sendConfirmDestinationBlock"), + params = SendDestinationComponentParams.DestinationBlockParams( + state = model.uiState.value.destinationUM, + analyticsCategoryName = params.analyticsCategoryName, + analyticsSendSource = CommonSendAnalyticEvents.CommonSendSource.Send, + userWalletId = params.userWalletId, + cryptoCurrency = params.cryptoCurrency, + blockClickEnableFlow = MutableStateFlow(true), + predefinedValues = params.predefinedValues, + isAddContactAvailable = true, + ), + onResult = {}, + onClick = {}, + ) + + @Composable + override fun Title() { + AppBarWithBackButtonAndIcon( + onBackClick = { + model.onBackClick() + router.pop() + }, + backIconRes = R.drawable.ic_close_24, + backgroundColor = TangemTheme.colors.background.tertiary, + modifier = Modifier.height(TangemTheme.dimens.size56), + ) + } @Composable override fun Content(modifier: Modifier) { @@ -32,11 +82,43 @@ internal class SendConfirmSuccessComponent( ) } + @Composable + override fun Footer() { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .padding( + start = 16.dp, + end = 16.dp, + bottom = 16.dp, + ), + ) { + DoneButtons( + (NavigationButton( + textReference = resourceReference(R.string.common_explore), + iconRes = R.drawable.ic_web_24, + onClick = model::onExploreClick, + ) to NavigationButton( + textReference = resourceReference(R.string.common_share), + iconRes = R.drawable.ic_share_24, + onClick = model::onShareClick, + )).takeIf { params.txUrl.isNotEmpty() }, + ) + PrimaryButton( + text = stringResourceSafe(R.string.common_close), + onClick = router::pop, + modifier = Modifier.fillMaxWidth(), + ) + } + } + data class Params( val sendUMFlow: StateFlow, - val destinationBlockComponent: SendDestinationBlockComponent, + val userWalletId: UserWalletId, + val cryptoCurrency: CryptoCurrency, val analyticsCategoryName: String, - val currentRoute: Flow, + val predefinedValues: PredefinedValues, val txUrl: String, val callback: ModelCallback, ) @@ -44,4 +126,9 @@ internal class SendConfirmSuccessComponent( interface ModelCallback { fun onResult(sendUM: SendUM) } + + @AssistedFactory + interface Factory { + fun create(appComponentContext: AppComponentContext, params: Params): SendConfirmSuccessComponent + } } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/success/model/SendConfirmSuccessModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/success/model/SendConfirmSuccessModel.kt index f13a80be84..c7854d8fef 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/success/model/SendConfirmSuccessModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/success/model/SendConfirmSuccessModel.kt @@ -1,28 +1,17 @@ package com.tangem.features.send.send.success.model import androidx.compose.runtime.Stable -import com.tangem.common.routing.AppRouter -import com.tangem.common.ui.navigationButtons.NavigationButton -import com.tangem.common.ui.navigationButtons.NavigationUM import com.tangem.core.analytics.api.AnalyticsEventHandler 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.navigation.share.ShareManager import com.tangem.core.navigation.url.UrlOpener -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.stringReference import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents.SendScreenSource -import com.tangem.features.send.common.CommonSendRoute import com.tangem.features.send.send.success.SendConfirmSuccessComponent import com.tangem.features.send.send.ui.state.SendUM -import com.tangem.features.send.impl.R import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach import javax.inject.Inject @Stable @@ -31,7 +20,6 @@ internal class SendConfirmSuccessModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, private val analyticsEventHandler: AnalyticsEventHandler, - private val appRouter: AppRouter, private val urlOpener: UrlOpener, private val shareManager: ShareManager, ) : Model() { @@ -39,73 +27,23 @@ internal class SendConfirmSuccessModel @Inject constructor( private val _uiState = params.sendUMFlow val uiState = _uiState - init { - configConfirmSuccessNavigation() + fun onBackClick() { + analyticsEventHandler.send( + CommonSendAnalyticEvents.CloseButtonClicked( + categoryName = params.analyticsCategoryName, + source = SendScreenSource.Confirm, + isFromSummary = true, + isValid = true, + ), + ) } - private fun configConfirmSuccessNavigation() { - combine( - flow = uiState, - flow2 = params.currentRoute, - transform = { state, route -> state to route }, - ).filter { (state, route) -> - // Emit the success navigation exactly once. Building NavigationUM.Content here creates fresh - // lambdas every time, so the SendUM written back via callback.onResult is never equal to the - // previous one — without this guard the combine re-triggers itself endlessly and the success - // screen recomposes forever (never reaching Compose idle). See [REDACTED_TASK_KEY]. - route is CommonSendRoute.ConfirmSuccess && - (state.navigationUM as? NavigationUM.Content)?.source != - CommonSendRoute.ConfirmSuccess.javaClass.simpleName - }.onEach { (state, _) -> - params.callback.onResult( - state.copy( - navigationUM = NavigationUM.Content( - source = CommonSendRoute.ConfirmSuccess.javaClass.simpleName, - title = stringReference(""), - subtitle = null, - backIconRes = R.drawable.ic_close_24, - backIconClick = { - analyticsEventHandler.send( - CommonSendAnalyticEvents.CloseButtonClicked( - categoryName = params.analyticsCategoryName, - source = SendScreenSource.Confirm, - isFromSummary = true, - isValid = true, - ), - ) - appRouter.pop() - }, - primaryButton = NavigationButton( - textReference = resourceReference(R.string.common_close), - iconRes = null, - isEnabled = true, - isHapticClick = false, - onClick = { - appRouter.pop() - }, - ), - prevButton = null, - secondaryPairButtonsUM = (NavigationButton( - textReference = resourceReference(R.string.common_explore), - iconRes = R.drawable.ic_web_24, - onClick = ::onExploreClick, - ) to NavigationButton( - textReference = resourceReference(R.string.common_share), - iconRes = R.drawable.ic_share_24, - onClick = ::onShareClick, - )).takeIf { params.txUrl.isNotEmpty() }, - ), - ), - ) - }.launchIn(modelScope) - } - - private fun onExploreClick() { + fun onExploreClick() { analyticsEventHandler.send(CommonSendAnalyticEvents.ExploreButtonClicked(params.analyticsCategoryName)) urlOpener.openUrl(params.txUrl) } - private fun onShareClick() { + fun onShareClick() { analyticsEventHandler.send(CommonSendAnalyticEvents.ShareButtonClicked(params.analyticsCategoryName)) shareManager.shareText(params.txUrl) } diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/success/ui/SendConfirmSuccessContent.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/success/ui/SendConfirmSuccessContent.kt index 598a87cf9d..edfa9bc05a 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/success/ui/SendConfirmSuccessContent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/success/ui/SendConfirmSuccessContent.kt @@ -11,7 +11,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import com.tangem.common.ui.amountScreen.ui.AmountBlock -import com.tangem.common.ui.navigationButtons.NavigationButtonsBlockV2 import com.tangem.core.ui.components.Fade import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.transactions.TransactionDoneTitle @@ -63,14 +62,6 @@ internal fun SendConfirmSuccessContent(sendUM: SendUM, destinationBlockComponent backgroundColor = TangemTheme.colors.background.tertiary, ) } - NavigationButtonsBlockV2( - navigationUM = sendUM.navigationUM, - modifier = Modifier.padding( - start = 16.dp, - end = 16.dp, - bottom = 16.dp, - ), - ) } } } diff --git a/features/send/impl/src/main/java/com/tangem/features/send/send/ui/state/SendUM.kt b/features/send/impl/src/main/java/com/tangem/features/send/send/ui/state/SendUM.kt index 0ebe64eceb..70c2599c4c 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/send/ui/state/SendUM.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/send/ui/state/SendUM.kt @@ -1,9 +1,8 @@ package com.tangem.features.send.send.ui.state import com.tangem.common.ui.amountScreen.models.AmountState -import com.tangem.common.ui.navigationButtons.NavigationUM -import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.common.ui.state.ConfirmUM import com.tangem.features.send.send.confirm.model.ConfirmData @@ -12,6 +11,5 @@ internal data class SendUM( val destinationUM: DestinationUM, val feeSelectorUM: FeeSelectorUM, val confirmUM: ConfirmUM, - val navigationUM: NavigationUM, val confirmData: ConfirmData?, ) \ No newline at end of file diff --git a/features/send/impl/src/test/java/com/tangem/features/send/send/model/SendModelNavigationTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/send/model/SendModelNavigationTest.kt new file mode 100644 index 0000000000..1b070f7492 --- /dev/null +++ b/features/send/impl/src/test/java/com/tangem/features/send/send/model/SendModelNavigationTest.kt @@ -0,0 +1,198 @@ +package com.tangem.features.send.send.model + +import arrow.core.right +import com.tangem.blockchain.common.Blockchain +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.model.MutableParamsContainer +import com.tangem.core.decompose.navigation.Router +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase +import com.tangem.features.send.api.SendComponent +import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents.SendScreenSource +import com.tangem.features.send.common.CommonSendRoute +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents.CloseButtonClicked as CloseButtonClickedEvent + +/** + * Guards the navigation refactor that moved the footer/app-bar actions out of the model into + * `DefaultSendComponent`. The model's [SendModel.onBackClick] / [SendModel.onNextClick] no longer read + * an internal `currentRoute` StateFlow — they receive the active [com.tangem.core.decompose.navigation.Route] + * as a parameter and decide routing/analytics from it. These are pure, synchronous decisions, so each + * method is asserted *before* advancing the scheduler; the model is then destroyed inside the test body + * so its `init {}` collectors (all `modelScope.launch`/`launchIn`, including an infinite status collector) + * are cancelled — never run — before `runTest`'s terminal advance. + */ +@OptIn(ExperimentalCoroutinesApi::class) +internal class SendModelNavigationTest { + + private val router: Router = mockk(relaxed = true) + private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true) + private val listenToQrScanningUseCase: ListenToQrScanningUseCase = mockk(relaxed = true) + private val cryptoCurrency = MockCryptoCurrencyFactory().createCoin(Blockchain.Ethereum) + + // region onNextClick + + @Test + fun `GIVEN manual entry on amount WHEN onNextClick THEN pushes destination`() = runTest { + val model = createModel(this) + + model.onNextClick(CommonSendRoute.Amount(isEditMode = false)) + + verify(exactly = 1) { router.push(CommonSendRoute.Destination(isEditMode = false)) } + verify(exactly = 0) { router.push(CommonSendRoute.Confirm) } + model.onDestroy() + } + + @Test + fun `GIVEN main-screen QR predefined values WHEN onNextClick on amount THEN skips destination and pushes confirm`() = + runTest { + // Arrange — address-only predefined values resolve to a MAIN_SCREEN QrCode (isFromMainScreenQr = true). + val model = createModel(this, params = qrParams()) + + // Act + model.onNextClick(CommonSendRoute.Amount(isEditMode = false)) + + // Assert + verify(exactly = 1) { router.push(CommonSendRoute.Confirm) } + verify(exactly = 0) { router.push(CommonSendRoute.Destination(isEditMode = false)) } + model.onDestroy() + } + + @Test + fun `GIVEN destination step WHEN onNextClick THEN pushes confirm`() = runTest { + val model = createModel(this) + + model.onNextClick(CommonSendRoute.Destination(isEditMode = false)) + + verify(exactly = 1) { router.push(CommonSendRoute.Confirm) } + model.onDestroy() + } + + @Test + fun `GIVEN edit-mode route WHEN onNextClick THEN pops instead of advancing`() = runTest { + val model = createModel(this) + + model.onNextClick(CommonSendRoute.Amount(isEditMode = true)) + + verify(exactly = 1) { router.pop() } + verify(exactly = 0) { router.push(any()) } + model.onDestroy() + } + + @Test + fun `GIVEN confirm-success route WHEN onNextClick THEN pops`() = runTest { + val model = createModel(this) + + model.onNextClick(CommonSendRoute.ConfirmSuccess) + + verify(exactly = 1) { router.pop() } + verify(exactly = 0) { router.push(any()) } + model.onDestroy() + } + + // endregion + + // region onBackClick + + @Test + fun `GIVEN amount step not in edit mode WHEN onBackClick THEN sends amount close analytics and pops`() = runTest { + val model = createModel(this) + + model.onBackClick(CommonSendRoute.Amount(isEditMode = false)) + + verify(exactly = 1) { + analyticsEventHandler.send(match { it is CloseButtonClickedEvent && it.source == SendScreenSource.Amount }) + } + verify(exactly = 1) { router.pop() } + model.onDestroy() + } + + @Test + fun `GIVEN destination step not in edit mode WHEN onBackClick THEN sends address close analytics and pops`() = + runTest { + val model = createModel(this) + + model.onBackClick(CommonSendRoute.Destination(isEditMode = false)) + + verify(exactly = 1) { + analyticsEventHandler.send( + match { it is CloseButtonClickedEvent && it.source == SendScreenSource.Address }, + ) + } + verify(exactly = 1) { router.pop() } + model.onDestroy() + } + + @Test + fun `GIVEN edit-mode route WHEN onBackClick THEN pops without close analytics`() = runTest { + val model = createModel(this) + + model.onBackClick(CommonSendRoute.Amount(isEditMode = true)) + + verify(exactly = 0) { analyticsEventHandler.send(any()) } + verify(exactly = 1) { router.pop() } + model.onDestroy() + } + + // endregion + + private fun manualParams() = SendComponent.Params( + userWalletId = UserWalletId(stringValue = "0123456789"), + currency = cryptoCurrency, + ) + + private fun qrParams() = SendComponent.Params( + userWalletId = UserWalletId(stringValue = "0123456789"), + currency = cryptoCurrency, + destinationAddress = "0xRECIPIENT", + ) + + private fun createModel(testScope: TestScope, params: SendComponent.Params = manualParams()): SendModel { + // Runs synchronously in init {}; a relaxed Either would break getOrElse, so stub a Right. + every { listenToQrScanningUseCase(any()) } returns emptyFlow().right() + return SendModel( + paramsContainer = MutableParamsContainer(value = params), + dispatchers = testScope.createTestingCoroutineDispatcherProvider(), + router = router, + getUserWalletUseCase = mockk(relaxed = true), + getFeePaidCryptoCurrencyStatusSyncUseCase = mockk(relaxed = true), + getSelectedAppCurrencyUseCase = mockk(relaxed = true), + listenToQrScanningUseCase = listenToQrScanningUseCase, + parseQrCodeUseCase = mockk(relaxed = true), + sendConfirmAlertFactory = mockk(relaxed = true), + saveBlockchainErrorUseCase = mockk(relaxed = true), + getWalletMetaInfoUseCase = mockk(relaxed = true), + sendFeedbackEmailUseCase = mockk(relaxed = true), + getBalanceHidingSettingsUseCase = mockk(relaxed = true), + createTransferTransactionUseCase = mockk(relaxed = true), + getFeeUseCase = mockk(relaxed = true), + getFeeForGaslessUseCase = mockk(relaxed = true), + getFeeForTokenUseCase = mockk(relaxed = true), + getAccountCurrencyStatusUseCase = mockk(relaxed = true), + isAccountsModeEnabledUseCase = mockk(relaxed = true), + sendAmountUpdateTrigger = mockk(relaxed = true), + analyticsEventHandler = analyticsEventHandler, + ) + } + + 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 From c96e72fac40cab2e9b453c99f01460ba5809a8c7 Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 25 Jun 2026 12:00:27 +0500 Subject: [PATCH 076/210] Updated on 2026-08-14 --- .../send/sendnft/DefaultNFTSendComponent.kt | 163 +++++++++--------- .../confirm/NFTSendConfirmComponent.kt | 62 ++++++- .../confirm/model/NFTSendConfirmModel.kt | 66 ++----- .../send/sendnft/model/NFTSendModel.kt | 25 +-- .../success/NFTSendSuccessComponent.kt | 66 ++++++- .../success/model/NFTSendSuccessModel.kt | 80 ++------- .../success/ui/NFTSendSuccessContent.kt | 41 ++--- .../send/sendnft/ui/state/NFTSendUM.kt | 4 +- .../model/NFTSendModelNavigationTest.kt | 137 +++++++++++++++ 9 files changed, 380 insertions(+), 264 deletions(-) create mode 100644 features/send/impl/src/test/java/com/tangem/features/send/sendnft/model/NFTSendModelNavigationTest.kt diff --git a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/DefaultNFTSendComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/DefaultNFTSendComponent.kt index e06f11e545..05b948fa64 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/DefaultNFTSendComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/DefaultNFTSendComponent.kt @@ -4,7 +4,6 @@ import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.arkivanov.decompose.router.stack.StackNavigation import com.arkivanov.decompose.router.stack.childStack @@ -16,14 +15,15 @@ import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.decompose.navigation.inner.InnerRouter -import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.decompose.ComposableModularContentComponent import com.tangem.core.ui.extensions.resourceReference import com.tangem.features.send.api.NFTSendComponent import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents +import com.tangem.features.send.api.subcomponents.destination.DestinationRoute import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponent import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams import com.tangem.features.send.common.CommonSendRoute -import com.tangem.features.send.common.ui.SendContent +import com.tangem.features.send.common.ui.SendModularContent import com.tangem.features.send.common.ui.state.ConfirmUM import com.tangem.features.send.impl.R import com.tangem.features.send.sendnft.confirm.NFTSendConfirmComponent @@ -32,16 +32,14 @@ import com.tangem.features.send.sendnft.success.NFTSendSuccessComponent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.launch internal class DefaultNFTSendComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, @Assisted private val params: NFTSendComponent.Params, + private val destinationComponentFactory: SendDestinationComponent.Factory, private val nftSendConfirmComponentFactory: NFTSendConfirmComponent.Factory, private val nftSendSuccessComponentFactory: NFTSendSuccessComponent.Factory, private val analyticsEventHandler: AnalyticsEventHandler, - private val sendDestinationComponentFactory: SendDestinationComponent.Factory, ) : NFTSendComponent, AppComponentContext by appComponentContext { private val stackNavigation = StackNavigation() @@ -77,37 +75,35 @@ internal class DefaultNFTSendComponent @AssistedInject constructor( lifecycle = lifecycle, mode = ObserveLifecycleMode.CREATE_DESTROY, ) { stack -> - componentScope.launch { - when (val activeComponent = stack.active.instance) { - is NFTSendConfirmComponent -> { - val fromCurrency = model.cryptoCurrency - val fromDerivationIndex = model.account?.derivationIndex?.value - .takeIf { model.isAccountsMode } - analyticsEventHandler.send( - CommonSendAnalyticEvents.ConfirmationScreenOpened( - categoryName = analyticsCategoryName, - source = analyticsSendSource, - sendBlockchain = fromCurrency.network.name, - sendToken = fromCurrency.symbol, - fromDerivationIndex = fromDerivationIndex, - toDerivationIndex = null, - ), - ) - if (model.currentRouteFlow.value.isEditMode) { - activeComponent.updateState(model.uiState.value) - } - } - is SendDestinationComponent -> { - analyticsEventHandler.send( - CommonSendAnalyticEvents.AddressScreenOpened( - categoryName = analyticsCategoryName, - source = analyticsSendSource, - ), - ) - activeComponent.updateState(model.uiState.value.destinationUM) + when (val activeComponent = stack.active.instance) { + is NFTSendConfirmComponent -> { + val fromCurrency = model.cryptoCurrency + val fromDerivationIndex = model.account?.derivationIndex?.value + .takeIf { model.isAccountsMode } + analyticsEventHandler.send( + CommonSendAnalyticEvents.ConfirmationScreenOpened( + categoryName = analyticsCategoryName, + source = analyticsSendSource, + sendBlockchain = fromCurrency.network.name, + sendToken = fromCurrency.symbol, + fromDerivationIndex = fromDerivationIndex, + toDerivationIndex = null, + ), + ) + // Push current state into a reused Confirm on (re)entry. Confirm.isEditMode is `true` + if (stack.active.configuration.isEditMode) { + activeComponent.updateState(model.uiState.value) } } - model.currentRouteFlow.emit(stack.active.configuration) + is SendDestinationComponent -> { + analyticsEventHandler.send( + CommonSendAnalyticEvents.AddressScreenOpened( + categoryName = analyticsCategoryName, + source = analyticsSendSource, + ), + ) + activeComponent.updateState(model.uiState.value.destinationUM) + } } } } @@ -115,67 +111,71 @@ internal class DefaultNFTSendComponent @AssistedInject constructor( @Composable override fun Content(modifier: Modifier) { val stackState by childStack.subscribeAsState() - val state by model.uiState.collectAsStateWithLifecycle() - BackHandler(onBack = model::onBackClick) - SendContent( - navigationUM = state.navigationUM, - confirmUM = state.confirmUM, - stackState = stackState, - ) + BackHandler(onBack = ::onChildBack) + SendModularContent(stackState = stackState) } - private fun createChild(route: CommonSendRoute, factoryContext: AppComponentContext) = when (route) { - is CommonSendRoute.Destination -> getDestinationComponent(factoryContext) + private fun createChild( + route: CommonSendRoute, + factoryContext: AppComponentContext, + ): ComposableModularContentComponent = when (route) { + is CommonSendRoute.Destination -> getDestinationComponent(route, factoryContext) CommonSendRoute.Confirm -> getConfirmComponent(factoryContext) CommonSendRoute.ConfirmSuccess -> getSuccessComponent(factoryContext) - else -> getStubComponent() + // Empty is the bootstrap placeholder until the currency status resolves; NFT has no Amount step. + CommonSendRoute.Empty, + is CommonSendRoute.Amount, + -> ComposableModularContentComponent.EMPTY } - private fun getDestinationComponent(factoryContext: AppComponentContext): SendDestinationComponent = - sendDestinationComponentFactory.create( - context = factoryContext, - params = SendDestinationComponentParams.DestinationParams( - state = model.uiState.value.destinationUM, - currentRoute = model.currentRouteFlow.filterIsInstance(), - isBalanceHidingFlow = model.isBalanceHiddenFlow, - title = resourceReference(R.string.nft_send), - analyticsCategoryName = analyticsCategoryName, - analyticsSendSource = analyticsSendSource, - userWalletId = params.userWalletId, - cryptoCurrency = model.cryptoCurrency, - callback = model, - ), - ) - - private fun getConfirmComponent(factoryContext: AppComponentContext) = nftSendConfirmComponentFactory.create( - appComponentContext = factoryContext, - params = NFTSendConfirmComponent.Params( - state = model.uiState.value, - analyticsCategoryName = analyticsCategoryName, - userWallet = model.userWallet, - nftAsset = params.nftAsset, - nftCollectionName = params.nftCollectionName, - cryptoCurrencyStatus = model.cryptoCurrencyStatus, - feeCryptoCurrencyStatus = model.feeCryptoCurrencyStatus, - appCurrency = model.appCurrency, - callback = model, - currentRoute = model.currentRouteFlow.filterIsInstance(), + private fun getDestinationComponent( + route: DestinationRoute, + factoryContext: AppComponentContext, + ): ComposableModularContentComponent = destinationComponentFactory.create( + context = factoryContext, + params = SendDestinationComponentParams.DestinationParams( + state = model.uiState.value.destinationUM, + route = route, isBalanceHidingFlow = model.isBalanceHiddenFlow, - onLoadFee = model::loadFee, + title = resourceReference(R.string.nft_send), + analyticsCategoryName = analyticsCategoryName, analyticsSendSource = analyticsSendSource, - account = model.account, - isAccountsMode = model.isAccountsMode, - onSendTransaction = { innerRouter.replaceAll(CommonSendRoute.ConfirmSuccess) }, + userWalletId = params.userWalletId, + cryptoCurrency = model.cryptoCurrency, + callback = model, ), ) - private fun getSuccessComponent(factoryContext: AppComponentContext): ComposableContentComponent { + private fun getConfirmComponent(factoryContext: AppComponentContext): ComposableModularContentComponent { + return nftSendConfirmComponentFactory.create( + appComponentContext = factoryContext, + params = NFTSendConfirmComponent.Params( + state = model.uiState.value, + analyticsCategoryName = analyticsCategoryName, + userWallet = model.userWallet, + nftAsset = params.nftAsset, + nftCollectionName = params.nftCollectionName, + cryptoCurrencyStatus = model.cryptoCurrencyStatus, + feeCryptoCurrencyStatus = model.feeCryptoCurrencyStatus, + appCurrency = model.appCurrency, + callback = model, + isBalanceHidingFlow = model.isBalanceHiddenFlow, + onLoadFee = model::loadFee, + analyticsSendSource = analyticsSendSource, + account = model.account, + isAccountsMode = model.isAccountsMode, + onSendTransaction = { innerRouter.replaceAll(CommonSendRoute.ConfirmSuccess) }, + ), + ) + } + + private fun getSuccessComponent(factoryContext: AppComponentContext): ComposableModularContentComponent { val txUrl = (model.uiState.value.confirmUM as? ConfirmUM.Success)?.txUrl if (txUrl == null) { model.showAlertError() - return getStubComponent() + return ComposableModularContentComponent.EMPTY } return nftSendSuccessComponentFactory.create( @@ -189,7 +189,6 @@ internal class DefaultNFTSendComponent @AssistedInject constructor( nftAsset = params.nftAsset, nftCollectionName = params.nftCollectionName, callback = model, - currentRoute = model.currentRouteFlow.filterIsInstance(), txUrl = txUrl, account = model.account, isAccountsMode = model.isAccountsMode, @@ -197,8 +196,6 @@ internal class DefaultNFTSendComponent @AssistedInject constructor( ) } - private fun getStubComponent() = ComposableContentComponent { } - private fun onChildBack() { val isEmptyRoute = childStack.value.active.configuration == CommonSendRoute.Empty val isEmptyStack = childStack.value.backStack.isEmpty() diff --git a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/NFTSendConfirmComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/NFTSendConfirmComponent.kt index 1093c06d6c..297793f4e6 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/NFTSendConfirmComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/NFTSendConfirmComponent.kt @@ -1,17 +1,28 @@ package com.tangem.features.send.sendnft.confirm +import androidx.compose.animation.* +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import arrow.core.Either import com.tangem.blockchain.common.transaction.TransactionFee +import com.tangem.common.ui.footers.SendingText +import com.tangem.common.ui.navigationButtons.NavigationPrimaryButton import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.child import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon +import com.tangem.core.ui.decompose.ComposableModularContentComponent +import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.extensions.wrappedList +import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus @@ -19,21 +30,20 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.nft.models.NFTAsset import com.tangem.domain.transaction.error.GetFeeError import com.tangem.features.nft.component.NFTDetailsBlockComponent -import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockComponent -import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.entity.PredefinedValues +import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams.DestinationBlockParams +import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockComponent import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams.FeeStateConfiguration -import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams.DestinationBlockParams -import com.tangem.features.send.common.CommonSendRoute +import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent import com.tangem.features.send.common.ui.state.ConfirmUM +import com.tangem.features.send.impl.R import com.tangem.features.send.sendnft.confirm.model.NFTSendConfirmModel import com.tangem.features.send.sendnft.confirm.ui.NFTSendConfirmContent import com.tangem.features.send.sendnft.ui.state.NFTSendUM import com.tangem.features.send.subcomponents.destination.DefaultSendDestinationBlockComponent import com.tangem.features.send.subcomponents.notifications.DefaultSendNotificationsComponent -import com.tangem.features.send.impl.R import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -45,7 +55,7 @@ internal class NFTSendConfirmComponent @AssistedInject constructor( @Assisted params: Params, nftDetailsBlockComponentFactory: NFTDetailsBlockComponent.Factory, feeSelectorComponentFactory: FeeSelectorBlockComponent.Factory, -) : ComposableContentComponent, AppComponentContext by appComponentContext { +) : ComposableModularContentComponent, AppComponentContext by appComponentContext { private val model: NFTSendConfirmModel = getOrCreateModel(params = params) @@ -129,6 +139,20 @@ internal class NFTSendConfirmComponent @AssistedInject constructor( model.updateState(state) } + @Composable + override fun Title() { + AppBarWithBackButtonAndIcon( + text = stringResourceSafe(R.string.nft_send), + onBackClick = { + model.onBackClick() + router.pop() + }, + backIconRes = R.drawable.ic_back_24, + backgroundColor = TangemTheme.colors.background.tertiary, + modifier = Modifier.height(TangemTheme.dimens.size56), + ) + } + @Composable override fun Content(modifier: Modifier) { val state by model.uiState.collectAsStateWithLifecycle() @@ -144,6 +168,29 @@ internal class NFTSendConfirmComponent @AssistedInject constructor( ) } + @Composable + override fun Footer() { + val state by model.uiState.collectAsStateWithLifecycle() + Column { + val sendingFooter = (state.confirmUM as? ConfirmUM.Content)?.sendingFooter + AnimatedVisibility( + visible = sendingFooter != null, + enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(), + ) { + SendingText(footerText = sendingFooter ?: TextReference.EMPTY) + } + NavigationPrimaryButton( + primaryButton = model.primaryButtonUM(), + modifier = Modifier.padding( + start = 16.dp, + end = 16.dp, + bottom = 16.dp, + ), + ) + } + } + data class Params( val state: NFTSendUM, val analyticsCategoryName: String, @@ -157,7 +204,6 @@ internal class NFTSendConfirmComponent @AssistedInject constructor( val account: Account.CryptoPortfolio?, val isAccountsMode: Boolean, val callback: ModelCallback, - val currentRoute: Flow, val isBalanceHidingFlow: StateFlow, val onLoadFee: suspend () -> Either, val onSendTransaction: () -> Unit, diff --git a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/model/NFTSendConfirmModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/model/NFTSendConfirmModel.kt index cec3676b65..98216a2934 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/model/NFTSendConfirmModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/confirm/model/NFTSendConfirmModel.kt @@ -5,7 +5,6 @@ import arrow.core.getOrElse import com.tangem.blockchain.common.TransactionData import com.tangem.common.routing.AppRouter import com.tangem.common.ui.navigationButtons.NavigationButton -import com.tangem.common.ui.navigationButtons.NavigationUM import com.tangem.common.ui.userwallet.ext.walletInterationIcon import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsParam @@ -32,28 +31,28 @@ import com.tangem.domain.transaction.usecase.CreateNFTTransferTransactionUseCase import com.tangem.domain.transaction.usecase.SendTransactionUseCase import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.features.nft.entity.NFTSendSuccessTrigger -import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent -import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent.Params.NotificationData import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents.SendScreenSource -import com.tangem.features.send.api.subcomponents.feeSelector.callbacks.FeeSelectorModelCallback import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorCheckReloadListener import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorCheckReloadTrigger import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger +import com.tangem.features.send.api.subcomponents.feeSelector.callbacks.FeeSelectorModelCallback +import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent +import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent.Params.NotificationData import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateListener import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateTrigger import com.tangem.features.send.common.CommonSendRoute import com.tangem.features.send.common.SendBalanceUpdater import com.tangem.features.send.common.SendConfirmAlertFactory import com.tangem.features.send.common.ui.state.ConfirmUM +import com.tangem.features.send.impl.R import com.tangem.features.send.sendnft.analytics.NFTSendAnalyticHelper import com.tangem.features.send.sendnft.confirm.NFTSendConfirmComponent import com.tangem.features.send.sendnft.confirm.model.transformers.NFTSendConfirmInitialStateTransformer import com.tangem.features.send.sendnft.confirm.model.transformers.NFTSendConfirmSendingStateTransformer import com.tangem.features.send.sendnft.confirm.model.transformers.NFTSendConfirmationNotificationsTransformerV2 import com.tangem.features.send.sendnft.ui.state.NFTSendUM -import com.tangem.features.send.impl.R import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.extensions.stripZeroPlainString import com.tangem.utils.logging.TangemLogger @@ -122,7 +121,6 @@ internal class NFTSendConfirmModel @Inject constructor( private var sendIdleTimer: Long = 0L init { - configConfirmNavigation() subscribeOnNotificationsUpdateTrigger() subscribeOnCheckFeeResultUpdates() subscribeOnTapHelpUpdates() @@ -371,54 +369,18 @@ internal class NFTSendConfirmModel @Inject constructor( } } - private fun configConfirmNavigation() { - combine( - flow = uiState, - flow2 = params.currentRoute, - transform = { state, route -> state to route }, - ).onEach { (state, _) -> - val confirmUM = state.confirmUM - params.callback.onResult( - state.copy( - navigationUM = NavigationUM.Content( - source = CommonSendRoute.Confirm.javaClass.simpleName, - title = resourceReference(R.string.nft_send), - subtitle = null, - backIconRes = when (confirmUM) { - is ConfirmUM.Success -> R.drawable.ic_close_24 - else -> R.drawable.ic_back_24 - }, - backIconClick = { - analyticsEventHandler.send( - CommonSendAnalyticEvents.CloseButtonClicked( - categoryName = analyticsCategoryName, - source = SendScreenSource.Confirm, - isFromSummary = true, - isValid = confirmUM.isPrimaryButtonEnabled, - ), - ) - router.pop() - }, - primaryButton = primaryButtonUM(), - prevButton = null, - secondaryPairButtonsUM = ( - NavigationButton( - textReference = resourceReference(R.string.common_explore), - iconRes = R.drawable.ic_web_24, - onClick = ::onExploreClick, - ) to NavigationButton( - textReference = resourceReference(R.string.common_share), - iconRes = R.drawable.ic_share_24, - onClick = ::onShareClick, - ) - ).takeUnless { (confirmUM as? ConfirmUM.Success)?.txUrl.isNullOrBlank() }, - ), - ), - ) - }.launchIn(modelScope) + fun onBackClick() { + analyticsEventHandler.send( + CommonSendAnalyticEvents.CloseButtonClicked( + categoryName = analyticsCategoryName, + source = SendScreenSource.Confirm, + isFromSummary = true, + isValid = uiState.value.confirmUM.isPrimaryButtonEnabled, + ), + ) } - private fun primaryButtonUM(): NavigationButton { + fun primaryButtonUM(): NavigationButton { val confirmUM = uiState.value.confirmUM val isContent = confirmUM is ConfirmUM.Content val isReadyToSend = isContent && !confirmUM.isSending diff --git a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/model/NFTSendModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/model/NFTSendModel.kt index 46af42fff2..69094e6fb5 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/model/NFTSendModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/model/NFTSendModel.kt @@ -5,13 +5,13 @@ import arrow.core.Either import arrow.core.getOrElse import arrow.core.left import com.tangem.blockchain.common.transaction.TransactionFee -import com.tangem.common.ui.navigationButtons.NavigationUM import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsParam import com.tangem.core.analytics.models.Basic 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.navigation.Route import com.tangem.core.decompose.navigation.Router import com.tangem.datasource.local.nft.converter.NFTSdkAssetConverter import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase @@ -36,9 +36,9 @@ import com.tangem.domain.transaction.usecase.GetFeeUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.nft.entity.NFTSendSuccessTrigger import com.tangem.features.send.api.NFTSendComponent -import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponent import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.common.CommonSendRoute import com.tangem.features.send.common.SendConfirmAlertFactory import com.tangem.features.send.common.ui.state.ConfirmUM @@ -82,8 +82,6 @@ internal class NFTSendModel @Inject constructor( val initialRoute = CommonSendRoute.Empty - val currentRouteFlow = MutableStateFlow(initialRoute) - private val userWalletId = params.userWalletId private val nftAsset = params.nftAsset @@ -107,10 +105,6 @@ internal class NFTSendModel @Inject constructor( initAppCurrency() } - override fun onNavigationResult(navigationUM: NavigationUM) { - uiState.update { it.copy(navigationUM = navigationUM) } - } - override fun onResult(nftSendUM: NFTSendUM) { uiState.value = nftSendUM } @@ -119,8 +113,8 @@ internal class NFTSendModel @Inject constructor( uiState.update { it.copy(destinationUM = destinationUM) } } - override fun onBackClick() { - if (currentRouteFlow.value == CommonSendRoute.ConfirmSuccess) { + override fun onBackClick(currentRoute: Route) { + if (currentRoute == CommonSendRoute.ConfirmSuccess) { modelScope.launch { nftSendSuccessTrigger.triggerSuccessNFTSend() } @@ -128,14 +122,14 @@ internal class NFTSendModel @Inject constructor( router.pop() } - override fun onNextClick() { - if (currentRouteFlow.value.isEditMode) { - onBackClick() + override fun onNextClick(currentRoute: Route) { + if ((currentRoute as? CommonSendRoute)?.isEditMode == true) { + onBackClick(currentRoute) } else { - when (currentRouteFlow.value) { + when (currentRoute) { is CommonSendRoute.Destination -> router.push(CommonSendRoute.Confirm) CommonSendRoute.Confirm -> router.replaceAll(CommonSendRoute.ConfirmSuccess) - else -> onBackClick() + else -> onBackClick(currentRoute) } } } @@ -241,6 +235,5 @@ internal class NFTSendModel @Inject constructor( destinationUM = DestinationUM.Empty(), feeSelectorUM = FeeSelectorUM.Loading, confirmUM = ConfirmUM.Empty, - navigationUM = NavigationUM.Empty, ) } \ No newline at end of file diff --git a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/success/NFTSendSuccessComponent.kt b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/success/NFTSendSuccessComponent.kt index 2642e9da0d..2384aa7724 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/success/NFTSendSuccessComponent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/success/NFTSendSuccessComponent.kt @@ -1,15 +1,27 @@ package com.tangem.features.send.sendnft.success +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.common.ui.navigationButtons.DoneButtons +import com.tangem.common.ui.navigationButtons.NavigationButton import com.tangem.common.ui.navigationButtons.NavigationModelCallback import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.child import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon +import com.tangem.core.ui.decompose.ComposableModularContentComponent import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet @@ -19,24 +31,22 @@ import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.entity.PredefinedValues import com.tangem.features.send.api.subcomponents.destination.SendDestinationBlockComponent import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams.DestinationBlockParams -import com.tangem.features.send.common.CommonSendRoute +import com.tangem.features.send.impl.R import com.tangem.features.send.sendnft.success.model.NFTSendSuccessModel import com.tangem.features.send.sendnft.success.ui.NFTSendSuccessContent import com.tangem.features.send.sendnft.ui.state.NFTSendUM -import com.tangem.features.send.impl.R import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow internal class NFTSendSuccessComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, - @Assisted params: Params, + @Assisted private val params: Params, nftDetailsBlockComponentFactory: NFTDetailsBlockComponent.Factory, sendDestinationBlockComponentFactory: SendDestinationBlockComponent.Factory, -) : ComposableContentComponent, AppComponentContext by appComponentContext { +) : ComposableModularContentComponent, AppComponentContext by appComponentContext { private val model: NFTSendSuccessModel = getOrCreateModel(params = params) @@ -69,6 +79,19 @@ internal class NFTSendSuccessComponent @AssistedInject constructor( onClick = {}, ) + @Composable + override fun Title() { + AppBarWithBackButtonAndIcon( + onBackClick = { + model.onBackClick() + router.pop() + }, + backIconRes = R.drawable.ic_close_24, + backgroundColor = TangemTheme.colors.background.tertiary, + modifier = Modifier.height(TangemTheme.dimens.size56), + ) + } + @Composable override fun Content(modifier: Modifier) { val state by model.uiState.collectAsStateWithLifecycle() @@ -80,11 +103,40 @@ internal class NFTSendSuccessComponent @AssistedInject constructor( ) } + @Composable + override fun Footer() { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + .padding( + start = 16.dp, + end = 16.dp, + bottom = 16.dp, + ), + ) { + DoneButtons( + (NavigationButton( + textReference = resourceReference(R.string.common_explore), + iconRes = R.drawable.ic_web_24, + onClick = model::onExploreClick, + ) to NavigationButton( + textReference = resourceReference(R.string.common_share), + iconRes = R.drawable.ic_share_24, + onClick = model::onShareClick, + )).takeIf { params.txUrl.isNotEmpty() }, + ) + PrimaryButton( + text = stringResourceSafe(R.string.common_close), + onClick = router::pop, + modifier = Modifier.fillMaxWidth(), + ) + } + } + data class Params( val nftSendUMFlow: StateFlow, val analyticsCategoryName: String, val analyticsSendSource: CommonSendAnalyticEvents.CommonSendSource, - val currentRoute: Flow, val cryptoCurrencyStatus: CryptoCurrencyStatus, val userWallet: UserWallet, val nftAsset: NFTAsset, diff --git a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/success/model/NFTSendSuccessModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/success/model/NFTSendSuccessModel.kt index 5c9061f7f8..7ebfaa2317 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/success/model/NFTSendSuccessModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/success/model/NFTSendSuccessModel.kt @@ -1,27 +1,17 @@ package com.tangem.features.send.sendnft.success.model import androidx.compose.runtime.Stable -import com.tangem.common.ui.navigationButtons.NavigationButton -import com.tangem.common.ui.navigationButtons.NavigationUM import com.tangem.core.analytics.api.AnalyticsEventHandler 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.navigation.share.ShareManager import com.tangem.core.navigation.url.UrlOpener -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.stringReference import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents.SendScreenSource -import com.tangem.features.send.common.CommonSendRoute import com.tangem.features.send.send.ui.state.SendUM import com.tangem.features.send.sendnft.success.NFTSendSuccessComponent -import com.tangem.features.send.impl.R import com.tangem.utils.coroutines.CoroutineDispatcherProvider -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach import javax.inject.Inject @Stable @@ -37,71 +27,23 @@ internal class NFTSendSuccessModel @Inject constructor( val uiState = params.nftSendUMFlow - init { - configConfirmSuccessNavigation() + fun onBackClick() { + analyticsEventHandler.send( + CommonSendAnalyticEvents.CloseButtonClicked( + categoryName = params.analyticsCategoryName, + source = SendScreenSource.Confirm, + isFromSummary = true, + isValid = true, + ), + ) } - private fun configConfirmSuccessNavigation() { - combine( - flow = uiState, - flow2 = params.currentRoute, - transform = { state, route -> state to route }, - ).filter { (state, route) -> - // Emit the success navigation exactly once. Building NavigationUM.Content here creates fresh - // lambdas every time, so the SendUM written back via callback.onResult is never equal to the - // previous one — without this guard the combine re-triggers itself endlessly and the success - // screen recomposes forever (never reaching Compose idle). See [REDACTED_TASK_KEY]. - route is CommonSendRoute.ConfirmSuccess && - (state.navigationUM as? NavigationUM.Content)?.source != - CommonSendRoute.ConfirmSuccess.javaClass.simpleName - }.onEach { (state, _) -> - params.callback.onResult( - state.copy( - navigationUM = NavigationUM.Content( - source = CommonSendRoute.ConfirmSuccess.javaClass.simpleName, - title = stringReference(""), - subtitle = null, - backIconRes = R.drawable.ic_close_24, - backIconClick = { - analyticsEventHandler.send( - CommonSendAnalyticEvents.CloseButtonClicked( - categoryName = params.analyticsCategoryName, - source = SendScreenSource.Confirm, - isFromSummary = true, - isValid = true, - ), - ) - params.callback.onBackClick() - }, - primaryButton = NavigationButton( - textReference = resourceReference(R.string.common_close), - iconRes = null, - isEnabled = true, - isHapticClick = false, - onClick = params.callback::onBackClick, - ), - prevButton = null, - secondaryPairButtonsUM = NavigationButton( - textReference = resourceReference(R.string.common_explore), - iconRes = R.drawable.ic_web_24, - onClick = ::onExploreClick, - ) to NavigationButton( - textReference = resourceReference(R.string.common_share), - iconRes = R.drawable.ic_share_24, - onClick = ::onShareClick, - ), - ), - ), - ) - }.launchIn(modelScope) - } - - private fun onExploreClick() { + fun onExploreClick() { analyticsEventHandler.send(CommonSendAnalyticEvents.ExploreButtonClicked(params.analyticsCategoryName)) urlOpener.openUrl(params.txUrl) } - private fun onShareClick() { + fun onShareClick() { analyticsEventHandler.send(CommonSendAnalyticEvents.ShareButtonClicked(params.analyticsCategoryName)) shareManager.shareText(params.txUrl) } diff --git a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/success/ui/NFTSendSuccessContent.kt b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/success/ui/NFTSendSuccessContent.kt index aa4bd4ceff..8ca229c671 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/success/ui/NFTSendSuccessContent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/success/ui/NFTSendSuccessContent.kt @@ -9,7 +9,6 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.tangem.common.ui.navigationButtons.NavigationButtonsBlockV2 import com.tangem.core.ui.components.Fade import com.tangem.core.ui.components.SpacerH import com.tangem.core.ui.components.transactions.TransactionDoneTitle @@ -23,8 +22,8 @@ import com.tangem.features.nft.component.NFTDetailsBlockComponent import com.tangem.features.send.api.subcomponents.destination.SendDestinationBlockComponent import com.tangem.features.send.common.ui.FeeBlockSuccess import com.tangem.features.send.common.ui.state.ConfirmUM -import com.tangem.features.send.sendnft.ui.state.NFTSendUM import com.tangem.features.send.impl.R +import com.tangem.features.send.sendnft.ui.state.NFTSendUM import kotlinx.coroutines.delay @Composable @@ -52,30 +51,20 @@ internal fun NFTSendSuccessContent( label = "Animate success content", modifier = modifier, ) { - Column { - Box( - modifier = Modifier - .weight(1f) - .background(TangemTheme.colors.background.tertiary), - ) { - SuccessContent( - nftSendUM = nftSendUM, - nftDetailsBlockComponent = nftDetailsBlockComponent, - destinationBlockComponent = destinationBlockComponent, - modifier = Modifier.fillMaxHeight(), - ) - Fade( - modifier = Modifier.align(Alignment.BottomCenter), - backgroundColor = TangemTheme.colors.background.tertiary, - ) - } - NavigationButtonsBlockV2( - navigationUM = nftSendUM.navigationUM, - modifier = Modifier.padding( - start = 16.dp, - end = 16.dp, - bottom = 16.dp, - ), + Box( + modifier = Modifier + .fillMaxWidth() + .background(TangemTheme.colors.background.tertiary), + ) { + SuccessContent( + nftSendUM = nftSendUM, + nftDetailsBlockComponent = nftDetailsBlockComponent, + destinationBlockComponent = destinationBlockComponent, + modifier = Modifier.fillMaxHeight(), + ) + Fade( + modifier = Modifier.align(Alignment.BottomCenter), + backgroundColor = TangemTheme.colors.background.tertiary, ) } } diff --git a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/ui/state/NFTSendUM.kt b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/ui/state/NFTSendUM.kt index 83701052e9..7dd0e5fcdb 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/sendnft/ui/state/NFTSendUM.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/sendnft/ui/state/NFTSendUM.kt @@ -1,13 +1,11 @@ package com.tangem.features.send.sendnft.ui.state -import com.tangem.common.ui.navigationButtons.NavigationUM -import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.common.ui.state.ConfirmUM internal data class NFTSendUM( val destinationUM: DestinationUM, val feeSelectorUM: FeeSelectorUM, val confirmUM: ConfirmUM, - val navigationUM: NavigationUM, ) \ No newline at end of file diff --git a/features/send/impl/src/test/java/com/tangem/features/send/sendnft/model/NFTSendModelNavigationTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/sendnft/model/NFTSendModelNavigationTest.kt new file mode 100644 index 0000000000..46eb6327b7 --- /dev/null +++ b/features/send/impl/src/test/java/com/tangem/features/send/sendnft/model/NFTSendModelNavigationTest.kt @@ -0,0 +1,137 @@ +package com.tangem.features.send.sendnft.model + +import arrow.core.left +import arrow.core.right +import com.tangem.core.decompose.model.MutableParamsContainer +import com.tangem.core.decompose.navigation.Router +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.nft.models.NFTAsset +import com.tangem.domain.wallets.models.errors.GetUserWalletError +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.features.nft.entity.NFTSendSuccessTrigger +import com.tangem.features.send.api.NFTSendComponent +import com.tangem.features.send.common.CommonSendRoute +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +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.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test + +/** + * Guards the NFT-send navigation refactor: [NFTSendModel.onBackClick] / [NFTSendModel.onNextClick] now + * receive the active [com.tangem.core.decompose.navigation.Route] as a parameter (instead of reading an + * internal `currentRouteFlow`). Routing is synchronous, so those assertions run without advancing; the + * one async effect — firing `NFTSendSuccessTrigger.triggerSuccessNFTSend()` when leaving the success + * screen — is verified after `advanceUntilIdle()`. + * + * `init {}` collectors are kept harmless under `advanceUntilIdle()` by stubbing the wallet lookup to the + * not-found branch (so no further currency/account fetching is reached) and the app-currency lookup. + */ +@OptIn(ExperimentalCoroutinesApi::class) +internal class NFTSendModelNavigationTest { + + private val router: Router = mockk(relaxed = true) + private val getUserWalletUseCase: GetUserWalletUseCase = mockk(relaxed = true) + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk(relaxed = true) + private val nftSendSuccessTrigger: NFTSendSuccessTrigger = mockk(relaxed = true) + + private var model: NFTSendModel? = null + + @AfterEach + fun tearDown() { + model?.onDestroy() + model = null + } + + @Test + fun `GIVEN destination step WHEN onNextClick THEN pushes confirm`() = runTest { + val model = createModel(this) + + model.onNextClick(CommonSendRoute.Destination(isEditMode = false)) + + verify(exactly = 1) { router.push(CommonSendRoute.Confirm) } + } + + @Test + fun `GIVEN edit-mode destination WHEN onNextClick THEN pops instead of advancing`() = runTest { + val model = createModel(this) + + model.onNextClick(CommonSendRoute.Destination(isEditMode = true)) + + verify(exactly = 1) { router.pop() } + verify(exactly = 0) { router.push(any()) } + } + + @Test + fun `GIVEN destination step WHEN onBackClick THEN pops without firing success trigger`() = runTest { + val model = createModel(this) + + model.onBackClick(CommonSendRoute.Destination(isEditMode = false)) + advanceUntilIdle() + + verify(exactly = 1) { router.pop() } + coVerify(exactly = 0) { nftSendSuccessTrigger.triggerSuccessNFTSend() } + } + + @Test + fun `GIVEN confirm-success route WHEN onBackClick THEN fires success trigger and pops`() = runTest { + val model = createModel(this) + + model.onBackClick(CommonSendRoute.ConfirmSuccess) + advanceUntilIdle() + + coVerify(exactly = 1) { nftSendSuccessTrigger.triggerSuccessNFTSend() } + verify(exactly = 1) { router.pop() } + } + + private fun createModel(testScope: TestScope): NFTSendModel { + every { getUserWalletUseCase(any()) } returns GetUserWalletError.UserWalletNotFound.left() + coEvery { getSelectedAppCurrencyUseCase.invokeSync() } returns AppCurrency.Default.right() + + val params = NFTSendComponent.Params( + userWalletId = UserWalletId(stringValue = "0123456789"), + nftAsset = mockk(relaxed = true), + nftCollectionName = "Test Collection", + ) + return NFTSendModel( + paramsContainer = MutableParamsContainer(value = params), + dispatchers = testScope.createTestingCoroutineDispatcherProvider(), + router = router, + getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase, + getUserWalletUseCase = getUserWalletUseCase, + multiWalletCryptoCurrenciesSupplier = mockk(relaxed = true), + getFeePaidCryptoCurrencyStatusSyncUseCase = mockk(relaxed = true), + createNFTTransferTransactionUseCase = mockk(relaxed = true), + getFeeUseCase = mockk(relaxed = true), + saveBlockchainErrorUseCase = mockk(relaxed = true), + getWalletMetaInfoUseCase = mockk(relaxed = true), + sendFeedbackEmailUseCase = mockk(relaxed = true), + alertFactory = mockk(relaxed = true), + nftSendSuccessTrigger = nftSendSuccessTrigger, + isAccountsModeEnabledUseCase = mockk(relaxed = true), + getAccountCurrencyStatusUseCase = mockk(relaxed = true), + analyticsEventHandler = mockk(relaxed = true), + ).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 From e271cce581239100a59d02906f492ea169336dfb Mon Sep 17 00:00:00 2001 From: Tangem Date: Thu, 25 Jun 2026 12:01:06 +0500 Subject: [PATCH 077/210] Updated on 2026-08-14 --- features/swap-v2/impl/build.gradle.kts | 2 + .../v2/impl/amount/SwapAmountComponent.kt | 53 +++++- .../impl/amount/SwapAmountComponentParams.kt | 3 +- .../v2/impl/amount/model/SwapAmountModel.kt | 48 +----- .../DefaultSendWithSwapComponent.kt | 47 +++--- .../confirm/SendWithSwapConfirmComponent.kt | 150 ++++++++++++++++- .../confirm/model/SendWithSwapConfirmModel.kt | 77 ++++----- .../impl/sendviaswap/entity/SendWithSwapUM.kt | 4 +- .../sendviaswap/model/SendWithSwapModel.kt | 31 ++-- .../success/SendWithSwapSuccessComponent.kt | 66 +++++++- .../success/model/SendWithSwapSuccessModel.kt | 45 +----- .../success/ui/SendWithSwapSuccessContent.kt | 43 +---- .../sendviaswap/ui/SendWithSwapContent.kt | 152 +----------------- .../model/SendWithSwapModelNavigationTest.kt | 138 ++++++++++++++++ 14 files changed, 464 insertions(+), 395 deletions(-) create mode 100644 features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/sendviaswap/model/SendWithSwapModelNavigationTest.kt diff --git a/features/swap-v2/impl/build.gradle.kts b/features/swap-v2/impl/build.gradle.kts index bab79b9a43..5f2307c771 100644 --- a/features/swap-v2/impl/build.gradle.kts +++ b/features/swap-v2/impl/build.gradle.kts @@ -98,4 +98,6 @@ dependencies { testImplementation(deps.test.junit5) testImplementation(deps.test.truth) testImplementation(deps.test.mockk) + testImplementation(deps.test.coroutine) + testImplementation(projects.common.test) } \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/SwapAmountComponent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/SwapAmountComponent.kt index 91b726bd17..c8d24d34c3 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/SwapAmountComponent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/SwapAmountComponent.kt @@ -1,9 +1,13 @@ package com.tangem.features.swap.v2.impl.amount import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.extensions.compose.subscribeAsState @@ -14,10 +18,14 @@ import com.tangem.common.ui.navigationButtons.NavigationModelCallback import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon import com.tangem.core.ui.decompose.ComposableBottomSheetComponent -import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.decompose.ComposableModularContentComponent +import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.express.models.ExpressRateType +import com.tangem.features.swap.v2.impl.R import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM import com.tangem.features.swap.v2.impl.amount.model.SwapAmountModel import com.tangem.features.swap.v2.impl.amount.ui.SwapAmountContent @@ -28,7 +36,7 @@ import dagger.assisted.AssistedInject internal class SwapAmountComponent @AssistedInject constructor( @Assisted appComponentContext: AppComponentContext, @Assisted private val params: SwapAmountComponentParams.AmountParams, -) : ComposableContentComponent, AppComponentContext by appComponentContext { +) : ComposableModularContentComponent, AppComponentContext by appComponentContext { private val model: SwapAmountModel = getOrCreateModel(params = params) @@ -49,6 +57,23 @@ internal class SwapAmountComponent @AssistedInject constructor( fun updateState(amountUM: SwapAmountUM) = model.updateState(amountUM) + @Composable + override fun Title() { + AppBarWithBackButtonAndIcon( + text = stringResourceSafe(R.string.send_amount_label), + onBackClick = { + params.callback.onBackClick(params.route) + }, + backIconRes = if (params.route.isEditMode) { + R.drawable.ic_back_24 + } else { + R.drawable.ic_close_24 + }, + backgroundColor = TangemTheme.colors.background.tertiary, + modifier = Modifier.height(TangemTheme.dimens.size56), + ) + } + @Composable override fun Content(modifier: Modifier) { val amountUM by model.uiState.collectAsStateWithLifecycle() @@ -63,6 +88,30 @@ internal class SwapAmountComponent @AssistedInject constructor( rateInfo.child?.instance?.BottomSheet() } + @Composable + override fun Footer() { + val state by model.uiState.collectAsStateWithLifecycle() + PrimaryButton( + text = if (params.route.isEditMode) { + stringResourceSafe(R.string.common_continue) + } else { + stringResourceSafe(R.string.common_next) + }, + enabled = state.isPrimaryButtonEnabled, + onClick = { + model.onAmountNext() + params.callback.onNextClick(params.route) + }, + modifier = Modifier + .fillMaxWidth() + .padding( + start = 16.dp, + end = 16.dp, + bottom = 16.dp, + ), + ) + } + private fun rateInfoChild( config: ExpressRateType, componentContext: ComponentContext, diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/SwapAmountComponentParams.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/SwapAmountComponentParams.kt index 50fc5eba87..0c2c6dded1 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/SwapAmountComponentParams.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/SwapAmountComponentParams.kt @@ -10,7 +10,6 @@ import com.tangem.domain.swap.models.SwapDirection import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM import com.tangem.features.swap.v2.impl.sendviaswap.SendWithSwapRoute -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow internal sealed class SwapAmountComponentParams { @@ -41,7 +40,7 @@ internal sealed class SwapAmountComponentParams { override val isAccountModeFlow: StateFlow, val title: TextReference, val callback: SwapAmountComponent.ModelCallback, - val currentRoute: Flow, + val route: SendWithSwapRoute, ) : SwapAmountComponentParams() data class AmountBlockParams( diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt index 09064c9ed5..6043bcf6c9 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/amount/model/SwapAmountModel.kt @@ -8,14 +8,11 @@ import com.tangem.common.routing.AppRouter import com.tangem.common.ui.amountScreen.converters.MaxEnterAmountConverter import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary -import com.tangem.common.ui.navigationButtons.NavigationButton -import com.tangem.common.ui.navigationButtons.NavigationUM import com.tangem.common.ui.notifications.NotificationId import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.di.ModelScoped import com.tangem.core.decompose.model.Model import com.tangem.core.decompose.model.ParamsContainer -import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.datasource.local.swap.SwapBestRateAnimationStore import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase @@ -40,7 +37,6 @@ import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.features.send.api.subcomponents.amount.analytics.CommonSendAmountAnalyticEvents import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger import com.tangem.features.swap.v2.api.choosetoken.SwapChooseTokenNetworkListener -import com.tangem.features.swap.v2.impl.R import com.tangem.features.swap.v2.impl.amount.SwapAmountBlockComponent.SwapChooseProviderConfig import com.tangem.features.swap.v2.impl.amount.SwapAmountComponentParams import com.tangem.features.swap.v2.impl.amount.SwapAmountReduceListener @@ -54,7 +50,6 @@ import com.tangem.features.swap.v2.impl.amount.model.transformers.* import com.tangem.features.swap.v2.impl.chooseprovider.SwapChooseProviderComponent import com.tangem.features.swap.v2.impl.common.SwapAlertFactory import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM -import com.tangem.features.swap.v2.impl.sendviaswap.SendWithSwapRoute import com.tangem.features.swap.v2.impl.sendviaswap.analytics.SendWithSwapAnalyticEvents import com.tangem.features.swap.v2.impl.sendviaswap.analytics.SendWithSwapAnalyticEvents.NoticeFixedRate.toAnalyticsRateType import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -125,7 +120,6 @@ internal class SwapAmountModel @Inject constructor( private val amountAnalyticsSender = SwapAmountAnalyticsSender(analyticsEventHandler) private var autoUpdateSubscriberJob: Job? = null - private var navigationJob: Job? = null val uiState: StateFlow field = MutableStateFlow(params.amountUM) @@ -156,7 +150,6 @@ internal class SwapAmountModel @Inject constructor( } else { QUOTES_UPDATE_DELAY } - configAmountNavigation() quoteTaskScheduler.scheduleTask( scope = modelScope, task = loadQuotesTask(initialDelay = initialDelay), @@ -167,7 +160,6 @@ internal class SwapAmountModel @Inject constructor( fun onStop() { quoteTaskScheduler.cancelTask() autoUpdateSubscriberJob?.cancel() - navigationJob?.cancel() } override fun onDestroy() { @@ -325,7 +317,7 @@ internal class SwapAmountModel @Inject constructor( val isShowSendViaSwapNotification = shouldShowNotificationUseCase( NotificationId.SendViaSwapTokenSelectorNotification.key, ) - val isEditMode = amountParams.currentRoute.firstOrNull()?.isEditMode == true + val isEditMode = amountParams.route.isEditMode val selectedCurrency = (uiState.value as? SwapAmountUM.Content)?.secondaryCryptoCurrencyStatus?.currency appRouter.push( AppRoute.ChooseManagedTokens( @@ -354,7 +346,7 @@ internal class SwapAmountModel @Inject constructor( val amountParams = params as? SwapAmountComponentParams.AmountParams ?: return modelScope.launch { - if (amountParams.currentRoute.firstOrNull()?.isEditMode == true) { + if (amountParams.route.isEditMode) { swapAmountAlertFactory.showCloseSendWithSwapAlert { params.callback.resetSendWithSwapNavigation(resetNavigation = true) confirmSendWithSwapClose() @@ -934,42 +926,6 @@ internal class SwapAmountModel @Inject constructor( ) } - private fun configAmountNavigation() { - val params = params as? SwapAmountComponentParams.AmountParams ?: return - navigationJob?.cancel() - navigationJob = combine( - flow = uiState, - flow2 = params.currentRoute, - transform = { state, route -> state to route }, - ).filter { (_, route) -> route is SendWithSwapRoute.Amount }.onEach { (state, route) -> - params.callback.onNavigationResult( - NavigationUM.Content( - source = SendWithSwapRoute.Amount::class.java.simpleName, - title = resourceReference(R.string.common_amount), - subtitle = null, - backIconRes = if (route.isEditMode) { - R.drawable.ic_back_24 - } else { - R.drawable.ic_close_24 - }, - backIconClick = params.callback::onBackClick, - primaryButton = NavigationButton( - textReference = if (route.isEditMode) { - resourceReference(R.string.common_continue) - } else { - resourceReference(R.string.common_next) - }, - isEnabled = state.isPrimaryButtonEnabled, - onClick = { - onAmountNext() - params.callback.onNextClick() - }, - ), - ), - ) - }.launchIn(modelScope) - } - private companion object { const val DEBOUNCE_AMOUNT_DELAY = 500L const val QUOTES_UPDATE_DELAY = 10000L diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/DefaultSendWithSwapComponent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/DefaultSendWithSwapComponent.kt index f34b1ad38e..edf0958a75 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/DefaultSendWithSwapComponent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/DefaultSendWithSwapComponent.kt @@ -4,21 +4,18 @@ import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.arkivanov.decompose.router.stack.StackNavigation import com.arkivanov.decompose.router.stack.childStack import com.arkivanov.decompose.router.stack.pop import com.arkivanov.decompose.value.ObserveLifecycleMode import com.arkivanov.decompose.value.subscribe -import com.tangem.common.ui.navigationButtons.NavigationUM import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.decompose.navigation.inner.InnerRouter -import com.tangem.core.navigation.url.UrlOpener -import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.decompose.ComposableModularContentComponent import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.swap.models.R import com.tangem.domain.swap.models.SwapDirection @@ -41,7 +38,6 @@ import com.tangem.features.swap.v2.impl.sendviaswap.ui.SendWithSwapContent import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.launch internal class DefaultSendWithSwapComponent @AssistedInject constructor( @@ -50,7 +46,6 @@ internal class DefaultSendWithSwapComponent @AssistedInject constructor( private val sendDestinationComponentFactory: SendDestinationComponent.Factory, private val confirmComponentFactory: SendWithSwapConfirmComponent.Factory, private val analyticsEventHandler: AnalyticsEventHandler, - private val urlOpener: UrlOpener, ) : SendWithSwapComponent, AppComponentContext by appComponentContext { private val stackNavigation = StackNavigation() @@ -99,7 +94,7 @@ internal class DefaultSendWithSwapComponent @AssistedInject constructor( activeComponent.updateState(model.uiState.value.destinationUM) } is SendWithSwapConfirmComponent -> { - if (model.currentRoute.value.isEditMode) { + if (stack.active.configuration.isEditMode) { activeComponent.updateState(model.uiState.value) } // Re-sync destination from parent on Confirm entry, bypassing the edit-mode gate ([REDACTED_TASK_KEY]). @@ -120,7 +115,6 @@ internal class DefaultSendWithSwapComponent @AssistedInject constructor( ) } } - model.currentRoute.emit(stack.active.configuration) } } } @@ -128,35 +122,33 @@ internal class DefaultSendWithSwapComponent @AssistedInject constructor( @Composable override fun Content(modifier: Modifier) { val stackState by childStack.subscribeAsState() - val state by model.uiState.collectAsStateWithLifecycle() BackHandler( - onBack = { - (state.navigationUM as? NavigationUM.Content)?.backIconClick() ?: onChildBack() - }, + onBack = ::onChildBack, ) + SendWithSwapContent( - navigationUM = state.navigationUM, - confirmUM = state.confirmUM, stackState = stackState, - onLinkClick = urlOpener::openUrl, ) } private fun createChild(route: SendWithSwapRoute, childContext: AppComponentContext) = when (route) { - is SendWithSwapRoute.Amount -> getAmountComponent(factoryContext = childContext) - is SendWithSwapRoute.Destination -> getDestinationComponent(factoryContext = childContext) + is SendWithSwapRoute.Amount -> getAmountComponent(route, factoryContext = childContext) + is SendWithSwapRoute.Destination -> getDestinationComponent(route = route, factoryContext = childContext) is SendWithSwapRoute.Confirm -> getConfirmComponent(factoryContext = childContext) is SendWithSwapRoute.Success -> getSuccessComponent(factoryContext = childContext) } - private fun getAmountComponent(factoryContext: AppComponentContext): ComposableContentComponent { + private fun getAmountComponent( + route: SendWithSwapRoute.Amount, + factoryContext: AppComponentContext, + ): ComposableModularContentComponent { return SwapAmountComponent( appComponentContext = factoryContext, params = SwapAmountComponentParams.AmountParams( amountUM = model.uiState.value.amountUM, title = resourceReference(R.string.common_send), - currentRoute = model.currentRoute, + route = route, isBalanceHidingFlow = model.isBalanceHiddenFlow, analyticsCategoryName = model.analyticCategoryName, primaryCryptoCurrencyStatusFlow = model.primaryCryptoCurrencyStatusFlow, @@ -172,17 +164,20 @@ internal class DefaultSendWithSwapComponent @AssistedInject constructor( ) } - private fun getDestinationComponent(factoryContext: AppComponentContext): ComposableContentComponent { + private fun getDestinationComponent( + route: DestinationRoute, + factoryContext: AppComponentContext, + ): ComposableModularContentComponent { val amountContentUM = model.uiState.value.amountUM as? SwapAmountUM.Content - ?: return ComposableContentComponent.EMPTY + ?: return ComposableModularContentComponent.EMPTY val secondaryCryptoCurrency = amountContentUM.secondaryCryptoCurrencyStatus?.currency - ?: return ComposableContentComponent.EMPTY + ?: return ComposableModularContentComponent.EMPTY return sendDestinationComponentFactory.create( context = factoryContext, params = SendDestinationComponentParams.DestinationParams( state = model.uiState.value.destinationUM, - currentRoute = model.currentRoute.filterIsInstance(), + route = route, isBalanceHidingFlow = model.isBalanceHiddenFlow, analyticsCategoryName = model.analyticCategoryName, analyticsSendSource = model.analyticsSendSource, @@ -195,12 +190,11 @@ internal class DefaultSendWithSwapComponent @AssistedInject constructor( ) } - private fun getConfirmComponent(factoryContext: AppComponentContext): ComposableContentComponent { + private fun getConfirmComponent(factoryContext: AppComponentContext): ComposableModularContentComponent { return confirmComponentFactory.create( appComponentContext = factoryContext, params = SendWithSwapConfirmComponent.Params( sendWithSwapUM = model.uiState.value, - currentRoute = model.currentRoute.filterIsInstance(), isBalanceHidingFlow = model.isBalanceHiddenFlow, appCurrency = model.appCurrency, userWallet = model.userWallet, @@ -216,12 +210,11 @@ internal class DefaultSendWithSwapComponent @AssistedInject constructor( ) } - private fun getSuccessComponent(factoryContext: AppComponentContext): ComposableContentComponent { + private fun getSuccessComponent(factoryContext: AppComponentContext): ComposableModularContentComponent { return SendWithSwapSuccessComponent( appComponentContext = factoryContext, params = SendWithSwapSuccessComponent.Params( sendWithSwapUMFlow = model.uiState, - currentRoute = model.currentRoute.filterIsInstance(), callback = model, analyticsCategoryName = model.analyticCategoryName, ), diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt index 9ae5d97b6f..1a601cbc7d 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/SendWithSwapConfirmComponent.kt @@ -1,37 +1,52 @@ package com.tangem.features.swap.v2.impl.sendviaswap.confirm +import androidx.compose.animation.* +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.withLink +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.common.ui.footers.SendingText +import com.tangem.common.ui.navigationButtons.NavigationPrimaryButton import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.context.child import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.navigation.url.UrlOpener +import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon +import com.tangem.core.ui.decompose.ComposableModularContentComponent +import com.tangem.core.ui.extensions.* +import com.tangem.core.ui.res.TangemTheme import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.account.Account import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.swap.models.SwapDirection -import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockComponent -import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.entity.PredefinedValues -import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams.* import com.tangem.features.send.api.subcomponents.destination.SendDestinationBlockComponent import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM +import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorBlockComponent +import com.tangem.features.send.api.subcomponents.feeSelector.params.FeeSelectorParams.* +import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent +import com.tangem.features.swap.v2.impl.R import com.tangem.features.swap.v2.impl.amount.SwapAmountBlockComponent import com.tangem.features.swap.v2.impl.amount.SwapAmountComponentParams import com.tangem.features.swap.v2.impl.common.SwapUtils.SEND_WITH_SWAP_PROVIDER_TYPES import com.tangem.features.swap.v2.impl.common.entity.ConfirmUM import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM import com.tangem.features.swap.v2.impl.notifications.SwapNotificationsComponent -import com.tangem.features.swap.v2.impl.sendviaswap.SendWithSwapRoute import com.tangem.features.swap.v2.impl.sendviaswap.confirm.model.SendWithSwapConfirmModel import com.tangem.features.swap.v2.impl.sendviaswap.confirm.ui.SendWithSwapConfirmContent import com.tangem.features.swap.v2.impl.sendviaswap.entity.SendWithSwapUM +import com.tangem.utils.StringsSigns import com.tangem.utils.extensions.orZero import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -44,7 +59,8 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor( sendDestinationBlockComponentFactory: SendDestinationBlockComponent.Factory, feeSelectorBlockComponentFactory: FeeSelectorBlockComponent.Factory, sendNotificationsComponentFactory: SendNotificationsComponent.Factory, -) : ComposableContentComponent, AppComponentContext by appComponentContext { + private val urlOpener: UrlOpener, +) : ComposableModularContentComponent, AppComponentContext by appComponentContext { private val model: SendWithSwapConfirmModel = getOrCreateModel(params = params) @@ -177,6 +193,17 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor( } } + @Composable + override fun Title() { + AppBarWithBackButtonAndIcon( + text = stringResourceSafe(R.string.send_with_swap_confirm_title), + onBackClick = router::pop, + backIconRes = R.drawable.ic_back_24, + backgroundColor = TangemTheme.colors.background.tertiary, + modifier = Modifier.height(TangemTheme.dimens.size56), + ) + } + @Composable override fun Content(modifier: Modifier) { val sendWithSwapUM by model.uiState.collectAsStateWithLifecycle() @@ -196,13 +223,120 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor( ) } + @Composable + override fun Footer() { + val state by model.uiState.collectAsStateWithLifecycle() + Column { + val confirmUMContent = state.confirmUM as? ConfirmUM.Content + AnimatedVisibility( + visible = confirmUMContent != null, + enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(), + ) { + val sendFooter = confirmUMContent?.sendingFooter ?: TextReference.EMPTY + val legalFooter = getAnnotatedStringForLegals( + tosUM = confirmUMContent?.tosUM, + sendFooter = sendFooter, + onClick = urlOpener::openUrl, + ) + val footerText = remember(sendFooter, legalFooter) { + if (sendFooter != TextReference.EMPTY || legalFooter != TextReference.EMPTY) { + combinedReference(sendFooter, legalFooter) + } else { + TextReference.EMPTY + } + } + SendingText(footerText = footerText) + } + NavigationPrimaryButton( + primaryButton = model.primaryButtonUM(state.confirmUM), + modifier = Modifier.padding( + start = 16.dp, + end = 16.dp, + bottom = 16.dp, + ), + ) + } + } + + @Composable + private fun getAnnotatedStringForLegals( + tosUM: ConfirmUM.Content.TosUM?, + sendFooter: TextReference, + onClick: (String) -> Unit, + ): TextReference { + if (tosUM == null) return TextReference.EMPTY + val tos = tosUM.tosLink + val policy = tosUM.policyLink + return if (tos != null && policy != null) { + val tosTitle = tos.title.resolveReference() + val policyTitle = policy.title.resolveReference() + val fullString = stringResourceSafe(id = R.string.express_legal_two_placeholders, tosTitle, policyTitle) + val tosIndex = fullString.indexOf(tosTitle) + val policyIndex = fullString.indexOf(policyTitle) + + annotatedReference { + if (!sendFooter.resolveReference().endsWith(StringsSigns.POINT_SIGN)) { + append(StringsSigns.POINT_SIGN) + } + appendSpace() + append(fullString.substring(0, tosIndex)) + withLink( + link = LinkAnnotation.Clickable( + tag = "TOS_TAG", + linkInteractionListener = { onClick(tos.link) }, + ), + block = { + appendColored( + text = fullString.substring(tosIndex, tosIndex + tosTitle.length), + color = TangemTheme.colors.text.accent, + ) + }, + ) + append(fullString.substring(tosIndex + tosTitle.length, policyIndex)) + withLink( + link = LinkAnnotation.Clickable( + tag = "POLICY_TAG", + linkInteractionListener = { onClick(policy.link) }, + ), + block = { + appendColored( + text = fullString.substring(policyIndex, policyIndex + policyTitle.length), + color = TangemTheme.colors.text.accent, + ) + }, + ) + } + } else { + val legal = requireNotNull(tos ?: policy) { "tos or policy must not be null" } + val legalTitle = legal.title.resolveReference() + val fullString = stringResourceSafe(id = R.string.express_legal_one_placeholder, legalTitle) + val legalIndex = fullString.indexOf(legalTitle) + + annotatedReference { + append(fullString.substring(0, legalIndex)) + withLink( + link = LinkAnnotation.Clickable( + tag = "LEGAL_TAG", + linkInteractionListener = { onClick(legal.link) }, + ), + block = { + appendColored( + text = fullString.substring(legalIndex, legalIndex + legalTitle.length), + color = TangemTheme.colors.text.accent, + ) + }, + ) + } + } + } + data class Params( val sendWithSwapUM: SendWithSwapUM, val analyticsCategoryName: String, val analyticsSendSource: CommonSendAnalyticEvents.CommonSendSource, val userWallet: UserWallet, val appCurrency: AppCurrency, - val currentRoute: Flow, val swapDirection: SwapDirection, val isBalanceHidingFlow: StateFlow, val primaryCryptoCurrencyStatusFlow: StateFlow, @@ -218,6 +352,6 @@ internal class SendWithSwapConfirmComponent @AssistedInject constructor( } interface ModelCallback { - fun onResult(route: SendWithSwapRoute, sendWithSwapUM: SendWithSwapUM) + fun onResult(sendWithSwapUM: SendWithSwapUM) } } \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt index 9c63c30efb..31c16b0228 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/confirm/model/SendWithSwapConfirmModel.kt @@ -8,7 +8,6 @@ import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.navigationButtons.NavigationButton -import com.tangem.common.ui.navigationButtons.NavigationUM import com.tangem.common.ui.userwallet.ext.walletInterationIcon import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.models.AnalyticsParam @@ -38,14 +37,14 @@ import com.tangem.domain.transaction.usecase.EstimateFeeUseCase import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForGaslessTxUseCase import com.tangem.domain.transaction.usecase.gasless.EstimateFeeForTokenUseCase import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase -import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent -import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent.Params.NotificationData import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents.SendScreenSource -import com.tangem.features.send.api.subcomponents.feeSelector.callbacks.FeeSelectorModelCallback -import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger +import com.tangem.features.send.api.subcomponents.feeSelector.callbacks.FeeSelectorModelCallback +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM +import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent +import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsComponent.Params.NotificationData import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateListener import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateTrigger import com.tangem.features.swap.v2.api.SwapFeatureToggles @@ -176,7 +175,6 @@ internal class SendWithSwapConfirmModel @Inject constructor( init { updateAmountSubtractAvailability() - configConfirmNavigation() initialState() subscribeOnNotificationUpdates() subscribeOnTapHelpUpdates() @@ -383,6 +381,7 @@ internal class SendWithSwapConfirmModel @Inject constructor( swapDataModel = data, ), ) + params.callback.onResult(uiState.value) router.replaceAll(SendWithSwapRoute.Success) }, expressOperationType = ExpressOperationType.SEND_WITH_SWAP, @@ -582,49 +581,29 @@ internal class SendWithSwapConfirmModel @Inject constructor( } } - private fun configConfirmNavigation() { - combine( - flow = uiState, - flow2 = params.currentRoute, - transform = { state, route -> state to route }, - ).filter { - it.second is SendWithSwapRoute.Confirm - }.onEach { (state, _) -> - val confirmUM = state.confirmUM - val isContent = confirmUM is ConfirmUM.Content - val isReadyToSend = isContent && !confirmUM.isTransactionInProcess - val isHoldToConfirm = params.userWallet.isHotWallet && isContent - params.callback.onResult( - route = SendWithSwapRoute.Confirm, - sendWithSwapUM = state.copy( - navigationUM = NavigationUM.Content( - source = SendWithSwapRoute.Confirm.javaClass.simpleName, - title = resourceReference(id = R.string.send_with_swap_confirm_title), - subtitle = null, - backIconRes = R.drawable.ic_back_24, - backIconClick = router::pop, - primaryButton = NavigationButton( - textReference = getPrimaryButtonText(confirmUM, isHoldToConfirm), - iconRes = walletInterationIcon(params.userWallet), - isIconVisible = isReadyToSend && !isHoldToConfirm, - isHapticClick = isReadyToSend, - isHoldToConfirm = isHoldToConfirm, - isEnabled = confirmUM.isPrimaryButtonEnabled, - onClick = { - when (confirmUM) { - is ConfirmUM.Content -> if (confirmUM.isTransactionInProcess) { - return@NavigationButton - } else { - onSendClick() - } - else -> return@NavigationButton - } - }, - ), - ), - ), - ) - }.launchIn(modelScope) + fun primaryButtonUM(confirmUM: ConfirmUM): NavigationButton { + val isContent = confirmUM is ConfirmUM.Content + val isReadyToSend = isContent && !confirmUM.isTransactionInProcess + val isHoldToConfirm = params.userWallet.isHotWallet && isContent + + return NavigationButton( + textReference = getPrimaryButtonText(confirmUM, isHoldToConfirm), + iconRes = walletInterationIcon(params.userWallet), + isIconVisible = isReadyToSend && !isHoldToConfirm, + isHapticClick = isReadyToSend, + isHoldToConfirm = isHoldToConfirm, + isEnabled = confirmUM.isPrimaryButtonEnabled, + onClick = { + when (confirmUM) { + is ConfirmUM.Content -> if (confirmUM.isTransactionInProcess) { + return@NavigationButton + } else { + onSendClick() + } + else -> return@NavigationButton + } + }, + ) } private fun getPrimaryButtonText(confirmUM: ConfirmUM, isHoldToConfirm: Boolean): TextReference { diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/entity/SendWithSwapUM.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/entity/SendWithSwapUM.kt index 1b3c333b80..b4702ac55d 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/entity/SendWithSwapUM.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/entity/SendWithSwapUM.kt @@ -1,8 +1,7 @@ package com.tangem.features.swap.v2.impl.sendviaswap.entity -import com.tangem.common.ui.navigationButtons.NavigationUM -import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM import com.tangem.features.swap.v2.impl.common.entity.ConfirmUM @@ -11,5 +10,4 @@ internal data class SendWithSwapUM( val destinationUM: DestinationUM, val feeSelectorUM: FeeSelectorUM, val confirmUM: ConfirmUM, - val navigationUM: NavigationUM, ) \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/model/SendWithSwapModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/model/SendWithSwapModel.kt index 671ae85c1d..3424e016eb 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/model/SendWithSwapModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/model/SendWithSwapModel.kt @@ -1,10 +1,10 @@ package com.tangem.features.swap.v2.impl.sendviaswap.model import arrow.core.getOrElse -import com.tangem.common.ui.navigationButtons.NavigationUM 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.navigation.Route import com.tangem.core.decompose.navigation.Router import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase @@ -20,9 +20,9 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.swap.models.SwapDirection import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents -import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponent import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.swap.v2.api.SendWithSwapComponent import com.tangem.features.swap.v2.impl.amount.SwapAmountComponent import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM @@ -61,7 +61,6 @@ internal class SendWithSwapModel @Inject constructor( val analyticCategoryName = CommonSendAnalyticEvents.SEND_CATEGORY val analyticsSendSource = CommonSendAnalyticEvents.CommonSendSource.SendWithSwap val initialRoute = SendWithSwapRoute.Amount(false) - val currentRoute = MutableStateFlow(initialRoute) var userWallet: UserWallet by Delegates.notNull() var appCurrency: AppCurrency = AppCurrency.Default @@ -108,14 +107,8 @@ internal class SendWithSwapModel @Inject constructor( uiState.update { it.copy(destinationUM = destinationUM) } } - override fun onResult(route: SendWithSwapRoute, sendWithSwapUM: SendWithSwapUM) { - if (currentRoute.value == route) { - uiState.value = sendWithSwapUM - } - } - - override fun onNavigationResult(navigationUM: NavigationUM) { - uiState.update { it.copy(navigationUM = navigationUM) } + override fun onResult(sendWithSwapUM: SendWithSwapUM) { + uiState.value = sendWithSwapUM } override fun onSeparatorClick(lastAmount: String, isEnterInFiatSelected: Boolean) { @@ -129,7 +122,6 @@ internal class SendWithSwapModel @Inject constructor( destinationUM = DestinationUM.Empty(), feeSelectorUM = FeeSelectorUM.Loading, confirmUM = ConfirmUM.Empty, - navigationUM = NavigationUM.Empty, ) } if (resetNavigation) { @@ -137,17 +129,17 @@ internal class SendWithSwapModel @Inject constructor( } } - override fun onBackClick() = router.pop() + override fun onBackClick(currentRoute: Route) = router.pop() - override fun onNextClick() { - if (currentRoute.value.isEditMode) { - onBackClick() + override fun onNextClick(currentRoute: Route) { + if ((currentRoute as? SendWithSwapRoute)?.isEditMode == true) { + onBackClick(currentRoute) } else { - when (currentRoute.value) { + when (currentRoute) { is SendWithSwapRoute.Amount -> router.push(SendWithSwapRoute.Destination(isEditMode = false)) is SendWithSwapRoute.Destination -> router.push(SendWithSwapRoute.Confirm) SendWithSwapRoute.Confirm -> router.push(SendWithSwapRoute.Success) - SendWithSwapRoute.Success -> onBackClick() + SendWithSwapRoute.Success -> onBackClick(currentRoute) } } } @@ -171,7 +163,7 @@ internal class SendWithSwapModel @Inject constructor( ) } }, - popBack = ::onBackClick, + popBack = router::pop, ) }, ) @@ -189,7 +181,6 @@ internal class SendWithSwapModel @Inject constructor( destinationUM = DestinationUM.Empty(), feeSelectorUM = FeeSelectorUM.Loading, confirmUM = ConfirmUM.Empty, - navigationUM = NavigationUM.Empty, ) } diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/SendWithSwapSuccessComponent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/SendWithSwapSuccessComponent.kt index 26d40108f5..07c8cc6e43 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/SendWithSwapSuccessComponent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/SendWithSwapSuccessComponent.kt @@ -1,37 +1,91 @@ package com.tangem.features.swap.v2.impl.sendviaswap.success +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.common.ui.navigationButtons.DoneButtons +import com.tangem.common.ui.navigationButtons.NavigationButton import com.tangem.common.ui.navigationButtons.NavigationModelCallback import com.tangem.core.decompose.context.AppComponentContext import com.tangem.core.decompose.model.getOrCreateModel -import com.tangem.core.ui.decompose.ComposableContentComponent -import com.tangem.features.swap.v2.impl.sendviaswap.SendWithSwapRoute +import com.tangem.core.ui.components.PrimaryButton +import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon +import com.tangem.core.ui.decompose.ComposableModularContentComponent +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.features.swap.v2.impl.R +import com.tangem.features.swap.v2.impl.common.entity.ConfirmUM import com.tangem.features.swap.v2.impl.sendviaswap.entity.SendWithSwapUM import com.tangem.features.swap.v2.impl.sendviaswap.success.model.SendWithSwapSuccessModel import com.tangem.features.swap.v2.impl.sendviaswap.success.ui.SendWithSwapSuccessContent -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow internal class SendWithSwapSuccessComponent( appComponentContext: AppComponentContext, - params: Params, -) : ComposableContentComponent, AppComponentContext by appComponentContext { + private val params: Params, +) : ComposableModularContentComponent, AppComponentContext by appComponentContext { private val model: SendWithSwapSuccessModel = getOrCreateModel(params = params) + @Composable + override fun Title() { + AppBarWithBackButtonAndIcon( + onBackClick = router::pop, + backIconRes = R.drawable.ic_close_24, + backgroundColor = TangemTheme.colors.background.tertiary, + modifier = Modifier.height(TangemTheme.dimens.size56), + ) + } + @Composable override fun Content(modifier: Modifier) { val state by model.uiState.collectAsStateWithLifecycle() SendWithSwapSuccessContent(sendWithSwapUM = state) } + @Composable + override fun Footer() { + val state by model.uiState.collectAsStateWithLifecycle() + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .padding( + start = 16.dp, + end = 16.dp, + bottom = 16.dp, + ), + ) { + DoneButtons( + (NavigationButton( + textReference = resourceReference(R.string.common_explore), + iconRes = R.drawable.ic_web_24, + onClick = model::onExploreClick, + ) to NavigationButton( + textReference = resourceReference(R.string.common_share), + iconRes = R.drawable.ic_share_24, + onClick = model::onShareClick, + )).takeUnless { (state.confirmUM as? ConfirmUM.Success)?.txUrl.isNullOrBlank() }, + ) + PrimaryButton( + text = stringResourceSafe(R.string.common_close), + onClick = router::pop, + modifier = Modifier.fillMaxWidth(), + ) + } + } + data class Params( val sendWithSwapUMFlow: StateFlow, val analyticsCategoryName: String, - val currentRoute: Flow, val callback: NavigationModelCallback, ) } \ No newline at end of file diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/model/SendWithSwapSuccessModel.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/model/SendWithSwapSuccessModel.kt index 6b743b5db5..8837471c49 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/model/SendWithSwapSuccessModel.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/model/SendWithSwapSuccessModel.kt @@ -1,18 +1,11 @@ package com.tangem.features.swap.v2.impl.sendviaswap.success.model -import com.tangem.common.routing.AppRouter -import com.tangem.common.ui.navigationButtons.NavigationButton -import com.tangem.common.ui.navigationButtons.NavigationUM 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.navigation.share.ShareManager import com.tangem.core.navigation.url.UrlOpener -import com.tangem.core.ui.extensions.TextReference -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.features.swap.v2.impl.R import com.tangem.features.swap.v2.impl.common.entity.ConfirmUM -import com.tangem.features.swap.v2.impl.sendviaswap.SendWithSwapRoute import com.tangem.features.swap.v2.impl.sendviaswap.entity.SendWithSwapUM import com.tangem.features.swap.v2.impl.sendviaswap.success.SendWithSwapSuccessComponent import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -24,7 +17,6 @@ internal class SendWithSwapSuccessModel @Inject constructor( override val dispatchers: CoroutineDispatcherProvider, private val urlOpener: UrlOpener, private val shareManager: ShareManager, - private val appRouter: AppRouter, paramsContainer: ParamsContainer, ) : Model() { @@ -34,47 +26,14 @@ internal class SendWithSwapSuccessModel @Inject constructor( val confirmUM = uiState.value.confirmUM as? ConfirmUM.Success - init { - configConfirmSuccessNavigation() - } - - private fun configConfirmSuccessNavigation() { - params.callback.onNavigationResult( - NavigationUM.Content( - source = SendWithSwapRoute.Success.javaClass.simpleName, - title = TextReference.EMPTY, - subtitle = null, - backIconRes = R.drawable.ic_close_24, - backIconClick = appRouter::pop, - primaryButton = NavigationButton( - textReference = resourceReference(R.string.common_close), - iconRes = null, - isEnabled = true, - isHapticClick = false, - onClick = appRouter::pop, - ), - prevButton = null, - secondaryPairButtonsUM = (NavigationButton( - textReference = resourceReference(R.string.common_explore), - iconRes = R.drawable.ic_web_24, - onClick = ::onExploreClick, - ) to NavigationButton( - textReference = resourceReference(R.string.common_share), - iconRes = R.drawable.ic_share_24, - onClick = ::onShareClick, - )).takeUnless { confirmUM?.txUrl.isNullOrBlank() }, - ), - ) - } - - private fun onExploreClick() { + fun onExploreClick() { if (confirmUM == null) return // analyticsEventHandler.send(CommonSendAnalyticEvents.ExploreButtonClicked(params.analyticsCategoryName)) urlOpener.openUrl(confirmUM.txUrl) } - private fun onShareClick() { + fun onShareClick() { if (confirmUM == null) return // analyticsEventHandler.send(CommonSendAnalyticEvents.ShareButtonClicked(params.analyticsCategoryName)) diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/ui/SendWithSwapSuccessContent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/ui/SendWithSwapSuccessContent.kt index bf24732fbe..bbfcb80e96 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/ui/SendWithSwapSuccessContent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/success/ui/SendWithSwapSuccessContent.kt @@ -21,9 +21,6 @@ import com.tangem.common.ui.account.AccountTitle import com.tangem.common.ui.account.AccountTitleUM import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.utils.getFiatReference -import com.tangem.common.ui.navigationButtons.NavigationButton -import com.tangem.common.ui.navigationButtons.NavigationButtonsBlockV2 -import com.tangem.common.ui.navigationButtons.NavigationUM import com.tangem.core.ui.components.Fade import com.tangem.core.ui.components.currency.icon.CurrencyIcon import com.tangem.core.ui.components.icons.identicon.IdentIcon @@ -45,27 +42,24 @@ import com.tangem.domain.express.models.ExpressProviderType import com.tangem.domain.swap.models.SwapDataModel import com.tangem.domain.swap.models.SwapDataTransactionModel import com.tangem.domain.utils.convertToSdkAmount +import com.tangem.features.send.api.subcomponents.destination.entity.DestinationTextFieldUM +import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeExtraInfo import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeNonce import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM -import com.tangem.features.send.api.subcomponents.destination.entity.DestinationTextFieldUM -import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM import com.tangem.features.swap.v2.impl.R import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountFieldUM import com.tangem.features.swap.v2.impl.amount.entity.SwapAmountUM import com.tangem.features.swap.v2.impl.amount.ui.preview.SwapAmountContentPreview import com.tangem.features.swap.v2.impl.common.entity.ConfirmUM import com.tangem.features.swap.v2.impl.common.entity.SwapQuoteUM -import com.tangem.features.swap.v2.impl.sendviaswap.SendWithSwapRoute import com.tangem.features.swap.v2.impl.sendviaswap.entity.SendWithSwapUM import kotlinx.collections.immutable.persistentListOf import java.math.BigDecimal @Composable internal fun SendWithSwapSuccessContent(sendWithSwapUM: SendWithSwapUM) { - if (sendWithSwapUM.navigationUM !is NavigationUM.Content) return - Column { Box( modifier = Modifier @@ -82,14 +76,6 @@ internal fun SendWithSwapSuccessContent(sendWithSwapUM: SendWithSwapUM) { backgroundColor = TangemTheme.colors.background.tertiary, ) } - NavigationButtonsBlockV2( - navigationUM = sendWithSwapUM.navigationUM, - modifier = Modifier.padding( - start = 16.dp, - end = 16.dp, - bottom = 16.dp, - ), - ) } } @@ -458,31 +444,6 @@ private fun SendWithSwapSuccessContent_Preview() { isPrimaryButtonEnabled = false, ), ), - navigationUM = NavigationUM.Content( - source = SendWithSwapRoute.Success.javaClass.simpleName, - title = TextReference.EMPTY, - subtitle = null, - backIconRes = R.drawable.ic_close_24, - backIconClick = {}, - additionalIconRes = null, - additionalIconClick = null, - primaryButton = NavigationButton( - textReference = resourceReference(R.string.common_close), - isEnabled = true, - onClick = {}, - ), - secondaryPairButtonsUM = NavigationButton( - textReference = resourceReference(R.string.common_explore), - iconRes = R.drawable.ic_web_24, - isEnabled = true, - onClick = {}, - ) to NavigationButton( - textReference = resourceReference(R.string.common_share), - iconRes = R.drawable.ic_share_24, - isEnabled = true, - onClick = {}, - ), - ), ), ) } diff --git a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/ui/SendWithSwapContent.kt b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/ui/SendWithSwapContent.kt index dab9d55aa2..7d8a0218c1 100644 --- a/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/ui/SendWithSwapContent.kt +++ b/features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/sendviaswap/ui/SendWithSwapContent.kt @@ -1,45 +1,25 @@ package com.tangem.features.swap.v2.impl.sendviaswap.ui -import androidx.compose.animation.* import androidx.compose.foundation.background import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.layout.* import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.LinkAnnotation -import androidx.compose.ui.text.withLink -import androidx.compose.ui.unit.dp import com.arkivanov.decompose.extensions.compose.stack.Children import com.arkivanov.decompose.extensions.compose.stack.animation.fade import com.arkivanov.decompose.extensions.compose.stack.animation.plus import com.arkivanov.decompose.extensions.compose.stack.animation.slide import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation import com.arkivanov.decompose.router.stack.ChildStack -import com.tangem.common.ui.footers.SendingText -import com.tangem.common.ui.navigationButtons.NavigationButton -import com.tangem.common.ui.navigationButtons.NavigationPrimaryButton -import com.tangem.common.ui.navigationButtons.NavigationUM import com.tangem.core.ui.components.Fade -import com.tangem.core.ui.components.appbar.AppBarWithBackButton -import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.core.ui.decompose.ComposableModularContentComponent import com.tangem.core.ui.extensions.* import com.tangem.core.ui.res.TangemTheme -import com.tangem.features.swap.v2.impl.R -import com.tangem.features.swap.v2.impl.common.entity.ConfirmUM import com.tangem.features.swap.v2.impl.sendviaswap.SendWithSwapRoute -import com.tangem.utils.StringsSigns @Composable -internal fun SendWithSwapContent( - navigationUM: NavigationUM, - confirmUM: ConfirmUM, - stackState: ChildStack, - onLinkClick: (String) -> Unit, -) { - val navigationUMContent = navigationUM as? NavigationUM.Content ?: return - +internal fun SendWithSwapContent(stackState: ChildStack) { Column( modifier = Modifier .background(color = TangemTheme.colors.background.tertiary) @@ -48,12 +28,7 @@ internal fun SendWithSwapContent( .systemBarsPadding(), horizontalAlignment = Alignment.CenterHorizontally, ) { - AppBarWithBackButton( - text = navigationUMContent.title.resolveReference(), - onBackClick = navigationUMContent.backIconClick, - iconRes = navigationUMContent.additionalIconRes, - modifier = Modifier.height(TangemTheme.dimens.size56), - ) + stackState.active.instance.Title() Children( stack = stackState, animation = stackAnimation { child -> @@ -76,125 +51,6 @@ internal fun SendWithSwapContent( } } } - if (stackState.active.configuration != SendWithSwapRoute.Success) { - SendWithSwapFooter( - confirmUM = confirmUM, - stackState = stackState, - primaryButton = navigationUMContent.primaryButton, - onLinkClick = onLinkClick, - ) - } - } -} - -@Composable -private fun SendWithSwapFooter( - confirmUM: ConfirmUM, - stackState: ChildStack, - primaryButton: NavigationButton, - onLinkClick: (String) -> Unit, -) { - Column { - AnimatedVisibility( - visible = stackState.active.configuration == SendWithSwapRoute.Confirm, - enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(), - ) { - val confirmContentUM = confirmUM as? ConfirmUM.Content - val sendFooter = confirmContentUM?.sendingFooter ?: TextReference.EMPTY - val legalFooter = getAnnotatedStringForLegals( - tosUM = confirmContentUM?.tosUM, - sendFooter = sendFooter, - onClick = onLinkClick, - ) - val footerText = remember(sendFooter, legalFooter) { - if (sendFooter != TextReference.EMPTY || legalFooter != TextReference.EMPTY) { - combinedReference(sendFooter, legalFooter) - } else { - TextReference.EMPTY - } - } - SendingText(footerText = footerText) - } - NavigationPrimaryButton( - primaryButton = primaryButton, - modifier = Modifier.padding( - start = 16.dp, - end = 16.dp, - bottom = 16.dp, - ), - ) - } -} - -@Composable -private fun getAnnotatedStringForLegals( - tosUM: ConfirmUM.Content.TosUM?, - sendFooter: TextReference, - onClick: (String) -> Unit, -): TextReference { - if (tosUM == null) return TextReference.EMPTY - val tos = tosUM.tosLink - val policy = tosUM.policyLink - return if (tos != null && policy != null) { - val tosTitle = tos.title.resolveReference() - val policyTitle = policy.title.resolveReference() - val fullString = stringResourceSafe(id = R.string.express_legal_two_placeholders, tosTitle, policyTitle) - val tosIndex = fullString.indexOf(tosTitle) - val policyIndex = fullString.indexOf(policyTitle) - - annotatedReference { - if (!sendFooter.resolveReference().endsWith(StringsSigns.POINT_SIGN)) { - append(StringsSigns.POINT_SIGN) - } - appendSpace() - append(fullString.substring(0, tosIndex)) - withLink( - link = LinkAnnotation.Clickable( - tag = "TOS_TAG", - linkInteractionListener = { onClick(tos.link) }, - ), - block = { - appendColored( - text = fullString.substring(tosIndex, tosIndex + tosTitle.length), - color = TangemTheme.colors.text.accent, - ) - }, - ) - append(fullString.substring(tosIndex + tosTitle.length, policyIndex)) - withLink( - link = LinkAnnotation.Clickable( - tag = "POLICY_TAG", - linkInteractionListener = { onClick(policy.link) }, - ), - block = { - appendColored( - text = fullString.substring(policyIndex, policyIndex + policyTitle.length), - color = TangemTheme.colors.text.accent, - ) - }, - ) - } - } else { - val legal = requireNotNull(tos ?: policy) { "tos or policy must not be null" } - val legalTitle = legal.title.resolveReference() - val fullString = stringResourceSafe(id = R.string.express_legal_one_placeholder, legalTitle) - val legalIndex = fullString.indexOf(legalTitle) - - annotatedReference { - append(fullString.substring(0, legalIndex)) - withLink( - link = LinkAnnotation.Clickable( - tag = "LEGAL_TAG", - linkInteractionListener = { onClick(legal.link) }, - ), - block = { - appendColored( - text = fullString.substring(legalIndex, legalIndex + legalTitle.length), - color = TangemTheme.colors.text.accent, - ) - }, - ) - } + stackState.active.instance.Footer() } } \ No newline at end of file diff --git a/features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/sendviaswap/model/SendWithSwapModelNavigationTest.kt b/features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/sendviaswap/model/SendWithSwapModelNavigationTest.kt new file mode 100644 index 0000000000..3f50b05aa9 --- /dev/null +++ b/features/swap-v2/impl/src/test/java/com/tangem/features/swap/v2/impl/sendviaswap/model/SendWithSwapModelNavigationTest.kt @@ -0,0 +1,138 @@ +package com.tangem.features.swap.v2.impl.sendviaswap.model + +import arrow.core.left +import com.tangem.blockchain.common.Blockchain +import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.core.decompose.model.MutableParamsContainer +import com.tangem.core.decompose.navigation.Router +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.wallets.models.errors.GetUserWalletError +import com.tangem.domain.wallets.usecase.GetUserWalletUseCase +import com.tangem.features.send.api.entry.SendEntryRoute +import com.tangem.features.swap.v2.api.SendWithSwapComponent +import com.tangem.features.swap.v2.impl.sendviaswap.SendWithSwapRoute +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +/** + * Guards the send-with-swap navigation refactor: [SendWithSwapModel.onBackClick] / + * [SendWithSwapModel.onNextClick] now receive the active [com.tangem.core.decompose.navigation.Route] + * as a parameter instead of reading an internal `currentRoute` StateFlow. Routing is a pure, + * synchronous decision over [SendWithSwapRoute], so each method is asserted before any advance; the + * model is then destroyed inside the test body so its `init {}` collectors (`initAppCurrency`, + * `subscribeOnBalanceHidden`) are cancelled — never run — before `runTest`'s terminal advance. The + * synchronous `initUserWallet()` is steered to the not-found branch so it never touches the router + * during construction. + */ +@OptIn(ExperimentalCoroutinesApi::class) +internal class SendWithSwapModelNavigationTest { + + private val router: Router = mockk(relaxed = true) + private val getUserWalletUseCase: GetUserWalletUseCase = mockk(relaxed = true) + private val cryptoCurrency = MockCryptoCurrencyFactory().createCoin(Blockchain.Ethereum) + + @Test + fun `GIVEN amount step WHEN onNextClick THEN pushes destination`() = runTest { + val model = createModel(this) + + model.onNextClick(SendWithSwapRoute.Amount(isEditMode = false)) + + verify(exactly = 1) { router.push(SendWithSwapRoute.Destination(isEditMode = false)) } + model.onDestroy() + } + + @Test + fun `GIVEN destination step WHEN onNextClick THEN pushes confirm`() = runTest { + val model = createModel(this) + + model.onNextClick(SendWithSwapRoute.Destination(isEditMode = false)) + + verify(exactly = 1) { router.push(SendWithSwapRoute.Confirm) } + model.onDestroy() + } + + @Test + fun `GIVEN confirm step WHEN onNextClick THEN pushes success`() = runTest { + val model = createModel(this) + + model.onNextClick(SendWithSwapRoute.Confirm) + + verify(exactly = 1) { router.push(SendWithSwapRoute.Success) } + model.onDestroy() + } + + @Test + fun `GIVEN success step WHEN onNextClick THEN pops`() = runTest { + val model = createModel(this) + + model.onNextClick(SendWithSwapRoute.Success) + + verify(exactly = 1) { router.pop() } + verify(exactly = 0) { router.push(any()) } + model.onDestroy() + } + + @Test + fun `GIVEN edit-mode amount WHEN onNextClick THEN pops instead of advancing`() = runTest { + val model = createModel(this) + + model.onNextClick(SendWithSwapRoute.Amount(isEditMode = true)) + + verify(exactly = 1) { router.pop() } + verify(exactly = 0) { router.push(any()) } + model.onDestroy() + } + + @Test + fun `GIVEN any route WHEN onBackClick THEN pops`() = runTest { + val model = createModel(this) + + model.onBackClick(SendWithSwapRoute.Destination(isEditMode = false)) + + verify(exactly = 1) { router.pop() } + model.onDestroy() + } + + private fun createModel(testScope: TestScope): SendWithSwapModel { + // Synchronous initUserWallet() runs at construction → keep it on the not-found branch (no router calls). + every { getUserWalletUseCase(any()) } returns GetUserWalletError.UserWalletNotFound.left() + + val params = SendWithSwapComponent.Params( + userWalletId = UserWalletId(stringValue = "0123456789"), + currency = cryptoCurrency, + callback = null, + currentRoute = MutableStateFlow(SendEntryRoute.SendWithSwap), + ) + return SendWithSwapModel( + dispatchers = testScope.createTestingCoroutineDispatcherProvider(), + router = router, + getFeePaidCryptoCurrencyStatusSyncUseCase = mockk(relaxed = true), + getUserWalletUseCase = getUserWalletUseCase, + getSelectedAppCurrencyUseCase = mockk(relaxed = true), + getBalanceHidingSettingsUseCase = mockk(relaxed = true), + getAccountCurrencyStatusUseCase = mockk(relaxed = true), + isAccountsModeEnabledUseCase = mockk(relaxed = true), + swapAlertFactory = mockk(relaxed = true), + paramsContainer = MutableParamsContainer(value = params), + ) + } + + 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 From ee18d4395427a0d6a46ce959ffea37364d8d88ac Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 29 Jun 2026 15:06:28 +0500 Subject: [PATCH 078/210] Updated on 2026-08-14 --- .../features/send/send/SendModelTestBase.kt | 50 +++-- .../confirm/model/SendConfirmModelTest.kt | 73 ++++--- .../features/send/send/model/SendModelTest.kt | 31 ++- .../confirm/model/NFTSendConfirmModelTest.kt | 58 +++--- .../send/sendnft/model/NFTSendModelTest.kt | 41 ++-- .../amount/model/SendAmountModelTest.kt | 59 +++--- .../model/SendDestinationModelTest.kt | 181 ++++++++++++------ 7 files changed, 292 insertions(+), 201 deletions(-) diff --git a/features/send/impl/src/test/java/com/tangem/features/send/send/SendModelTestBase.kt b/features/send/impl/src/test/java/com/tangem/features/send/send/SendModelTestBase.kt index 8658c5b023..317c4fdebb 100644 --- a/features/send/impl/src/test/java/com/tangem/features/send/send/SendModelTestBase.kt +++ b/features/send/impl/src/test/java/com/tangem/features/send/send/SendModelTestBase.kt @@ -1,16 +1,21 @@ package com.tangem.features.send.send import arrow.core.Either +import arrow.core.right import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.transaction.Fee import com.tangem.common.routing.AppRouter +import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.model.MutableParamsContainer import com.tangem.core.decompose.model.ParamsContainer import com.tangem.core.decompose.navigation.Router +import com.tangem.core.navigation.share.ShareManager +import com.tangem.core.navigation.url.UrlOpener import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase +import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase @@ -21,55 +26,45 @@ import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.qrscanning.models.SourceType import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase import com.tangem.domain.qrscanning.usecases.ParseQrCodeUseCase +import com.tangem.domain.settings.IsSendTapHelpEnabledUseCase +import com.tangem.domain.settings.NeverShowTapHelpUseCase +import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase +import com.tangem.domain.tokens.repository.CurrenciesRepository import com.tangem.domain.quotes.IsHighNetworkFeeUseCase import com.tangem.domain.transaction.usecase.CreateTransferTransactionUseCase import com.tangem.domain.transaction.usecase.GetFeeUseCase import com.tangem.domain.transaction.usecase.SendTransactionUseCase +import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase import com.tangem.domain.transaction.usecase.gasless.GetFeeForGaslessUseCase import com.tangem.domain.transaction.usecase.gasless.GetFeeForTokenUseCase +import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.send.api.SendComponent import com.tangem.features.send.api.SendFeatureToggles import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.entity.PredefinedValues +import com.tangem.features.send.api.subcomponents.amount.SendAmountReduceTrigger +import com.tangem.features.send.api.subcomponents.amount.SendAmountUpdateTrigger +import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorCheckReloadListener import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorCheckReloadTrigger import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateListener import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateTrigger import com.tangem.features.send.common.SendBalanceUpdater import com.tangem.features.send.common.SendConfirmAlertFactory +import com.tangem.features.send.common.ui.state.ConfirmUM import com.tangem.features.send.send.analytics.SendAnalyticHelper import com.tangem.features.send.send.confirm.SendConfirmComponent import com.tangem.features.send.send.confirm.model.SendConfirmModel -import com.tangem.features.send.send.ui.state.SendUM -import com.tangem.features.send.api.subcomponents.amount.SendAmountReduceTrigger -import com.tangem.features.send.api.subcomponents.amount.SendAmountUpdateTrigger -import com.tangem.features.send.testDispatcherProvider -import com.tangem.core.navigation.share.ShareManager -import com.tangem.core.navigation.url.UrlOpener -import com.tangem.domain.settings.IsSendTapHelpEnabledUseCase -import com.tangem.domain.settings.NeverShowTapHelpUseCase -import com.tangem.domain.account.status.usecase.ManageCryptoCurrenciesUseCase -import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase -import com.tangem.domain.tokens.repository.CurrenciesRepository -import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase -import com.tangem.domain.transaction.usecase.gasless.CreateAndSendGaslessTransactionUseCase -import com.tangem.domain.qrscanning.models.SourceType -import arrow.core.right -import com.tangem.common.ui.amountScreen.models.AmountState -import com.tangem.common.ui.navigationButtons.NavigationUM -import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM -import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM -import com.tangem.features.send.common.ui.state.ConfirmUM import com.tangem.features.send.send.model.SendModel -import io.mockk.MockKAnnotations -import io.mockk.clearMocks -import io.mockk.coEvery -import io.mockk.every -import io.mockk.mockk +import com.tangem.features.send.send.ui.state.SendUM +import com.tangem.features.send.testDispatcherProvider +import io.mockk.* import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf @@ -89,7 +84,8 @@ internal abstract class SendModelTestBase { protected val router: Router = mockk(relaxed = true) protected val appRouter: AppRouter = mockk(relaxed = true) protected val getUserWalletUseCase: GetUserWalletUseCase = mockk(relaxed = true) - protected val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase = mockk(relaxed = true) + protected val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase = + mockk(relaxed = true) protected val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk(relaxed = true) protected val listenToQrScanningUseCase: ListenToQrScanningUseCase = mockk(relaxed = true) protected val parseQrCodeUseCase: ParseQrCodeUseCase = mockk(relaxed = true) @@ -260,7 +256,6 @@ internal abstract class SendModelTestBase { destinationUM = DestinationUM.Empty(), feeSelectorUM = FeeSelectorUM.Loading, confirmUM = ConfirmUM.Empty, - navigationUM = NavigationUM.Empty, confirmData = null, ), cryptoCurrencyStatus: CryptoCurrencyStatus = testCryptoCurrencyStatus, @@ -278,7 +273,6 @@ internal abstract class SendModelTestBase { isAccountModeFlow = kotlinx.coroutines.flow.MutableStateFlow(false), appCurrency = AppCurrency.Default, callback = mockk(relaxed = true), - currentRoute = kotlinx.coroutines.flow.flowOf(), isBalanceHidingFlow = kotlinx.coroutines.flow.MutableStateFlow(false), predefinedValues = PredefinedValues.Empty, onLoadFee = { Either.Right(mockk(relaxed = true)) }, diff --git a/features/send/impl/src/test/java/com/tangem/features/send/send/confirm/model/SendConfirmModelTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/send/confirm/model/SendConfirmModelTest.kt index 2ee87f066a..370ac09ac5 100644 --- a/features/send/impl/src/test/java/com/tangem/features/send/send/confirm/model/SendConfirmModelTest.kt +++ b/features/send/impl/src/test/java/com/tangem/features/send/send/confirm/model/SendConfirmModelTest.kt @@ -7,36 +7,25 @@ import com.tangem.blockchain.common.Amount import com.tangem.blockchain.common.transaction.Fee import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.common.ui.amountScreen.models.AmountState -import com.tangem.common.ui.navigationButtons.NavigationUM import com.tangem.core.decompose.model.MutableParamsContainer import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.transaction.models.TransactionFeeExtended +import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeExtraInfo import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeNonce import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM -import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM import com.tangem.features.send.common.ui.state.ConfirmUM -import com.tangem.features.send.send.ui.state.SendUM -import com.tangem.domain.transaction.models.TransactionFeeExtended import com.tangem.features.send.send.SendModelTestBase +import com.tangem.features.send.send.ui.state.SendUM import com.tangem.test.core.ProvideTestModels -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockk -import io.mockk.mockkStatic -import io.mockk.unmockkStatic -import io.mockk.verify +import io.mockk.* import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.* import org.junit.jupiter.params.ParameterizedTest import java.math.BigDecimal @@ -73,10 +62,30 @@ internal class SendConfirmModelTest : SendModelTestBase() { // Assert if (model.expectedSendInitiated) { - coVerify(exactly = 1) { createTransferTransactionUseCase(any(), any(), any(), any(), any(), any(), any()) } + coVerify(exactly = 1) { + createTransferTransactionUseCase( + any(), + any(), + any(), + any(), + any(), + any(), + any() + ) + } coVerify(exactly = 0) { feeSelectorCheckReloadTrigger.triggerCheckUpdate() } } else { - coVerify(exactly = 0) { createTransferTransactionUseCase(any(), any(), any(), any(), any(), any(), any()) } + coVerify(exactly = 0) { + createTransferTransactionUseCase( + any(), + any(), + any(), + any(), + any(), + any(), + any() + ) + } coVerify(exactly = 1) { feeSelectorCheckReloadTrigger.triggerCheckUpdate() } } } @@ -108,9 +117,29 @@ internal class SendConfirmModelTest : SendModelTestBase() { // Assert if (model.expectedSendInitiated) { - coVerify(exactly = 1) { createTransferTransactionUseCase(any(), any(), any(), any(), any(), any(), any()) } + coVerify(exactly = 1) { + createTransferTransactionUseCase( + any(), + any(), + any(), + any(), + any(), + any(), + any() + ) + } } else { - coVerify(exactly = 0) { createTransferTransactionUseCase(any(), any(), any(), any(), any(), any(), any()) } + coVerify(exactly = 0) { + createTransferTransactionUseCase( + any(), + any(), + any(), + any(), + any(), + any(), + any() + ) + } } } @@ -161,7 +190,8 @@ internal class SendConfirmModelTest : SendModelTestBase() { fun `GIVEN successful send WHEN verifyAndSend THEN notify onSendTransaction`() = runTest { // Arrange val onSendTransaction = mockk<() -> Unit>(relaxed = true) - val callback = mockk(relaxed = true) + val callback = + mockk(relaxed = true) val resultFlow = MutableSharedFlow(extraBufferCapacity = 1) every { feeSelectorCheckReloadListener.checkReloadResultFlow } returns resultFlow coEvery { sendTransactionUseCase(any(), any(), any()) } returns "txHash".right() @@ -256,7 +286,6 @@ internal class SendConfirmModelTest : SendModelTestBase() { destinationUM = destination, feeSelectorUM = feeSelector, confirmUM = mockk(relaxed = true), - navigationUM = NavigationUM.Empty, confirmData = null, ) } diff --git a/features/send/impl/src/test/java/com/tangem/features/send/send/model/SendModelTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/send/model/SendModelTest.kt index 0615266589..e9b708db9e 100644 --- a/features/send/impl/src/test/java/com/tangem/features/send/send/model/SendModelTest.kt +++ b/features/send/impl/src/test/java/com/tangem/features/send/send/model/SendModelTest.kt @@ -11,7 +11,6 @@ import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents import com.tangem.features.send.api.entity.PredefinedValues import com.tangem.features.send.common.CommonSendRoute import com.tangem.features.send.common.ui.state.ConfirmUM -import com.tangem.common.ui.navigationButtons.NavigationUM import com.tangem.features.send.send.SendModelTestBase import io.mockk.coEvery import io.mockk.mockk @@ -33,7 +32,6 @@ internal class SendModelTest : SendModelTestBase() { fun `GIVEN amount route AND predefined main screen QR WHEN onNextClick THEN push Confirm`() = runTest { // Arrange val model = createSendModel(this) - model.currentRoute.value = CommonSendRoute.Amount(isEditMode = false) model.predefinedValues = PredefinedValues.Content.QrCode( amount = "1.0", address = "addr123", @@ -42,7 +40,7 @@ internal class SendModelTest : SendModelTestBase() { ) // Act - model.onNextClick() + model.onNextClick(CommonSendRoute.Amount(isEditMode = false)) // Assert verify(exactly = 1) { router.push(CommonSendRoute.Confirm, any()) } @@ -52,11 +50,10 @@ internal class SendModelTest : SendModelTestBase() { fun `GIVEN amount route AND NOT main screen QR WHEN onNextClick THEN push Destination`() = runTest { // Arrange val model = createSendModel(this) - model.currentRoute.value = CommonSendRoute.Amount(isEditMode = false) model.predefinedValues = PredefinedValues.Empty // Act - model.onNextClick() + model.onNextClick(CommonSendRoute.Amount(isEditMode = false)) // Assert verify(exactly = 1) { router.push(CommonSendRoute.Destination(isEditMode = false), any()) } @@ -66,10 +63,9 @@ internal class SendModelTest : SendModelTestBase() { fun `GIVEN destination route WHEN onNextClick THEN push Confirm`() = runTest { // Arrange val model = createSendModel(this) - model.currentRoute.value = CommonSendRoute.Destination(isEditMode = false) // Act - model.onNextClick() + model.onNextClick(CommonSendRoute.Destination(isEditMode = false)) // Assert verify(exactly = 1) { router.push(CommonSendRoute.Confirm, any()) } @@ -79,10 +75,9 @@ internal class SendModelTest : SendModelTestBase() { fun `GIVEN route in edit mode WHEN onNextClick THEN pop without push`() = runTest { // Arrange val model = createSendModel(this) - model.currentRoute.value = CommonSendRoute.Amount(isEditMode = true) // Act - model.onNextClick() + model.onNextClick(CommonSendRoute.Amount(isEditMode = true)) // Assert verify(exactly = 1) { router.pop(any()) } @@ -90,19 +85,18 @@ internal class SendModelTest : SendModelTestBase() { } @Test - fun `GIVEN confirm route WHEN onNextClick THEN pop (Confirm isEditMode is true so push branch is dead)`() = + fun `GIVEN confirm route WHEN onNextClick THEN push ConfirmSuccess`() = runTest { // Arrange - // CommonSendRoute.Confirm.isEditMode == true, so onNextClick short-circuits to onBackClick(). + // CommonSendRoute.Confirm.isEditMode == false, so onNextClick pushes ConfirmSuccess. val model = createSendModel(this) - model.currentRoute.value = CommonSendRoute.Confirm // Act - model.onNextClick() + model.onNextClick(CommonSendRoute.Confirm) // Assert - verify(exactly = 1) { router.pop(any()) } - verify(exactly = 0) { router.push(CommonSendRoute.ConfirmSuccess, any()) } + verify(exactly = 1) { router.push(CommonSendRoute.ConfirmSuccess, any()) } + verify(exactly = 0) { router.pop(any()) } } } @@ -210,10 +204,9 @@ internal class SendModelTest : SendModelTestBase() { fun `GIVEN amount route non-edit WHEN onBackClick THEN send analytics and pop`() = runTest { // Arrange val model = createSendModel(this) - model.currentRoute.value = CommonSendRoute.Amount(isEditMode = false) // Act - model.onBackClick() + model.onBackClick(CommonSendRoute.Amount(isEditMode = false)) // Assert verify(exactly = 1) { analyticsEventHandler.send(any()) } @@ -224,10 +217,9 @@ internal class SendModelTest : SendModelTestBase() { fun `GIVEN destination route edit WHEN onBackClick THEN pop without analytics`() = runTest { // Arrange val model = createSendModel(this) - model.currentRoute.value = CommonSendRoute.Destination(isEditMode = true) // Act - model.onBackClick() + model.onBackClick(CommonSendRoute.Destination(isEditMode = true)) // Assert verify(exactly = 0) { analyticsEventHandler.send(any()) } @@ -251,7 +243,6 @@ internal class SendModelTest : SendModelTestBase() { assertThat(state.feeSelectorUM).isEqualTo(FeeSelectorUMRedesigned.Loading) assertThat(state.confirmUM).isEqualTo(ConfirmUM.Empty) assertThat(state.confirmData).isNull() - assertThat(state.navigationUM).isEqualTo(NavigationUM.Empty) verify(exactly = 1) { router.popTo(CommonSendRoute.Amount(isEditMode = false), any()) } } } diff --git a/features/send/impl/src/test/java/com/tangem/features/send/sendnft/confirm/model/NFTSendConfirmModelTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/sendnft/confirm/model/NFTSendConfirmModelTest.kt index 0d88f78693..e6e57ae776 100644 --- a/features/send/impl/src/test/java/com/tangem/features/send/sendnft/confirm/model/NFTSendConfirmModelTest.kt +++ b/features/send/impl/src/test/java/com/tangem/features/send/sendnft/confirm/model/NFTSendConfirmModelTest.kt @@ -6,9 +6,7 @@ import arrow.core.right import com.tangem.blockchain.common.Amount import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.transaction.Fee -import com.tangem.blockchain.nft.models.NFTAsset as SdkNFTAsset import com.tangem.common.routing.AppRouter -import com.tangem.common.ui.navigationButtons.NavigationUM import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.model.MutableParamsContainer import com.tangem.core.decompose.model.ParamsContainer @@ -32,50 +30,36 @@ import com.tangem.domain.transaction.usecase.SendTransactionUseCase import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase import com.tangem.features.nft.entity.NFTSendSuccessTrigger import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents -import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeExtraInfo -import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem -import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeNonce -import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.api.subcomponents.destination.entity.DestinationUM import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorCheckReloadListener import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorCheckReloadTrigger import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeExtraInfo +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeItem +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeNonce +import com.tangem.features.send.api.subcomponents.feeSelector.entity.FeeSelectorUM import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateListener import com.tangem.features.send.api.subcomponents.notifications.SendNotificationsUpdateTrigger import com.tangem.features.send.common.SendBalanceUpdater import com.tangem.features.send.common.SendConfirmAlertFactory import com.tangem.features.send.common.ui.state.ConfirmUM import com.tangem.features.send.loadedStatus -import com.tangem.features.send.testDispatcherProvider import com.tangem.features.send.sendnft.analytics.NFTSendAnalyticHelper import com.tangem.features.send.sendnft.confirm.NFTSendConfirmComponent import com.tangem.features.send.sendnft.ui.state.NFTSendUM +import com.tangem.features.send.testDispatcherProvider import com.tangem.test.core.ProvideTestModels -import io.mockk.MockKAnnotations -import io.mockk.clearMocks -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockk -import io.mockk.mockkObject -import io.mockk.mockkStatic -import io.mockk.unmockkObject -import io.mockk.unmockkStatic -import io.mockk.verify +import io.mockk.* import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.* import org.junit.jupiter.params.ParameterizedTest import java.math.BigDecimal +import com.tangem.blockchain.nft.models.NFTAsset as SdkNFTAsset @OptIn(ExperimentalCoroutinesApi::class) internal class NFTSendConfirmModelTest { @@ -166,10 +150,30 @@ internal class NFTSendConfirmModelTest { // Assert if (model.expectedSendInitiated) { - coVerify(exactly = 1) { createNFTTransferTransactionUseCase(any(), any(), any(), any(), any(), any(), any()) } + coVerify(exactly = 1) { + createNFTTransferTransactionUseCase( + any(), + any(), + any(), + any(), + any(), + any(), + any() + ) + } coVerify(exactly = 0) { feeSelectorCheckReloadTrigger.triggerCheckUpdate() } } else { - coVerify(exactly = 0) { createNFTTransferTransactionUseCase(any(), any(), any(), any(), any(), any(), any()) } + coVerify(exactly = 0) { + createNFTTransferTransactionUseCase( + any(), + any(), + any(), + any(), + any(), + any(), + any() + ) + } coVerify(exactly = 1) { feeSelectorCheckReloadTrigger.triggerCheckUpdate() } } } @@ -303,7 +307,6 @@ internal class NFTSendConfirmModelTest { account = null, isAccountsMode = false, callback = mockk(relaxed = true), - currentRoute = flowOf(), isBalanceHidingFlow = kotlinx.coroutines.flow.MutableStateFlow(false), onLoadFee = { mockk(relaxed = true).right() }, onSendTransaction = {}, @@ -328,7 +331,6 @@ internal class NFTSendConfirmModelTest { destinationUM = destination, feeSelectorUM = feeSelector, confirmUM = mockk(relaxed = true), - navigationUM = NavigationUM.Empty, ) } diff --git a/features/send/impl/src/test/java/com/tangem/features/send/sendnft/model/NFTSendModelTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/sendnft/model/NFTSendModelTest.kt index a70fcd7035..c3dda73985 100644 --- a/features/send/impl/src/test/java/com/tangem/features/send/sendnft/model/NFTSendModelTest.kt +++ b/features/send/impl/src/test/java/com/tangem/features/send/sendnft/model/NFTSendModelTest.kt @@ -102,31 +102,41 @@ internal class NFTSendModelTest { // Arrange val sut = buildModel() advanceUntilIdle() - sut.currentRouteFlow.value = model.route // Act - sut.onNextClick() + sut.onNextClick(model.route) advanceUntilIdle() // Assert - if (model.expectPushConfirm) { - verify(exactly = 1) { router.push(CommonSendRoute.Confirm, any()) } - verify(exactly = 0) { router.pop(any()) } - } else { - verify(exactly = 1) { router.pop(any()) } - verify(exactly = 0) { router.push(any(), any()) } - // Confirm.isEditMode == true, so the `Confirm -> replaceAll(ConfirmSuccess)` branch is unreachable - verify(exactly = 0) { router.replaceAll(CommonSendRoute.ConfirmSuccess, onComplete = any()) } + when (model.expectedAction) { + NextClickAction.PushConfirm -> { + verify(exactly = 1) { router.push(CommonSendRoute.Confirm, any()) } + verify(exactly = 0) { router.pop(any()) } + verify(exactly = 0) { router.replaceAll(*anyVararg(), onComplete = any()) } + } + NextClickAction.ReplaceAllConfirmSuccess -> { + // Confirm route → replaceAll(ConfirmSuccess) + verify(exactly = 1) { router.replaceAll(CommonSendRoute.ConfirmSuccess, onComplete = any()) } + verify(exactly = 0) { router.pop(any()) } + verify(exactly = 0) { router.push(any(), any()) } + } + NextClickAction.Pop -> { + verify(exactly = 1) { router.pop(any()) } + verify(exactly = 0) { router.push(any(), any()) } + verify(exactly = 0) { router.replaceAll(*anyVararg(), onComplete = any()) } + } } } private fun provideTestModels() = listOf( - NextClickModel(route = CommonSendRoute.Destination(isEditMode = false), expectPushConfirm = true), - NextClickModel(route = CommonSendRoute.Destination(isEditMode = true), expectPushConfirm = false), - NextClickModel(route = CommonSendRoute.Confirm, expectPushConfirm = false), + NextClickModel(route = CommonSendRoute.Destination(isEditMode = false), expectedAction = NextClickAction.PushConfirm), + NextClickModel(route = CommonSendRoute.Destination(isEditMode = true), expectedAction = NextClickAction.Pop), + NextClickModel(route = CommonSendRoute.Confirm, expectedAction = NextClickAction.ReplaceAllConfirmSuccess), ) } + enum class NextClickAction { PushConfirm, ReplaceAllConfirmSuccess, Pop } + @Nested @TestInstance(TestInstance.Lifecycle.PER_CLASS) inner class OnBackClick { @@ -138,10 +148,9 @@ internal class NFTSendModelTest { // Arrange val sut = buildModel() advanceUntilIdle() - sut.currentRouteFlow.value = model.route // Act - sut.onBackClick() + sut.onBackClick(model.route) advanceUntilIdle() // Assert @@ -221,7 +230,7 @@ internal class NFTSendModelTest { ) } - data class NextClickModel(val route: CommonSendRoute, val expectPushConfirm: Boolean) + data class NextClickModel(val route: CommonSendRoute, val expectedAction: NextClickAction) data class BackClickModel(val route: CommonSendRoute, val expectedTriggerCalls: Int) diff --git a/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/amount/model/SendAmountModelTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/amount/model/SendAmountModelTest.kt index 19312d7771..a327f4b0a9 100644 --- a/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/amount/model/SendAmountModelTest.kt +++ b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/amount/model/SendAmountModelTest.kt @@ -1,8 +1,11 @@ package com.tangem.features.send.subcomponents.amount.model import arrow.core.right +import com.google.common.truth.Truth.assertThat import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer.ReduceByData import com.tangem.common.ui.amountScreen.models.AmountState +import com.tangem.core.analytics.api.AnalyticsEventHandler +import com.tangem.core.decompose.model.MutableParamsContainer import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.account.Account @@ -13,43 +16,28 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.tokens.GetMinimumTransactionAmountSyncUseCase import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.domain.wallets.usecase.GetWalletsUseCase -import com.tangem.core.analytics.api.AnalyticsEventHandler -import com.tangem.core.decompose.model.MutableParamsContainer import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents -import com.tangem.features.send.api.subcomponents.amount.analytics.CommonSendAmountAnalyticEvents import com.tangem.features.send.api.entity.PredefinedValues -import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger -import com.tangem.features.send.common.CommonSendRoute -import com.tangem.features.send.loadedStatus -import com.tangem.features.send.testDispatcherProvider -import com.tangem.features.send.api.subcomponents.amount.AmountRoute import com.tangem.features.send.api.subcomponents.amount.SendAmountComponent import com.tangem.features.send.api.subcomponents.amount.SendAmountComponentParams import com.tangem.features.send.api.subcomponents.amount.SendAmountReduceListener import com.tangem.features.send.api.subcomponents.amount.SendAmountUpdateListener +import com.tangem.features.send.api.subcomponents.amount.analytics.CommonSendAmountAnalyticEvents +import com.tangem.features.send.api.subcomponents.feeSelector.FeeSelectorReloadTrigger +import com.tangem.features.send.common.CommonSendRoute +import com.tangem.features.send.loadedStatus +import com.tangem.features.send.testDispatcherProvider import com.tangem.test.core.ProvideTestModels -import com.google.common.truth.Truth.assertThat -import io.mockk.MockKAnnotations -import io.mockk.clearMocks -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify +import io.mockk.* import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Disabled -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.* import org.junit.jupiter.params.ParameterizedTest import java.math.BigDecimal @@ -79,7 +67,14 @@ internal class SendAmountModelTest { fun setUp() { MockKAnnotations.init(this) // PER_CLASS parameterized nested classes reuse one instance — reset verified mocks between rows. - clearMocks(callback, sendAmountAlertFactory, analyticsEventHandler, answers = false, recordedCalls = true, childMocks = false) + clearMocks( + callback, + sendAmountAlertFactory, + analyticsEventHandler, + answers = false, + recordedCalls = true, + childMocks = false + ) every { getUserWalletUseCase.invokeFlow(testUserWalletId) } returns flowOf(coldWallet().right()) coEvery { getMinimumTransactionAmountSyncUseCase(any(), any()) } returns BigDecimal.ONE.right() coEvery { getSelectedAppCurrencyUseCase.invokeSync() } returns AppCurrency.Default.right() @@ -107,12 +102,7 @@ internal class SendAmountModelTest { PredefinedValues.Empty } // Start off an Amount route so the navigation combine stays idle until the wallet is loaded. - val currentRoute = MutableStateFlow(CommonSendRoute.Confirm) - val sut = buildModel(predefinedValues = predefined, currentRoute = currentRoute) - advanceUntilIdle() - - // Act — flip to Amount so setSendWithSwapAvailability() re-runs with the loaded wallet - currentRoute.value = CommonSendRoute.Amount(isEditMode = false) + val sut = buildModel(predefinedValues = predefined, route = CommonSendRoute.Amount(false)) advanceUntilIdle() // Assert @@ -138,7 +128,7 @@ internal class SendAmountModelTest { fun `WHEN onConvertToAnotherToken THEN reset-alert in edit mode else convert directly`(model: ConvertModel) = runTest { // Arrange - val sut = buildModel(currentRoute = MutableStateFlow(CommonSendRoute.Amount(isEditMode = model.isEditMode))) + val sut = buildModel(route = CommonSendRoute.Amount(isEditMode = model.isEditMode)) advanceUntilIdle() // Act @@ -248,7 +238,10 @@ internal class SendAmountModelTest { } private fun provideTestModels() = listOf( - AmountNextModel(isFiat = true, expectedType = CommonSendAmountAnalyticEvents.SelectedCurrencyType.AppCurrency), + AmountNextModel( + isFiat = true, + expectedType = CommonSendAmountAnalyticEvents.SelectedCurrencyType.AppCurrency + ), AmountNextModel(isFiat = false, expectedType = CommonSendAmountAnalyticEvents.SelectedCurrencyType.Token), ) } @@ -257,7 +250,7 @@ internal class SendAmountModelTest { private fun TestScope.buildModel( predefinedValues: PredefinedValues = PredefinedValues.Empty, - currentRoute: MutableStateFlow = MutableStateFlow(CommonSendRoute.Amount(isEditMode = false)), + route: CommonSendRoute.Amount = CommonSendRoute.Amount(isEditMode = false), cryptoCurrencyStatusFlow: MutableStateFlow = MutableStateFlow(loadedStatus(cryptoCurrency, balance = BigDecimal.TEN)), state: AmountState = AmountState.Empty, @@ -275,7 +268,7 @@ internal class SendAmountModelTest { accountFlow = MutableStateFlow(null), isAccountModeFlow = MutableStateFlow(false), callback = callback, - currentRoute = currentRoute.filterIsInstance(), + route = route, ) return SendAmountModel( paramsContainer = MutableParamsContainer(params), diff --git a/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModelTest.kt b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModelTest.kt index e1017e7b10..2b7811ea97 100644 --- a/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModelTest.kt +++ b/features/send/impl/src/test/java/com/tangem/features/send/subcomponents/destination/model/SendDestinationModelTest.kt @@ -2,6 +2,8 @@ package com.tangem.features.send.subcomponents.destination.model import arrow.core.left import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.common.ui.account.AccountIconUM import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.decompose.model.MutableParamsContainer import com.tangem.core.decompose.navigation.Router @@ -9,6 +11,8 @@ import com.tangem.core.ui.extensions.stringReference import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier import com.tangem.domain.account.status.usecase.GetBackupProblematicWalletForAddressUseCase import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase +import com.tangem.domain.addressbook.model.* +import com.tangem.domain.addressbook.usecase.GetContactsUseCase import com.tangem.domain.feedback.SendBackupProblemEmailUseCase import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.network.CryptoCurrencyAddress @@ -21,25 +25,16 @@ import com.tangem.domain.tokens.GetNetworkAddressesUseCase import com.tangem.domain.transaction.error.AddressValidation import com.tangem.domain.transaction.error.AddressValidationResult import com.tangem.domain.transaction.usecase.IsMemoRequiredUseCase -import com.google.common.truth.Truth.assertThat -import com.tangem.common.ui.account.AccountIconUM -import com.tangem.domain.addressbook.model.AddressEntry -import com.tangem.domain.addressbook.model.AddressEntryId -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.usecase.GetContactsUseCase -import com.tangem.features.addressbook.MatchedContact -import com.tangem.features.addressbook.SelectedContact -import com.tangem.features.send.api.entity.PredefinedValues -import kotlinx.collections.immutable.toImmutableList import com.tangem.domain.transaction.usecase.IsSelfSendAvailableUseCase import com.tangem.domain.transaction.usecase.ValidateWalletAddressUseCase import com.tangem.domain.transaction.usecase.ValidateWalletMemoUseCase import com.tangem.domain.txhistory.usecase.GetFixedTxHistoryItemsUseCase import com.tangem.domain.wallets.usecase.GetWalletsUseCase import com.tangem.features.addressbook.ContactSelectionListener +import com.tangem.features.addressbook.MatchedContact +import com.tangem.features.addressbook.SelectedContact import com.tangem.features.send.api.analytics.CommonSendAnalyticEvents +import com.tangem.features.send.api.entity.PredefinedValues import com.tangem.features.send.api.subcomponents.destination.DestinationRoute import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponent import com.tangem.features.send.api.subcomponents.destination.SendDestinationComponentParams @@ -49,13 +44,14 @@ import com.tangem.features.send.subcomponents.destination.SendDestinationAlertFa import com.tangem.features.send.subcomponents.destination.analytics.EnterAddressSource import com.tangem.features.send.subcomponents.destination.analytics.SendDestinationAnalyticEvents import com.tangem.features.send.testDispatcherProvider -import io.mockk.MockKAnnotations -import io.mockk.clearMocks -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify +import com.tangem.test.core.ProvideTestModels +import io.mockk.* +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import com.tangem.features.send.api.subcomponents.destination.entity.DestinationTextFieldUM +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -64,7 +60,6 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest -import com.tangem.test.core.ProvideTestModels import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test @@ -127,7 +122,15 @@ internal class SendDestinationModelTest { fun `GIVEN valid non-problematic address WHEN address entered THEN send valid analytics without backup alert`() = runTest { // Arrange - coEvery { validateWalletAddressUseCase(any(), any(), any(), any>(), any()) } returns + coEvery { + validateWalletAddressUseCase( + any(), + any(), + any(), + any>(), + any() + ) + } returns AddressValidation.Success.Valid.right() val sut = buildModel() advanceUntilIdle() @@ -144,14 +147,22 @@ internal class SendDestinationModelTest { } verify(exactly = 0) { sendDestinationAlertFactory.showRecipientBackupErrorAlert(any()) } // InputField is not an auto-next source → no auto-advance even for a valid address - verify(exactly = 0) { callback.onNextClick() } + verify(exactly = 0) { callback.onNextClick(CommonSendRoute.Destination(false)) } } @Test fun `GIVEN valid backup-problematic address WHEN address entered THEN show recipient backup error alert`() = runTest { // Arrange - coEvery { validateWalletAddressUseCase(any(), any(), any(), any>(), any()) } returns + coEvery { + validateWalletAddressUseCase( + any(), + any(), + any(), + any>(), + any() + ) + } returns AddressValidation.Success.Valid.right() coEvery { getBackupProblematicWalletForAddressUseCase(any()) } returns testUserWalletId val sut = buildModel() @@ -172,7 +183,15 @@ internal class SendDestinationModelTest { @Test fun `GIVEN invalid address WHEN address entered THEN send invalid analytics`() = runTest { // Arrange - coEvery { validateWalletAddressUseCase(any(), any(), any(), any>(), any()) } returns + coEvery { + validateWalletAddressUseCase( + any(), + any(), + any(), + any>(), + any() + ) + } returns AddressValidation.Error.InvalidAddress.left() val sut = buildModel() advanceUntilIdle() @@ -193,7 +212,15 @@ internal class SendDestinationModelTest { fun `GIVEN memo change with null type WHEN handled THEN no address-entered analytics and no auto-next`() = runTest { // Arrange - coEvery { validateWalletAddressUseCase(any(), any(), any(), any>(), any()) } returns + coEvery { + validateWalletAddressUseCase( + any(), + any(), + any(), + any>(), + any() + ) + } returns AddressValidation.Success.Valid.right() val sut = buildModel() advanceUntilIdle() @@ -206,7 +233,7 @@ internal class SendDestinationModelTest { verify(exactly = 0) { analyticsEventHandler.send(any()) } - verify(exactly = 0) { callback.onNextClick() } + verify(exactly = 0) { callback.onNextClick(CommonSendRoute.Destination(false)) } } } @@ -231,7 +258,7 @@ internal class SendDestinationModelTest { advanceUntilIdle() // Assert - verify(exactly = model.expectedNextClicks) { callback.onNextClick() } + verify(exactly = model.expectedNextClicks) { callback.onNextClick(CommonSendRoute.Destination(false)) } } private fun provideTestModels() = listOf( @@ -256,7 +283,15 @@ internal class SendDestinationModelTest { advanceUntilIdle() // Assert - coVerify(exactly = 0) { validateWalletAddressUseCase(any(), any(), any(), any>(), any()) } + coVerify(exactly = 0) { + validateWalletAddressUseCase( + any(), + any(), + any(), + any>(), + any() + ) + } } } @@ -286,25 +321,38 @@ internal class SendDestinationModelTest { } @Test - fun `GIVEN a contact is set WHEN route switches to edit mode THEN the contact is reset`() = runTest { - // Arrange - coEvery { - validateWalletAddressUseCase(any(), any(), any(), any>(), any()) - } returns AddressValidation.Success.Valid.right() - val currentRoute = MutableStateFlow(CommonSendRoute.Destination(isEditMode = false)) - val sut = buildModel(currentRoute = currentRoute) - advanceUntilIdle() - sut.applySelectedContact(selectedContact(name = "Dave", address = "0xDave")) - advanceUntilIdle() - assertThat(content(sut).addressTextField.contactName).isEqualTo("Dave") + fun `GIVEN a contact is pre-set in state AND route is edit mode WHEN model initializes THEN the contact is reset`() = + runTest { + // Arrange — build initial state with a contact name already set and isInitialized = true + // so the InitialStateTransformer does NOT overwrite it, leaving resetContactOnEdit() to clear it. + val stateWithContact = DestinationUM.Content( + isPrimaryButtonEnabled = false, + isInitialized = true, + addressTextField = DestinationTextFieldUM.RecipientAddress( + value = "0xDave", + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next, keyboardType = KeyboardType.Text), + placeholder = com.tangem.core.ui.extensions.stringReference(""), + label = com.tangem.core.ui.extensions.stringReference(""), + isValuePasted = false, + contactName = "Dave", + ), + memoTextField = null, + recent = persistentListOf(), + wallets = persistentListOf(), + networkName = "Ethereum", + isRecentHidden = false, + ) - // Act — entering edit mode must clear the bound contact - currentRoute.value = CommonSendRoute.Destination(isEditMode = true) - advanceUntilIdle() + // Act — init with isEditMode = true triggers resetContactOnEdit() + val sut = buildModel( + currentRoute = CommonSendRoute.Destination(isEditMode = true), + initialState = stateWithContact, + ) + advanceUntilIdle() - // Assert - assertThat(content(sut).addressTextField.contactName).isNull() - } + // Assert + assertThat(content(sut).addressTextField.contactName).isNull() + } } @Nested @@ -335,9 +383,19 @@ internal class SendDestinationModelTest { private fun provideTestModels() = listOf( // saved "0xAddr", entered "0xaddr" → case-insensitive match - ContactRecognitionModel(savedName = "Alice", savedAddress = "0xAddr", enteredAddress = "0xaddr", expectedContactName = "Alice"), + ContactRecognitionModel( + savedName = "Alice", + savedAddress = "0xAddr", + enteredAddress = "0xaddr", + expectedContactName = "Alice" + ), // entered address not among saved contacts → no recognition - ContactRecognitionModel(savedName = "Alice", savedAddress = "0xOther", enteredAddress = "0xAddr", expectedContactName = null), + ContactRecognitionModel( + savedName = "Alice", + savedAddress = "0xOther", + enteredAddress = "0xAddr", + expectedContactName = null + ), ) } @@ -411,29 +469,44 @@ internal class SendDestinationModelTest { private fun provideTestModels() = listOf( // not available -> never shown, even for a fresh valid address - AddContactModel(isAddContactAvailable = false, savedAddresses = emptyList(), enteredAddress = "0xFresh", expectedShown = false), + AddContactModel( + isAddContactAvailable = false, + savedAddresses = emptyList(), + enteredAddress = "0xFresh", + expectedShown = false + ), // available + address not in the book -> shown - AddContactModel(isAddContactAvailable = true, savedAddresses = emptyList(), enteredAddress = "0xFresh", expectedShown = true), + AddContactModel( + isAddContactAvailable = true, + savedAddresses = emptyList(), + enteredAddress = "0xFresh", + expectedShown = true + ), // available but address already saved -> hidden - AddContactModel(isAddContactAvailable = true, savedAddresses = listOf("0xSaved"), enteredAddress = "0xSaved", expectedShown = false), + AddContactModel( + isAddContactAvailable = true, + savedAddresses = listOf("0xSaved"), + enteredAddress = "0xSaved", + expectedShown = false + ), ) } // region fixtures private fun TestScope.buildModel( - currentRoute: MutableStateFlow = - MutableStateFlow(CommonSendRoute.Destination(isEditMode = false)), + currentRoute: DestinationRoute = CommonSendRoute.Destination(isEditMode = false), + initialState: DestinationUM = DestinationUM.Empty(), ): SendDestinationModel { val params = SendDestinationComponentParams.DestinationParams( - state = DestinationUM.Empty(), + state = initialState, analyticsCategoryName = "test_send", analyticsSendSource = CommonSendAnalyticEvents.CommonSendSource.Send, cryptoCurrency = cryptoCurrency, userWalletId = testUserWalletId, title = stringReference("Send to"), isBalanceHidingFlow = MutableStateFlow(false), - currentRoute = currentRoute, + route = currentRoute, callback = callback, isAllowSelfSend = false, ) From 7b4ae4e23d3eb1292362c0fe704a5eeb9af73e93 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 29 Jun 2026 14:54:25 +0200 Subject: [PATCH 079/210] Updated on 2026-08-14 --- .../tangem/tap/routing/utils/ChildFactory.kt | 1 + .../com/tangem/common/routing/AppRoute.kt | 3 + .../data/common/network/NetworkFactory.kt | 167 +------ .../domain/qrscanning/models/SourceType.kt | 1 + features/address-book/impl/build.gradle.kts | 2 + .../addaddress/DefaultAddAddressComponent.kt | 1 + .../addaddress/model/AddAddressModel.kt | 214 +++++++- .../state/AddAddressStateController.kt | 10 +- ...UpdateAddAddressInitialStateTransformer.kt | 36 +- .../UpdateAddressInputTransformer.kt | 20 +- .../UpdateAddressValidationTransformer.kt | 70 ++- .../UpdateMemoInputTransformer.kt | 15 + .../converter/ChosenNetworkConverter.kt | 14 + .../addaddress/ui/AddAddressContent.kt | 93 +++- .../addressbook/addaddress/ui/MemoRow.kt | 101 ++++ .../addressbook/addaddress/ui/NetworkBlock.kt | 58 +-- .../addaddress/ui/state/AddAddressUM.kt | 35 +- .../common/AddressBookChildFactory.kt | 11 + .../common/AddressBookClickIntents.kt | 6 + .../common/AddressMemoValidator.kt | 28 ++ .../common/DefaultAddressBookComponent.kt | 19 +- .../common/SelectNetworksResultHolder.kt | 30 ++ .../common/SupportedNetworksMatcher.kt | 26 + .../addressbook/di/AddressBookModelModule.kt | 6 + .../editcontact/ui/state/ValidatedAddress.kt | 3 + .../addressbook/route/AddressBookRoute.kt | 10 + .../DefaultSelectNetworksComponent.kt | 37 ++ .../model/SelectNetworksModel.kt | 97 ++++ .../state/SelectNetworksStateController.kt | 47 ++ .../UpdateNetworksContentTransformer.kt | 45 ++ ...teSelectNetworksInitialStateTransformer.kt | 25 + ...pdateSelectNetworksSearchBarTransformer.kt | 16 + .../converter/SelectNetworkItemConverter.kt | 28 ++ .../ui/SelectNetworksContent.kt | 195 ++++++++ .../ui/state/SelectNetworksUM.kt | 26 + .../addaddress/model/AddAddressModelTest.kt | 457 +++++++++++++++--- .../model/SelectNetworksModelTest.kt | 181 +++++++ .../InitializeQrScanningStateTransformer.kt | 5 + .../blockchainsdk/utils/TransactionExtras.kt | 175 +++++++ 39 files changed, 1985 insertions(+), 329 deletions(-) create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateMemoInputTransformer.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/converter/ChosenNetworkConverter.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/MemoRow.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressMemoValidator.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/SelectNetworksResultHolder.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/SupportedNetworksMatcher.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/DefaultSelectNetworksComponent.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/model/SelectNetworksModel.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/state/SelectNetworksStateController.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/state/transformers/UpdateNetworksContentTransformer.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/state/transformers/UpdateSelectNetworksInitialStateTransformer.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/state/transformers/UpdateSelectNetworksSearchBarTransformer.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/state/transformers/converter/SelectNetworkItemConverter.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/ui/SelectNetworksContent.kt create mode 100644 features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/ui/state/SelectNetworksUM.kt create mode 100644 features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/selectnetworks/model/SelectNetworksModelTest.kt create mode 100644 libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/TransactionExtras.kt diff --git a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt index 13e28e17e8..7c1054f8cc 100644 --- a/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt +++ b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt @@ -374,6 +374,7 @@ internal class ChildFactory @Inject constructor( is AppRoute.QrScanning.Source.Send -> SourceType.SEND is AppRoute.QrScanning.Source.WalletConnect -> SourceType.WALLET_CONNECT is AppRoute.QrScanning.Source.MainScreen -> SourceType.MAIN_SCREEN + is AppRoute.QrScanning.Source.AddressBook -> SourceType.ADDRESS_BOOK } createComponentChild( context = context, diff --git a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt index 93c4629c98..63c6e73fdb 100644 --- a/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt +++ b/common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt @@ -195,6 +195,7 @@ sealed class AppRoute(val path: String) : Route { is Send -> "/$networkName" WalletConnect -> "" MainScreen -> "" + AddressBook -> "" } data class Send(val networkName: String) : Source() @@ -202,6 +203,8 @@ sealed class AppRoute(val path: String) : Route { data object WalletConnect : Source() data object MainScreen : Source() + + data object AddressBook : Source() } } diff --git a/data/common/src/main/kotlin/com/tangem/data/common/network/NetworkFactory.kt b/data/common/src/main/kotlin/com/tangem/data/common/network/NetworkFactory.kt index 5adabfe981..a8a877a103 100644 --- a/data/common/src/main/kotlin/com/tangem/data/common/network/NetworkFactory.kt +++ b/data/common/src/main/kotlin/com/tangem/data/common/network/NetworkFactory.kt @@ -4,6 +4,7 @@ import androidx.annotation.VisibleForTesting import com.tangem.blockchain.common.Blockchain import com.tangem.blockchain.common.FeePaidCurrency import com.tangem.blockchainsdk.utils.ExcludedBlockchains +import com.tangem.blockchainsdk.utils.getSupportedTransactionExtras import com.tangem.blockchainsdk.utils.toBlockchain import com.tangem.blockchainsdk.utils.toNetworkId import com.tangem.domain.card.common.extensions.canHandleToken @@ -217,172 +218,6 @@ class NetworkFactory @Inject constructor( } } - @Suppress("LongMethod") - private fun Blockchain.getSupportedTransactionExtras(): Network.TransactionExtrasType { - return when (this) { - Blockchain.XRP -> Network.TransactionExtrasType.DESTINATION_TAG - Blockchain.Binance, - Blockchain.TON, - Blockchain.Cosmos, - Blockchain.TerraV1, - Blockchain.TerraV2, - Blockchain.Stellar, - Blockchain.Hedera, - Blockchain.Algorand, - Blockchain.Sei, - Blockchain.InternetComputer, - Blockchain.Casper, - -> Network.TransactionExtrasType.MEMO - // region Other blockchains - Blockchain.Unknown, - Blockchain.Alephium, - Blockchain.AlephiumTestnet, - Blockchain.Arbitrum, - Blockchain.ArbitrumTestnet, - Blockchain.Avalanche, - Blockchain.AvalancheTestnet, - Blockchain.BinanceTestnet, - Blockchain.BSC, - Blockchain.BSCTestnet, - Blockchain.Bitcoin, - Blockchain.BitcoinTestnet, - Blockchain.BitcoinCash, - Blockchain.BitcoinCashTestnet, - Blockchain.Cardano, - Blockchain.CosmosTestnet, - Blockchain.Dogecoin, - Blockchain.Ducatus, - Blockchain.Ethereum, - Blockchain.EthereumTestnet, - Blockchain.EthereumClassic, - Blockchain.EthereumClassicTestnet, - Blockchain.Fantom, - Blockchain.FantomTestnet, - Blockchain.Litecoin, - Blockchain.Near, - Blockchain.NearTestnet, - Blockchain.Polkadot, - Blockchain.PolkadotTestnet, - Blockchain.Kava, - Blockchain.KavaTestnet, - Blockchain.Kusama, - Blockchain.Polygon, - Blockchain.PolygonTestnet, - Blockchain.RSK, - Blockchain.SeiTestnet, - Blockchain.StellarTestnet, - Blockchain.Solana, - Blockchain.SolanaTestnet, - Blockchain.Tezos, - Blockchain.Tron, - Blockchain.TronTestnet, - Blockchain.Gnosis, - Blockchain.Dash, - Blockchain.Optimism, - Blockchain.OptimismTestnet, - Blockchain.Dischain, - Blockchain.EthereumPow, - Blockchain.EthereumPowTestnet, - Blockchain.Kaspa, - Blockchain.KaspaTestnet, - Blockchain.Telos, - Blockchain.TelosTestnet, - Blockchain.TONTestnet, - Blockchain.Ravencoin, - Blockchain.Clore, - Blockchain.RavencoinTestnet, - Blockchain.Cronos, - Blockchain.AlephZero, - Blockchain.AlephZeroTestnet, - Blockchain.OctaSpace, - Blockchain.OctaSpaceTestnet, - Blockchain.Chia, - Blockchain.ChiaTestnet, - Blockchain.Decimal, - Blockchain.DecimalTestnet, - Blockchain.XDC, - Blockchain.XDCTestnet, - Blockchain.VeChain, - Blockchain.VeChainTestnet, - Blockchain.Aptos, - Blockchain.AptosTestnet, - Blockchain.Playa3ull, - Blockchain.Shibarium, - Blockchain.ShibariumTestnet, - Blockchain.AlgorandTestnet, - Blockchain.HederaTestnet, - Blockchain.Aurora, - Blockchain.AuroraTestnet, - Blockchain.Areon, - Blockchain.AreonTestnet, - Blockchain.PulseChain, - Blockchain.PulseChainTestnet, - Blockchain.ZkSyncEra, - Blockchain.ZkSyncEraTestnet, - Blockchain.Nexa, - Blockchain.NexaTestnet, - Blockchain.Moonbeam, - Blockchain.MoonbeamTestnet, - Blockchain.Manta, - Blockchain.MantaTestnet, - Blockchain.PolygonZkEVM, - Blockchain.PolygonZkEVMTestnet, - Blockchain.Radiant, - Blockchain.Fact0rn, - Blockchain.Base, - Blockchain.BaseTestnet, - Blockchain.Moonriver, - Blockchain.MoonriverTestnet, - Blockchain.Mantle, - Blockchain.MantleTestnet, - Blockchain.Flare, - Blockchain.FlareTestnet, - Blockchain.Taraxa, - Blockchain.TaraxaTestnet, - Blockchain.Koinos, - Blockchain.KoinosTestnet, - Blockchain.Joystream, - Blockchain.Bittensor, - Blockchain.Filecoin, - Blockchain.Blast, - Blockchain.BlastTestnet, - Blockchain.Cyber, - Blockchain.CyberTestnet, - Blockchain.Sui, - Blockchain.SuiTestnet, - Blockchain.EnergyWebChain, - Blockchain.EnergyWebChainTestnet, - Blockchain.EnergyWebX, - Blockchain.EnergyWebXTestnet, - Blockchain.CasperTestnet, - Blockchain.Core, - Blockchain.CoreTestnet, - Blockchain.Xodex, - Blockchain.Canxium, - Blockchain.Chiliz, - Blockchain.ChilizTestnet, - Blockchain.VanarChain, - Blockchain.VanarChainTestnet, - Blockchain.OdysseyChain, Blockchain.OdysseyChainTestnet, - Blockchain.Bitrock, Blockchain.BitrockTestnet, - Blockchain.Sonic, Blockchain.SonicTestnet, - Blockchain.ApeChain, Blockchain.ApeChainTestnet, - Blockchain.Scroll, Blockchain.ScrollTestnet, - Blockchain.ZkLinkNova, Blockchain.ZkLinkNovaTestnet, - Blockchain.Pepecoin, Blockchain.PepecoinTestnet, - Blockchain.Hyperliquid, Blockchain.HyperliquidTestnet, - Blockchain.Quai, Blockchain.QuaiTestnet, - Blockchain.Linea, Blockchain.LineaTestnet, - Blockchain.ArbitrumNova, - Blockchain.Plasma, Blockchain.PlasmaTestnet, - Blockchain.Adi, Blockchain.AdiTestnet, - Blockchain.SeiEvm, Blockchain.SeiEvmTestnet, - Blockchain.Monad, Blockchain.MonadTestnet, - -> Network.TransactionExtrasType.NONE - // endregion - } - } - private fun Blockchain.getNameResolvingType(): Network.NameResolvingType { return when (this) { Blockchain.Ethereum, Blockchain.EthereumTestnet -> Network.NameResolvingType.ENS diff --git a/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/SourceType.kt b/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/SourceType.kt index 3f4e122ff6..0a1d238a12 100644 --- a/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/SourceType.kt +++ b/domain/qr-scanning/models/src/main/java/com/tangem/domain/qrscanning/models/SourceType.kt @@ -4,4 +4,5 @@ enum class SourceType { WALLET_CONNECT, SEND, MAIN_SCREEN, + ADDRESS_BOOK, } \ No newline at end of file diff --git a/features/address-book/impl/build.gradle.kts b/features/address-book/impl/build.gradle.kts index 6cf352ac01..a3833531f8 100644 --- a/features/address-book/impl/build.gradle.kts +++ b/features/address-book/impl/build.gradle.kts @@ -19,6 +19,8 @@ dependencies { implementation(projects.domain.account) implementation(projects.domain.addressBook) implementation(projects.domain.models) + implementation(projects.domain.qrScanning) + implementation(projects.domain.qrScanning.models) implementation(projects.domain.wallets) /** Common */ diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/DefaultAddAddressComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/DefaultAddAddressComponent.kt index 62d3e586a8..43f75ec912 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/DefaultAddAddressComponent.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/DefaultAddAddressComponent.kt @@ -31,6 +31,7 @@ internal class DefaultAddAddressComponent( data class Params( val onBackClick: () -> Unit, + val onSelectNetworksClick: (address: String, selectedNetworkIds: List) -> Unit, val onConfirm: (ValidatedAddress) -> Unit, ) } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModel.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModel.kt index 925354b0d5..36a6d06870 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModel.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModel.kt @@ -1,65 +1,113 @@ package com.tangem.features.addressbook.addaddress.model +import arrow.core.getOrElse +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.getSupportedTransactionExtras +import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.common.routing.AppRoute 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.navigation.Router import com.tangem.core.ui.clipboard.ClipboardManager -import com.tangem.domain.account.supplier.MultiAccountListSupplier -import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.qrscanning.models.SourceType +import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase import com.tangem.features.addressbook.addaddress.DefaultAddAddressComponent import com.tangem.features.addressbook.addaddress.state.AddAddressStateController import com.tangem.features.addressbook.addaddress.state.transformers.UpdateAddAddressInitialStateTransformer import com.tangem.features.addressbook.addaddress.state.transformers.UpdateAddressInputTransformer import com.tangem.features.addressbook.addaddress.state.transformers.UpdateAddressValidationTransformer +import com.tangem.features.addressbook.addaddress.state.transformers.UpdateMemoInputTransformer import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM +import com.tangem.features.addressbook.common.AddressMemoValidator +import com.tangem.features.addressbook.common.SelectNetworksResultHolder +import com.tangem.features.addressbook.common.SupportedNetworksMatcher +import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.* import javax.inject.Inject -@OptIn(FlowPreview::class) +@Suppress("LongParameterList") +@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) @ModelScoped internal class AddAddressModel @Inject constructor( paramsContainer: ParamsContainer, override val dispatchers: CoroutineDispatcherProvider, - multiAccountListSupplier: MultiAccountListSupplier, + private val supportedNetworksMatcher: SupportedNetworksMatcher, + private val memoValidator: AddressMemoValidator, + private val listenToQrScanningUseCase: ListenToQrScanningUseCase, private val clipboardManager: ClipboardManager, private val stateController: AddAddressStateController, + private val selectNetworksResultHolder: SelectNetworksResultHolder, + private val router: Router, ) : Model() { private val params: DefaultAddAddressComponent.Params = paramsContainer.require() val state: StateFlow get() = stateController.uiState - private val availableCoins: StateFlow> = multiAccountListSupplier() - .map { accountLists -> - accountLists - .flatMap { it.flattenCurrencies() } - .filterIsInstance() - .distinctBy { it.network.id } - } - .flowOn(dispatchers.default) - .stateIn(modelScope, SharingStarted.Eagerly, emptyList()) - - private val addressInput = state + private val validation: StateFlow = state .map { it.addressField.value } .distinctUntilChanged() .debounce(ADD_ADDRESS_DEBOUNCE) + .map { address -> + AddressValidation(address = address, matchedBlockchains = supportedNetworksMatcher.match(address)) + } + .flowOn(dispatchers.default) + .stateIn(modelScope, SharingStarted.Eagerly, AddressValidation(address = "", matchedBlockchains = emptyList())) + + /** `true` when a non-blank memo doesn't pass the chosen network's format rules (e.g. XRP destination tag). */ + private val isMemoInvalid = MutableStateFlow(false) + + private val selectedNetworkIds = MutableStateFlow?>(null) + + private val chosenNetworks: StateFlow = combine( + validation, + selectedNetworkIds, + ) { validation, selected -> + val matched = validation.matchedBlockchains + ChosenNetworks( + address = validation.address, + matched = matched, + displayed = displayedNetworks(matched, selected), + selected = selectedNetworks(matched, selected), + ) + } + .flowOn(dispatchers.default) + .stateIn( + modelScope, + SharingStarted.Eagerly, + ChosenNetworks(address = "", matched = emptyList(), displayed = emptyList(), selected = emptyList()), + ) init { + // Drop any selection left over from a previous AddAddress session before subscribing to it. + selectNetworksResultHolder.clear() updateInitialState() - subscribeToAddressValidation() + subscribeToValidation() + subscribeToMemoValidation() + resetSelectionOnAddressChange() + subscribeToSelectedNetworks() + subscribeToQrScanResult() } private fun updateInitialState() { stateController.update( UpdateAddAddressInitialStateTransformer( - onAddressChange = { onAddressChange(value = it) }, - onAddressClear = { onAddressChange("") }, - onPasteClick = ::onPaste, - onQrClick = { /* [REDACTED_TODO_COMMENT] */ }, - onBackClick = params.onBackClick, - onConfirmClick = ::validateAndConfirm, + intents = UpdateAddAddressInitialStateTransformer.Intents( + onAddressChange = ::onAddressChange, + onAddressClear = { onAddressChange("") }, + onPasteClick = ::onPaste, + onQrClick = ::onQrClick, + onBackClick = params.onBackClick, + onNetworkClick = ::onNetworkClick, + onMemoChange = ::onMemoChange, + onMemoPasteClick = ::onMemoPaste, + onConfirmClick = ::validateAndConfirm, + ), ), ) } @@ -68,24 +116,138 @@ internal class AddAddressModel @Inject constructor( stateController.update(UpdateAddressInputTransformer(value = value)) } - private fun subscribeToAddressValidation() { - combine(addressInput, availableCoins) { input, coins -> - UpdateAddressValidationTransformer(address = input, coins = coins) + private fun onMemoChange(value: String) { + stateController.update(UpdateMemoInputTransformer(value = value)) + } + + private fun subscribeToValidation() { + combine(chosenNetworks, isMemoInvalid) { networks, memoInvalid -> + UpdateAddressValidationTransformer( + address = networks.address, + matchedBlockchains = networks.matched, + displayedBlockchains = networks.displayed, + selectedBlockchains = networks.selected, + isMemoInvalid = memoInvalid, + ) } .onEach(stateController::update) .flowOn(dispatchers.default) .launchIn(modelScope) } + private fun subscribeToMemoValidation() { + val memoInput = state.map { it.memoField.value }.distinctUntilChanged().debounce(MEMO_DEBOUNCE) + combine(memoInput, chosenNetworks) { memo, networks -> memo to networks.extrasBlockchain } + .mapLatest { (memo, blockchain) -> + blockchain != null && memo.isNotBlank() && !memoValidator.isValid(blockchain, memo) + } + .onEach { isMemoInvalid.value = it } + .flowOn(dispatchers.default) + .launchIn(modelScope) + } + + private fun resetSelectionOnAddressChange() { + validation + .map { it.address } + .distinctUntilChanged() + .onEach { selectedNetworkIds.value = null } + .launchIn(modelScope) + } + + private fun subscribeToSelectedNetworks() { + selectNetworksResultHolder.selectedNetworkIds + .filterNotNull() + .onEach { ids -> + selectedNetworkIds.value = ids + selectNetworksResultHolder.clear() + } + .launchIn(modelScope) + } + private fun onPaste() { onAddressChange(value = clipboardManager.getText().orEmpty()) } + private fun onMemoPaste() { + onMemoChange(value = clipboardManager.getText().orEmpty()) + } + + private fun onQrClick() { + router.push(AppRoute.QrScanning(source = AppRoute.QrScanning.Source.AddressBook)) + } + + private fun subscribeToQrScanResult() { + listenToQrScanningUseCase(SourceType.ADDRESS_BOOK) + .getOrElse { emptyFlow() } + .onEach { onAddressChange(value = normalizeScannedAddress(it)) } + .flowOn(dispatchers.default) + .launchIn(modelScope) + } + + /** + * Extracts the bare address from a scanned payment URI like `ethereum:0xADDR@1?amount=1.5`: drops the query + * (`?…`), the chain suffix (`@…`) and the scheme (`scheme:`). A plain address is returned unchanged. + */ + private fun normalizeScannedAddress(raw: String): String { + val withoutQueryAndChain = raw.trim().substringBefore('?').substringBefore('@') + return withoutQueryAndChain.substringAfter(':', missingDelimiterValue = withoutQueryAndChain) + } + + private fun onNetworkClick() { + params.onSelectNetworksClick( + stateController.uiState.value.addressField.value, + selectedNetworkIds.value?.toList().orEmpty(), + ) + } + private fun validateAndConfirm() { - // TODO Address book ([REDACTED_TASK_KEY]): navigate to the network-selection with the address and its matching networks. + val networks = chosenNetworks.value + if (networks.selected.isEmpty()) return + + val memoField = stateController.uiState.value.memoField + val memo = memoField.value.trim().takeIf { memoField.isVisible && it.isNotEmpty() } + params.onConfirm( + ValidatedAddress( + address = networks.address, + networkIds = networks.selected.map { it.toNetworkId() }.toImmutableList(), + memo = memo, + ), + ) + } + + /** What the network block shows: all matched networks until the user narrows them down, then the picked subset. */ + private fun displayedNetworks(matched: List, selected: Set?): List { + if (selected == null) return matched + return matched.filter { it.toNetworkId() in selected } + } + + /** + * What is actually selected for saving. A single matched network is auto-selected (there is nothing to choose and + * the selection screen can't be opened); otherwise the user must pick explicitly before saving. + */ + private fun selectedNetworks(matched: List, selected: Set?): List { + if (selected == null) return listOfNotNull(matched.singleOrNull()) + return matched.filter { it.toNetworkId() in selected } + } + + private data class AddressValidation( + val address: String, + val matchedBlockchains: List, + ) + + private data class ChosenNetworks( + val address: String, + val matched: List, + val displayed: List, + val selected: List, + ) { + /** The first selected network that supports a memo / destination tag, if any. */ + val extrasBlockchain: Blockchain? + get() = selected.firstOrNull { it.getSupportedTransactionExtras().isTxExtrasSupported() } } companion object { private const val ADD_ADDRESS_DEBOUNCE = 500L + private const val MEMO_DEBOUNCE = 300L } } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/AddAddressStateController.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/AddAddressStateController.kt index b0f71b411c..9c1b47d57a 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/AddAddressStateController.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/AddAddressStateController.kt @@ -31,13 +31,21 @@ internal class AddAddressStateController @Inject constructor() { label = resourceReference(R.string.common_address), isError = false, ), + memoField = AddAddressUM.MemoFieldUM( + isVisible = false, + value = "", + label = resourceReference(R.string.send_extras_hint_memo), + isError = false, + onValueChange = {}, + onPasteClick = {}, + ), buttonUM = TangemButtonUM( text = TextReference.Res(R.string.address_book_add_address), type = TangemButtonType.Primary, isEnabled = false, onClick = {}, ), - chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Empty, + chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Hidden, onAddressChange = {}, onAddressClear = {}, onPasteClick = {}, diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddAddressInitialStateTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddAddressInitialStateTransformer.kt index 15007b6655..089e805bfe 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddAddressInitialStateTransformer.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddAddressInitialStateTransformer.kt @@ -8,22 +8,34 @@ import com.tangem.utils.transformer.Transformer * state produced by [com.tangem.features.addressbook.addaddress.state.AddAddressStateController]. */ internal class UpdateAddAddressInitialStateTransformer( - private val onAddressChange: (String) -> Unit, - private val onAddressClear: () -> Unit, - private val onPasteClick: () -> Unit, - private val onQrClick: () -> Unit, - private val onBackClick: () -> Unit, - private val onConfirmClick: () -> Unit, + private val intents: Intents, ) : Transformer { override fun transform(prevState: AddAddressUM): AddAddressUM { return prevState.copy( - onAddressChange = onAddressChange, - onAddressClear = onAddressClear, - onPasteClick = onPasteClick, - onQrClick = onQrClick, - onBackClick = onBackClick, - buttonUM = prevState.buttonUM.copy(onClick = onConfirmClick), + onAddressChange = intents.onAddressChange, + onAddressClear = intents.onAddressClear, + onPasteClick = intents.onPasteClick, + onQrClick = intents.onQrClick, + onBackClick = intents.onBackClick, + onNetworkClick = intents.onNetworkClick, + memoField = prevState.memoField.copy( + onValueChange = intents.onMemoChange, + onPasteClick = intents.onMemoPasteClick, + ), + buttonUM = prevState.buttonUM.copy(onClick = intents.onConfirmClick), ) } + + data class Intents( + val onAddressChange: (String) -> Unit, + val onAddressClear: () -> Unit, + val onPasteClick: () -> Unit, + val onQrClick: () -> Unit, + val onBackClick: () -> Unit, + val onNetworkClick: () -> Unit, + val onMemoChange: (String) -> Unit, + val onMemoPasteClick: () -> Unit, + val onConfirmClick: () -> Unit, + ) } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddressInputTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddressInputTransformer.kt index f9b84f065e..d1b7454081 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddressInputTransformer.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddressInputTransformer.kt @@ -3,23 +3,41 @@ package com.tangem.features.addressbook.addaddress.state.transformers import com.tangem.core.ui.R import com.tangem.core.ui.extensions.resourceReference import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM +import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM.ChosenNetworkStateUM import com.tangem.utils.transformer.Transformer /** * Updates the address field with a freshly entered/pasted [value] and clears any previous error, restoring the default - * label. The actual (re)validation runs after a debounce — see [UpdateAddressValidationTransformer]. + * label. The confirm button is disabled while validation is pending; the actual (re)validation runs after a debounce — + * see [UpdateAddressValidationTransformer]. + * + * The network selector reflects the pending validation: a non-blank address shows [ChosenNetworkStateUM.Loading], but + * an already-resolved selector keeps its networks on screen instead of flashing back to the spinner on every keystroke. */ internal class UpdateAddressInputTransformer( private val value: String, ) : Transformer { override fun transform(prevState: AddAddressUM): AddAddressUM { + val chosenNetworkState = when { + value.isBlank() -> ChosenNetworkStateUM.Hidden + prevState.chosenNetworkStateUM is ChosenNetworkStateUM.Result -> prevState.chosenNetworkStateUM + else -> ChosenNetworkStateUM.Loading + } + val memoField = if (value.isBlank()) { + prevState.memoField.copy(isVisible = false, value = "", isError = false) + } else { + prevState.memoField + } return prevState.copy( addressField = prevState.addressField.copy( value = value, isError = false, label = resourceReference(R.string.common_address), ), + chosenNetworkStateUM = chosenNetworkState, + buttonUM = prevState.buttonUM.copy(isEnabled = false), + memoField = memoField, ) } } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddressValidationTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddressValidationTransformer.kt index 6d7c469965..8421b1efc7 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddressValidationTransformer.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateAddressValidationTransformer.kt @@ -1,28 +1,51 @@ package com.tangem.features.addressbook.addaddress.state.transformers -import com.tangem.blockchainsdk.utils.toBlockchain +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.getSupportedTransactionExtras import com.tangem.core.ui.R import com.tangem.core.ui.extensions.resourceReference -import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.network.Network +import com.tangem.features.addressbook.addaddress.state.transformers.converter.ChosenNetworkConverter import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM +import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM.ChosenNetworkStateUM import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.toImmutableList /** - * Validates [address] against the wallet's [coins] and reflects the result in the UI. + * Reflects the result of validating an address (and its memo) in the UI. * - * The network is not chosen on this screen (it is selected on the next screen), so the address is valid when it matches - * at least one of the available networks — the same blockchain check the Send flow uses. An invalid (non-empty, - * matching nothing) address surfaces the error in the field label and disables the confirm button. + * [matchedBlockchains] are all supported networks the address resolves to. [displayedBlockchains] is what the network + * block shows — all matched networks until the user narrows them down on the SelectNetworks screen, then the picked + * subset. [selectedBlockchains] is what is actually chosen for saving (a single match is auto-selected; for several + * matches the user must pick explicitly). While the address is blank or matches nothing the network selector stays + * [ChosenNetworkStateUM.Hidden]; an invalid (non-empty, matching nothing) address surfaces the error in the field label. + * + * The confirm button is enabled only once at least one network is actually selected (and the memo, if any, is valid) — + * showing the available networks is not the same as selecting them. The memo field is shown when a selected network + * supports transaction extras; [isMemoInvalid] marks a malformed memo. */ internal class UpdateAddressValidationTransformer( private val address: String, - private val coins: List, + private val matchedBlockchains: List, + private val displayedBlockchains: List, + private val selectedBlockchains: List, + private val isMemoInvalid: Boolean, ) : Transformer { override fun transform(prevState: AddAddressUM): AddAddressUM { - val hasMatchedAnyNetwork = address.isNotBlank() && - coins.any { it.network.toBlockchain().validateAddress(address) } - val isError = address.isNotBlank() && !hasMatchedAnyNetwork + val hasMatch = matchedBlockchains.isNotEmpty() + val isError = address.isNotBlank() && !hasMatch + + val chosenNetworkState = if (hasMatch) { + ChosenNetworkStateUM.Result( + networkUMList = displayedBlockchains.map(ChosenNetworkConverter()::convert).toImmutableList(), + // A single matched network leaves nothing to choose, so the selection screen is not opened. + isClickable = matchedBlockchains.size > 1, + ) + } else { + ChosenNetworkStateUM.Hidden + } + val label = if (isError) { resourceReference(R.string.address_book_invalid_address_error) } else { @@ -30,7 +53,32 @@ internal class UpdateAddressValidationTransformer( } return prevState.copy( addressField = prevState.addressField.copy(isError = isError, label = label), - buttonUM = prevState.buttonUM.copy(isEnabled = hasMatchedAnyNetwork), + chosenNetworkStateUM = chosenNetworkState, + memoField = resolveMemoField(prevState.memoField), + buttonUM = prevState.buttonUM.copy(isEnabled = selectedBlockchains.isNotEmpty() && !isMemoInvalid), + ) + } + + /** + * Shows the memo field with the right label when a chosen network supports transaction extras; hides it and clears + * the value otherwise (e.g. the supporting network was deselected or the address changed). A malformed memo + * ([isMemoInvalid]) turns the field label into an error. + */ + private fun resolveMemoField(prevMemoField: AddAddressUM.MemoFieldUM): AddAddressUM.MemoFieldUM { + val extrasType = selectedBlockchains + .map { it.getSupportedTransactionExtras() } + .firstOrNull { it.isTxExtrasSupported() } + ?: return prevMemoField.copy(isVisible = false, value = "", isError = false) + + val fieldLabelRes = when (extrasType) { + Network.TransactionExtrasType.DESTINATION_TAG -> R.string.send_destination_tag_field + else -> R.string.send_extras_hint_memo + } + val labelRes = if (isMemoInvalid) R.string.send_memo_destination_tag_error else fieldLabelRes + return prevMemoField.copy( + isVisible = true, + label = resourceReference(labelRes), + isError = isMemoInvalid, ) } } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateMemoInputTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateMemoInputTransformer.kt new file mode 100644 index 0000000000..3a0d347b70 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/UpdateMemoInputTransformer.kt @@ -0,0 +1,15 @@ +package com.tangem.features.addressbook.addaddress.state.transformers + +import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM +import com.tangem.utils.transformer.Transformer + +internal class UpdateMemoInputTransformer( + private val value: String, +) : Transformer { + + override fun transform(prevState: AddAddressUM): AddAddressUM { + return prevState.copy( + memoField = prevState.memoField.copy(value = value), + ) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/converter/ChosenNetworkConverter.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/converter/ChosenNetworkConverter.kt new file mode 100644 index 0000000000..fdc8a26a64 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/state/transformers/converter/ChosenNetworkConverter.kt @@ -0,0 +1,14 @@ +package com.tangem.features.addressbook.addaddress.state.transformers.converter + +import com.tangem.blockchain.common.Blockchain +import com.tangem.common.ui.extensions.getActiveIconRes +import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM.ChosenNetworkStateUM.Result.NetworkUM +import com.tangem.utils.converter.Converter + +internal class ChosenNetworkConverter : Converter { + + override fun convert(value: Blockchain): NetworkUM = NetworkUM( + networkName = value.fullName, + iconResId = getActiveIconRes(value), + ) +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/AddAddressContent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/AddAddressContent.kt index 63f24efd33..f225531ff8 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/AddAddressContent.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/AddAddressContent.kt @@ -1,11 +1,14 @@ package com.tangem.features.addressbook.addaddress.ui import android.content.res.Configuration +import androidx.compose.animation.* +import androidx.compose.animation.core.snap import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -21,6 +24,7 @@ import com.tangem.core.ui.ds.topbar.TangemTopBar import com.tangem.core.ui.ds2.button.TangemButton import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM @@ -70,17 +74,12 @@ internal fun AddAddressContent(state: AddAddressUM, modifier: Modifier = Modifie onQrClick = state.onQrClick, onPasteClick = state.onPasteClick, ) - SpacerH(20.dp) - NetworkBlock( - modifier = Modifier - .padding(horizontal = 16.dp) - .clip(RoundedCornerShape(16.dp)) - .fillMaxWidth() - .background(color = TangemTheme.colors3.bg.secondary), + MemoSection(memoField = state.memoField) + NetworkSelector( chosenNetworkStateUM = state.chosenNetworkStateUM, - onNetworkSelectClick = state.onNetworkClick, + onNetworkClick = state.onNetworkClick, ) - PrimaryButton(state.buttonUM) + AddButton(state.buttonUM) } } } @@ -88,7 +87,71 @@ internal fun AddAddressContent(state: AddAddressUM, modifier: Modifier = Modifie } @Composable -private fun ColumnScope.PrimaryButton(buttonUM: TangemButtonUM) { +private fun MemoSection(memoField: AddAddressUM.MemoFieldUM) { + AnimatedVisibility( + visible = memoField.isVisible, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + Column(modifier = Modifier.fillMaxWidth()) { + MemoRow( + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(top = 12.dp), + memoField = memoField, + ) + SpacerH(10.dp) + Text( + modifier = Modifier.padding(horizontal = 32.dp), + text = stringResourceSafe(R.string.send_recipient_memo_footer_v2), + style = TangemTheme.typography3.caption.medium, + color = TangemTheme.colors3.text.secondary, + ) + Text( + modifier = Modifier.padding(horizontal = 32.dp), + text = stringResourceSafe(R.string.send_recipient_memo_footer_v2_highlighted), + style = TangemTheme.typography3.caption.medium, + color = TangemTheme.colors3.text.primary, + ) + } + } +} + +@Composable +private fun NetworkSelector(chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM, onNetworkClick: () -> Unit) { + AnimatedContent( + targetState = chosenNetworkStateUM, + transitionSpec = { + ContentTransform( + targetContentEnter = fadeIn(), + initialContentExit = fadeOut(), + sizeTransform = SizeTransform(clip = false) { _, _ -> snap() }, + ) + }, + contentKey = { it::class }, + modifier = Modifier.animateContentSize(), + label = "network_selector", + ) { networkState -> + when (networkState) { + AddAddressUM.ChosenNetworkStateUM.Hidden -> Box(modifier = Modifier.fillMaxWidth()) + AddAddressUM.ChosenNetworkStateUM.Loading, + is AddAddressUM.ChosenNetworkStateUM.Result, + -> NetworkBlock( + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(top = 16.dp) + .clip(RoundedCornerShape(16.dp)) + .fillMaxWidth() + .background(color = TangemTheme.colors3.bg.secondary), + chosenNetworkStateUM = networkState, + onNetworkSelectClick = onNetworkClick, + ) + } + } +} + +@Composable +private fun ColumnScope.AddButton(buttonUM: TangemButtonUM) { Spacer(modifier = Modifier.weight(1f)) TangemButton( modifier = Modifier @@ -114,13 +177,21 @@ private fun Preview_AddAddressContent() { placeholder = resourceReference(R.string.address_book_enter_address), label = resourceReference(R.string.common_address), ), + memoField = AddAddressUM.MemoFieldUM( + isVisible = false, + value = "", + label = resourceReference(R.string.send_extras_hint_memo), + isError = false, + onValueChange = {}, + onPasteClick = {}, + ), buttonUM = TangemButtonUM( text = TextReference.Res(R.string.address_book_add_address), type = TangemButtonType.Primary, isEnabled = false, onClick = { }, ), - chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Empty, + chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Hidden, onAddressChange = {}, onAddressClear = {}, onPasteClick = {}, diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/MemoRow.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/MemoRow.kt new file mode 100644 index 0000000000..4cc1f1da64 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/MemoRow.kt @@ -0,0 +1,101 @@ +package com.tangem.features.addressbook.addaddress.ui + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.components.fields.SimpleTextField +import com.tangem.core.ui.ds2.button.TangemButton +import com.tangem.core.ui.ds2.row.TangemRow +import com.tangem.core.ui.ds2.row.TangemRowContentLead +import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resolveReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.core.ui.res.generated.icons.Icons +import com.tangem.core.ui.res.generated.icons.ic_cross_circle_20_filled +import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM + +@Composable +internal fun MemoRow(memoField: AddAddressUM.MemoFieldUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .clip(RoundedCornerShape(24.dp)) + .fillMaxWidth() + .background(TangemTheme.colors3.bg.secondary), + ) { + Text( + modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 4.dp), + text = memoField.label.resolveReference(), + style = TangemTheme.typography3.caption.medium, + color = if (memoField.isError) { + TangemTheme.colors3.text.status.error + } else { + TangemTheme.colors3.text.secondary + }, + ) + TangemRow( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = TangemRowVerticalAlignment.Center, + contentLead = TangemRowContentLead.Start, + titleSlot = { + SimpleTextField( + modifier = Modifier.weight(1f), + value = memoField.value, + onValueChange = memoField.onValueChange, + placeholder = resourceReference(R.string.send_optional_field), + ) + }, + endSlot = { + if (memoField.value.isNotEmpty()) { + Icon( + modifier = Modifier + .clip(CircleShape) + .clickable(onClick = { memoField.onValueChange("") }), + imageVector = Icons.ic_cross_circle_20_filled, + tint = TangemTheme.colors3.icon.tertiary, + contentDescription = null, + ) + } else { + TangemButton( + size = TangemButton.Size.X9, + text = TextReference.Res(id = R.string.common_paste), + onClick = memoField.onPasteClick, + ) + } + }, + ) + } +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_MemoRow() { + TangemThemePreviewRedesign { + MemoRow( + memoField = AddAddressUM.MemoFieldUM( + isVisible = true, + value = "123456", + label = resourceReference(R.string.send_destination_tag_field), + isError = false, + onValueChange = {}, + onPasteClick = {}, + ), + ) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/NetworkBlock.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/NetworkBlock.kt index e2b3e1b6a5..4dd21d9cfa 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/NetworkBlock.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/NetworkBlock.kt @@ -20,12 +20,10 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEachIndexed import com.tangem.core.ui.R import com.tangem.core.ui.components.SpacerH12 -import com.tangem.core.ui.components.SpacerW import com.tangem.core.ui.ds2.loader.TangemLoader import com.tangem.core.ui.ds2.loader.TangemLoaderSize import com.tangem.core.ui.ds2.row.TangemRow import com.tangem.core.ui.ds2.row.TangemRowVerticalAlignment -import com.tangem.core.ui.extensions.clickableSingle import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign @@ -48,7 +46,10 @@ internal fun NetworkBlock( chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM, modifier: Modifier = Modifier, ) { + val isClickable = chosenNetworkStateUM is AddAddressUM.ChosenNetworkStateUM.Result && + chosenNetworkStateUM.isClickable TangemRow( + onClick = if (isClickable) onNetworkSelectClick else null, verticalAlignment = TangemRowVerticalAlignment.Center, modifier = modifier, titleSlot = { @@ -59,45 +60,27 @@ internal fun NetworkBlock( ) }, endSlot = { - SelectNetworkButton( - onNetworkSelectClick = onNetworkSelectClick, - chosenNetworkStateUM = chosenNetworkStateUM, - ) + when (chosenNetworkStateUM) { + AddAddressUM.ChosenNetworkStateUM.Loading -> TangemLoader(size = TangemLoaderSize.X20) + is AddAddressUM.ChosenNetworkStateUM.Result -> NetworkRow(chosenNetworkStateUM = chosenNetworkStateUM) + AddAddressUM.ChosenNetworkStateUM.Hidden -> Unit + } }, ) } @Composable -private fun SelectNetworkButton( - onNetworkSelectClick: () -> Unit, - chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM, -) { - Row( - modifier = Modifier.clickableSingle( - onClick = onNetworkSelectClick, - enabled = chosenNetworkStateUM !is AddAddressUM.ChosenNetworkStateUM.Loading, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - when (chosenNetworkStateUM) { - is AddAddressUM.ChosenNetworkStateUM.Result -> NetworkIconsResolver(chosenNetworkStateUM.networkUMList) - AddAddressUM.ChosenNetworkStateUM.Loading -> TangemLoader(size = TangemLoaderSize.X20) - AddAddressUM.ChosenNetworkStateUM.Empty -> { - Text( - modifier = Modifier.padding(start = 8.dp), - text = stringResourceSafe(R.string.address_book_select_network), - style = TangemTheme.typography3.body.medium, - color = TangemTheme.colors3.text.secondary, - ) - SpacerW(4.dp) - ChevronIcon() - } - } +private fun NetworkRow(chosenNetworkStateUM: AddAddressUM.ChosenNetworkStateUM.Result) { + Row(verticalAlignment = Alignment.CenterVertically) { + NetworkIconsResolver( + networks = chosenNetworkStateUM.networkUMList, + showChevron = chosenNetworkStateUM.isClickable, + ) } } @Composable -private fun NetworkIconsResolver(networks: ImmutableList) { +private fun NetworkIconsResolver(networks: ImmutableList, showChevron: Boolean) { when (networks.size) { 0 -> Unit 1 -> { @@ -112,13 +95,13 @@ private fun NetworkIconsResolver(networks: ImmutableList) { style = TangemTheme.typography3.body.medium, color = TangemTheme.colors3.text.secondary, ) - ChevronIcon() + if (showChevron) ChevronIcon() } // 3 and any larger count share the same rendering: up to MAX_VISIBLE_NETWORKS overlapping // icons, plus a "+N" badge that appears only when there are more than that. else -> { OverlappingNetworkIcons(networks) - ChevronIcon() + if (showChevron) ChevronIcon() } } } @@ -191,6 +174,7 @@ private fun Preview_NetworkBlock() { networkUMList = persistentListOf( NetworkUM(networkName = "Ethereum", iconResId = R.drawable.img_eth_22), ), + isClickable = false, ), ) SpacerH12() @@ -202,6 +186,7 @@ private fun Preview_NetworkBlock() { NetworkUM(networkName = "BSC", iconResId = R.drawable.img_bsc_22), NetworkUM(networkName = "Polygon", iconResId = R.drawable.img_polygon_22), ), + isClickable = true, ), ) SpacerH12() @@ -211,12 +196,9 @@ private fun Preview_NetworkBlock() { networkUMList = List(15) { NetworkUM(networkName = "Network", iconResId = R.drawable.img_eth_22) }.toImmutableList(), + isClickable = true, ), ) - SpacerH12() - NetworkBlock(chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Loading, onNetworkSelectClick = {}) - SpacerH12() - NetworkBlock(chosenNetworkStateUM = AddAddressUM.ChosenNetworkStateUM.Empty, onNetworkSelectClick = {}) } } } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/state/AddAddressUM.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/state/AddAddressUM.kt index 45704c2355..b01a540f2f 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/state/AddAddressUM.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/addaddress/ui/state/AddAddressUM.kt @@ -3,11 +3,13 @@ package com.tangem.features.addressbook.addaddress.ui.state import androidx.annotation.DrawableRes import androidx.compose.runtime.Immutable import com.tangem.core.ui.ds.button.TangemButtonUM +import com.tangem.core.ui.extensions.TextReference import kotlinx.collections.immutable.ImmutableList @Immutable internal data class AddAddressUM( val addressField: AddressFieldUM, + val memoField: MemoFieldUM, val buttonUM: TangemButtonUM, val chosenNetworkStateUM: ChosenNetworkStateUM, val onAddressChange: (String) -> Unit, @@ -17,12 +19,39 @@ internal data class AddAddressUM( val onBackClick: () -> Unit, val onNetworkClick: () -> Unit, ) { + + /** + * Optional memo / destination-tag input shown below the address only when a chosen network supports transaction + * extras. [isVisible] toggles the whole field; [label] adapts to memo vs destination tag. + */ + @Immutable + data class MemoFieldUM( + val isVisible: Boolean, + val value: String, + val label: TextReference, + val isError: Boolean, + val onValueChange: (String) -> Unit, + val onPasteClick: () -> Unit, + ) + @Immutable sealed interface ChosenNetworkStateUM { - data object Loading : ChosenNetworkStateUM - data object Empty : ChosenNetworkStateUM - data class Result(val networkUMList: ImmutableList) : ChosenNetworkStateUM { + /** No address entered yet, or the address matched nothing — the network selector is not shown. */ + data object Hidden : ChosenNetworkStateUM + + /** A non-blank address is being validated against the supported networks. */ + data object Loading : ChosenNetworkStateUM + + /** + * A valid address resolved to [networkUMList] (the currently selected networks). [isClickable] is `false` when + * the address matched only a single network — there is nothing to choose, so the network-selection screen is + * not opened. + */ + data class Result( + val networkUMList: ImmutableList, + val isClickable: Boolean, + ) : ChosenNetworkStateUM { data class NetworkUM( val networkName: String, @DrawableRes val iconResId: Int, diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookChildFactory.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookChildFactory.kt index d07a64c7f5..457ea265a1 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookChildFactory.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookChildFactory.kt @@ -9,6 +9,7 @@ import com.tangem.features.addressbook.editcontact.DefaultEditContactComponent import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress import com.tangem.features.addressbook.list.DefaultAddressBookListComponent import com.tangem.features.addressbook.route.AddressBookRoute +import com.tangem.features.addressbook.selectnetworks.DefaultSelectNetworksComponent import kotlinx.collections.immutable.persistentListOf import javax.inject.Inject @@ -47,9 +48,19 @@ internal class AddressBookChildFactory @Inject constructor( appComponentContext = context, params = DefaultAddAddressComponent.Params( onBackClick = clickIntents::onAddAddressBack, + onSelectNetworksClick = clickIntents::onSelectNetworksClick, onConfirm = clickIntents::onAddressConfirmed, ), ) + is AddressBookRoute.SelectNetworks -> DefaultSelectNetworksComponent( + appComponentContext = context, + params = DefaultSelectNetworksComponent.Params( + address = route.address, + selectedNetworkIds = route.selectedNetworkIds, + onBackClick = clickIntents::onSelectNetworksBack, + onDone = clickIntents::onNetworksSelected, + ), + ) } /** Builds the address attached up-front in WithContactCreation mode, when both the address and network are known. */ diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookClickIntents.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookClickIntents.kt index 5b13a5204f..315ba66a4a 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookClickIntents.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressBookClickIntents.kt @@ -23,4 +23,10 @@ internal interface AddressBookClickIntents { fun onAddAddressBack() fun onAddressConfirmed(address: ValidatedAddress) + + fun onSelectNetworksClick(address: String, selectedNetworkIds: List) + + fun onSelectNetworksBack() + + fun onNetworksSelected(selectedNetworkIds: Set) } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressMemoValidator.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressMemoValidator.kt new file mode 100644 index 0000000000..8302fbbaef --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/AddressMemoValidator.kt @@ -0,0 +1,28 @@ +package com.tangem.features.addressbook.common + +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchain.common.memo.MemoState +import com.tangem.blockchain.extensions.Result +import com.tangem.blockchainsdk.BlockchainSDKFactory +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.withContext +import javax.inject.Inject + +internal class AddressMemoValidator @Inject constructor( + private val blockchainSDKFactory: BlockchainSDKFactory, + private val dispatchers: CoroutineDispatcherProvider, +) { + + suspend fun isValid(blockchain: Blockchain, memo: String): Boolean = withContext(dispatchers.io) { + val factory = blockchainSDKFactory.getMemoValidatorFactorySync() ?: return@withContext true + when (val result = factory.create(blockchain).validateMemo(memo)) { + is Result.Success -> when (result.data) { + MemoState.Valid, + MemoState.NotSupported, + -> true + MemoState.Invalid -> false + } + is Result.Failure -> true + } + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/DefaultAddressBookComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/DefaultAddressBookComponent.kt index 5066885b29..3e216c7bb1 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/DefaultAddressBookComponent.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/DefaultAddressBookComponent.kt @@ -29,13 +29,15 @@ internal class DefaultAddressBookComponent @AssistedInject constructor( @Assisted private val params: AddressBookComponent.Params, private val childFactory: AddressBookChildFactory, private val resultHolder: AddressBookResultHolder, + private val selectNetworksResultHolder: SelectNetworksResultHolder, ) : AddressBookComponent, AppComponentContext by context { private val navigation = StackNavigation() init { - // Drop any address left over from a previous session before the (possibly preloaded) stack starts collecting. + // Drop any results left over from a previous session before the (possibly preloaded) stack starts collecting. resultHolder.clear() + selectNetworksResultHolder.clear() } private val clickIntents = object : AddressBookClickIntents { @@ -64,6 +66,21 @@ internal class DefaultAddressBookComponent @AssistedInject constructor( resultHolder.setConfirmedAddress(address) navigation.pop() } + + override fun onSelectNetworksClick(address: String, selectedNetworkIds: List) { + navigation.pushNew( + AddressBookRoute.SelectNetworks(address = address, selectedNetworkIds = selectedNetworkIds), + ) + } + + override fun onSelectNetworksBack() { + navigation.pop() + } + + override fun onNetworksSelected(selectedNetworkIds: Set) { + selectNetworksResultHolder.setSelectedNetworkIds(selectedNetworkIds) + navigation.pop() + } } private val contentStack = childStack( diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/SelectNetworksResultHolder.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/SelectNetworksResultHolder.kt new file mode 100644 index 0000000000..7895cdee16 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/SelectNetworksResultHolder.kt @@ -0,0 +1,30 @@ +package com.tangem.features.addressbook.common + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Carries the set of network ids confirmed on the SelectNetworks screen back to the AddAddress screen. + * + * The two screens live in independent model scopes, so a shared singleton holder hands the result over instead of + * routing it through navigation. Only the "Done" action sets a result; the producer calls [setSelectedNetworkIds], the + * consumer observes [selectedNetworkIds] and calls [clear] after applying it so it is not re-applied on resubscription. + * + * Mirrors [AddressBookResultHolder]. + */ +@Singleton +internal class SelectNetworksResultHolder @Inject constructor() { + + val selectedNetworkIds: StateFlow?> + field = MutableStateFlow?>(null) + + fun setSelectedNetworkIds(networkIds: Set) { + selectedNetworkIds.value = networkIds + } + + fun clear() { + selectedNetworkIds.value = null + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/SupportedNetworksMatcher.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/SupportedNetworksMatcher.kt new file mode 100644 index 0000000000..b21065b273 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/common/SupportedNetworksMatcher.kt @@ -0,0 +1,26 @@ +package com.tangem.features.addressbook.common + +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.ExcludedBlockchains +import javax.inject.Inject + +/** + * Finds every supported mainnet network whose address format matches a given address. + * + * The match runs over the whole SDK blockchain set (minus testnets and excluded chains), not just the networks already + * added to the wallet — entering/scanning an address must surface every network it could belong to. + */ +internal class SupportedNetworksMatcher @Inject constructor( + excludedBlockchains: ExcludedBlockchains, +) { + + private val supportedBlockchains: List = Blockchain.entries + .filter { !it.isTestnet() && it !in excludedBlockchains } + + fun match(address: String): List { + if (address.isBlank()) return emptyList() + return supportedBlockchains.filter { blockchain -> + runCatching { blockchain.validateAddress(address) }.getOrDefault(false) + } + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookModelModule.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookModelModule.kt index 9b21153da9..cd4e7b32e2 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookModelModule.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookModelModule.kt @@ -6,6 +6,7 @@ import com.tangem.features.addressbook.addaddress.model.AddAddressModel import com.tangem.features.addressbook.block.model.ContactsBlockModel import com.tangem.features.addressbook.list.model.AddressBookListModel import com.tangem.features.addressbook.editcontact.model.EditContactModel +import com.tangem.features.addressbook.selectnetworks.model.SelectNetworksModel import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -35,4 +36,9 @@ internal interface AddressBookModelModule { @IntoMap @ClassKey(AddAddressModel::class) fun bindAddAddressModel(model: AddAddressModel): Model + + @Binds + @IntoMap + @ClassKey(SelectNetworksModel::class) + fun bindSelectNetworksModel(model: SelectNetworksModel): Model } \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/state/ValidatedAddress.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/state/ValidatedAddress.kt index 8d36e4c6a8..17d22645a8 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/state/ValidatedAddress.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/editcontact/ui/state/ValidatedAddress.kt @@ -9,9 +9,12 @@ import kotlinx.collections.immutable.ImmutableList * A single address can belong to several networks (e.g. the same address across EVM chains), so it carries a list of * [networkIds]. This is the in-progress (pre-save) representation accumulated in [EditContactUM]; the [networkIds] are * used to rebuild the domain `AddressEntry`s when the contact is persisted. + * [memo] is an optional destination tag / memo entered for networks that support transaction extras (XRP, Stellar, TON, + * …). It is `null` when the matched networks don't support extras or the user left it empty. */ @Immutable data class ValidatedAddress( val address: String, val networkIds: ImmutableList, + val memo: String? = null, ) \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/route/AddressBookRoute.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/route/AddressBookRoute.kt index e0a39904b6..caa8ece2d7 100644 --- a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/route/AddressBookRoute.kt +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/route/AddressBookRoute.kt @@ -30,6 +30,16 @@ internal sealed class AddressBookRoute { @Serializable data object AddAddress : AddressBookRoute() + /** + * Network-selection screen for the [address] entered on [AddAddress]. [selectedNetworkIds] carries the current + * selection so it can be restored; empty means nothing is pre-selected. + */ + @Serializable + data class SelectNetworks( + val address: String, + val selectedNetworkIds: kotlin.collections.List = emptyList(), + ) : AddressBookRoute() + /** How the contacts list is shown — agnostic of which feature opened it. */ @Serializable sealed interface ListMode { diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/DefaultSelectNetworksComponent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/DefaultSelectNetworksComponent.kt new file mode 100644 index 0000000000..f36705c5be --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/DefaultSelectNetworksComponent.kt @@ -0,0 +1,37 @@ +package com.tangem.features.addressbook.selectnetworks + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.tangem.core.decompose.context.AppComponentContext +import com.tangem.core.decompose.model.getOrCreateModel +import com.tangem.core.ui.decompose.ComposableContentComponent +import com.tangem.features.addressbook.selectnetworks.model.SelectNetworksModel +import com.tangem.features.addressbook.selectnetworks.ui.SelectNetworksContent + +internal class DefaultSelectNetworksComponent( + appComponentContext: AppComponentContext, + params: Params, +) : ComposableContentComponent, AppComponentContext by appComponentContext { + + private val model: SelectNetworksModel = getOrCreateModel(params) + + @Composable + override fun Content(modifier: Modifier) { + val state by model.state.collectAsStateWithLifecycle() + SelectNetworksContent( + state = state, + modifier = modifier, + ) + BackHandler(onBack = state.onBackClick) + } + + data class Params( + val address: String, + val selectedNetworkIds: List, + val onBackClick: () -> Unit, + val onDone: (selectedNetworkIds: Set) -> Unit, + ) +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/model/SelectNetworksModel.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/model/SelectNetworksModel.kt new file mode 100644 index 0000000000..594990b053 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/model/SelectNetworksModel.kt @@ -0,0 +1,97 @@ +package com.tangem.features.addressbook.selectnetworks.model + +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.decompose.model.Model +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.features.addressbook.common.SupportedNetworksMatcher +import com.tangem.features.addressbook.selectnetworks.DefaultSelectNetworksComponent +import com.tangem.features.addressbook.selectnetworks.state.SelectNetworksStateController +import com.tangem.features.addressbook.selectnetworks.state.transformers.UpdateNetworksContentTransformer +import com.tangem.features.addressbook.selectnetworks.state.transformers.UpdateSelectNetworksInitialStateTransformer +import com.tangem.features.addressbook.selectnetworks.state.transformers.UpdateSelectNetworksSearchBarTransformer +import com.tangem.features.addressbook.selectnetworks.ui.state.SelectNetworksUM +import com.tangem.utils.coroutines.CoroutineDispatcherProvider +import kotlinx.coroutines.flow.* +import javax.inject.Inject + +@Suppress("NamedArguments") +@ModelScoped +internal class SelectNetworksModel @Inject constructor( + paramsContainer: ParamsContainer, + supportedNetworksMatcher: SupportedNetworksMatcher, + override val dispatchers: CoroutineDispatcherProvider, + private val stateController: SelectNetworksStateController, +) : Model() { + + private val params: DefaultSelectNetworksComponent.Params = paramsContainer.require() + private val query = MutableStateFlow("") + private val isSearchActive = MutableStateFlow(false) + + private val matchedBlockchains: List = supportedNetworksMatcher.match(params.address) + + private val selectedNetworks = MutableStateFlow( + params.selectedNetworkIds.toSet().intersect( + matchedBlockchains.map { blockchain -> blockchain.toNetworkId() }.toSet(), + ), + ) + + val state: StateFlow get() = stateController.uiState + + init { + updateInitialState() + subscribeToContent() + } + + private fun updateInitialState() { + stateController.update( + UpdateSelectNetworksInitialStateTransformer( + onQueryChange = ::onQueryChange, + onActiveChange = ::onActiveChange, + onBackClick = params.onBackClick, + onDoneClick = ::onDoneClick, + ), + ) + } + + private fun subscribeToContent() { + combine(query, selectedNetworks) { query, selection -> + UpdateNetworksContentTransformer( + matchedBlockchains = matchedBlockchains, + query = query, + selectedNetworkIds = selection, + onToggle = ::onToggle, + ) + } + .onEach(stateController::update) + .flowOn(dispatchers.default) + .launchIn(modelScope) + } + + private fun onQueryChange(value: String) { + query.value = value + updateSearchBar(query = value, isActive = isSearchActive.value) + } + + private fun onActiveChange(isActive: Boolean) { + isSearchActive.value = isActive + updateSearchBar(query = query.value, isActive = isActive) + } + + /** Reflects the search field immediately on the caller (main) thread, decoupled from the content recomputation. */ + private fun updateSearchBar(query: String, isActive: Boolean) { + stateController.update(UpdateSelectNetworksSearchBarTransformer(query = query, isActive = isActive)) + } + + private fun onToggle(networkId: String) { + val current = selectedNetworks.value + selectedNetworks.value = if (networkId in current) current - networkId else current + networkId + } + + private fun onDoneClick() { + val selected = selectedNetworks.value + if (selected.isEmpty()) return + params.onDone(selected) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/state/SelectNetworksStateController.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/state/SelectNetworksStateController.kt new file mode 100644 index 0000000000..5b7275aeb0 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/state/SelectNetworksStateController.kt @@ -0,0 +1,47 @@ +package com.tangem.features.addressbook.selectnetworks.state + +import com.tangem.core.decompose.di.ModelScoped +import com.tangem.core.ui.R +import com.tangem.core.ui.ds.button.TangemButtonType +import com.tangem.core.ui.ds.button.TangemButtonUM +import com.tangem.core.ui.ds2.search.TangemSearch +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.features.addressbook.selectnetworks.ui.state.SelectNetworksUM +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject + +@ModelScoped +internal class SelectNetworksStateController @Inject constructor() { + + val uiState: StateFlow + field = MutableStateFlow(value = getInitialState()) + + fun update(transformer: Transformer) { + uiState.update(function = transformer::transform) + } + + private fun getInitialState(): SelectNetworksUM = SelectNetworksUM( + searchBar = TangemSearch.State( + placeholderText = resourceReference(R.string.common_search), + query = "", + onQueryChange = {}, + isActive = false, + onActiveChange = {}, + onClearClick = {}, + onCloseClick = {}, + ), + networks = persistentListOf(), + doneButton = TangemButtonUM( + text = TextReference.Res(R.string.common_done), + type = TangemButtonType.Primary, + isEnabled = false, + onClick = {}, + ), + onBackClick = {}, + ) +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/state/transformers/UpdateNetworksContentTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/state/transformers/UpdateNetworksContentTransformer.kt new file mode 100644 index 0000000000..63837c244a --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/state/transformers/UpdateNetworksContentTransformer.kt @@ -0,0 +1,45 @@ +package com.tangem.features.addressbook.selectnetworks.state.transformers + +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.features.addressbook.selectnetworks.state.transformers.converter.SelectNetworkItemConverter +import com.tangem.features.addressbook.selectnetworks.ui.state.SelectNetworksUM +import com.tangem.utils.transformer.Transformer +import kotlinx.collections.immutable.toImmutableList + +internal class UpdateNetworksContentTransformer( + private val matchedBlockchains: List, + private val query: String, + private val selectedNetworkIds: Set, + private val onToggle: (networkId: String) -> Unit, +) : Transformer { + + override fun transform(prevState: SelectNetworksUM): SelectNetworksUM { + val visible = if (query.isBlank()) { + matchedBlockchains + } else { + matchedBlockchains.filter { blockchain -> + blockchain.fullName.contains(query, ignoreCase = true) || + blockchain.currency.contains(query, ignoreCase = true) || + blockchain.name.contains(query, ignoreCase = true) + } + } + val networks = visible + .map { blockchain -> + SelectNetworkItemConverter().convert( + SelectNetworkItemConverter.Input( + blockchain = blockchain, + isSelected = blockchain.toNetworkId() in selectedNetworkIds, + onToggle = onToggle, + ), + ) + } + .toImmutableList() + + // Search field is owned by UpdateSelectNetworksSearchBarTransformer and intentionally left untouched here. + return prevState.copy( + networks = networks, + doneButton = prevState.doneButton.copy(isEnabled = selectedNetworkIds.isNotEmpty()), + ) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/state/transformers/UpdateSelectNetworksInitialStateTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/state/transformers/UpdateSelectNetworksInitialStateTransformer.kt new file mode 100644 index 0000000000..2421d856d8 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/state/transformers/UpdateSelectNetworksInitialStateTransformer.kt @@ -0,0 +1,25 @@ +package com.tangem.features.addressbook.selectnetworks.state.transformers + +import com.tangem.features.addressbook.selectnetworks.ui.state.SelectNetworksUM +import com.tangem.utils.transformer.Transformer + +internal class UpdateSelectNetworksInitialStateTransformer( + private val onQueryChange: (String) -> Unit, + private val onActiveChange: (Boolean) -> Unit, + private val onBackClick: () -> Unit, + private val onDoneClick: () -> Unit, +) : Transformer { + + override fun transform(prevState: SelectNetworksUM): SelectNetworksUM { + return prevState.copy( + searchBar = prevState.searchBar.copy( + onQueryChange = onQueryChange, + onActiveChange = onActiveChange, + onCloseClick = { onActiveChange(false) }, + onClearClick = { onQueryChange("") }, + ), + doneButton = prevState.doneButton.copy(onClick = onDoneClick), + onBackClick = onBackClick, + ) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/state/transformers/UpdateSelectNetworksSearchBarTransformer.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/state/transformers/UpdateSelectNetworksSearchBarTransformer.kt new file mode 100644 index 0000000000..874f4478bd --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/state/transformers/UpdateSelectNetworksSearchBarTransformer.kt @@ -0,0 +1,16 @@ +package com.tangem.features.addressbook.selectnetworks.state.transformers + +import com.tangem.features.addressbook.selectnetworks.ui.state.SelectNetworksUM +import com.tangem.utils.transformer.Transformer + +internal class UpdateSelectNetworksSearchBarTransformer( + private val query: String, + private val isActive: Boolean, +) : Transformer { + + override fun transform(prevState: SelectNetworksUM): SelectNetworksUM { + return prevState.copy( + searchBar = prevState.searchBar.copy(query = query, isActive = isActive), + ) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/state/transformers/converter/SelectNetworkItemConverter.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/state/transformers/converter/SelectNetworkItemConverter.kt new file mode 100644 index 0000000000..8c1219e6ec --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/state/transformers/converter/SelectNetworkItemConverter.kt @@ -0,0 +1,28 @@ +package com.tangem.features.addressbook.selectnetworks.state.transformers.converter + +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.common.ui.extensions.getActiveIconRes +import com.tangem.features.addressbook.selectnetworks.ui.state.SelectNetworksUM.NetworkItemUM +import com.tangem.utils.converter.Converter + +internal class SelectNetworkItemConverter : Converter { + + data class Input( + val blockchain: Blockchain, + val isSelected: Boolean, + val onToggle: (networkId: String) -> Unit, + ) + + override fun convert(value: Input): NetworkItemUM { + val id = value.blockchain.toNetworkId() + return NetworkItemUM( + id = id, + name = value.blockchain.fullName, + symbol = value.blockchain.currency, + iconResId = getActiveIconRes(value.blockchain), + isSelected = value.isSelected, + onCheckedChange = { value.onToggle(id) }, + ) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/ui/SelectNetworksContent.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/ui/SelectNetworksContent.kt new file mode 100644 index 0000000000..efa891b007 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/ui/SelectNetworksContent.kt @@ -0,0 +1,195 @@ +package com.tangem.features.addressbook.selectnetworks.ui + +import android.content.res.Configuration +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tangem.core.ui.R +import com.tangem.core.ui.components.SpacerWMax +import com.tangem.core.ui.ds.button.TangemButtonType +import com.tangem.core.ui.ds.button.TangemButtonUM +import com.tangem.core.ui.ds.image.TangemIconUM +import com.tangem.core.ui.ds.topbar.TangemTopBar +import com.tangem.core.ui.ds2.button.TangemButton +import com.tangem.core.ui.ds2.checkbox.TangemCheckmark +import com.tangem.core.ui.ds2.search.TangemSearch +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.clickableSingle +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringResourceSafe +import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.features.addressbook.selectnetworks.ui.state.SelectNetworksUM +import com.tangem.features.addressbook.selectnetworks.ui.state.SelectNetworksUM.NetworkItemUM +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun SelectNetworksContent(state: SelectNetworksUM, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxSize() + .background(color = TangemTheme.colors3.bg.primary) + .systemBarsPadding(), + ) { + TangemTopBar( + title = resourceReference(R.string.common_choose_network), + startContent = { + TangemButton( + iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_back_24), + onClick = state.onBackClick, + size = TangemButton.Size.X11, + variant = TangemButton.Variant.Material, + ) + }, + ) + TangemSearch( + state = state.searchBar, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + ) + val doneButtonVerticalPadding = 12.dp + val doneButtonAreaHeight = 48.dp + doneButtonVerticalPadding * 2 + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + ) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = doneButtonAreaHeight) + .background( + color = TangemTheme.colors3.bg.secondary, + shape = RoundedCornerShape(24.dp), + ), + contentPadding = PaddingValues(horizontal = 16.dp), + ) { + item { + Text( + modifier = Modifier.padding(top = 16.dp, bottom = 4.dp), + text = stringResourceSafe(R.string.common_available_networks), + style = TangemTheme.typography3.caption.medium, + color = TangemTheme.colors3.text.secondary, + ) + } + items(items = state.networks, key = NetworkItemUM::id) { item -> + NetworkRow(item = item) + } + } + TangemButton( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = doneButtonVerticalPadding) + .imePadding(), + onClick = state.doneButton.onClick, + isEnabled = state.doneButton.isEnabled, + size = TangemButton.Size.X12, + text = state.doneButton.text, + ) + } + } +} + +@Composable +private fun NetworkRow(item: NetworkItemUM) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickableSingle(onClick = item.onCheckedChange) + .padding(vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painter = painterResource(id = item.iconResId), + contentDescription = null, + modifier = Modifier + .size(36.dp) + .clip(CircleShape), + ) + Text( + modifier = Modifier.padding(start = 12.dp), + text = item.name, + style = TangemTheme.typography3.body.medium, + color = TangemTheme.colors3.text.primary, + ) + Text( + modifier = Modifier.padding(start = 4.dp), + text = item.symbol, + style = TangemTheme.typography3.body.medium, + color = TangemTheme.colors3.text.secondary, + ) + SpacerWMax() + TangemCheckmark( + checked = item.isSelected, + onCheckedChange = { item.onCheckedChange() }, + ) + } +} + +@Preview(showBackground = true, widthDp = 360) +@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun Preview_SelectNetworksContent() { + TangemThemePreviewRedesign { + SelectNetworksContent( + state = SelectNetworksUM( + searchBar = TangemSearch.State( + placeholderText = resourceReference(R.string.common_search), + query = "", + onQueryChange = {}, + isActive = false, + onActiveChange = {}, + onCloseClick = {}, + ), + networks = persistentListOf( + NetworkItemUM( + id = "ethereum", + name = "Ethereum", + symbol = "ETH", + iconResId = R.drawable.img_eth_22, + isSelected = true, + onCheckedChange = {}, + ), + NetworkItemUM( + id = "bsc", + name = "BNB Smart Chain", + iconResId = R.drawable.img_bsc_22, + isSelected = false, + symbol = "BNB", + onCheckedChange = {}, + ), + NetworkItemUM( + id = "polygon", + name = "Polygon", + iconResId = R.drawable.img_polygon_22, + isSelected = true, + symbol = "POL", + onCheckedChange = {}, + ), + ), + doneButton = TangemButtonUM( + text = TextReference.Res(R.string.common_done), + type = TangemButtonType.Primary, + isEnabled = true, + onClick = {}, + ), + onBackClick = {}, + ), + ) + } +} \ No newline at end of file diff --git a/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/ui/state/SelectNetworksUM.kt b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/ui/state/SelectNetworksUM.kt new file mode 100644 index 0000000000..eb565d9c15 --- /dev/null +++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/selectnetworks/ui/state/SelectNetworksUM.kt @@ -0,0 +1,26 @@ +package com.tangem.features.addressbook.selectnetworks.ui.state + +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Immutable +import com.tangem.core.ui.ds.button.TangemButtonUM +import com.tangem.core.ui.ds2.search.TangemSearch +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal data class SelectNetworksUM( + val searchBar: TangemSearch.State, + val networks: ImmutableList, + val doneButton: TangemButtonUM, + val onBackClick: () -> Unit, +) { + + @Immutable + data class NetworkItemUM( + val id: String, + val name: String, + val symbol: String, + @DrawableRes val iconResId: Int, + val isSelected: Boolean, + val onCheckedChange: () -> Unit, + ) +} \ No newline at end of file diff --git a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModelTest.kt b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModelTest.kt index cce48d3bbe..32bfd72b9f 100644 --- a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModelTest.kt +++ b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/addaddress/model/AddAddressModelTest.kt @@ -1,27 +1,33 @@ package com.tangem.features.addressbook.addaddress.model +import arrow.core.right import com.google.common.truth.Truth.assertThat import com.tangem.blockchain.common.Blockchain -import com.tangem.common.test.domain.token.MockCryptoCurrencyFactory +import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.common.routing.AppRoute import com.tangem.core.decompose.model.MutableParamsContainer import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.core.decompose.navigation.Router import com.tangem.core.ui.R import com.tangem.core.ui.clipboard.ClipboardManager import com.tangem.core.ui.extensions.resourceReference -import com.tangem.domain.account.models.AccountList -import com.tangem.domain.account.supplier.MultiAccountListSupplier -import com.tangem.domain.models.account.Account -import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.qrscanning.models.SourceType +import com.tangem.domain.qrscanning.usecases.ListenToQrScanningUseCase import com.tangem.features.addressbook.addaddress.DefaultAddAddressComponent import com.tangem.features.addressbook.addaddress.state.AddAddressStateController +import com.tangem.features.addressbook.addaddress.ui.state.AddAddressUM.ChosenNetworkStateUM +import com.tangem.features.addressbook.common.AddressMemoValidator +import com.tangem.features.addressbook.common.SelectNetworksResultHolder +import com.tangem.features.addressbook.common.SupportedNetworksMatcher import com.tangem.features.addressbook.editcontact.ui.state.ValidatedAddress -import com.tangem.test.mock.MockAccounts import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.clearMocks +import io.mockk.coEvery import io.mockk.every import io.mockk.mockk +import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope @@ -33,25 +39,30 @@ import org.junit.jupiter.api.* @TestInstance(TestInstance.Lifecycle.PER_CLASS) internal class AddAddressModelTest { - private val multiAccountListSupplier: MultiAccountListSupplier = mockk() + private val supportedNetworksMatcher: SupportedNetworksMatcher = mockk() + private val memoValidator: AddressMemoValidator = mockk() private val clipboardManager: ClipboardManager = mockk() - - private val cryptoCurrencyFactory = MockCryptoCurrencyFactory() - private val ethereum = cryptoCurrencyFactory.createCoin(Blockchain.Ethereum) - private val bitcoin = cryptoCurrencyFactory.createCoin(Blockchain.Bitcoin) + private val listenToQrScanningUseCase: ListenToQrScanningUseCase = mockk() + private val router: Router = mockk(relaxed = true) + private val selectNetworksResultHolder = SelectNetworksResultHolder() private var model: AddAddressModel? = null @BeforeEach fun resetMocks() { - clearMocks(multiAccountListSupplier, clipboardManager) - // Default: no accounts, so no coins are available unless a test overrides it. - every { multiAccountListSupplier.invoke() } returns flowOf(emptyList()) + clearMocks(supportedNetworksMatcher, memoValidator, clipboardManager, listenToQrScanningUseCase, router) + selectNetworksResultHolder.clear() + // Default: an address matches nothing unless a test stubs a specific value. + every { supportedNetworksMatcher.match(any()) } returns emptyList() + // Default: any memo passes unless a test stubs an invalid one. + coEvery { memoValidator.isValid(any(), any()) } returns true + // Default: no QR results unless a test overrides it. + every { listenToQrScanningUseCase(SourceType.ADDRESS_BOOK) } returns flowOf().right() } @AfterEach fun tearDown() { - // Cancels modelScope, stopping the long-lived availableCoins / address-input collectors. + // Cancels modelScope, stopping the long-lived validation / address-input collectors. model?.onDestroy() model = null } @@ -98,13 +109,14 @@ internal class AddAddressModelTest { assertThat(model.state.value.addressField.value).isEqualTo(address) } - // validateAndConfirm() is an unimplemented seam — the button click must NOT emit a result yet. + // No network matches, so the button is disabled; clicking it must not emit a result. @Test - fun `GIVEN typed address WHEN button clicked THEN onConfirm not called yet`() = runTest { + fun `GIVEN no matching network WHEN button clicked THEN onConfirm not called`() = runTest { // Arrange var confirmed: ValidatedAddress? = null val model = createModel(testScope = this, onConfirm = { confirmed = it }) model.state.value.onAddressChange("0xABC") + advanceUntilIdle() // Act model.state.value.buttonUM.onClick() @@ -119,14 +131,14 @@ internal class AddAddressModelTest { inner class Validation { @Test - fun `GIVEN coins available WHEN valid address typed THEN no error AND button enabled`() = runTest { - // Arrange - every { multiAccountListSupplier.invoke() } returns flowOf(listOf(accountListWith(ethereum, bitcoin))) + fun `GIVEN single matching network WHEN typed THEN no error AND button enabled`() = runTest { + // Arrange — a single matched network is auto-selected, so the button is enabled right away. + every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.Ethereum) val model = createModel(testScope = this) advanceUntilIdle() // Act - model.state.value.onAddressChange(VALID_ETH_ADDRESS) + model.state.value.onAddressChange(ADDRESS) advanceUntilIdle() // Assert @@ -136,14 +148,31 @@ internal class AddAddressModelTest { } @Test - fun `GIVEN coins available WHEN address matches no network THEN error AND button disabled`() = runTest { - // Arrange - every { multiAccountListSupplier.invoke() } returns flowOf(listOf(accountListWith(ethereum, bitcoin))) + fun `GIVEN several matching networks WHEN typed THEN no error but button disabled until selection`() = runTest { + // Arrange — several matches are shown for context, but none is selected until the user picks explicitly. + every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.Ethereum, Blockchain.BSC) val model = createModel(testScope = this) advanceUntilIdle() // Act - model.state.value.onAddressChange("not-an-address") + model.state.value.onAddressChange(ADDRESS) + advanceUntilIdle() + + // Assert + val state = model.state.value + assertThat(state.addressField.isError).isFalse() + assertThat(state.buttonUM.isEnabled).isFalse() + } + + @Test + fun `GIVEN address matching no network WHEN typed THEN error AND button disabled`() = runTest { + // Arrange + every { supportedNetworksMatcher.match(ADDRESS) } returns emptyList() + val model = createModel(testScope = this) + advanceUntilIdle() + + // Act + model.state.value.onAddressChange(ADDRESS) advanceUntilIdle() // Assert @@ -157,7 +186,6 @@ internal class AddAddressModelTest { @Test fun `GIVEN empty address WHEN validated THEN no error AND button disabled`() = runTest { // Arrange - every { multiAccountListSupplier.invoke() } returns flowOf(listOf(accountListWith(ethereum))) val model = createModel(testScope = this) advanceUntilIdle() @@ -170,54 +198,362 @@ internal class AddAddressModelTest { assertThat(state.addressField.isError).isFalse() assertThat(state.buttonUM.isEnabled).isFalse() } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class NetworkSelector { - // The address is typed before coins load; validity must resolve reactively once the supplier emits them. @Test - fun `GIVEN address typed before coins load WHEN coins emitted THEN validated reactively`() = runTest { - // Arrange - val accountsFlow = MutableStateFlow>(emptyList()) - every { multiAccountListSupplier.invoke() } returns accountsFlow + fun `GIVEN blank address WHEN validated THEN selector hidden`() = runTest { + // Act val model = createModel(testScope = this) advanceUntilIdle() - // Act — type while coins are still empty - model.state.value.onAddressChange(VALID_ETH_ADDRESS) - advanceUntilIdle() - // Assert intermediate: nothing to match yet - assertThat(model.state.value.buttonUM.isEnabled).isFalse() + // Assert + assertThat(model.state.value.chosenNetworkStateUM).isEqualTo(ChosenNetworkStateUM.Hidden) + } - // Act — coins arrive later - accountsFlow.value = listOf(accountListWith(ethereum, bitcoin)) + @Test + fun `GIVEN invalid address WHEN validated THEN selector hidden`() = runTest { + // Arrange + every { supportedNetworksMatcher.match(ADDRESS) } returns emptyList() + val model = createModel(testScope = this) + advanceUntilIdle() + + // Act + model.state.value.onAddressChange(ADDRESS) advanceUntilIdle() // Assert - val state = model.state.value - assertThat(state.buttonUM.isEnabled).isTrue() - assertThat(state.addressField.isError).isFalse() + assertThat(model.state.value.chosenNetworkStateUM).isEqualTo(ChosenNetworkStateUM.Hidden) + } + + @Test + fun `GIVEN address matching several networks WHEN validated THEN all shown AND clickable`() = runTest { + // Arrange + every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.Ethereum, Blockchain.BSC) + val model = createModel(testScope = this) + advanceUntilIdle() + + // Act + model.state.value.onAddressChange(ADDRESS) + advanceUntilIdle() + + // Assert — all matched networks are shown by default; the block opens the selection screen to narrow them. + val result = model.state.value.chosenNetworkStateUM as ChosenNetworkStateUM.Result + assertThat(result.networkUMList.map { it.networkName }) + .containsExactly(Blockchain.Ethereum.fullName, Blockchain.BSC.fullName) + assertThat(result.isClickable).isTrue() + } + + @Test + fun `GIVEN address matching a single network WHEN validated THEN selector is not clickable`() = runTest { + // Arrange + every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.Bitcoin) + val model = createModel(testScope = this) + advanceUntilIdle() + + // Act + model.state.value.onAddressChange(ADDRESS) + advanceUntilIdle() + + // Assert + val result = model.state.value.chosenNetworkStateUM as ChosenNetworkStateUM.Result + assertThat(result.networkUMList.map { it.networkName }).containsExactly(Blockchain.Bitcoin.fullName) + assertThat(result.isClickable).isFalse() + } + + @Test + fun `GIVEN valid address WHEN onNetworkClick THEN opens selector with address and default selection`() = + runTest { + // Arrange + var openedAddress: String? = null + var openedSelection: List = listOf("sentinel") + every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.Ethereum, Blockchain.BSC) + val model = createModel( + testScope = this, + onSelectNetworksClick = { address, selection -> + openedAddress = address + openedSelection = selection + }, + ) + advanceUntilIdle() + model.state.value.onAddressChange(ADDRESS) + advanceUntilIdle() + + // Act + model.state.value.onNetworkClick() + + // Assert — empty selection means "nothing selected yet" on the selection screen. + assertThat(openedAddress).isEqualTo(ADDRESS) + assertThat(openedSelection).isEmpty() + } + + @Test + fun `GIVEN networks chosen via holder WHEN applied THEN selector reflects subset AND confirm uses it`() = + runTest { + // Arrange + var confirmed: ValidatedAddress? = null + every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.Ethereum, Blockchain.BSC) + val model = createModel(testScope = this, onConfirm = { confirmed = it }) + advanceUntilIdle() + model.state.value.onAddressChange(ADDRESS) + advanceUntilIdle() + + // Act — the user keeps only Ethereum on the network-selection screen. + selectNetworksResultHolder.setSelectedNetworkIds(setOf(Blockchain.Ethereum.toNetworkId())) + advanceUntilIdle() + + // Assert — selector shows the subset and the result is consumed. + val chosen = model.state.value.chosenNetworkStateUM as ChosenNetworkStateUM.Result + assertThat(chosen.networkUMList.map { it.networkName }).containsExactly(Blockchain.Ethereum.fullName) + assertThat(selectNetworksResultHolder.selectedNetworkIds.value).isNull() + + // And confirm persists only the kept network. + model.state.value.buttonUM.onClick() + assertThat(confirmed).isEqualTo( + ValidatedAddress( + address = ADDRESS, + networkIds = persistentListOf(Blockchain.Ethereum.toNetworkId()), + ), + ) + } + + @Test + fun `GIVEN non-blank address WHEN typed THEN loading shown AND button blocked until validated`() = runTest { + // Arrange + every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.Ethereum, Blockchain.BSC) + val model = createModel(testScope = this) + advanceUntilIdle() + + // Act — typed, but validation is still debounced. + model.state.value.onAddressChange(ADDRESS) + + // Assert + assertThat(model.state.value.chosenNetworkStateUM).isEqualTo(ChosenNetworkStateUM.Loading) + assertThat(model.state.value.buttonUM.isEnabled).isFalse() + } + + @Test + fun `GIVEN resolved networks WHEN address edited THEN keeps result without flashing loading`() = runTest { + // Arrange + every { supportedNetworksMatcher.match(any()) } returns listOf(Blockchain.Ethereum, Blockchain.BSC) + val model = createModel(testScope = this) + model.state.value.onAddressChange(ADDRESS) + advanceUntilIdle() + assertThat(model.state.value.chosenNetworkStateUM).isInstanceOf(ChosenNetworkStateUM.Result::class.java) + + // Act — keep typing; validation is pending again. + model.state.value.onAddressChange(ADDRESS + "00") + + // Assert — the resolved networks stay on screen (no spinner), but the button is blocked while validating. + assertThat(model.state.value.chosenNetworkStateUM).isInstanceOf(ChosenNetworkStateUM.Result::class.java) + assertThat(model.state.value.buttonUM.isEnabled).isFalse() + } + + @Test + fun `GIVEN single matched network WHEN confirmed THEN it is persisted`() = runTest { + // Arrange — a single match is auto-selected, so confirm works without opening the selection screen. + var confirmed: ValidatedAddress? = null + every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.Ethereum) + val model = createModel(testScope = this, onConfirm = { confirmed = it }) + advanceUntilIdle() + model.state.value.onAddressChange(ADDRESS) + advanceUntilIdle() + + // Act + model.state.value.buttonUM.onClick() + + // Assert + assertThat(confirmed).isEqualTo( + ValidatedAddress( + address = ADDRESS, + networkIds = persistentListOf(Blockchain.Ethereum.toNetworkId()), + ), + ) + } + + @Test + fun `GIVEN several matched networks AND none selected WHEN confirmed THEN nothing persisted`() = runTest { + // Arrange + var confirmed: ValidatedAddress? = null + every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.Ethereum, Blockchain.BSC) + val model = createModel(testScope = this, onConfirm = { confirmed = it }) + advanceUntilIdle() + model.state.value.onAddressChange(ADDRESS) + advanceUntilIdle() + + // Act — networks are shown but not selected, so confirming is a no-op. + model.state.value.buttonUM.onClick() + + // Assert + assertThat(confirmed).isNull() } } - private fun accountListWith(vararg currencies: CryptoCurrency): AccountList { - val walletId = MockAccounts.userWalletId - val accounts = listOf( - Account.CryptoPortfolio.createMainAccount( - userWalletId = walletId, - cryptoCurrencies = currencies.toList(), - ), - ) - return AccountList( - userWalletId = walletId, - accounts = accounts, - totalAccounts = accounts.size, - totalArchivedAccounts = 0, - ).getOrNull()!! + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class Memo { + + @Test + fun `GIVEN address matching an extras network WHEN validated THEN memo field shown`() = runTest { + // Arrange — XRP supports a destination tag. + every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.XRP) + val model = createModel(testScope = this) + advanceUntilIdle() + + // Act + model.state.value.onAddressChange(ADDRESS) + advanceUntilIdle() + + // Assert + val memoField = model.state.value.memoField + assertThat(memoField.isVisible).isTrue() + assertThat(memoField.label).isEqualTo(resourceReference(R.string.send_destination_tag_field)) + } + + @Test + fun `GIVEN non-extras networks WHEN validated THEN memo field hidden`() = runTest { + // Arrange + every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.Ethereum, Blockchain.BSC) + val model = createModel(testScope = this) + advanceUntilIdle() + + // Act + model.state.value.onAddressChange(ADDRESS) + advanceUntilIdle() + + // Assert + assertThat(model.state.value.memoField.isVisible).isFalse() + } + + @Test + fun `GIVEN extras network and memo entered WHEN confirmed THEN memo included`() = runTest { + // Arrange + var confirmed: ValidatedAddress? = null + every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.XRP) + val model = createModel(testScope = this, onConfirm = { confirmed = it }) + advanceUntilIdle() + model.state.value.onAddressChange(ADDRESS) + advanceUntilIdle() + + // Act + model.state.value.memoField.onValueChange("123456") + model.state.value.buttonUM.onClick() + + // Assert + assertThat(confirmed).isEqualTo( + ValidatedAddress( + address = ADDRESS, + networkIds = persistentListOf(Blockchain.XRP.toNetworkId()), + memo = "123456", + ), + ) + } + + @Test + fun `GIVEN invalid memo WHEN entered THEN memo error shown AND button blocked`() = runTest { + // Arrange + every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.XRP) + coEvery { memoValidator.isValid(Blockchain.XRP, "bad-tag") } returns false + val model = createModel(testScope = this) + advanceUntilIdle() + model.state.value.onAddressChange(ADDRESS) + advanceUntilIdle() + + // Act — type a malformed destination tag. + model.state.value.memoField.onValueChange("bad-tag") + advanceUntilIdle() + + // Assert + assertThat(model.state.value.memoField.isError).isTrue() + assertThat(model.state.value.buttonUM.isEnabled).isFalse() + } + + @Test + fun `GIVEN non-extras network WHEN confirmed THEN memo is null`() = runTest { + // Arrange + var confirmed: ValidatedAddress? = null + every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(Blockchain.Ethereum) + val model = createModel(testScope = this, onConfirm = { confirmed = it }) + advanceUntilIdle() + model.state.value.onAddressChange(ADDRESS) + advanceUntilIdle() + + // Act + model.state.value.buttonUM.onClick() + + // Assert + assertThat(confirmed?.memo).isNull() + } + + @Test + fun `WHEN memo paste clicked THEN clipboard goes into memo and not address`() = runTest { + // Arrange + every { clipboardManager.getText() } returns "TAG-123" + val model = createModel(testScope = this) + + // Act + model.state.value.memoField.onPasteClick() + + // Assert + assertThat(model.state.value.memoField.value).isEqualTo("TAG-123") + assertThat(model.state.value.addressField.value).isEmpty() + } + } + + @Nested + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + inner class QrScan { + + @Test + fun `WHEN onQrClick THEN navigates to address-book QR scanning`() = runTest { + // Arrange + val model = createModel(testScope = this) + + // Act + model.state.value.onQrClick() + + // Assert + verify { router.push(AppRoute.QrScanning(source = AppRoute.QrScanning.Source.AddressBook)) } + } + + @Test + fun `GIVEN scanned address WHEN emitted THEN address field updated`() = runTest { + // Arrange + every { listenToQrScanningUseCase(SourceType.ADDRESS_BOOK) } returns flowOf(ADDRESS).right() + val model = createModel(testScope = this) + + // Act + advanceUntilIdle() + + // Assert + assertThat(model.state.value.addressField.value).isEqualTo(ADDRESS) + } + + @Test + fun `GIVEN scanned payment URI WHEN emitted THEN scheme and query stripped`() = runTest { + // Arrange + every { listenToQrScanningUseCase(SourceType.ADDRESS_BOOK) } returns + flowOf("ethereum:$ADDRESS?amount=1.5").right() + val model = createModel(testScope = this) + + // Act + advanceUntilIdle() + + // Assert + assertThat(model.state.value.addressField.value).isEqualTo(ADDRESS) + } } private fun createModel( testScope: TestScope, onConfirm: (ValidatedAddress) -> Unit = {}, + onSelectNetworksClick: (String, List) -> Unit = { _, _ -> }, params: DefaultAddAddressComponent.Params = DefaultAddAddressComponent.Params( onBackClick = {}, + onSelectNetworksClick = onSelectNetworksClick, onConfirm = onConfirm, ), paramsContainer: ParamsContainer = MutableParamsContainer(value = params), @@ -225,9 +561,13 @@ internal class AddAddressModelTest { return AddAddressModel( paramsContainer = paramsContainer, dispatchers = testScope.createTestingCoroutineDispatcherProvider(), - multiAccountListSupplier = multiAccountListSupplier, + supportedNetworksMatcher = supportedNetworksMatcher, + memoValidator = memoValidator, + listenToQrScanningUseCase = listenToQrScanningUseCase, clipboardManager = clipboardManager, stateController = AddAddressStateController(), + selectNetworksResultHolder = selectNetworksResultHolder, + router = router, ).also { model = it } } @@ -243,7 +583,6 @@ internal class AddAddressModelTest { } private companion object { - // EIP-55 checksummed address from the spec — guaranteed to pass Ethereum validation. - const val VALID_ETH_ADDRESS = "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed" + const val ADDRESS = "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed" } } \ No newline at end of file diff --git a/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/selectnetworks/model/SelectNetworksModelTest.kt b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/selectnetworks/model/SelectNetworksModelTest.kt new file mode 100644 index 0000000000..2a8fd39c9e --- /dev/null +++ b/features/address-book/impl/src/test/kotlin/com/tangem/features/addressbook/selectnetworks/model/SelectNetworksModelTest.kt @@ -0,0 +1,181 @@ +package com.tangem.features.addressbook.selectnetworks.model + +import com.google.common.truth.Truth.assertThat +import com.tangem.blockchain.common.Blockchain +import com.tangem.blockchainsdk.utils.toNetworkId +import com.tangem.core.decompose.model.MutableParamsContainer +import com.tangem.core.decompose.model.ParamsContainer +import com.tangem.features.addressbook.common.SupportedNetworksMatcher +import com.tangem.features.addressbook.selectnetworks.DefaultSelectNetworksComponent +import com.tangem.features.addressbook.selectnetworks.state.SelectNetworksStateController +import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.* + +@OptIn(ExperimentalCoroutinesApi::class) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class SelectNetworksModelTest { + + private val supportedNetworksMatcher: SupportedNetworksMatcher = mockk() + + private val ethereum = Blockchain.Ethereum + private val bsc = Blockchain.BSC + + private var model: SelectNetworksModel? = null + + @BeforeEach + fun resetMocks() { + clearMocks(supportedNetworksMatcher) + // The address resolves to two networks unless a test overrides it. + every { supportedNetworksMatcher.match(ADDRESS) } returns listOf(ethereum, bsc) + } + + @AfterEach + fun tearDown() { + model?.onDestroy() + model = null + } + + @Test + fun `GIVEN no prior selection WHEN created THEN nothing selected AND done disabled`() = runTest { + // Act + val model = createModel(testScope = this) + advanceUntilIdle() + + // Assert — all matched networks are listed but none is checked by default. + val state = model.state.value + assertThat(state.networks.map { it.name }).containsExactly(ethereum.fullName, bsc.fullName) + assertThat(state.networks.none { it.isSelected }).isTrue() + assertThat(state.doneButton.isEnabled).isFalse() + } + + @Test + fun `GIVEN explicit selection WHEN created THEN only those networks selected`() = runTest { + // Act + val model = createModel(testScope = this, selectedNetworkIds = listOf(ethereum.toNetworkId())) + advanceUntilIdle() + + // Assert + val networks = model.state.value.networks + assertThat(networks.first { it.id == ethereum.toNetworkId() }.isSelected).isTrue() + assertThat(networks.first { it.id == bsc.toNetworkId() }.isSelected).isFalse() + } + + @Test + fun `GIVEN nothing selected WHEN a network toggled on THEN it becomes selected AND done enabled`() = runTest { + // Arrange + val model = createModel(testScope = this) + advanceUntilIdle() + + // Act + model.state.value.networks.first { it.id == ethereum.toNetworkId() }.onCheckedChange() + advanceUntilIdle() + + // Assert + val networks = model.state.value.networks + assertThat(networks.first { it.id == ethereum.toNetworkId() }.isSelected).isTrue() + assertThat(networks.first { it.id == bsc.toNetworkId() }.isSelected).isFalse() + assertThat(model.state.value.doneButton.isEnabled).isTrue() + } + + @Test + fun `GIVEN a selected network toggled off THEN done disabled again`() = runTest { + // Arrange + val model = createModel(testScope = this, selectedNetworkIds = listOf(ethereum.toNetworkId())) + advanceUntilIdle() + + // Act + model.state.value.networks.first { it.id == ethereum.toNetworkId() }.onCheckedChange() + advanceUntilIdle() + + // Assert + assertThat(model.state.value.networks.none { it.isSelected }).isTrue() + assertThat(model.state.value.doneButton.isEnabled).isFalse() + } + + @Test + fun `GIVEN query WHEN typed THEN list filtered by network name`() = runTest { + // Arrange + val model = createModel(testScope = this) + advanceUntilIdle() + + // Act + model.state.value.searchBar.onQueryChange(ethereum.fullName) + advanceUntilIdle() + + // Assert + assertThat(model.state.value.networks.map { it.name }).containsExactly(ethereum.fullName) + } + + @Test + fun `GIVEN selected networks WHEN done clicked THEN onDone called with them`() = runTest { + // Arrange + var result: Set? = null + val model = createModel(testScope = this, onDone = { result = it }) + advanceUntilIdle() + model.state.value.networks.first { it.id == ethereum.toNetworkId() }.onCheckedChange() + advanceUntilIdle() + + // Act + model.state.value.doneButton.onClick() + + // Assert + assertThat(result).containsExactly(ethereum.toNetworkId()) + } + + @Test + fun `GIVEN no networks selected WHEN done clicked THEN onDone not called`() = runTest { + // Arrange + var result: Set? = null + val model = createModel(testScope = this, onDone = { result = it }) + advanceUntilIdle() + + // Act — nothing selected by default. + model.state.value.doneButton.onClick() + + // Assert + assertThat(result).isNull() + } + + private fun createModel( + testScope: TestScope, + selectedNetworkIds: List = emptyList(), + onDone: (Set) -> Unit = {}, + params: DefaultSelectNetworksComponent.Params = DefaultSelectNetworksComponent.Params( + address = ADDRESS, + selectedNetworkIds = selectedNetworkIds, + onBackClick = {}, + onDone = onDone, + ), + paramsContainer: ParamsContainer = MutableParamsContainer(value = params), + ): SelectNetworksModel { + return SelectNetworksModel( + paramsContainer = paramsContainer, + dispatchers = testScope.createTestingCoroutineDispatcherProvider(), + supportedNetworksMatcher = supportedNetworksMatcher, + stateController = SelectNetworksStateController(), + ).also { model = it } + } + + private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider { + val testDispatcher = StandardTestDispatcher(testScheduler) + return TestingCoroutineDispatcherProvider( + main = testDispatcher, + mainImmediate = testDispatcher, + io = testDispatcher, + default = testDispatcher, + single = testDispatcher, + ) + } + + private companion object { + const val ADDRESS = "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed" + } +} \ No newline at end of file diff --git a/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/transformers/InitializeQrScanningStateTransformer.kt b/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/transformers/InitializeQrScanningStateTransformer.kt index ca3528a5e9..c1ef19d53e 100644 --- a/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/transformers/InitializeQrScanningStateTransformer.kt +++ b/features/qr-scanning/impl/src/main/java/com/tangem/feature/qrscanning/presentation/transformers/InitializeQrScanningStateTransformer.kt @@ -23,6 +23,7 @@ internal class InitializeQrScanningStateTransformer( SourceType.SEND -> network?.let { resourceReference(R.string.send_qrcode_scan_info, wrappedList(it)) } SourceType.WALLET_CONNECT -> resourceReference(R.string.wc_qr_scan_hint) SourceType.MAIN_SCREEN -> resourceReference(R.string.main_qr_scan_hint) + SourceType.ADDRESS_BOOK -> resourceReference(R.string.main_qr_scan_hint) } return QrScanningState( @@ -49,6 +50,10 @@ internal class InitializeQrScanningStateTransformer( title = null, startIcon = R.drawable.ic_close_24, ) + SourceType.ADDRESS_BOOK -> TopBarConfig( + title = null, + startIcon = R.drawable.ic_back_24, + ) } } diff --git a/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/TransactionExtras.kt b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/TransactionExtras.kt new file mode 100644 index 0000000000..13dd7d0e13 --- /dev/null +++ b/libs/blockchain-sdk/src/main/java/com/tangem/blockchainsdk/utils/TransactionExtras.kt @@ -0,0 +1,175 @@ +package com.tangem.blockchainsdk.utils + +import com.tangem.blockchain.common.Blockchain +import com.tangem.domain.models.network.Network + +/** + * The kind of transaction extras (memo / destination tag) a [Blockchain] supports, mapped to the domain + * [Network.TransactionExtrasType]. Single source of truth for both [com.tangem.data.common.network.NetworkFactory] and + * any feature that needs to know whether an address on this chain can carry a memo/tag. + */ +@Suppress("LongMethod") +fun Blockchain.getSupportedTransactionExtras(): Network.TransactionExtrasType { + return when (this) { + Blockchain.XRP -> Network.TransactionExtrasType.DESTINATION_TAG + Blockchain.Binance, + Blockchain.TON, + Blockchain.Cosmos, + Blockchain.TerraV1, + Blockchain.TerraV2, + Blockchain.Stellar, + Blockchain.Hedera, + Blockchain.Algorand, + Blockchain.Sei, + Blockchain.InternetComputer, + Blockchain.Casper, + -> Network.TransactionExtrasType.MEMO + // region Other blockchains + Blockchain.Unknown, + Blockchain.Alephium, + Blockchain.AlephiumTestnet, + Blockchain.Arbitrum, + Blockchain.ArbitrumTestnet, + Blockchain.Avalanche, + Blockchain.AvalancheTestnet, + Blockchain.BinanceTestnet, + Blockchain.BSC, + Blockchain.BSCTestnet, + Blockchain.Bitcoin, + Blockchain.BitcoinTestnet, + Blockchain.BitcoinCash, + Blockchain.BitcoinCashTestnet, + Blockchain.Cardano, + Blockchain.CosmosTestnet, + Blockchain.Dogecoin, + Blockchain.Ducatus, + Blockchain.Ethereum, + Blockchain.EthereumTestnet, + Blockchain.EthereumClassic, + Blockchain.EthereumClassicTestnet, + Blockchain.Fantom, + Blockchain.FantomTestnet, + Blockchain.Litecoin, + Blockchain.Near, + Blockchain.NearTestnet, + Blockchain.Polkadot, + Blockchain.PolkadotTestnet, + Blockchain.Kava, + Blockchain.KavaTestnet, + Blockchain.Kusama, + Blockchain.Polygon, + Blockchain.PolygonTestnet, + Blockchain.RSK, + Blockchain.SeiTestnet, + Blockchain.StellarTestnet, + Blockchain.Solana, + Blockchain.SolanaTestnet, + Blockchain.Tezos, + Blockchain.Tron, + Blockchain.TronTestnet, + Blockchain.Gnosis, + Blockchain.Dash, + Blockchain.Optimism, + Blockchain.OptimismTestnet, + Blockchain.Dischain, + Blockchain.EthereumPow, + Blockchain.EthereumPowTestnet, + Blockchain.Kaspa, + Blockchain.KaspaTestnet, + Blockchain.Telos, + Blockchain.TelosTestnet, + Blockchain.TONTestnet, + Blockchain.Ravencoin, + Blockchain.Clore, + Blockchain.RavencoinTestnet, + Blockchain.Cronos, + Blockchain.AlephZero, + Blockchain.AlephZeroTestnet, + Blockchain.OctaSpace, + Blockchain.OctaSpaceTestnet, + Blockchain.Chia, + Blockchain.ChiaTestnet, + Blockchain.Decimal, + Blockchain.DecimalTestnet, + Blockchain.XDC, + Blockchain.XDCTestnet, + Blockchain.VeChain, + Blockchain.VeChainTestnet, + Blockchain.Aptos, + Blockchain.AptosTestnet, + Blockchain.Playa3ull, + Blockchain.Shibarium, + Blockchain.ShibariumTestnet, + Blockchain.AlgorandTestnet, + Blockchain.HederaTestnet, + Blockchain.Aurora, + Blockchain.AuroraTestnet, + Blockchain.Areon, + Blockchain.AreonTestnet, + Blockchain.PulseChain, + Blockchain.PulseChainTestnet, + Blockchain.ZkSyncEra, + Blockchain.ZkSyncEraTestnet, + Blockchain.Nexa, + Blockchain.NexaTestnet, + Blockchain.Moonbeam, + Blockchain.MoonbeamTestnet, + Blockchain.Manta, + Blockchain.MantaTestnet, + Blockchain.PolygonZkEVM, + Blockchain.PolygonZkEVMTestnet, + Blockchain.Radiant, + Blockchain.Fact0rn, + Blockchain.Base, + Blockchain.BaseTestnet, + Blockchain.Moonriver, + Blockchain.MoonriverTestnet, + Blockchain.Mantle, + Blockchain.MantleTestnet, + Blockchain.Flare, + Blockchain.FlareTestnet, + Blockchain.Taraxa, + Blockchain.TaraxaTestnet, + Blockchain.Koinos, + Blockchain.KoinosTestnet, + Blockchain.Joystream, + Blockchain.Bittensor, + Blockchain.Filecoin, + Blockchain.Blast, + Blockchain.BlastTestnet, + Blockchain.Cyber, + Blockchain.CyberTestnet, + Blockchain.Sui, + Blockchain.SuiTestnet, + Blockchain.EnergyWebChain, + Blockchain.EnergyWebChainTestnet, + Blockchain.EnergyWebX, + Blockchain.EnergyWebXTestnet, + Blockchain.CasperTestnet, + Blockchain.Core, + Blockchain.CoreTestnet, + Blockchain.Xodex, + Blockchain.Canxium, + Blockchain.Chiliz, + Blockchain.ChilizTestnet, + Blockchain.VanarChain, + Blockchain.VanarChainTestnet, + Blockchain.OdysseyChain, Blockchain.OdysseyChainTestnet, + Blockchain.Bitrock, Blockchain.BitrockTestnet, + Blockchain.Sonic, Blockchain.SonicTestnet, + Blockchain.ApeChain, Blockchain.ApeChainTestnet, + Blockchain.Scroll, Blockchain.ScrollTestnet, + Blockchain.ZkLinkNova, Blockchain.ZkLinkNovaTestnet, + Blockchain.Pepecoin, Blockchain.PepecoinTestnet, + Blockchain.Hyperliquid, Blockchain.HyperliquidTestnet, + Blockchain.Quai, Blockchain.QuaiTestnet, + Blockchain.Linea, Blockchain.LineaTestnet, + Blockchain.ArbitrumNova, + Blockchain.Plasma, Blockchain.PlasmaTestnet, + Blockchain.Adi, Blockchain.AdiTestnet, + Blockchain.SeiEvm, Blockchain.SeiEvmTestnet, + Blockchain.Monad, Blockchain.MonadTestnet, + -> Network.TransactionExtrasType.NONE + // endregion + } +} \ No newline at end of file From 61ad9e7bb430e6f2aca04c1b10688353bff13e92 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 29 Jun 2026 15:57:04 +0300 Subject: [PATCH 080/210] Updated on 2026-08-14 --- .../kotlin/com/tangem/common/BaseTestCase.kt | 2 -- .../configs/feature_toggles_config.json | 4 ---- .../domain/GetMultiWalletWarningsFactory.kt | 3 --- .../GetWalletNotificationsCarouselFactory.kt | 3 --- ...tWalletNotificationsCarouselFactoryTest.kt | 15 +++---------- .../supply/api/YieldSupplyFeatureToggles.kt | 5 ----- .../impl/DefaultYieldSupplyFeatureToggles.kt | 15 ------------- .../active/model/YieldSupplyActiveModel.kt | 3 --- .../impl/di/YieldSupplyFeatureModule.kt | 21 ------------------- .../impl/entry/model/YieldSupplyEntryModel.kt | 5 +---- .../impl/main/model/YieldSupplyModel.kt | 7 ++----- .../YieldSupplyActiveModelBoostBlockTest.kt | 14 ------------- .../entry/model/YieldSupplyEntryModelTest.kt | 21 +------------------ .../impl/main/model/YieldSupplyModelTest.kt | 5 ----- 14 files changed, 7 insertions(+), 116 deletions(-) delete mode 100644 features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/YieldSupplyFeatureToggles.kt delete mode 100644 features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/DefaultYieldSupplyFeatureToggles.kt delete mode 100644 features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/di/YieldSupplyFeatureModule.kt diff --git a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt index 95ccc3e96a..f9bbb9d7cb 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt @@ -207,8 +207,6 @@ abstract class BaseTestCase : TestCase( "AND_15103_SWAP_RATE_EXPERIENCE_ENABLED" to true, "AND_15122_SWAP_PREDEFINED_BUTTONS_ENABLED" to true, "TWI_1512_HIDE_STORIES_FOR_REFERRAL_ENABLED" to true, - // 5.39.2 - "AND_15154_YIELD_PROMO_ENABLED" to true, // 5.40 "TWI_1377_MANAGE_FUNDS" to true, // 6.0 diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index 4504deb187..17d74b9d0c 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -115,10 +115,6 @@ "name": "AND_15122_SWAP_PREDEFINED_BUTTONS_ENABLED", "version": "5.39" }, - { - "name": "AND_15154_YIELD_PROMO_ENABLED", - "version": "5.39.2" - }, { "name": "TWI_1512_HIDE_STORIES_FOR_REFERRAL_ENABLED", "version": "5.39" diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt index 4b9931244c..e628ea2581 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetMultiWalletWarningsFactory.kt @@ -39,7 +39,6 @@ import com.tangem.feature.wallet.presentation.account.AccountDependencies import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification import com.tangem.features.hotwallet.HotWalletFeatureToggles import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles -import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles import com.tangem.hot.sdk.model.HotWalletId import com.tangem.lib.crypto.BlockchainUtils import com.tangem.utils.annotations.RemoveWithToggle @@ -70,7 +69,6 @@ internal class GetMultiWalletWarningsFactory @Inject constructor( private val hotWalletFeatureToggles: HotWalletFeatureToggles, private val shouldShowYieldBoostMainBannerUseCase: ShouldShowYieldBoostMainBannerUseCase, private val yieldSupplyGetShouldShowMainPromoUseCase: YieldSupplyGetShouldShowMainPromoUseCase, - private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles, private val designFeatureToggles: DesignFeatureToggles, private val walletFeatureToggles: WalletFeatureToggles, ) { @@ -208,7 +206,6 @@ internal class GetMultiWalletWarningsFactory @Inject constructor( clickIntents: WalletClickIntents, ) { if (!shouldShowLocal) return - if (!yieldSupplyFeatureToggles.isYieldPromoEnabled) return if (designFeatureToggles.isRedesignEnabled) return val shouldShow = shouldShowYieldBoostMainBannerUseCase(userWallet.walletId).getOrNull() == true if (!shouldShow) return diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsCarouselFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsCarouselFactory.kt index 82610ab8c7..5b88f3fc17 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsCarouselFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsCarouselFactory.kt @@ -15,7 +15,6 @@ import com.tangem.domain.yield.supply.promo.usecase.ShouldShowYieldBoostMainBann import com.tangem.domain.yield.supply.usecase.YieldSupplyGetShouldShowMainPromoUseCase import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotificationUM -import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles import com.tangem.utils.extensions.addIf import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -38,7 +37,6 @@ internal class GetWalletNotificationsCarouselFactory @Inject constructor( private val notificationsRepository: NotificationsRepository, private val shouldShowYieldBoostMainBannerUseCase: ShouldShowYieldBoostMainBannerUseCase, private val yieldSupplyGetShouldShowMainPromoUseCase: YieldSupplyGetShouldShowMainPromoUseCase, - private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles, private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, ) { fun create(userWallet: UserWallet, clickIntents: WalletClickIntents): Flow> { @@ -86,7 +84,6 @@ internal class GetWalletNotificationsCarouselFactory @Inject constructor( clickIntents: WalletClickIntents, ) { if (!shouldShowLocal) return - if (!yieldSupplyFeatureToggles.isYieldPromoEnabled) return val shouldShow = shouldShowYieldBoostMainBannerUseCase(userWallet.walletId).getOrNull() == true if (!shouldShow) return add( diff --git a/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsCarouselFactoryTest.kt b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsCarouselFactoryTest.kt index 5ce92507a5..37e3bfdbb8 100644 --- a/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsCarouselFactoryTest.kt +++ b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsCarouselFactoryTest.kt @@ -18,7 +18,6 @@ import com.tangem.domain.yield.supply.promo.usecase.ShouldShowYieldBoostMainBann import com.tangem.domain.yield.supply.usecase.YieldSupplyGetShouldShowMainPromoUseCase import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotificationUM -import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles import io.mockk.clearMocks import io.mockk.coEvery import io.mockk.every @@ -42,7 +41,6 @@ internal class GetWalletNotificationsCarouselFactoryTest { private val notificationsRepository: NotificationsRepository = mockk() private val shouldShowYieldBoostMainBannerUseCase: ShouldShowYieldBoostMainBannerUseCase = mockk() private val yieldSupplyGetShouldShowMainPromoUseCase: YieldSupplyGetShouldShowMainPromoUseCase = mockk() - private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles = mockk() private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier = mockk(relaxed = true) private val clickIntents: WalletClickIntents = mockk(relaxed = true) private val userWallet: UserWallet.Hot = mockk(relaxed = true) @@ -53,7 +51,6 @@ internal class GetWalletNotificationsCarouselFactoryTest { notificationsRepository = notificationsRepository, shouldShowYieldBoostMainBannerUseCase = shouldShowYieldBoostMainBannerUseCase, yieldSupplyGetShouldShowMainPromoUseCase = yieldSupplyGetShouldShowMainPromoUseCase, - yieldSupplyFeatureToggles = yieldSupplyFeatureToggles, singleAccountStatusListSupplier = singleAccountStatusListSupplier, ) @@ -65,7 +62,6 @@ internal class GetWalletNotificationsCarouselFactoryTest { notificationsRepository, shouldShowYieldBoostMainBannerUseCase, yieldSupplyGetShouldShowMainPromoUseCase, - yieldSupplyFeatureToggles, singleAccountStatusListSupplier, clickIntents, userWallet, @@ -77,7 +73,6 @@ internal class GetWalletNotificationsCarouselFactoryTest { every { isReadyToShowRateAppUseCase() } returns flowOf(false) every { getWalletsUseCase() } returns flowOf(emptyList()) every { yieldSupplyGetShouldShowMainPromoUseCase() } returns flowOf(true) - every { yieldSupplyFeatureToggles.isYieldPromoEnabled } returns true coEvery { shouldShowYieldBoostMainBannerUseCase(any()) } returns Either.Right(true) // Balance is loaded by default, so banners gated on balance are not suppressed. every { @@ -89,7 +84,6 @@ internal class GetWalletNotificationsCarouselFactoryTest { @MethodSource("provideTestModels") fun `GIVEN gating conditions WHEN create THEN yield boost banner visibility matches`(model: Model) = runTest { // Arrange - every { yieldSupplyFeatureToggles.isYieldPromoEnabled } returns model.toggleEnabled every { yieldSupplyGetShouldShowMainPromoUseCase() } returns flowOf(model.shouldShowLocal) coEvery { shouldShowYieldBoostMainBannerUseCase(WALLET_ID) } returns model.mainBanner @@ -145,19 +139,16 @@ internal class GetWalletNotificationsCarouselFactoryTest { ) internal data class Model( - val toggleEnabled: Boolean, val shouldShowLocal: Boolean, val mainBanner: Either, val expectedShown: Boolean, ) private fun provideTestModels() = listOf( - Model(toggleEnabled = true, shouldShowLocal = true, mainBanner = Either.Right(true), expectedShown = true), - Model(toggleEnabled = false, shouldShowLocal = true, mainBanner = Either.Right(true), expectedShown = false), - Model(toggleEnabled = true, shouldShowLocal = false, mainBanner = Either.Right(true), expectedShown = false), - Model(toggleEnabled = true, shouldShowLocal = true, mainBanner = Either.Right(false), expectedShown = false), + Model(shouldShowLocal = true, mainBanner = Either.Right(true), expectedShown = true), + Model(shouldShowLocal = false, mainBanner = Either.Right(true), expectedShown = false), + Model(shouldShowLocal = true, mainBanner = Either.Right(false), expectedShown = false), Model( - toggleEnabled = true, shouldShowLocal = true, mainBanner = Either.Left(RuntimeException("boom")), expectedShown = false, diff --git a/features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/YieldSupplyFeatureToggles.kt b/features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/YieldSupplyFeatureToggles.kt deleted file mode 100644 index 6e45ae6093..0000000000 --- a/features/yield-supply/api/src/main/java/com/tangem/features/yield/supply/api/YieldSupplyFeatureToggles.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.tangem.features.yield.supply.api - -interface YieldSupplyFeatureToggles { - val isYieldPromoEnabled: Boolean -} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/DefaultYieldSupplyFeatureToggles.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/DefaultYieldSupplyFeatureToggles.kt deleted file mode 100644 index f277bfeff8..0000000000 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/DefaultYieldSupplyFeatureToggles.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.tangem.features.yield.supply.impl - -import com.tangem.core.configtoggle.FeatureToggles -import com.tangem.core.configtoggle.feature.FeatureTogglesManager -import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles -import javax.inject.Inject - -internal class DefaultYieldSupplyFeatureToggles @Inject constructor( - featureTogglesManager: FeatureTogglesManager, -) : YieldSupplyFeatureToggles { - - override val isYieldPromoEnabled: Boolean = featureTogglesManager.isFeatureEnabled( - toggle = FeatureToggles.AND_15154_YIELD_PROMO_ENABLED, - ) -} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/model/YieldSupplyActiveModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/model/YieldSupplyActiveModel.kt index 8751c8920d..c51653bd5b 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/model/YieldSupplyActiveModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/active/model/YieldSupplyActiveModel.kt @@ -33,7 +33,6 @@ import com.tangem.domain.yield.supply.models.YieldBoostStatus import com.tangem.domain.yield.supply.promo.usecase.GetYieldBoostStatusUseCase import com.tangem.domain.yield.supply.usecase.* import com.tangem.features.yield.supply.api.YieldSupplyActiveComponent -import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics import com.tangem.features.yield.supply.impl.R import com.tangem.core.res.R as CoreResR @@ -72,7 +71,6 @@ internal class YieldSupplyActiveModel @Inject constructor( private val appRouter: AppRouter, private val yieldSupplyGetDustMinAmountUseCase: YieldSupplyGetDustMinAmountUseCase, private val getYieldBoostStatusUseCase: GetYieldBoostStatusUseCase, - private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles, private val boostStoryPreloader: YieldBoostStoryPreloader, ) : Model(), YieldSupplyStopEarningComponent.ModelCallback, YieldSupplyApproveComponent.ModelCallback { @@ -236,7 +234,6 @@ internal class YieldSupplyActiveModel @Inject constructor( } private fun loadBoostBlock() { - if (!yieldSupplyFeatureToggles.isYieldPromoEnabled) return modelScope.launch(dispatchers.io) { val token = cryptoCurrency as? CryptoCurrency.Token ?: return@launch val cached = getYieldBoostStatusUseCase(userWalletId).getOrNull() diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/di/YieldSupplyFeatureModule.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/di/YieldSupplyFeatureModule.kt deleted file mode 100644 index 0905d00fe8..0000000000 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/di/YieldSupplyFeatureModule.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.tangem.features.yield.supply.impl.di - -import com.tangem.core.configtoggle.feature.FeatureTogglesManager -import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles -import com.tangem.features.yield.supply.impl.DefaultYieldSupplyFeatureToggles -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -internal object YieldSupplyFeatureModule { - - @Provides - @Singleton - fun provideYieldSupplyFeatureToggles(featureTogglesManager: FeatureTogglesManager): YieldSupplyFeatureToggles { - return DefaultYieldSupplyFeatureToggles(featureTogglesManager) - } -} \ No newline at end of file diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/entry/model/YieldSupplyEntryModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/entry/model/YieldSupplyEntryModel.kt index 3be94f3cc6..c0ad54b7ac 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/entry/model/YieldSupplyEntryModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/entry/model/YieldSupplyEntryModel.kt @@ -14,7 +14,6 @@ import com.tangem.domain.tokens.model.details.NavigationAction import com.tangem.domain.yield.supply.promo.usecase.IsYieldBoostPromoEnabledForTokenUseCase import com.tangem.domain.yield.supply.usecase.YieldSupplyEnterStatusUseCase import com.tangem.features.yield.supply.api.YieldSupplyEntryComponent -import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles import com.tangem.features.yield.supply.api.entry.YieldSupplyEntryRoute import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.coroutines.launch @@ -31,7 +30,6 @@ internal class YieldSupplyEntryModel @Inject constructor( private val yieldSupplyEnterStatusUseCase: YieldSupplyEnterStatusUseCase, private val singleAccountStatusListSupplier: SingleAccountStatusListSupplier, private val isYieldBoostPromoEnabledForTokenUseCase: IsYieldBoostPromoEnabledForTokenUseCase, - private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles, ) : Model() { private val params = paramsContainer.require() @@ -96,8 +94,7 @@ internal class YieldSupplyEntryModel @Inject constructor( return if (isActiveYield) { YieldSupplyEntryRoute.Active(cryptoCurrency = token) } else { - val isPromoEnabled = yieldSupplyFeatureToggles.isYieldPromoEnabled && - isYieldBoostPromoEnabledForTokenUseCase(userWalletId, token).getOrElse { false } + val isPromoEnabled = isYieldBoostPromoEnabledForTokenUseCase(userWalletId, token).getOrElse { false } YieldSupplyEntryRoute.Promo( cryptoCurrency = token, apy = params.apy, diff --git a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt index 55600bebc6..255e8d6b3d 100644 --- a/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt +++ b/features/yield-supply/impl/src/main/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModel.kt @@ -31,7 +31,6 @@ import com.tangem.domain.yield.supply.promo.usecase.GetBoostedApyUseCase import com.tangem.domain.yield.supply.promo.usecase.IsYieldBoostPromoEnabledForTokenUseCase import com.tangem.domain.yield.supply.usecase.* import com.tangem.features.yield.supply.api.YieldSupplyComponent -import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics import com.tangem.features.yield.supply.impl.R import com.tangem.features.yield.supply.impl.YieldBoostStoryPreloader @@ -68,7 +67,6 @@ internal class YieldSupplyModel @Inject constructor( private val yieldSupplyGetDustMinAmountUseCase: YieldSupplyGetDustMinAmountUseCase, private val isYieldBoostPromoEnabledForTokenUseCase: IsYieldBoostPromoEnabledForTokenUseCase, private val getBoostedApyUseCase: GetBoostedApyUseCase, - private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles, private val boostStoryPreloader: YieldBoostStoryPreloader, ) : Model(), YieldSupplyClickIntents { @@ -160,9 +158,8 @@ internal class YieldSupplyModel @Inject constructor( val cryptoCurrencyToken = cryptoCurrency as? CryptoCurrency.Token ?: return yieldSupplyGetTokenStatusUseCase(cryptoCurrencyToken) .onRight { tokenStatus -> - val isPromoEnabled = yieldSupplyFeatureToggles.isYieldPromoEnabled && - isYieldBoostPromoEnabledForTokenUseCase(params.userWalletId, cryptoCurrencyToken) - .getOrElse { false } + val isPromoEnabled = isYieldBoostPromoEnabledForTokenUseCase(params.userWalletId, cryptoCurrencyToken) + .getOrElse { false } val boostedApy = if (isPromoEnabled) getBoostedApyUseCase(tokenStatus.apy) else null uiStateLegacy.update( YieldSupplyTokenStatusSuccessTransformer( diff --git a/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/active/model/YieldSupplyActiveModelBoostBlockTest.kt b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/active/model/YieldSupplyActiveModelBoostBlockTest.kt index 311aa39285..1d0281d929 100644 --- a/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/active/model/YieldSupplyActiveModelBoostBlockTest.kt +++ b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/active/model/YieldSupplyActiveModelBoostBlockTest.kt @@ -23,7 +23,6 @@ import com.tangem.domain.yield.supply.usecase.YieldSupplyGetProtocolBalanceUseCa import com.tangem.domain.yield.supply.usecase.YieldSupplyGetTokenStatusUseCase import com.tangem.domain.yield.supply.usecase.YieldSupplyMinAmountUseCase import com.tangem.features.yield.supply.api.YieldSupplyActiveComponent -import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles import com.tangem.features.yield.supply.impl.YieldBoostStoryPreloader import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.coEvery @@ -54,7 +53,6 @@ class YieldSupplyActiveModelBoostBlockTest { private val appRouter: AppRouter = mockk(relaxed = true) private val yieldSupplyGetDustMinAmountUseCase: YieldSupplyGetDustMinAmountUseCase = mockk(relaxed = true) private val getYieldBoostStatusUseCase: GetYieldBoostStatusUseCase = mockk(relaxed = true) - private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles = mockk(relaxed = true) private val boostStoryPreloader: YieldBoostStoryPreloader = mockk(relaxed = true) private val analyticsHandler: AnalyticsEventHandler = mockk(relaxed = true) @@ -83,7 +81,6 @@ class YieldSupplyActiveModelBoostBlockTest { @BeforeEach fun setUp() { - every { yieldSupplyFeatureToggles.isYieldPromoEnabled } returns true every { getUserWalletUseCase.invoke(userWalletId) } returns userWallet.right() every { singleAccountStatusListSupplier.invoke(userWalletId) } returns emptyFlow() coEvery { getSelectedAppCurrencyUseCase.invokeSync() } returns AppCurrency.Default.right() @@ -108,7 +105,6 @@ class YieldSupplyActiveModelBoostBlockTest { appRouter = appRouter, yieldSupplyGetDustMinAmountUseCase = yieldSupplyGetDustMinAmountUseCase, getYieldBoostStatusUseCase = getYieldBoostStatusUseCase, - yieldSupplyFeatureToggles = yieldSupplyFeatureToggles, boostStoryPreloader = boostStoryPreloader, ) @@ -147,16 +143,6 @@ class YieldSupplyActiveModelBoostBlockTest { coVerify(exactly = 1) { getYieldBoostStatusUseCase(userWalletId, true) } } - @Test - fun `GIVEN promo toggle disabled WHEN model created THEN does not query boost status`() = runTest { - every { yieldSupplyFeatureToggles.isYieldPromoEnabled } returns false - - val model = createModel() - - assertThat(model.uiState.value.boostText).isNull() - coVerify(exactly = 0) { getYieldBoostStatusUseCase(any(), any()) } - } - private companion object { const val CONTRACT_ADDRESS = "0xCONTRACT" const val NETWORK_ID = "ethereum" diff --git a/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/entry/model/YieldSupplyEntryModelTest.kt b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/entry/model/YieldSupplyEntryModelTest.kt index 560732b75f..7579628c07 100644 --- a/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/entry/model/YieldSupplyEntryModelTest.kt +++ b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/entry/model/YieldSupplyEntryModelTest.kt @@ -23,7 +23,6 @@ import com.tangem.domain.yield.supply.models.YieldSupplyPendingStatus import com.tangem.domain.yield.supply.promo.usecase.IsYieldBoostPromoEnabledForTokenUseCase import com.tangem.domain.yield.supply.usecase.YieldSupplyEnterStatusUseCase import com.tangem.features.yield.supply.api.YieldSupplyEntryComponent -import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles import com.tangem.features.yield.supply.api.entry.YieldSupplyEntryRoute import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider import io.mockk.clearMocks @@ -48,7 +47,6 @@ internal class YieldSupplyEntryModelTest { private val enterStatusUseCase: YieldSupplyEnterStatusUseCase = mockk() private val accountStatusListSupplier: SingleAccountStatusListSupplier = mockk() private val isPromoEnabledUseCase: IsYieldBoostPromoEnabledForTokenUseCase = mockk() - private val yieldSupplyFeatureToggles: YieldSupplyFeatureToggles = mockk() private val accountStatusList: AccountStatusList = mockk() @@ -56,11 +54,10 @@ internal class YieldSupplyEntryModelTest { fun setUp() { clearMocks( router, enterStatusUseCase, accountStatusListSupplier, - isPromoEnabledUseCase, yieldSupplyFeatureToggles, + isPromoEnabledUseCase, ) mockkObject(CryptoCurrencyStatusOperations) coEvery { accountStatusListSupplier.getSyncOrNull(USER_WALLET_ID) } returns accountStatusList - every { yieldSupplyFeatureToggles.isYieldPromoEnabled } returns true } @AfterEach @@ -176,21 +173,6 @@ internal class YieldSupplyEntryModelTest { assertThat(route.cryptoCurrency).isEqualTo(token()) } - @Test - fun `GIVEN promo toggle disabled WHEN created THEN Promo route with promo disabled`() = runTest { - // Arrange - every { yieldSupplyFeatureToggles.isYieldPromoEnabled } returns false - stubStatusLookup(status(isActive = false).some()) - coEvery { enterStatusUseCase(USER_WALLET_ID, any()) } returns null.right() - - // Act - createModel(currency = token()) - - // Assert - val route = captureReplacedRoute() - assertThat((route as YieldSupplyEntryRoute.Promo).isPromoEnabled).isFalse() - } - @Test fun `GIVEN promo use case returns false WHEN created THEN Promo route with promo disabled`() = runTest { // Arrange @@ -228,7 +210,6 @@ internal class YieldSupplyEntryModelTest { yieldSupplyEnterStatusUseCase = enterStatusUseCase, singleAccountStatusListSupplier = accountStatusListSupplier, isYieldBoostPromoEnabledForTokenUseCase = isPromoEnabledUseCase, - yieldSupplyFeatureToggles = yieldSupplyFeatureToggles, ) private fun pendingEnter(): YieldSupplyPendingStatus = YieldSupplyPendingStatus.Enter(txIds = listOf("0xTx")) diff --git a/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModelTest.kt b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModelTest.kt index 359d3fd1aa..afacc7f2e6 100644 --- a/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModelTest.kt +++ b/features/yield-supply/impl/src/test/java/com/tangem/features/yield/supply/impl/main/model/YieldSupplyModelTest.kt @@ -43,7 +43,6 @@ import com.tangem.domain.yield.supply.usecase.YieldSupplyGetTokenStatusUseCase import com.tangem.domain.yield.supply.usecase.YieldSupplyIsAvailableUseCase import com.tangem.domain.yield.supply.usecase.YieldSupplyMinAmountUseCase import com.tangem.features.yield.supply.api.YieldSupplyComponent -import com.tangem.features.yield.supply.api.YieldSupplyFeatureToggles import com.tangem.features.yield.supply.api.analytics.YieldSupplyAnalytics import com.tangem.features.yield.supply.impl.YieldBoostStoryPreloader import com.tangem.features.yield.supply.impl.main.entity.YieldSupplyUM @@ -87,7 +86,6 @@ internal class YieldSupplyModelTest { private val getDustMinAmountUseCase: YieldSupplyGetDustMinAmountUseCase = mockk() private val isBoostPromoEnabledUseCase: IsYieldBoostPromoEnabledForTokenUseCase = mockk() private val getBoostedApyUseCase = GetBoostedApyUseCase() - private val featureToggles: YieldSupplyFeatureToggles = mockk() private val boostStoryPreloader: YieldBoostStoryPreloader = mockk(relaxed = true) private val userWalletId = UserWalletId("abcdef012345") @@ -108,7 +106,6 @@ internal class YieldSupplyModelTest { coEvery { singleNetworkStatusFetcher(any()) } returns Unit.right() coEvery { getTokenStatusUseCase(any()) } returns marketToken(isActive = true).right() coEvery { isBoostPromoEnabledUseCase(any(), any()) } returns false.right() - every { featureToggles.isYieldPromoEnabled } returns false coEvery { activateUseCase(any(), any(), any()) } returns true.right() coEvery { deactivateUseCase(any(), any()) } returns true.right() coEvery { minAmountUseCase(any(), any()) } returns BigDecimal("5").right() @@ -172,7 +169,6 @@ internal class YieldSupplyModelTest { @Test fun `GIVEN promo enabled for token WHEN status emitted THEN boosted available promo`() = runTest { // Arrange - every { featureToggles.isYieldPromoEnabled } returns true coEvery { isBoostPromoEnabledUseCase(any(), any()) } returns true.right() // Act @@ -583,7 +579,6 @@ internal class YieldSupplyModelTest { yieldSupplyGetDustMinAmountUseCase = getDustMinAmountUseCase, isYieldBoostPromoEnabledForTokenUseCase = isBoostPromoEnabledUseCase, getBoostedApyUseCase = getBoostedApyUseCase, - yieldSupplyFeatureToggles = featureToggles, boostStoryPreloader = boostStoryPreloader, ) From e5c8b6dc573f2d88751636ef0f1f4d389be8803e Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 29 Jun 2026 15:58:22 +0300 Subject: [PATCH 081/210] Updated on 2026-08-14 --- gradle/dependencies.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/dependencies.toml b/gradle/dependencies.toml index 22fc276875..68d82013d6 100644 --- a/gradle/dependencies.toml +++ b/gradle/dependencies.toml @@ -117,7 +117,7 @@ espresso-intents = "3.5.1" junit = "4.13.2" junit5 = "5.8.2" junitAndroidExt = "1.1.5" -mockk = "1.13.4" +mockk = "1.14.11" turbine = "1.2.0" truth = "1.1.3" kaspresso = "1.6.0" From 427c7aed11cc4ba4380c412548eac0168edf2fad Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 29 Jun 2026 15:59:09 +0300 Subject: [PATCH 082/210] Updated on 2026-08-14 --- .../kotlin/com/tangem/common/BaseTestCase.kt | 1 - .../configs/feature_toggles_config.json | 4 - .../FeatureTogglesNamingConventionTest.kt | 1 - .../api/GiveApprovalFeatureToggles.kt | 6 - .../impl/DefaultGiveApprovalFeatureToggles.kt | 15 -- .../impl/di/GiveApprovalBindsModule.kt | 6 - .../presentation/model/StakingClickIntents.kt | 5 - .../impl/presentation/model/StakingModel.kt | 115 +----------- .../state/stub/StakingClickIntentsStub.kt | 5 - ...pprovalBottomSheetInProgressTransformer.kt | 34 ---- ...pprovalBottomSheetTypeChangeTransformer.kt | 23 --- .../ShowApprovalBottomSheetTransformer.kt | 89 ---------- .../impl/presentation/ui/StakingScreen.kt | 3 - .../model/StakingModelTestBase.kt | 5 - .../model/StakingModelTransactionTest.kt | 165 ------------------ 15 files changed, 1 insertion(+), 476 deletions(-) delete mode 100644 features/approval/api/src/main/java/com/tangem/features/approval/api/GiveApprovalFeatureToggles.kt delete mode 100644 features/approval/impl/src/main/java/com/tangem/features/approval/impl/DefaultGiveApprovalFeatureToggles.kt delete mode 100644 features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/SetApprovalBottomSheetInProgressTransformer.kt delete mode 100644 features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/SetApprovalBottomSheetTypeChangeTransformer.kt delete mode 100644 features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/ShowApprovalBottomSheetTransformer.kt diff --git a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt index f9bbb9d7cb..6399061197 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt @@ -184,7 +184,6 @@ abstract class BaseTestCase : TestCase( toggleStates = mapOf( "SWAP_REDESIGN_ENABLED" to false, "ACCOUNTS_FEATURE_ENABLED" to true, - "GASLESS_APPROVAL_ENABLED" to true, "MAIN_SCREEN_QR_SCANNING_ENABLED" to true, "ADD_AND_MANAGE_TOKENS_ENABLED" to true, "ASSETS_DISCOVERY_ENABLED" to true, diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index 17d74b9d0c..021df0f6f5 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -23,10 +23,6 @@ "name": "APP_REDESIGN_ENABLED", "version": "6.0" }, - { - "name": "GASLESS_APPROVAL_ENABLED", - "version": "5.37" - }, { "name": "DYNAMIC_ADDRESSES_ENABLED", "version": "5.39" diff --git a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/feature/FeatureTogglesNamingConventionTest.kt b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/feature/FeatureTogglesNamingConventionTest.kt index 7eea6b890e..006cba99d2 100644 --- a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/feature/FeatureTogglesNamingConventionTest.kt +++ b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/feature/FeatureTogglesNamingConventionTest.kt @@ -43,7 +43,6 @@ internal class FeatureTogglesNamingConventionTest { "APP_REDESIGN_ENABLED", "ASSETS_DISCOVERY_ENABLED", "DYNAMIC_ADDRESSES_ENABLED", - "GASLESS_APPROVAL_ENABLED", "HEDERA_ERC20_ENABLED", "NEW_CARD_SCANNING_ENABLED", "SOLANA_SCALED_UI_AMOUNT_ENABLED", diff --git a/features/approval/api/src/main/java/com/tangem/features/approval/api/GiveApprovalFeatureToggles.kt b/features/approval/api/src/main/java/com/tangem/features/approval/api/GiveApprovalFeatureToggles.kt deleted file mode 100644 index 46410d3fbf..0000000000 --- a/features/approval/api/src/main/java/com/tangem/features/approval/api/GiveApprovalFeatureToggles.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.tangem.features.approval.api - -interface GiveApprovalFeatureToggles { - - val isGaslessApprovalEnabled: Boolean -} \ No newline at end of file diff --git a/features/approval/impl/src/main/java/com/tangem/features/approval/impl/DefaultGiveApprovalFeatureToggles.kt b/features/approval/impl/src/main/java/com/tangem/features/approval/impl/DefaultGiveApprovalFeatureToggles.kt deleted file mode 100644 index 1da5915c8f..0000000000 --- a/features/approval/impl/src/main/java/com/tangem/features/approval/impl/DefaultGiveApprovalFeatureToggles.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.tangem.features.approval.impl - -import com.tangem.core.configtoggle.FeatureToggles -import com.tangem.core.configtoggle.feature.FeatureTogglesManager -import com.tangem.features.approval.api.GiveApprovalFeatureToggles -import javax.inject.Inject - -internal class DefaultGiveApprovalFeatureToggles @Inject constructor( - private val featureToggles: FeatureTogglesManager, -) : GiveApprovalFeatureToggles { - - // Remove GiveTxPermissionBottomSheet and all dependencies with this toggle - override val isGaslessApprovalEnabled: Boolean - get() = featureToggles.isFeatureEnabled(FeatureToggles.GASLESS_APPROVAL_ENABLED) -} \ No newline at end of file diff --git a/features/approval/impl/src/main/java/com/tangem/features/approval/impl/di/GiveApprovalBindsModule.kt b/features/approval/impl/src/main/java/com/tangem/features/approval/impl/di/GiveApprovalBindsModule.kt index dd4f46f896..7033e919d1 100644 --- a/features/approval/impl/src/main/java/com/tangem/features/approval/impl/di/GiveApprovalBindsModule.kt +++ b/features/approval/impl/src/main/java/com/tangem/features/approval/impl/di/GiveApprovalBindsModule.kt @@ -4,11 +4,9 @@ import com.tangem.core.decompose.di.ModelComponent import com.tangem.core.decompose.model.Model import com.tangem.features.approval.api.GiveApprovalComponent import com.tangem.features.approval.api.GiveApprovalEntryComponent -import com.tangem.features.approval.api.GiveApprovalFeatureToggles import com.tangem.features.approval.api.SelectApprovalTypeComponent import com.tangem.features.approval.impl.DefaultGiveApprovalComponent import com.tangem.features.approval.impl.DefaultGiveApprovalEntryComponent -import com.tangem.features.approval.impl.DefaultGiveApprovalFeatureToggles import com.tangem.features.approval.impl.DefaultSelectApprovalTypeComponent import com.tangem.features.approval.impl.model.GiveApprovalModel import com.tangem.features.approval.impl.model.SelectApprovalTypeModel @@ -24,10 +22,6 @@ import javax.inject.Singleton @Module internal interface GiveApprovalFeatureModule { - @Singleton - @Binds - fun bindGiveApprovalFeatureToggle(toggles: DefaultGiveApprovalFeatureToggles): GiveApprovalFeatureToggles - @Binds @Singleton fun bindComponentFactory(factory: DefaultGiveApprovalComponent.Factory): GiveApprovalComponent.Factory diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingClickIntents.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingClickIntents.kt index 9ae89b73e3..7d44addd21 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingClickIntents.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingClickIntents.kt @@ -2,7 +2,6 @@ package com.tangem.features.staking.impl.presentation.model import androidx.compose.runtime.Immutable import com.tangem.common.ui.amountScreen.AmountScreenClickIntents -import com.tangem.common.ui.bottomsheet.permission.state.ApproveType import com.tangem.common.ui.notifications.NotificationUM import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.staking.model.StakingTarget @@ -47,10 +46,6 @@ internal interface StakingClickIntents : AmountScreenClickIntents { fun showApprovalBottomSheet() - fun onApproveTypeChange(approveType: ApproveType) - - fun onApprovalClick() - fun onAmountReduceByClick( reduceAmountBy: BigDecimal, reduceAmountByDiff: BigDecimal, diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt index e808f05372..6d5caf7d6b 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/model/StakingModel.kt @@ -7,15 +7,12 @@ import com.arkivanov.decompose.router.slot.activate import com.arkivanov.decompose.router.slot.dismiss import com.tangem.blockchain.common.TransactionData import com.tangem.blockchain.common.transaction.Fee -import com.tangem.blockchain.common.transaction.TransactionFee import com.tangem.common.TangemBlogUrlBuilder import com.tangem.common.getValidatorsCount import com.tangem.common.routing.AppRouter import com.tangem.common.ui.amountScreen.converters.AmountReduceByTransformer.ReduceByData import com.tangem.common.ui.amountScreen.models.AmountState import com.tangem.common.ui.amountScreen.models.EnterAmountBoundary -import com.tangem.common.ui.bottomsheet.permission.state.ApproveType -import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionBottomSheetConfig import com.tangem.common.ui.notifications.NotificationUM import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.api.ParamsInterceptorHolder @@ -66,7 +63,6 @@ import com.tangem.domain.transaction.usecase.* import com.tangem.domain.utils.convertToSdkAmount import com.tangem.domain.wallets.usecase.GetUserWalletUseCase import com.tangem.features.approval.api.GiveApprovalComponent -import com.tangem.features.approval.api.GiveApprovalFeatureToggles import com.tangem.features.staking.api.StakingComponent import com.tangem.features.staking.impl.R import com.tangem.features.staking.impl.analytics.StakingParamsInterceptor @@ -125,7 +121,6 @@ internal class StakingModel @Inject constructor( getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, private val getUserWalletUseCase: GetUserWalletUseCase, private val sendTransactionUseCase: SendTransactionUseCase, - private val createApprovalTransactionUseCase: CreateApprovalTransactionUseCase, private val getAllowanceUseCase: GetAllowanceUseCase, private val vibratorHapticManager: VibratorHapticManager, private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase, @@ -156,7 +151,6 @@ internal class StakingModel @Inject constructor( private val coroutineScope: AppCoroutineScope, private val innerRouter: InnerStakingRouter, private val messageSender: UiMessageSender, - private val giveApprovalFeatureToggles: GiveApprovalFeatureToggles, appRouter: AppRouter, ) : Model(), StakingClickIntents { @@ -311,7 +305,6 @@ internal class StakingModel @Inject constructor( private val transactionsInProgress: CopyOnWriteArrayList = CopyOnWriteArrayList() private val actionsJobHolder: JobHolder = JobHolder() - private val approvalJobHolder: JobHolder = JobHolder() private val feeJobHolder: JobHolder = JobHolder() private val sendTransactionJobHolder = JobHolder() private val stepChangesJobHolder = JobHolder() @@ -325,7 +318,6 @@ internal class StakingModel @Inject constructor( override fun onDestroy() { super.onDestroy() paramsInterceptorHolder.removeParamsInterceptor(StakingParamsInterceptor.ID) - approvalJobHolder.cancel() feeJobHolder.cancel() sendTransactionJobHolder.cancel() stepChangesJobHolder.cancel() @@ -820,112 +812,7 @@ internal class StakingModel @Inject constructor( } override fun showApprovalBottomSheet() { - if (giveApprovalFeatureToggles.isGaslessApprovalEnabled) { - approvalSlotNavigation.activate(Unit) - } else { - stateController.update( - ShowApprovalBottomSheetTransformer( - userWallet = userWallet, - appCurrencyProvider = Provider { currentAppCurrency.value }, - cryptoCurrencyStatusProvider = Provider { cryptoCurrencyStatus }, - feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, - ) { - stateController.update(DismissBottomSheetStateTransformer) - }, - ) - } - } - - override fun onApproveTypeChange(approveType: ApproveType) { - stateController.update(SetApprovalBottomSheetTypeChangeTransformer(approveType)) - } - - @Suppress("LongMethod") - override fun onApprovalClick() { - modelScope.launch { - stateController.update( - SetApprovalBottomSheetInProgressTransformer { - stateController.update(DismissBottomSheetStateTransformer) - }, - ) - - val tokenCryptoCurrency = - cryptoCurrencyStatus.currency as? CryptoCurrency.Token ?: error("No token currency") - val amountValue = (value.amountState as? AmountState.Data)?.amountTextField?.cryptoAmount?.value - - val confirmationState = value.confirmationState as? StakingStates.ConfirmationState.Data - ?: error("No confirmation state") - val fee = (confirmationState.feeState as? FeeState.Content)?.fee ?: error("No fee provided") - val approval = stakingApproval as? StakingApproval.Needed ?: error("No staking approve spender address") - - val approvalBottomSheetConfig = value.bottomSheetConfig?.content as? GiveTxPermissionBottomSheetConfig - val isLimitedApproval = approvalBottomSheetConfig?.data?.approveType == ApproveType.LIMITED - - val approvalTransaction = createApprovalTransactionUseCase( - amount = amountValue.takeIf { isLimitedApproval }, - contractAddress = tokenCryptoCurrency.contractAddress, - spenderAddress = approval.spenderAddress, - fee = fee, - cryptoCurrencyStatus = cryptoCurrencyStatus, - userWalletId = userWalletId, - ).fold( - ifLeft = { error -> - TangemLogger.e(error.toString()) - analyticsEventHandler.send( - StakingAnalyticsEvent.TransactionError( - errorCode = "CreateApprovalTxError", - ), - ) - stateController.update( - SetConfirmationStateAssentApprovalTransformer( - appCurrencyProvider = Provider { currentAppCurrency.value }, - feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, - fee = TransactionFee.Single(fee), - cryptoCurrencyStatus = cryptoCurrencyStatus, - ), - ) - stakingEventFactory.createGenericErrorAlert(error.message ?: error.toString()) - stateController.update( - SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus = cryptoCurrencyStatus), - ) - return@launch - }, - ifRight = { it }, - ) - - sendTransactionUseCase( - txData = approvalTransaction, - userWallet = userWallet, - network = tokenCryptoCurrency.network, - ).fold( - ifLeft = { error -> - TangemLogger.e(error.toString()) - analyticsEventHandler.send( - StakingAnalyticsEvent.TransactionError( - errorCode = error.getAnalyticsDescription(), - ), - ) - stateController.update( - SetConfirmationStateAssentApprovalTransformer( - appCurrencyProvider = Provider { currentAppCurrency.value }, - feeCryptoCurrencyStatus = feeCryptoCurrencyStatus, - fee = TransactionFee.Single(fee), - cryptoCurrencyStatus = cryptoCurrencyStatus, - ), - ) - stakingEventFactory.createSendTransactionErrorAlert(error) - stateController.update( - SetConfirmationStateResetAssentTransformer(cryptoCurrencyStatus = cryptoCurrencyStatus), - ) - }, - ifRight = { - stakingAnalyticSender.sendTransactionApprovalAnalytics(tokenCryptoCurrency) - stateController.update(SetApprovalInProgressTransformer) - stateController.update(DismissBottomSheetStateTransformer) - awaitForAllowance() - }, - ) - }.saveIn(approvalJobHolder) + approvalSlotNavigation.activate(Unit) } private fun updateNotifications(feeError: GetFeeError? = null, stakingError: StakingError? = null) { diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/stub/StakingClickIntentsStub.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/stub/StakingClickIntentsStub.kt index 46ef8238ce..3cb1ba8966 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/stub/StakingClickIntentsStub.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/stub/StakingClickIntentsStub.kt @@ -1,6 +1,5 @@ package com.tangem.features.staking.impl.presentation.state.stub -import com.tangem.common.ui.bottomsheet.permission.state.ApproveType import com.tangem.common.ui.notifications.NotificationUM import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.staking.model.StakingTarget @@ -46,10 +45,6 @@ internal object StakingClickIntentsStub : StakingClickIntents { override fun showApprovalBottomSheet() {} - override fun onApproveTypeChange(approveType: ApproveType) {} - - override fun onApprovalClick() {} - override fun onExploreClick() {} override fun onShareClick() {} diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/SetApprovalBottomSheetInProgressTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/SetApprovalBottomSheetInProgressTransformer.kt deleted file mode 100644 index 997a7e6743..0000000000 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/SetApprovalBottomSheetInProgressTransformer.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.tangem.features.staking.impl.presentation.state.transformers.approval - -import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionBottomSheetConfig -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent -import com.tangem.features.staking.impl.presentation.state.StakingUiState -import com.tangem.utils.transformer.Transformer - -internal class SetApprovalBottomSheetInProgressTransformer( - private val onDismiss: () -> Unit, -) : Transformer { - override fun transform(prevState: StakingUiState): StakingUiState { - val approvalBottomSheetConfig = prevState.bottomSheetConfig?.content as? GiveTxPermissionBottomSheetConfig - return prevState.copy( - bottomSheetConfig = prevState.bottomSheetConfig?.copy( - onDismissRequest = onDismiss, - isShown = true, - content = approvalBottomSheetConfig?.let { config -> - config.copy( - data = config.data.copy( - approveButton = config.data.approveButton.copy( - isEnabled = false, - isLoading = true, - ), - cancelButton = config.data.cancelButton.copy( - enabled = false, - ), - ), - onCancel = onDismiss, - ) - } as? TangemBottomSheetConfigContent ?: return prevState, - ), - ) - } -} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/SetApprovalBottomSheetTypeChangeTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/SetApprovalBottomSheetTypeChangeTransformer.kt deleted file mode 100644 index 099b77c51a..0000000000 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/SetApprovalBottomSheetTypeChangeTransformer.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.tangem.features.staking.impl.presentation.state.transformers.approval - -import com.tangem.common.ui.bottomsheet.permission.state.ApproveType -import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionBottomSheetConfig -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfigContent -import com.tangem.features.staking.impl.presentation.state.StakingUiState -import com.tangem.utils.transformer.Transformer - -internal class SetApprovalBottomSheetTypeChangeTransformer( - private val approveType: ApproveType, -) : Transformer { - override fun transform(prevState: StakingUiState): StakingUiState { - val approvalBottomSheetConfig = prevState.bottomSheetConfig?.content as? GiveTxPermissionBottomSheetConfig - - return prevState.copy( - bottomSheetConfig = prevState.bottomSheetConfig?.copy( - content = approvalBottomSheetConfig?.copy( - data = approvalBottomSheetConfig.data.copy(approveType = approveType), - ) as? TangemBottomSheetConfigContent ?: return prevState, - ), - ) - } -} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/ShowApprovalBottomSheetTransformer.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/ShowApprovalBottomSheetTransformer.kt deleted file mode 100644 index 61a3465420..0000000000 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/state/transformers/approval/ShowApprovalBottomSheetTransformer.kt +++ /dev/null @@ -1,89 +0,0 @@ -package com.tangem.features.staking.impl.presentation.state.transformers.approval - -import com.tangem.common.ui.amountScreen.models.AmountState -import com.tangem.common.ui.bottomsheet.permission.state.* -import com.tangem.common.ui.userwallet.ext.walletInterationIcon -import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig -import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.extensions.wrappedList -import com.tangem.core.ui.format.bigdecimal.crypto -import com.tangem.core.ui.format.bigdecimal.fiat -import com.tangem.core.ui.format.bigdecimal.format -import com.tangem.domain.appcurrency.model.AppCurrency -import com.tangem.domain.models.currency.CryptoCurrencyStatus -import com.tangem.domain.models.wallet.UserWallet -import com.tangem.features.staking.impl.R -import com.tangem.features.staking.impl.presentation.state.FeeState -import com.tangem.features.staking.impl.presentation.state.StakingStates -import com.tangem.features.staking.impl.presentation.state.StakingUiState -import com.tangem.utils.Provider -import com.tangem.utils.transformer.Transformer - -internal class ShowApprovalBottomSheetTransformer( - private val userWallet: UserWallet, - private val appCurrencyProvider: Provider, - private val cryptoCurrencyStatusProvider: Provider, - private val feeCryptoCurrencyStatus: CryptoCurrencyStatus?, - private val onDismiss: () -> Unit, -) : Transformer { - override fun transform(prevState: StakingUiState): StakingUiState { - val cryptoCurrency = cryptoCurrencyStatusProvider().currency - val cryptoCurrencyValue = cryptoCurrencyStatusProvider().value - - val amountState = prevState.amountState as? AmountState.Data ?: return prevState - val confirmationState = prevState.confirmationState as? StakingStates.ConfirmationState.Data ?: return prevState - val validatorState = prevState.validatorState as? StakingStates.ValidatorState.Data ?: return prevState - val feeState = confirmationState.feeState as? FeeState.Content ?: return prevState - val fee = feeState.fee ?: return prevState - - val walletAddress = cryptoCurrencyValue.networkAddress?.defaultAddress?.value.orEmpty() - val targetAddress = validatorState.chosenTarget.address - val feeCryptoValue = fee.amount.value.format { - crypto(fee.amount.currencySymbol, fee.amount.decimals) - } - val feeFiatValue = feeCryptoCurrencyStatus?.value?.fiatRate?.multiply(fee.amount.value).format { - fiat( - fiatCurrencyCode = appCurrencyProvider().code, - fiatCurrencySymbol = appCurrencyProvider().symbol, - ) - } - return prevState.copy( - bottomSheetConfig = TangemBottomSheetConfig( - isShown = true, - onDismissRequest = onDismiss, - content = GiveTxPermissionBottomSheetConfig( - data = GiveTxPermissionState.ReadyForRequest( - currency = cryptoCurrency.symbol, - amount = amountState.amountTextField.value, - approveType = ApproveType.UNLIMITED, - walletAddress = walletAddress, - spenderAddress = targetAddress, - fee = resourceReference( - R.string.common_crypto_fiat_format, - wrappedList(feeCryptoValue, feeFiatValue), - ), - approveButton = ApprovePermissionButton( - isEnabled = true, - isLoading = false, - onClick = prevState.clickIntents::onApprovalClick, - ), - cancelButton = CancelPermissionButton( - enabled = true, - ), - subtitle = resourceReference( - id = R.string.give_permission_staking_subtitle, - formatArgs = wrappedList(cryptoCurrency.symbol), - ), - dialogText = resourceReference(R.string.give_permission_staking_footer), - footerText = resourceReference(R.string.staking_give_permission_fee_footer), - onChangeApproveType = prevState.clickIntents::onApproveTypeChange, - onOpenLearnMoreAboutApproveClick = {}, - isResetApproval = false, - ), - walletInteractionIcon = walletInterationIcon(userWallet), - onCancel = onDismiss, - ), - ), - ) - } -} \ No newline at end of file diff --git a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingScreen.kt b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingScreen.kt index 836d37dec7..52778ebcf3 100644 --- a/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingScreen.kt +++ b/features/staking/impl/src/main/java/com/tangem/features/staking/impl/presentation/ui/StakingScreen.kt @@ -12,8 +12,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import com.tangem.common.ui.amountScreen.AmountScreenContent -import com.tangem.common.ui.bottomsheet.permission.GiveTxPermissionBottomSheet -import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionBottomSheetConfig import com.tangem.common.ui.navigationButtons.NavigationButtonsBlock import com.tangem.common.ui.navigationButtons.NavigationButtonsState import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon @@ -76,7 +74,6 @@ fun StakingBottomSheet(bottomSheetConfig: TangemBottomSheetConfig?) { if (bottomSheetConfig == null) return when (bottomSheetConfig.content) { is StakingInfoBottomSheetConfig -> StakingInfoBottomSheet(bottomSheetConfig) - is GiveTxPermissionBottomSheetConfig -> GiveTxPermissionBottomSheet(bottomSheetConfig) is StakingActionSelectionBottomSheetConfig -> StakingActionSelectorBottomSheet(bottomSheetConfig) is TonInitializeAccountBottomSheetConfig -> TonInitializeAccountBottomSheet(bottomSheetConfig) } diff --git a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTestBase.kt b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTestBase.kt index dccea3b0b6..cc4c746a1f 100644 --- a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTestBase.kt +++ b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTestBase.kt @@ -32,7 +32,6 @@ import com.tangem.domain.staking.repositories.P2PEthPoolRepository import com.tangem.domain.tokens.* import com.tangem.domain.transaction.usecase.* import com.tangem.domain.wallets.usecase.GetUserWalletUseCase -import com.tangem.features.approval.api.GiveApprovalFeatureToggles import com.tangem.features.staking.api.StakingComponent import com.tangem.features.staking.impl.navigation.InnerStakingRouter import com.tangem.features.staking.impl.presentation.state.StakingStateController @@ -86,7 +85,6 @@ internal abstract class StakingModelTestBase { protected val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase = mockk() protected val getMinimumTransactionAmountSyncUseCase: GetMinimumTransactionAmountSyncUseCase = mockk() protected val sendTransactionUseCase: SendTransactionUseCase = mockk() - protected val createApprovalTransactionUseCase: CreateApprovalTransactionUseCase = mockk() protected val getAllowanceUseCase: GetAllowanceUseCase = mockk() protected val vibratorHapticManager: VibratorHapticManager = mockk() protected val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase = mockk() @@ -114,7 +112,6 @@ internal abstract class StakingModelTestBase { private val coroutineScope: AppCoroutineScope = mockk() protected val innerRouter: InnerStakingRouter = mockk() protected val messageSender: UiMessageSender = mockk() - protected val giveApprovalFeatureToggles: GiveApprovalFeatureToggles = mockk() @BeforeEach fun setUp() { @@ -176,7 +173,6 @@ internal abstract class StakingModelTestBase { getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase, getUserWalletUseCase = getUserWalletUseCase, sendTransactionUseCase = sendTransactionUseCase, - createApprovalTransactionUseCase = createApprovalTransactionUseCase, getAllowanceUseCase = getAllowanceUseCase, vibratorHapticManager = vibratorHapticManager, getWalletMetaInfoUseCase = getWalletMetaInfoUseCase, @@ -207,7 +203,6 @@ internal abstract class StakingModelTestBase { coroutineScope = coroutineScope, innerRouter = innerRouter, messageSender = messageSender, - giveApprovalFeatureToggles = giveApprovalFeatureToggles, appRouter = appRouter, ) } diff --git a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTransactionTest.kt b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTransactionTest.kt index fbb6e9cca6..c83dfa7b5e 100644 --- a/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTransactionTest.kt +++ b/features/staking/impl/src/test/kotlin/com/tangem/features/staking/impl/presentation/model/StakingModelTransactionTest.kt @@ -22,9 +22,6 @@ import com.tangem.features.staking.impl.presentation.state.helpers.StakingTransa import com.tangem.features.staking.impl.presentation.state.transformers.SetConfirmationStateInProgressTransformer import com.tangem.features.staking.impl.presentation.state.transformers.SetConfirmationStateLoadingTransformer import com.tangem.features.staking.impl.presentation.state.transformers.SetConfirmationStateResetAssentTransformer -import com.tangem.features.staking.impl.presentation.state.transformers.approval.SetApprovalBottomSheetInProgressTransformer -import com.tangem.features.staking.impl.presentation.state.transformers.approval.SetApprovalBottomSheetTypeChangeTransformer -import com.tangem.features.staking.impl.presentation.state.transformers.approval.ShowApprovalBottomSheetTransformer import com.tangem.features.staking.impl.presentation.state.transformers.ton.CompleteInitializeBottomSheetTransformer import com.tangem.features.staking.impl.presentation.state.transformers.ton.SetFeeErrorToTonInitializeBottomSheetTransformer import com.tangem.features.staking.impl.presentation.state.transformers.ton.SetFeeToTonInitializeBottomSheetTransformer @@ -305,168 +302,6 @@ internal class StakingModelTransactionTest : StakingModelTestBase() { model.onDestroy() } - @Test - fun `GIVEN gasless approval enabled WHEN showApprovalBottomSheet THEN approvalSlotNavigation activated`() = - runTest { - every { giveApprovalFeatureToggles.isGaslessApprovalEnabled } returns true - - val model = createModel(testScope = this) - advanceUntilIdle() - - model.showApprovalBottomSheet() - - verify(exactly = 0) { - stateController.update( - transformer = match> { it is ShowApprovalBottomSheetTransformer } - ) - } - - model.onDestroy() - } - - @Test - fun `GIVEN gasless disabled WHEN showApprovalBottomSheet THEN ShowApprovalBottomSheetTransformer applied`() = - runTest { - every { giveApprovalFeatureToggles.isGaslessApprovalEnabled } returns false - - val model = createModel(testScope = this) - advanceUntilIdle() - - model.showApprovalBottomSheet() - - verify { - stateController.update( - transformer = match> { it is ShowApprovalBottomSheetTransformer } - ) - } - - model.onDestroy() - } - - @Test - fun `WHEN onApproveTypeChange THEN SetApprovalBottomSheetTypeChangeTransformer applied`() = runTest { - val model = createModel(testScope = this) - advanceUntilIdle() - - model.onApproveTypeChange(ApproveType.LIMITED) - - verify { - stateController.update( - transformer = match> { it is SetApprovalBottomSheetTypeChangeTransformer }, - ) - } - - model.onDestroy() - } - - @Test - fun `GIVEN approval needed WHEN onApprovalClick THEN in progress set and createApprovalTransaction called`() = - runTest { - val spenderAddress = "0x5e3Ef299fDDf15eAa0432E6e66473ace8c13D908" - val expectedNetwork = mockk { - every { name } returns "KEK" - } - val testToken: CryptoCurrency.Token = mockk(relaxed = true) { - every { network } returns expectedNetwork - } - val testCryptoCurrencyStatus: CryptoCurrencyStatus = mockk(relaxed = true) { - every { currency } returns testToken - } - val testAccountCurrencyStatus = mockk { - every { component1() } returns mockk(relaxed = true) - every { component2() } returns testCryptoCurrencyStatus - } - every { - getAccountCurrencyStatusUseCase(testUserWalletId, testCryptoCurrency) - } returns flowOf(testAccountCurrencyStatus) - coEvery { - getFeePaidCryptoCurrencyStatusSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) - } returns Either.Right(mockk(relaxed = true)) - coEvery { - getMinimumTransactionAmountSyncUseCase(testUserWalletId, testCryptoCurrencyStatus) - } returns Either.Left(mockk()) - - // Setup stakingApproval = Needed - mockkObject(StakingIntegrationID.Companion) - every { - StakingIntegrationID.create(any()) - } returns mockk { - every { approval } returns StakingApproval.Needed(spenderAddress) - } - coEvery { - getAllowanceUseCase(testUserWalletId, any(), spenderAddress) - } returns Either.Right(BigDecimal.TEN) - - every { - stakingOperationsFactory.createFeeLoader( - cryptoCurrencyStatus = any(), - userWallet = any(), - integration = any() - ) - } returns mockk { - coEvery { - getFee( - onStakingFee = any(), - onStakingFeeError = any(), - onApprovalFee = any(), - onFeeError = any() - ) - } just Runs - } - val expectedApprovalTx = Either.Right(mockk(relaxed = true)) - coEvery { - createApprovalTransactionUseCase.invoke( - cryptoCurrencyStatus = any(), - userWalletId = any(), - amount = any(), - fee = any(), - contractAddress = any(), - spenderAddress = any(), - ) - } returns expectedApprovalTx - coEvery { - sendTransactionUseCase(any(), any(), any()) - } returns Either.Right("txHash") - every { vibratorHapticManager.performOneTime(any()) } just Runs - - val model = createModel(testScope = this) - advanceUntilIdle() - - // Now override stateController.value with confirmation state after cryptoCurrencyStatus is initialized - val testFee: Fee.Common = mockk(relaxed = true) - val confirmationState = mockk(relaxed = true) { - every { feeState } returns mockk(relaxed = true) { - every { fee } returns testFee - } - } - val uiState = mockk(relaxed = true) { - every { this@mockk.confirmationState } returns confirmationState - every { bottomSheetConfig } returns null - } - every { stateController.value } returns uiState - - model.onApprovalClick() - advanceUntilIdle() - - verify { - stateController.update( - transformer = match> { - it is SetApprovalBottomSheetInProgressTransformer - }, - ) - } - coVerify { - sendTransactionUseCase( - txData = expectedApprovalTx.value, - userWallet = testUserWallet, - network = expectedNetwork, - ) - } - - model.onDestroy() - unmockkObject(StakingIntegrationID.Companion) - } - @Test fun `GIVEN approval needed AND amountState data WHEN getApprovalParams THEN returns non-null params`() = runTest { val spenderAddress = "0x5e3Ef299fDDf15eAa0432E6e66473ace8c13D908" From f1039e9d969797090af24db870d47b3fa6e3c02f Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 29 Jun 2026 14:03:06 +0100 Subject: [PATCH 083/210] Updated on 2026-08-14 --- .../tangem/tap/di/domain/AddressBookDomainModule.kt | 7 +++++++ .../addressbook/usecase/SyncAddressBooksUseCase.kt | 10 ++++++++++ features/wallet/impl/build.gradle.kts | 1 + .../feature/wallet/child/wallet/model/WalletModel.kt | 10 ++++++++++ 4 files changed, 28 insertions(+) create mode 100644 domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/SyncAddressBooksUseCase.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 d16d61ed8a..3c7d4cab2c 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 @@ -8,6 +8,7 @@ import com.tangem.domain.addressbook.time.DefaultIsoTimestampProvider import com.tangem.domain.addressbook.time.IsoTimestampProvider import com.tangem.domain.addressbook.usecase.DeleteContactUseCase import com.tangem.domain.addressbook.usecase.GetContactsUseCase +import com.tangem.domain.addressbook.usecase.SyncAddressBooksUseCase import com.tangem.domain.addressbook.usecase.ValidateContactAddressUseCase import com.tangem.domain.addressbook.usecase.ValidateContactNameUseCase import com.tangem.domain.common.wallets.UserWalletsListRepository @@ -85,6 +86,12 @@ object AddressBookDomainModule { return DeleteContactUseCase(repository = repository) } + @Provides + @Singleton + fun provideSyncAddressBooksUseCase(repository: AddressBookRepository): SyncAddressBooksUseCase { + return SyncAddressBooksUseCase(repository = repository) + } + @Provides @Singleton fun provideAddressBookCipher(): AddressBookCipher = AddressBookCipher() diff --git a/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/SyncAddressBooksUseCase.kt b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/SyncAddressBooksUseCase.kt new file mode 100644 index 0000000000..f0a76af9d5 --- /dev/null +++ b/domain/address-book/src/main/kotlin/com/tangem/domain/addressbook/usecase/SyncAddressBooksUseCase.kt @@ -0,0 +1,10 @@ +package com.tangem.domain.addressbook.usecase + +import com.tangem.domain.addressbook.repository.AddressBookRepository + +class SyncAddressBooksUseCase( + private val repository: AddressBookRepository, +) { + + suspend operator fun invoke() = repository.syncAddressBooks() +} \ No newline at end of file diff --git a/features/wallet/impl/build.gradle.kts b/features/wallet/impl/build.gradle.kts index 9e06c9a21b..cc4d6ecf6b 100644 --- a/features/wallet/impl/build.gradle.kts +++ b/features/wallet/impl/build.gradle.kts @@ -81,6 +81,7 @@ dependencies { /** Domain modules */ implementation(projects.domain.account) implementation(projects.domain.account.status) + implementation(projects.domain.addressBook) implementation(projects.domain.analytics) implementation(projects.domain.appCurrency) implementation(projects.domain.appCurrency.models) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt index 8a0506515f..79ee5b4a30 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt @@ -16,6 +16,7 @@ import com.tangem.core.ui.utils.parseBigDecimal import com.tangem.datasource.local.appsflyer.AppsFlyerStore import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase import com.tangem.domain.account.supplier.SingleAccountListSupplier +import com.tangem.domain.addressbook.usecase.SyncAddressBooksUseCase import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.apptheme.GetAppThemeModeUseCase @@ -127,6 +128,7 @@ internal class WalletModel @Inject constructor( private val walletFeatureToggles: WalletFeatureToggles, private val pushNotificationSettingsFeatureToggles: PushNotificationSettingsFeatureToggles, private val startAssetsDiscoveryUseCase: StartAssetsDiscoveryUseCase, + private val syncAddressBooksUseCase: SyncAddressBooksUseCase, val screenLifecycleProvider: ScreenLifecycleProvider, val innerWalletRouter: InnerWalletRouter, ) : Model() { @@ -162,6 +164,7 @@ internal class WalletModel @Inject constructor( subscribeToMainScreenQrScanning() enableNotificationsIfNeeded() applyPendingAssetsDiscovery() + syncAddressBooks() clickIntents.initialize(innerWalletRouter, modelScope) @@ -877,6 +880,13 @@ internal class WalletModel @Inject constructor( } } + private fun syncAddressBooks() { + modelScope.launch { + syncAddressBooksUseCase() + .onLeft { TangemLogger.e("Failed to sync address books: $it") } + } + } + private fun enableNotificationsIfNeeded() { modelScope.launch { val isUserAllowToEnableNotifications = notificationsRepository.isUserAllowToSubscribeOnPushNotifications() From 4faf6e9cb06e185b96146a7e5cc4a55ae211914d Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 29 Jun 2026 16:03:17 +0300 Subject: [PATCH 084/210] Updated on 2026-08-14 --- .../kotlin/com/tangem/common/BaseTestCase.kt | 1 - .../configs/feature_toggles_config.json | 4 ---- .../FeatureTogglesNamingConventionTest.kt | 1 - .../featuretoggles/WalletFeatureToggles.kt | 2 -- .../intents/WalletContentClickIntents.kt | 10 ++-------- .../DefaultWalletFeatureToggles.kt | 3 --- .../transformers/SetTokenListTransformer.kt | 3 --- .../converter/TokenListStateConverter.kt | 17 +++-------------- .../converter/WalletTokensListUMConverter.kt | 13 ++----------- .../subscribers/AccountListSubscriber.kt | 5 ----- .../subscribers/BasicAccountListSubscriber.kt | 3 --- .../subscribers/SingleWalletSubscriber.kt | 5 ----- .../SingleWalletWithTokenSubscriberLegacy.kt | 5 ----- .../SetTokenListTransformerTest.kt | 1 - .../WalletContentClickIntentsAnalyticsTest.kt | 19 +------------------ 15 files changed, 8 insertions(+), 84 deletions(-) diff --git a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt index 6399061197..680df9eddc 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt @@ -185,7 +185,6 @@ abstract class BaseTestCase : TestCase( "SWAP_REDESIGN_ENABLED" to false, "ACCOUNTS_FEATURE_ENABLED" to true, "MAIN_SCREEN_QR_SCANNING_ENABLED" to true, - "ADD_AND_MANAGE_TOKENS_ENABLED" to true, "ASSETS_DISCOVERY_ENABLED" to true, "VISA_ONBOARDING_ENABLED" to true, // Version-gated toggles released in versions <= 6.0 — forced on so tests run against the actual diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index 021df0f6f5..10c1e5776d 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -47,10 +47,6 @@ "name": "HEDERA_ERC20_ENABLED", "version": "5.37" }, - { - "name": "ADD_AND_MANAGE_TOKENS_ENABLED", - "version": "5.38" - }, { "name": "WALLET_CONNECT_BITCOIN_ENABLED", "version": "undefined" diff --git a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/feature/FeatureTogglesNamingConventionTest.kt b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/feature/FeatureTogglesNamingConventionTest.kt index 006cba99d2..27482ab781 100644 --- a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/feature/FeatureTogglesNamingConventionTest.kt +++ b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/feature/FeatureTogglesNamingConventionTest.kt @@ -39,7 +39,6 @@ internal class FeatureTogglesNamingConventionTest { /** Toggles created before the AND_/TWI_ naming convention. Do NOT add new entries. */ val EXCLUDED_TOGGLES_LIST = setOf( "ADDRESS_SYNC_ENABLED", - "ADD_AND_MANAGE_TOKENS_ENABLED", "APP_REDESIGN_ENABLED", "ASSETS_DISCOVERY_ENABLED", "DYNAMIC_ADDRESSES_ENABLED", diff --git a/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/featuretoggles/WalletFeatureToggles.kt b/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/featuretoggles/WalletFeatureToggles.kt index d61d46ab1f..e736c3833e 100644 --- a/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/featuretoggles/WalletFeatureToggles.kt +++ b/features/wallet/api/src/main/kotlin/com/tangem/features/wallet/featuretoggles/WalletFeatureToggles.kt @@ -7,8 +7,6 @@ package com.tangem.features.wallet.featuretoggles */ interface WalletFeatureToggles { - val isAddAndManageTokensEnabled: Boolean - val isAddFundsStage1Enabled: Boolean val isManageFundsEnabled: Boolean diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt index 0e34ecf2d4..e0b657e86e 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntents.kt @@ -37,7 +37,6 @@ import com.tangem.feature.wallet.presentation.wallet.state.transformers.CloseBot import com.tangem.feature.wallet.presentation.wallet.state.transformers.OpenBottomSheetTransformer import com.tangem.feature.wallet.presentation.wallet.state.transformers.converter.MultiWalletCurrencyActionsConverter import com.tangem.feature.wallet.presentation.wallet.state.utils.WalletEventSender -import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles import com.tangem.utils.coroutines.CoroutineDispatcherProvider import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.flow.collectLatest @@ -114,7 +113,6 @@ internal class WalletContentClickIntentsImplementor @Inject constructor( private val yieldSupplySetShouldShowMainPromoUseCase: YieldSupplySetShouldShowMainPromoUseCase, private val tokenListAnalyticsSender: TokenListAnalyticsSender, private val uiMessageSender: UiMessageSender, - private val walletFeatureToggles: WalletFeatureToggles, ) : BaseWalletClickIntents(), WalletContentClickIntents { override fun onDetailsClick() { @@ -123,12 +121,8 @@ internal class WalletContentClickIntentsImplementor @Inject constructor( override fun onOrganizeTokensClick() { val userWalletId = stateHolder.getSelectedWalletId() - if (walletFeatureToggles.isAddAndManageTokensEnabled) { - analyticsEventHandler.send(PortfolioAnalyticsEvent.ButtonAddManage()) - router.openAddAndManageBottomSheet(userWalletId = userWalletId) - } else { - router.openOrganizeTokensScreen(userWalletId = userWalletId) - } + analyticsEventHandler.send(PortfolioAnalyticsEvent.ButtonAddManage()) + router.openAddAndManageBottomSheet(userWalletId = userWalletId) } override fun onDismissMarketsTooltip() { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/featuretoggles/DefaultWalletFeatureToggles.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/featuretoggles/DefaultWalletFeatureToggles.kt index d465ad74b7..436dc1f2f9 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/featuretoggles/DefaultWalletFeatureToggles.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/featuretoggles/DefaultWalletFeatureToggles.kt @@ -9,9 +9,6 @@ internal class DefaultWalletFeatureToggles @Inject constructor( private val featureToggles: FeatureTogglesManager, ) : WalletFeatureToggles { - override val isAddAndManageTokensEnabled: Boolean - get() = featureToggles.isFeatureEnabled(FeatureToggles.ADD_AND_MANAGE_TOKENS_ENABLED) - override val isAddFundsStage1Enabled: Boolean get() = featureToggles.isFeatureEnabled(FeatureToggles.AND_15310_ADD_FUNDS_STAGE1) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListTransformer.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListTransformer.kt index 4d436e83be..9baaa325c1 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListTransformer.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListTransformer.kt @@ -27,7 +27,6 @@ internal class SetTokenListTransformer( private val shouldShowMainPromo: Boolean, private val isAccountsModeEnabled: Boolean, private val isRedesignEnabled: Boolean, - private val isAddAndManageTokensEnabled: Boolean, private val isMultipleCardsEnabled: Boolean, ) : WalletStateTransformer(userWallet.walletId) { @@ -123,7 +122,6 @@ internal class SetTokenListTransformer( yieldModuleApyMap = yieldSupplyApyMap, stakingAvailabilityMap = stakingAvailabilityMap, shouldShowMainPromo = shouldShowMainPromo, - isAddAndManageTokensEnabled = isAddAndManageTokensEnabled, ).convert(value = this) } @@ -167,7 +165,6 @@ internal class SetTokenListTransformer( shouldShowMainPromo = shouldShowMainPromo, isAccountsModeEnabled = isAccountsModeEnabled, expandedAccounts = params.expandedAccounts, - isAddAndManageTokensEnabled = isAddAndManageTokensEnabled, ).convert(value = params.accountList) } } \ No newline at end of file diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt index 7eb8860cc4..c33ce8fb2d 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/TokenListStateConverter.kt @@ -8,7 +8,6 @@ import com.tangem.core.ui.components.tokenlist.state.PortfolioTokensListItemUM import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM import com.tangem.core.ui.extensions.resourceReference import com.tangem.domain.account.models.AccountStatusList -import com.tangem.domain.account.models.hasMultiCurrencyAccount import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.card.common.util.cardTypesResolver import com.tangem.domain.models.TotalFiatBalance @@ -42,7 +41,6 @@ internal class TokenListStateConverter( private val yieldModuleApyMap: Map, private val stakingAvailabilityMap: Map, shouldShowMainPromo: Boolean, - private val isAddAndManageTokensEnabled: Boolean, ) : Converter { private val yieldSupplyPromoBannerConverter = YieldSupplyPromoBannerConverter( @@ -170,8 +168,7 @@ internal class TokenListStateConverter( } private fun getOrganizeTokensButtonStateV2(accountList: AccountStatusList): WalletOrganizeTokensButtonConfig? { - val shouldShowOrganizeIfOldButton = accountList.hasMultiCurrencyAccount() || isAddAndManageTokensEnabled - return if (shouldShowOrganizeIfOldButton && !isSingleCurrencyWalletWithToken()) { + return if (!isSingleCurrencyWalletWithToken()) { WalletOrganizeTokensButtonConfig( textRes = organizeButtonTextRes(), iconRes = organizeButtonIconRes(), @@ -183,17 +180,9 @@ internal class TokenListStateConverter( } } - private fun organizeButtonTextRes(): Int = if (isAddAndManageTokensEnabled) { - R.string.main_add_and_manage_tokens - } else { - R.string.organize_tokens_title - } + private fun organizeButtonTextRes(): Int = R.string.main_add_and_manage_tokens - private fun organizeButtonIconRes(): Int = if (isAddAndManageTokensEnabled) { - R.drawable.ic_filter_default_24 - } else { - R.drawable.ic_filter_24 - } + private fun organizeButtonIconRes(): Int = R.drawable.ic_filter_default_24 private fun isSingleCurrencyWalletWithToken(): Boolean { return selectedWallet is UserWallet.Cold && diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokensListUMConverter.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokensListUMConverter.kt index a446ce4b30..bbd03e0a0b 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokensListUMConverter.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/converter/WalletTokensListUMConverter.kt @@ -38,7 +38,6 @@ internal class WalletTokensListUMConverter( private val isAccountsModeEnabled: Boolean, private val expandedAccounts: Set, private val stakingAvailabilityMap: Map, - private val isAddAndManageTokensEnabled: Boolean, shouldShowMainPromo: Boolean, ) : Converter { @@ -161,16 +160,8 @@ internal class WalletTokensListUMConverter( } private fun getOrganizeButtonUM(accountList: AccountStatusList): TangemButtonUM? { - val textRes = if (isAddAndManageTokensEnabled) { - R.string.main_add_and_manage_tokens - } else { - R.string.organize_tokens_title - } - val iconRes = if (isAddAndManageTokensEnabled) { - R.drawable.ic_filter_default_24 - } else { - R.drawable.ic_filter_24 - } + val textRes = R.string.main_add_and_manage_tokens + val iconRes = R.drawable.ic_filter_default_24 return if (accountList.flattenCurrencies().isNotEmpty() && !selectedWallet.isSingleWalletWithToken()) { TangemButtonUM( text = resourceReference(textRes), diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/AccountListSubscriber.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/AccountListSubscriber.kt index 3af5b3f3b5..f98a5d1508 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/AccountListSubscriber.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/AccountListSubscriber.kt @@ -13,7 +13,6 @@ import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents import com.tangem.feature.wallet.presentation.account.AccountDependencies import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController import com.tangem.features.tangempay.TangemPayFeatureToggles -import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles import com.tangem.utils.coroutines.combine7 import com.tangem.utils.logging.TangemLogger import dagger.assisted.Assisted @@ -39,13 +38,9 @@ internal class AccountListSubscriber @AssistedInject constructor( private val stakingAvailabilityListUseCase: StakingAvailabilityListUseCase, private val yieldSupplyGetShouldShowMainPromoUseCase: YieldSupplyGetShouldShowMainPromoUseCase, private val designFeatureToggles: DesignFeatureToggles, - private val walletFeatureToggles: WalletFeatureToggles, private val tangemPayFeatureToggles: TangemPayFeatureToggles, ) : BasicAccountListSubscriber() { - override val isAddAndManageTokensEnabled: Boolean - get() = walletFeatureToggles.isAddAndManageTokensEnabled - override fun create(coroutineScope: CoroutineScope): Flow<*> { val walletId = userWallet.walletId.stringValue TangemLogger.i("$TAG[$walletId]: create() called, building combine7") diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/BasicAccountListSubscriber.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/BasicAccountListSubscriber.kt index 258669bd1b..1f71b4a6fb 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/BasicAccountListSubscriber.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/BasicAccountListSubscriber.kt @@ -34,7 +34,6 @@ internal abstract class BasicAccountListSubscriber : BasicWalletSubscriber() { abstract val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase abstract val stateController: WalletStateController abstract val clickIntents: WalletClickIntents - abstract val isAddAndManageTokensEnabled: Boolean override val singleAccountStatusListSupplier: SingleAccountStatusListSupplier get() = accountDependencies.singleAccountStatusListSupplier @@ -107,7 +106,6 @@ internal abstract class BasicAccountListSubscriber : BasicWalletSubscriber() { shouldShowMainPromo = shouldShowMainPromo, isAccountsModeEnabled = isAccountMode, isRedesignEnabled = true, - isAddAndManageTokensEnabled = isAddAndManageTokensEnabled, isMultipleCardsEnabled = isMultipleCardsEnabled, ), ) @@ -172,7 +170,6 @@ internal abstract class BasicAccountListSubscriber : BasicWalletSubscriber() { shouldShowMainPromo = shouldShowMainPromo, isAccountsModeEnabled = false, isRedesignEnabled = false, - isAddAndManageTokensEnabled = isAddAndManageTokensEnabled, isMultipleCardsEnabled = false, ), ) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/SingleWalletSubscriber.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/SingleWalletSubscriber.kt index 253f114062..e848c342a2 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/SingleWalletSubscriber.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/SingleWalletSubscriber.kt @@ -6,7 +6,6 @@ import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents import com.tangem.feature.wallet.presentation.account.AccountDependencies import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController import com.tangem.features.tangempay.TangemPayFeatureToggles -import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -22,13 +21,9 @@ internal class SingleWalletSubscriber @AssistedInject constructor( override val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, override val stateController: WalletStateController, override val clickIntents: WalletClickIntents, - private val walletFeatureToggles: WalletFeatureToggles, private val tangemPayFeatureToggles: TangemPayFeatureToggles, ) : BasicAccountListSubscriber() { - override val isAddAndManageTokensEnabled: Boolean - get() = walletFeatureToggles.isAddAndManageTokensEnabled - override fun create(coroutineScope: CoroutineScope): Flow = combine( flow = getAccountStatusListFlow(), flow2 = getAppCurrencyFlow(), diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/SingleWalletWithTokenSubscriberLegacy.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/SingleWalletWithTokenSubscriberLegacy.kt index 51c0c0a603..267847199c 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/SingleWalletWithTokenSubscriberLegacy.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/SingleWalletWithTokenSubscriberLegacy.kt @@ -5,7 +5,6 @@ import com.tangem.domain.models.wallet.UserWallet import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents import com.tangem.feature.wallet.presentation.account.AccountDependencies import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController -import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles import com.tangem.utils.annotations.RemoveWithToggle import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -22,12 +21,8 @@ internal class SingleWalletWithTokenSubscriberLegacy @AssistedInject constructor override val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, override val stateController: WalletStateController, override val clickIntents: WalletClickIntents, - private val walletFeatureToggles: WalletFeatureToggles, ) : BasicAccountListSubscriber() { - override val isAddAndManageTokensEnabled: Boolean - get() = walletFeatureToggles.isAddAndManageTokensEnabled - override fun create(coroutineScope: CoroutineScope): Flow = combine( flow = getAccountStatusListFlow(), flow2 = getAppCurrencyFlow(), diff --git a/features/wallet/impl/src/test/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListTransformerTest.kt b/features/wallet/impl/src/test/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListTransformerTest.kt index 8f79a07100..c2d1e1f38a 100644 --- a/features/wallet/impl/src/test/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListTransformerTest.kt +++ b/features/wallet/impl/src/test/java/com/tangem/feature/wallet/presentation/wallet/state/transformers/SetTokenListTransformerTest.kt @@ -74,7 +74,6 @@ class SetTokenListTransformerTest { shouldShowMainPromo = false, isAccountsModeEnabled = false, isRedesignEnabled = true, - isAddAndManageTokensEnabled = false, isMultipleCardsEnabled = false, ) } diff --git a/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntentsAnalyticsTest.kt b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntentsAnalyticsTest.kt index a3a30d54e8..d2f587485c 100644 --- a/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntentsAnalyticsTest.kt +++ b/features/wallet/impl/src/test/kotlin/com/tangem/feature/wallet/child/wallet/model/intents/WalletContentClickIntentsAnalyticsTest.kt @@ -6,7 +6,6 @@ import com.tangem.core.analytics.models.AnalyticsEvent import com.tangem.domain.models.wallet.UserWalletId import com.tangem.feature.wallet.presentation.router.InnerWalletRouter import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController -import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles import io.mockk.every import io.mockk.mockk import io.mockk.slot @@ -21,7 +20,6 @@ internal class WalletContentClickIntentsAnalyticsTest { private val stateHolder: WalletStateController = mockk(relaxed = true) private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true) - private val walletFeatureToggles: WalletFeatureToggles = mockk(relaxed = true) private val router: InnerWalletRouter = mockk(relaxed = true) private val userWalletId = UserWalletId(stringValue = "0123456789ABCDEF") @@ -45,16 +43,14 @@ internal class WalletContentClickIntentsAnalyticsTest { yieldSupplySetShouldShowMainPromoUseCase = mockk(relaxed = true), tokenListAnalyticsSender = mockk(relaxed = true), uiMessageSender = mockk(relaxed = true), - walletFeatureToggles = walletFeatureToggles, ) implementor.initialize(router = router, coroutineScope = TestScope()) return implementor } @Test - fun `GIVEN add and manage toggle enabled WHEN onOrganizeTokensClick THEN sends ButtonAddManage event and opens bottom sheet`() = + fun `WHEN onOrganizeTokensClick THEN sends ButtonAddManage event and opens bottom sheet`() = runTest { - every { walletFeatureToggles.isAddAndManageTokensEnabled } returns true val implementor = createImplementor() val captured = slot() @@ -67,17 +63,4 @@ internal class WalletContentClickIntentsAnalyticsTest { verify(exactly = 1) { router.openAddAndManageBottomSheet(userWalletId = userWalletId) } verify(exactly = 0) { router.openOrganizeTokensScreen(any()) } } - - @Test - fun `GIVEN add and manage toggle disabled WHEN onOrganizeTokensClick THEN does not send analytics and opens organize screen`() = - runTest { - every { walletFeatureToggles.isAddAndManageTokensEnabled } returns false - val implementor = createImplementor() - - implementor.onOrganizeTokensClick() - - verify(exactly = 0) { analyticsEventHandler.send(any()) } - verify(exactly = 1) { router.openOrganizeTokensScreen(userWalletId = userWalletId) } - verify(exactly = 0) { router.openAddAndManageBottomSheet(any()) } - } } \ No newline at end of file From e06dc1297dcc98f3b5ce0bc65ec6246736e5083a Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 29 Jun 2026 18:24:12 +0500 Subject: [PATCH 085/210] Updated on 2026-08-14 --- .../tangem/datasource/api/pay/TangemPayApi.kt | 13 ++++ .../response/BankCredentialsResponse.kt | 19 ++++++ data/visa/build.gradle.kts | 1 + .../PaymentAccountStatusValueDMConverter.kt | 2 + .../DefaultPaymentAccountStatusFetcher.kt | 59 +++++++++++++--- .../repository/DefaultOnboardingRepository.kt | 21 ++++++ .../data/pay/util/BankCredentialsConverter.kt | 19 ++++++ .../data/pay/util/CustomerInfoConverter.kt | 8 +++ .../MockAwareOnboardingRepository.kt | 10 +++ .../pay/util/BankCredentialsConverterTest.kt | 67 +++++++++++++++++++ .../domain/models/account/BankCredentials.kt | 20 ++++++ .../account/PaymentAccountStatusValue.kt | 3 + .../models/account/VirtualAccountOnramp.kt | 28 ++++++++ .../models/pay/TangemPayEligibilityType.kt | 3 + .../tangem/domain/pay/model/CustomerInfo.kt | 7 ++ .../pay/repository/OnboardingRepository.kt | 15 +++++ 16 files changed, 287 insertions(+), 8 deletions(-) create mode 100644 core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/BankCredentialsResponse.kt create mode 100644 data/visa/src/main/kotlin/com/tangem/data/pay/util/BankCredentialsConverter.kt create mode 100644 data/visa/src/test/kotlin/com/tangem/data/pay/util/BankCredentialsConverterTest.kt create mode 100644 domain/models/src/main/kotlin/com/tangem/domain/models/account/BankCredentials.kt create mode 100644 domain/models/src/main/kotlin/com/tangem/domain/models/account/VirtualAccountOnramp.kt diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt index 80fcf19f81..37f8928d79 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/TangemPayApi.kt @@ -23,6 +23,13 @@ interface TangemPayApi { @GET("v1/customer/me") suspend fun getCustomerMe(@Header("Authorization") authHeader: String): ApiResponse + /** Fiat bank requisites for the Virtual Account on-ramp (VA MVP0, TWI-1638). */ + @GET("v1/account/bank-credentials/{product_instance_id}") + suspend fun getBankCredentials( + @Header("Authorization") authHeader: String, + @Path("product_instance_id") productInstanceId: String, + ): ApiResponse + @GET("v1/customer/wallets/{customer_wallet_id}") suspend fun checkCustomerWalletId( @Path("customer_wallet_id") customerWalletId: String, @@ -40,6 +47,12 @@ interface TangemPayApi { @GET("v1/eligibility/channels") suspend fun getEligibilityChannels(): ApiResponse + /** Eligibility channels fetched with the user (customer-wallet) token (VA MVP0, TWI-1638). */ + @GET("v1/eligibility/channels") + suspend fun getUserEligibilityChannels( + @Header("Authorization") authHeader: String, + ): ApiResponse + @GET("v1/order/{order_id}") suspend fun getOrder( @Header("Authorization") authHeader: String, diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/BankCredentialsResponse.kt b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/BankCredentialsResponse.kt new file mode 100644 index 0000000000..409984fabc --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/api/pay/models/response/BankCredentialsResponse.kt @@ -0,0 +1,19 @@ +package com.tangem.datasource.api.pay.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * Response of `bff-v2/v1/account/bank-credentials/{product_instance_id}` — fiat bank requisites for the + * Virtual Account on-ramp (VA MVP0, TWI-1638). + */ +@JsonClass(generateAdapter = true) +data class BankCredentialsResponse( + @Json(name = "type") val type: String?, + @Json(name = "beneficiary_name") val beneficiaryName: String?, + @Json(name = "beneficiary_address") val beneficiaryAddress: String?, + @Json(name = "beneficiary_bank_name") val beneficiaryBankName: String?, + @Json(name = "beneficiary_bank_address") val beneficiaryBankAddress: String?, + @Json(name = "account_number") val accountNumber: String?, + @Json(name = "routing_number") val routingNumber: String?, +) \ No newline at end of file diff --git a/data/visa/build.gradle.kts b/data/visa/build.gradle.kts index 2cc54a86b8..420ab0b3d2 100644 --- a/data/visa/build.gradle.kts +++ b/data/visa/build.gradle.kts @@ -48,6 +48,7 @@ dependencies { implementation(projects.domain.quotes) implementation(projects.domain.common) implementation(projects.features.swap.domain) + implementation(projects.features.virtualAccounts.details.api) /** Project - Utils */ diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt index 3b7fe7face..55b9d892dd 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/converter/PaymentAccountStatusValueDMConverter.kt @@ -5,6 +5,7 @@ import com.tangem.datasource.local.visa.entity.PaymentAccountStatusValueDM import com.tangem.domain.models.StatusSource import com.tangem.domain.models.account.CardDisplayName import com.tangem.domain.models.account.PaymentAccountStatusValue +import com.tangem.domain.models.account.VirtualAccountOnramp import com.tangem.domain.models.pay.* import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.TangemPayCurrencyFactory @@ -118,6 +119,7 @@ internal class PaymentAccountStatusValueDMConverter @Inject constructor( ) }, error = null, + virtualAccount = VirtualAccountOnramp.None, ) is PaymentAccountStatusValueDM.UnderReview -> PaymentAccountStatusValue.UnderReview( source = StatusSource.CACHE, diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt index 0c90bc8ef9..e9df89acad 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/flow/DefaultPaymentAccountStatusFetcher.kt @@ -4,21 +4,16 @@ import arrow.core.Either import com.tangem.data.pay.store.PaymentAccountStatusesStore import com.tangem.domain.core.utils.catchOn import com.tangem.domain.models.StatusSource -import com.tangem.domain.models.account.Account -import com.tangem.domain.models.account.AccountStatus -import com.tangem.domain.models.account.PaymentAccountStatusValue -import com.tangem.domain.models.account.hasAccountData +import com.tangem.domain.models.account.* import com.tangem.domain.models.kyc.KycStatus -import com.tangem.domain.models.pay.TangemPayCard -import com.tangem.domain.models.pay.TangemPayCardFrozenState -import com.tangem.domain.models.pay.TangemPayCardLimitData -import com.tangem.domain.models.pay.TangemPayCardState +import com.tangem.domain.models.pay.* import com.tangem.domain.models.quote.QuoteStatus import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.TangemPayCurrencyFactory import com.tangem.domain.pay.TangemPayEligibilityManager import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher import com.tangem.domain.pay.model.CustomerInfo +import com.tangem.domain.pay.model.CustomerInfo.ProductInstance.SpecificationDataType import com.tangem.domain.pay.model.OrderData import com.tangem.domain.pay.model.OrderStatus import com.tangem.domain.pay.model.TangemPayEntryPoint @@ -26,6 +21,7 @@ import com.tangem.domain.pay.repository.* import com.tangem.domain.quotes.single.SingleQuoteStatusProducer import com.tangem.domain.quotes.single.SingleQuoteStatusSupplier import com.tangem.domain.visa.error.VisaApiError +import com.tangem.features.virtualaccount.VirtualAccountFeatureToggles import com.tangem.security.DeviceSecurityInfoProvider import com.tangem.security.isSecurityExposed import com.tangem.utils.coroutines.CoroutineDispatcherProvider @@ -66,6 +62,7 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( private val closeCardRepository: TangemPayCloseCardRepository, private val cardDetailsRepository: TangemPayCardDetailsRepository, private val issueCardRepository: TangemPayIssueCardRepository, + private val virtualAccountFeatureToggles: VirtualAccountFeatureToggles, ) : PaymentAccountStatusFetcher { private val logger = TangemLogger.withTag(TAG) @@ -382,6 +379,8 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( // the previously shown order and append newly seen cards at the end. val orderedCards = tangemPayCards.stableOrder(previousRealCardOrder(userWalletId)) + val virtualAccount = resolveVirtualAccountOnramp(userWalletId) + return PaymentAccountStatusValue.Loaded( source = StatusSource.ACTUAL, customerId = customerId, @@ -395,6 +394,50 @@ internal class DefaultPaymentAccountStatusFetcher @Inject constructor( availableForWithdrawal = availableForWithdrawal.orZero(), ), error = null, + virtualAccount = virtualAccount, + ) + } + + /** + * Resolves the Virtual Account on-ramp dimension (VA MVP0, TWI-1638). Gated by the feature toggle. + * If a product instance with [SpecificationDataType.ACCOUNT] exists, eagerly fetches its bank credentials + * ([VirtualAccountOnramp.Available]); otherwise surfaces [VirtualAccountOnramp.Eligible] when the wallet has + * the `VISA_VIRTUAL_ACCOUNT` eligibility channel (fetched fresh via the user token), else + * [VirtualAccountOnramp.None]. + */ + private suspend fun CustomerInfo.resolveVirtualAccountOnramp(userWalletId: UserWalletId): VirtualAccountOnramp { + if (!virtualAccountFeatureToggles.isVaMvp0Enabled) return VirtualAccountOnramp.None + + val accountInstance = productInstances.firstOrNull { + it.specificationDataType == SpecificationDataType.ACCOUNT + } + if (accountInstance != null) { + return onboardingRepository.getBankCredentials(userWalletId, accountInstance.id).fold( + ifLeft = { error -> + logger.e("getBankCredentials failed for ${accountInstance.id}: $error") + VirtualAccountOnramp.None + }, + ifRight = { credentials -> + VirtualAccountOnramp.Available( + productInstanceId = accountInstance.id, + bankCredentials = credentials, + ) + }, + ) + } + + return onboardingRepository.fetchCustomerEligibility(userWalletId).fold( + ifLeft = { error -> + logger.e("fetchCustomerEligibility failed for $userWalletId: $error") + VirtualAccountOnramp.None + }, + ifRight = { channels -> + if (channels.contains(TangemPayEligibilityType.VISA_VIRTUAL_ACCOUNT)) { + VirtualAccountOnramp.Eligible + } else { + VirtualAccountOnramp.None + } + }, ) } diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt index 932e7041bc..786fc9397c 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/repository/DefaultOnboardingRepository.kt @@ -6,6 +6,7 @@ import arrow.core.left import arrow.core.right import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.data.pay.store.PaymentAccountStatusesStore +import com.tangem.data.pay.util.BankCredentialsConverter import com.tangem.data.pay.util.CustomerInfoConverter import com.tangem.datasource.api.pay.TangemPayApi import com.tangem.datasource.api.pay.models.request.DeeplinkValidityRequest @@ -18,6 +19,7 @@ import com.tangem.datasource.local.visa.TangemPayStorage import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.AccountStatus +import com.tangem.domain.models.account.BankCredentials import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.kyc.KycStatus import com.tangem.domain.models.pay.TangemPayEligibilityType @@ -105,6 +107,15 @@ internal class DefaultOnboardingRepository @Inject constructor( } } + override suspend fun getBankCredentials( + userWalletId: UserWalletId, + productInstanceId: String, + ): Either { + return requestHelper.performRequest(userWalletId) { authHeader -> + tangemPayApi.getBankCredentials(authHeader = authHeader, productInstanceId = productInstanceId) + }.map { response -> BankCredentialsConverter.convert(response) } + } + override suspend fun isTangemPayDeactivated(userWalletId: UserWalletId): Boolean { return tangemPayStorage.isTangemPayDeactivated(userWalletId) } @@ -226,6 +237,16 @@ internal class DefaultOnboardingRepository @Inject constructor( return tangemPayStorage.getTangemPayEligibility().map(TangemPayEligibilityType::fromString) } + override suspend fun fetchCustomerEligibility( + userWalletId: UserWalletId, + ): Either> { + return requestHelper.performRequest(userWalletId) { authHeader -> + tangemPayApi.getUserEligibilityChannels(authHeader) + }.map { response -> + response.result.channels.map(TangemPayEligibilityType::fromString) + } + } + override suspend fun getHideMainOnboardingBanner(userWalletId: UserWalletId): Boolean { return tangemPayStorage.getHideMainOnboardingBanner(userWalletId) } diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/util/BankCredentialsConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/util/BankCredentialsConverter.kt new file mode 100644 index 0000000000..4f027487a6 --- /dev/null +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/util/BankCredentialsConverter.kt @@ -0,0 +1,19 @@ +package com.tangem.data.pay.util + +import com.tangem.datasource.api.pay.models.response.BankCredentialsResponse +import com.tangem.domain.models.account.BankCredentials +import com.tangem.utils.converter.Converter + +internal object BankCredentialsConverter : Converter { + override fun convert(value: BankCredentialsResponse): BankCredentials { + return BankCredentials( + type = value.type.orEmpty(), + beneficiaryName = value.beneficiaryName.orEmpty(), + beneficiaryAddress = value.beneficiaryAddress.orEmpty(), + beneficiaryBankName = value.beneficiaryBankName.orEmpty(), + beneficiaryBankAddress = value.beneficiaryBankAddress.orEmpty(), + accountNumber = value.accountNumber.orEmpty(), + routingNumber = value.routingNumber.orEmpty(), + ) + } +} \ No newline at end of file diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/util/CustomerInfoConverter.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/util/CustomerInfoConverter.kt index cc7ac9f9a8..2fd76d8480 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/util/CustomerInfoConverter.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/util/CustomerInfoConverter.kt @@ -14,6 +14,7 @@ import com.tangem.domain.models.pay.TangemPayCardLimitPeriod import com.tangem.domain.pay.model.CustomerInfo import com.tangem.domain.pay.model.CustomerInfo.CardInfo import com.tangem.domain.pay.model.CustomerInfo.ProductInstance +import com.tangem.domain.pay.model.CustomerInfo.ProductInstance.SpecificationDataType import com.tangem.domain.pay.model.CustomerInfo.ProductInstance.Status import com.tangem.utils.converter.Converter import com.tangem.utils.extensions.orZero @@ -62,6 +63,7 @@ internal object CustomerInfoConverter : Converter Status.CANCELED CustomerMeResponse.ProductInstance.Status.UNKNOWN -> Status.UNKNOWN } + + private fun CustomerMeResponse.ProductInstance.SpecificationDataType.toDomain(): SpecificationDataType = + when (this) { + CustomerMeResponse.ProductInstance.SpecificationDataType.ACCOUNT -> SpecificationDataType.ACCOUNT + CustomerMeResponse.ProductInstance.SpecificationDataType.CARD -> SpecificationDataType.CARD + } } \ No newline at end of file diff --git a/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareOnboardingRepository.kt b/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareOnboardingRepository.kt index 38ccbe554a..14dcad9d79 100644 --- a/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareOnboardingRepository.kt +++ b/data/visa/src/mocked/kotlin/com/tangem/data/pay/repository/MockAwareOnboardingRepository.kt @@ -6,6 +6,7 @@ import com.tangem.core.error.UniversalError import com.tangem.datasource.api.common.config.ApiConfig import com.tangem.datasource.api.common.config.ApiEnvironment import com.tangem.datasource.api.common.config.managers.ApiConfigsManager +import com.tangem.domain.models.account.BankCredentials import com.tangem.domain.models.pay.TangemPayEligibilityType import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.model.CustomerInfo @@ -47,6 +48,11 @@ internal class MockAwareOnboardingRepository @Inject constructor( override suspend fun getCustomerInfo(userWalletId: UserWalletId): Either = real.getCustomerInfo(userWalletId) + override suspend fun getBankCredentials( + userWalletId: UserWalletId, + productInstanceId: String, + ): Either = real.getBankCredentials(userWalletId, productInstanceId) + override suspend fun createOrder(userWalletId: UserWalletId): Either { if (isMockMode) { mockOrderIds.add(userWalletId) @@ -77,6 +83,10 @@ internal class MockAwareOnboardingRepository @Inject constructor( override suspend fun getCustomerEligibility(): List = real.getCustomerEligibility() + override suspend fun fetchCustomerEligibility( + userWalletId: UserWalletId, + ): Either> = real.fetchCustomerEligibility(userWalletId) + override fun getSavedCustomerInfo(userWalletId: UserWalletId): CustomerInfo? = real.getSavedCustomerInfo(userWalletId) diff --git a/data/visa/src/test/kotlin/com/tangem/data/pay/util/BankCredentialsConverterTest.kt b/data/visa/src/test/kotlin/com/tangem/data/pay/util/BankCredentialsConverterTest.kt new file mode 100644 index 0000000000..6ec1cceb6c --- /dev/null +++ b/data/visa/src/test/kotlin/com/tangem/data/pay/util/BankCredentialsConverterTest.kt @@ -0,0 +1,67 @@ +package com.tangem.data.pay.util + +import com.google.common.truth.Truth.assertThat +import com.tangem.datasource.api.pay.models.response.BankCredentialsResponse +import com.tangem.domain.models.account.BankCredentials +import org.junit.jupiter.api.Test + +internal class BankCredentialsConverterTest { + + @Test + fun `GIVEN full response WHEN convert THEN all fields mapped`() { + // Arrange + val response = BankCredentialsResponse( + type = "fiat", + beneficiaryName = "Ivan Ivanov", + beneficiaryAddress = "18, Rue Rubens 20, Paris, Ile-de-France 75013, US", + beneficiaryBankName = "SSB BANK", + beneficiaryBankAddress = "8700 Perry Highway, Pittsburgh, PA 15237, US", + accountNumber = "707613210122", + routingNumber = "043087080", + ) + + // Act + val actual = BankCredentialsConverter.convert(response) + + // Assert + val expected = BankCredentials( + type = "fiat", + beneficiaryName = "Ivan Ivanov", + beneficiaryAddress = "18, Rue Rubens 20, Paris, Ile-de-France 75013, US", + beneficiaryBankName = "SSB BANK", + beneficiaryBankAddress = "8700 Perry Highway, Pittsburgh, PA 15237, US", + accountNumber = "707613210122", + routingNumber = "043087080", + ) + assertThat(actual).isEqualTo(expected) + } + + @Test + fun `GIVEN null fields WHEN convert THEN mapped to empty strings`() { + // Arrange + val response = BankCredentialsResponse( + type = null, + beneficiaryName = null, + beneficiaryAddress = null, + beneficiaryBankName = null, + beneficiaryBankAddress = null, + accountNumber = null, + routingNumber = null, + ) + + // Act + val actual = BankCredentialsConverter.convert(response) + + // Assert + val expected = BankCredentials( + type = "", + beneficiaryName = "", + beneficiaryAddress = "", + beneficiaryBankName = "", + beneficiaryBankAddress = "", + accountNumber = "", + routingNumber = "", + ) + assertThat(actual).isEqualTo(expected) + } +} \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/account/BankCredentials.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/account/BankCredentials.kt new file mode 100644 index 0000000000..adbeacdb40 --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/account/BankCredentials.kt @@ -0,0 +1,20 @@ +package com.tangem.domain.models.account + +import kotlinx.serialization.Serializable + +/** + * Bank (fiat) credentials for a Virtual Account on-ramp — the wire/ACH requisites a user transfers funds to. + * + * Returned by `bff-v2/v1/account/bank-credentials/{product_instance_id}`. Sensitive data — kept transient + * (never persisted in the local payment-account cache). + */ +@Serializable +data class BankCredentials( + val type: String, + val beneficiaryName: String, + val beneficiaryAddress: String, + val beneficiaryBankName: String, + val beneficiaryBankAddress: String, + val accountNumber: String, + val routingNumber: String, +) \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt index d32bc79f81..e9699e8118 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/account/PaymentAccountStatusValue.kt @@ -149,6 +149,8 @@ sealed class PaymentAccountStatusValue { * [totalFiatBalance] resolves to [TotalFiatBalance.Failed]. * @property error Transient error overlaid on top of cached data when a refresh fails * (see [copySealed]), or `null` when the status is up to date. Not persisted. + * @property virtualAccount Virtual Account (Visa on-ramp) availability — VA MVP0 (TWI-1638). + * Transient: not persisted in the local cache. */ @Serializable data class Loaded( @@ -160,6 +162,7 @@ sealed class PaymentAccountStatusValue { val cards: List, val fiatRate: SerializedBigDecimal?, val error: Error?, + val virtualAccount: VirtualAccountOnramp, ) : PaymentAccountStatusValue() { val cryptoCurrencyStatus: CryptoCurrencyStatus = CryptoCurrencyStatus( currency = cryptoCurrency, diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/account/VirtualAccountOnramp.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/account/VirtualAccountOnramp.kt new file mode 100644 index 0000000000..2b504b4c0f --- /dev/null +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/account/VirtualAccountOnramp.kt @@ -0,0 +1,28 @@ +package com.tangem.domain.models.account + +import kotlinx.serialization.Serializable + +/** + * Virtual Account (Visa on-ramp) availability for a payment account — VA MVP0 (TWI-1638). + * + * Computed in the payment-account fetcher and surfaced on [PaymentAccountStatusValue.Loaded]. + * Transient: [Available.bankCredentials] is never persisted in the local cache. + */ +@Serializable +sealed interface VirtualAccountOnramp { + + /** On-ramp not applicable: feature toggle off, or wallet not eligible. */ + @Serializable + data object None : VirtualAccountOnramp + + /** No VA product instance yet, but the wallet is eligible to add funds (channel `VISA_VIRTUAL_ACCOUNT`). */ + @Serializable + data object Eligible : VirtualAccountOnramp + + /** VA product instance exists; [bankCredentials] are the fiat requisites for the bank-transfer top-up. */ + @Serializable + data class Available( + val productInstanceId: String, + val bankCredentials: BankCredentials, + ) : VirtualAccountOnramp +} \ No newline at end of file diff --git a/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayEligibilityType.kt b/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayEligibilityType.kt index 8c2bf08afa..2bac433902 100644 --- a/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayEligibilityType.kt +++ b/domain/models/src/main/kotlin/com/tangem/domain/models/pay/TangemPayEligibilityType.kt @@ -10,6 +10,8 @@ enum class TangemPayEligibilityType { DETAILS_VIRTUAL_ACCOUNT, DEEPLINK_VIRTUAL_ACCOUNT, + VISA_VIRTUAL_ACCOUNT, + UNKNOWN, ; @@ -21,6 +23,7 @@ enum class TangemPayEligibilityType { "BANNER_VIRTUAL_ACCOUNT" -> BANNER_VIRTUAL_ACCOUNT "DETAILS_VIRTUAL_ACCOUNT" -> DETAILS_VIRTUAL_ACCOUNT "DEEPLINK_VIRTUAL_ACCOUNT" -> DEEPLINK_VIRTUAL_ACCOUNT + "VISA_VIRTUAL_ACCOUNT" -> VISA_VIRTUAL_ACCOUNT else -> UNKNOWN } } diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt index 2b96bd312f..752773bb5f 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/CustomerInfo.kt @@ -67,6 +67,7 @@ data class CustomerInfo( val actualCardLimit: TangemPayCardLimit?, val adminCardLimit: TangemPayCardLimit?, val status: Status, + val specificationDataType: SpecificationDataType, ) { enum class Status { NEW, @@ -82,6 +83,12 @@ data class CustomerInfo( CANCELED, UNKNOWN, } + + /** `ACCOUNT` marks a Virtual Account instance (vs. a `CARD`); used by VA MVP0 (TWI-1638). */ + enum class SpecificationDataType { + ACCOUNT, + CARD, + } } data class CardInfo( diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/OnboardingRepository.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/OnboardingRepository.kt index bce59b45c7..169c6ed172 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/OnboardingRepository.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/repository/OnboardingRepository.kt @@ -2,6 +2,7 @@ package com.tangem.domain.pay.repository import arrow.core.Either import com.tangem.core.error.UniversalError +import com.tangem.domain.models.account.BankCredentials import com.tangem.domain.models.pay.TangemPayEligibilityType import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.model.CustomerInfo @@ -17,6 +18,12 @@ interface OnboardingRepository { suspend fun getCustomerInfo(userWalletId: UserWalletId): Either + /** Fiat bank requisites for the wallet's Virtual Account on-ramp instance (VA MVP0, TWI-1638). */ + suspend fun getBankCredentials( + userWalletId: UserWalletId, + productInstanceId: String, + ): Either + suspend fun createOrder(userWalletId: UserWalletId): Either suspend fun clearOrderId(userWalletId: UserWalletId) @@ -28,6 +35,14 @@ interface OnboardingRepository { suspend fun checkCustomerEligibility(): List suspend fun getCustomerEligibility(): List + /** + * Fetches eligibility channels fresh via the user token (always hits the network, no cache read/write). + * Differs from [checkCustomerEligibility] (static token, caches) and [getCustomerEligibility] (cache only). + */ + suspend fun fetchCustomerEligibility( + userWalletId: UserWalletId, + ): Either> + fun getSavedCustomerInfo(userWalletId: UserWalletId): CustomerInfo? suspend fun getHideMainOnboardingBanner(userWalletId: UserWalletId): Boolean From c6190d936685f2bfe839880ba8f9fe0323dfa5cb Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 29 Jun 2026 16:25:24 +0300 Subject: [PATCH 086/210] Updated on 2026-08-14 --- .../kotlin/com/tangem/common/BaseTestCase.kt | 1 - .../assets/configs/feature_toggles_config.json | 4 ---- .../FeatureTogglesNamingConventionTest.kt | 1 - .../toggles/DefaultStakingFeatureToggles.kt | 2 +- .../toggles/DefaultStakingFeatureTogglesTest.kt | 16 ++-------------- 5 files changed, 3 insertions(+), 21 deletions(-) diff --git a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt index 680df9eddc..6b62173e97 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt @@ -193,7 +193,6 @@ abstract class BaseTestCase : TestCase( // 5.37 "HEDERA_ERC20_ENABLED" to true, // 5.39 - "STAKING_ETH_ENABLED" to true, "DYNAMIC_ADDRESSES_ENABLED" to true, "SOLANA_TX_HISTORY_ENABLED" to true, "SOLANA_SCALED_UI_AMOUNT_ENABLED" to true, diff --git a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json index 10c1e5776d..0e7b71c7fb 100644 --- a/core/config-toggles/src/main/assets/configs/feature_toggles_config.json +++ b/core/config-toggles/src/main/assets/configs/feature_toggles_config.json @@ -11,10 +11,6 @@ "name": "VISA_ONBOARDING_ENABLED", "version": "undefined" }, - { - "name": "STAKING_ETH_ENABLED", - "version": "5.39" - }, { "name": "TWI_485_USEDESK_ENABLED", "version": "undefined" diff --git a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/feature/FeatureTogglesNamingConventionTest.kt b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/feature/FeatureTogglesNamingConventionTest.kt index 27482ab781..6447ddacc1 100644 --- a/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/feature/FeatureTogglesNamingConventionTest.kt +++ b/core/config-toggles/src/test/kotlin/com/tangem/core/configtoggle/feature/FeatureTogglesNamingConventionTest.kt @@ -46,7 +46,6 @@ internal class FeatureTogglesNamingConventionTest { "NEW_CARD_SCANNING_ENABLED", "SOLANA_SCALED_UI_AMOUNT_ENABLED", "SOLANA_TX_HISTORY_ENABLED", - "STAKING_ETH_ENABLED", "SWAP_AB_ENABLED", "VIRTUAL_ACCOUNTS_ENABLED", "VISA_ONBOARDING_ENABLED", diff --git a/data/staking/src/main/java/com/tangem/data/staking/toggles/DefaultStakingFeatureToggles.kt b/data/staking/src/main/java/com/tangem/data/staking/toggles/DefaultStakingFeatureToggles.kt index 6f62c8bc06..2ac6d5e339 100644 --- a/data/staking/src/main/java/com/tangem/data/staking/toggles/DefaultStakingFeatureToggles.kt +++ b/data/staking/src/main/java/com/tangem/data/staking/toggles/DefaultStakingFeatureToggles.kt @@ -15,7 +15,7 @@ internal class DefaultStakingFeatureToggles( } private fun StakingIntegrationID.getFeatureToggle(): FeatureToggles? = when (this) { - is StakingIntegrationID.P2PEthPool -> FeatureToggles.STAKING_ETH_ENABLED + is StakingIntegrationID.P2PEthPool -> null is StakingIntegrationID.StakeKit -> this.getStakeKitFeatureToggle() } diff --git a/data/staking/src/test/kotlin/com/tangem/data/staking/toggles/DefaultStakingFeatureTogglesTest.kt b/data/staking/src/test/kotlin/com/tangem/data/staking/toggles/DefaultStakingFeatureTogglesTest.kt index ca6766c8c8..6ec391fa7b 100644 --- a/data/staking/src/test/kotlin/com/tangem/data/staking/toggles/DefaultStakingFeatureTogglesTest.kt +++ b/data/staking/src/test/kotlin/com/tangem/data/staking/toggles/DefaultStakingFeatureTogglesTest.kt @@ -1,6 +1,5 @@ package com.tangem.data.staking.toggles -import com.tangem.core.configtoggle.FeatureToggles import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.domain.staking.model.StakingIntegrationID import com.google.common.truth.Truth.assertThat @@ -24,21 +23,10 @@ internal class DefaultStakingFeatureTogglesTest { } @Test - fun `P2PEthPool returns true when STAKING_ETH_ENABLED is enabled`() { - every { featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) } returns true - + fun `P2PEthPool integration is always enabled`() { assertThat(toggles.isIntegrationEnabled(StakingIntegrationID.P2PEthPool)).isTrue() - verify(exactly = 1) { featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) } - } - - @Test - fun `P2PEthPool returns false when STAKING_ETH_ENABLED is disabled`() { - every { featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) } returns false - - assertThat(toggles.isIntegrationEnabled(StakingIntegrationID.P2PEthPool)).isFalse() - - verify(exactly = 1) { featureTogglesManager.isFeatureEnabled(FeatureToggles.STAKING_ETH_ENABLED) } + verify(exactly = 0) { featureTogglesManager.isFeatureEnabled(any()) } } @Test From 881395850e313609681f65edc92d55ebf4a6cbd9 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 29 Jun 2026 15:36:19 +0000 Subject: [PATCH 087/210] Updated on 2026-08-14 --- gradle/tangem_dependencies.toml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 7f9bf93423..8b7ac52f4f 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -5,16 +5,16 @@ # https://github.com/tangem/tangem-sdk-android/ # https://github.com/tangem/vico -tangemBlockchainSdk = "releases-6.0-1590" +tangemBlockchainSdk = "develop-1586" #tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds -tangemCardSdk = "releases-6.0-626" +tangemCardSdk = "develop-630" #tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^ tangemVico = "tangem-master-21" #tangemVico = "0.0.1" # Keep it! - used for local builds ^ tangemHotSdk = "develop-550" #tangemHotSdk = "0.0.1" # Keep it! - used for local builds ^ - - +tangemUsedeskSdk = "main-9" +#tangemUsedeskSdk = "0.0.1" # Keep it! - used for local builds ^ [libraries] blockchain = { module = "com.tangem:blockchain", version.ref = "tangemBlockchainSdk" } @@ -23,6 +23,9 @@ card-core = { module = "com.tangem.tangem-sdk-kotlin:core", version.ref = "tange hot-core = { module = "com.tangem.tangem-hot-sdk-kotlin:core", version.ref = "tangemHotSdk" } hot-android = { module = "com.tangem.tangem-hot-sdk-kotlin:android", version.ref = "tangemHotSdk" } +usedesk-chat-sdk = { module = "com.tangem.usedesk:chat-sdk", version.ref = "tangemUsedeskSdk" } +usedesk-chat-gui = { module = "com.tangem.usedesk:chat-gui", version.ref = "tangemUsedeskSdk" } + vico-compose = { group = "com.tangem.vico", name = "compose", version.ref = "tangemVico" } vico-compose-m3 = { group = "com.tangem.vico", name = "compose-m3", version.ref = "tangemVico" } vico-core = { group = "com.tangem.vico", name = "core", version.ref = "tangemVico" } From 0b1a6cc24c3799f4a42c4e4fbbe1891e82c608f0 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 29 Jun 2026 20:29:08 +0400 Subject: [PATCH 088/210] Updated on 2026-08-14 --- .claude/rules/git-rules.md | 17 +- .claude/skills/create-pr/SKILL.md | 297 ++++++++++++++++++++++++++++++ 2 files changed, 309 insertions(+), 5 deletions(-) create mode 100644 .claude/skills/create-pr/SKILL.md diff --git a/.claude/rules/git-rules.md b/.claude/rules/git-rules.md index 02384fa5c2..27e083fbf0 100644 --- a/.claude/rules/git-rules.md +++ b/.claude/rules/git-rules.md @@ -4,10 +4,15 @@ | Type | Format | Example | |---------|-------------------------------------|-------------------------------------| -| Feature | `feature/AND-xxx_short_description` | `feature/AND-13391_balance_fetcher` | -| Bugfix | `bugfix/AND-xxx_short_description` | `bugfix/AND-14000_fix_crash` | -| Release | `releases/x.xx` | `releases/5.36` | -| Hotfix | `releases/x.xx.x` | `releases/5.36.1` | +| Feature | `feature/AND-xxx_short_description` | `feature/AND-13391_balance_fetcher` | +| Bugfix | `bugfix/AND-xxx_short_description` | `bugfix/AND-14000_fix_crash` | +| Technical | `tech/short_description` | `tech/update_ci_scripts` | +| Release | `releases/x.xx` | `releases/5.36` | +| Hotfix | `releases/x.xx.x` | `releases/5.36.1` | + +**Technical (`tech/`) branches** are for chore / tooling work with **no Jira task** — CI, scripts, +build/config, docs, repo tooling. They carry **no `AND-xxx`** in the branch name, commit subject, or +PR title. **Key branches:** @@ -21,4 +26,6 @@ Format: `AND-xxx Description` - Start with the Jira task number (AND-xxx) - Followed by a space and a short description in English -- Example: `[REDACTED_TASK_KEY] Finalize CryptoCurrencyBalanceFetcher refactoring` \ No newline at end of file +- Example: `[REDACTED_TASK_KEY] Finalize CryptoCurrencyBalanceFetcher refactoring` +- **Technical (`tech/`) branches** have no Jira task, so their commit subject (and PR title) is just + the English description, with **no `AND-xxx` prefix** — e.g. `Update CI scripts`. \ No newline at end of file diff --git a/.claude/skills/create-pr/SKILL.md b/.claude/skills/create-pr/SKILL.md new file mode 100644 index 0000000000..7bd8630f2d --- /dev/null +++ b/.claude/skills/create-pr/SKILL.md @@ -0,0 +1,297 @@ +--- +name: create-pr +description: Open a GitHub pull request for the current work via the GitHub CLI (gh), following Tangem repo conventions — branch naming (feature/bugfix/AND-xxx), commit format (AND-xxx Description), base develop, required trailers. Picks which changes to include, creates a feature branch off a protected branch, commits, and — only after explicit confirmation — pushes and opens the PR. Use when the user asks to "open/create a PR", "создай ПР / пул-реквест", "open a pull request", "залей в PR". +allowed-tools: Read, Bash, AskUserQuestion, Monitor, TaskStop +argument-hint: [AND-xxxxx] [title...] [--base develop] [--dry-run] +--- + +Open a GitHub pull request for the current changes via `gh`, following this repo's conventions. + +This skill is **interactive** and runs locally. **Pushing and opening the PR happen ONLY after an +explicit confirmation gate (Phase 4)** — never push or create the PR before the user confirms. + +## Conventions + +**Source of truth: [`.claude/rules/git-rules.md`](../../rules/git-rules.md)** — read it for branch +naming (`feature/`, `bugfix/`, **`tech/`**, `releases/`), the `AND-xxx Description` commit/PR-title +format, and the technical-PR exception (no Jira task → no `AND-xxx` in branch/commit/title). Do not +restate or fork those rules here; follow git-rules.md so this skill can't drift from it. + +This skill only adds what is **not** in git-rules.md: + +| Thing | Rule | +|---|---| +| Default PR base | `develop` (hotfix → the relevant `releases/x.xx`) | +| Protected branches | `develop`, `releases/*` — never commit directly; always branch off (Phase 2) | +| Commit trailer | `Co-Authored-By: Claude Opus 4.8 (1M context) <[REDACTED_EMAIL]>` | +| PR body footer | `🤖 Generated with [Claude Code](https://claude.com/claude-code)` | +| Code comments | **No `AND-xxx`** in code/KDoc (fine in branch/commit/PR) | + +**Dry-run:** if `$ARGUMENTS` contains `--dry-run`, do everything except the writes — no branch +creation, no commit, no push, no `gh pr create`. Print the exact branch name, commit message, file +list, and `gh pr create` command that would run, then stop (see Phase 4D). + +## Phase 0 — Preflight + +Run these and stop with a clear FATAL message if any fails: + +1. `gh auth status` — GitHub CLI must be authenticated. If not: `FATAL: gh is not authenticated. Run 'gh auth login'.` +2. `git rev-parse --abbrev-ref HEAD` — current branch. `git status --porcelain` — working tree. +3. `git remote get-url origin` and the repo's default branch (`gh repo view --json defaultBranchRef -q .defaultBranchRef.name`) for reference. + +**Primary flow (default): branch + commit from existing local changes.** This skill takes the +**current uncommitted working-tree changes**, puts them on the right branch, commits, pushes, and +opens the PR. The target branch is decided by the **task** (Phase 1), not by whichever branch you +happen to be on: +- If the current branch is already the correct branch **for this task** (`feature/AND-xxxxx_…` / + `bugfix/…` / `tech/…` matching the resolved task), commit the pending changes onto it. +- Otherwise — on a protected branch (`develop`/`releases/*`) **or on another task's feature branch** — + create a new branch **off the base** (Phase 5 cuts it from `origin/` so the other branch's + commits don't ride along). Git keeps the uncommitted working-tree changes across this checkout. + +Never leave local changes uncommitted and PR only what was already committed — the pending changes +are the point. + +Fallback (no local changes): if `git status --porcelain` is empty **and** the current branch already +has commits ahead of the base that aren't PR'd, switch to a "PR an existing branch" flow — skip the +commit steps and go straight to push + PR. If the tree is empty and there are no un-PR'd commits +either, there is nothing to open a PR for — stop and say so. + +## Phase 1 — Gather inputs + +Parse `$ARGUMENTS` for an `AND-\d+` task id, a title, and `--base `. Ask only for what's +missing (use `AskUserQuestion` for constrained choices, plain text otherwise): + +- **Task id** (`AND-xxxxx`) — **mandatory** for branch/commit/PR naming. **Always ask the user which + task this PR is for** — never decide it silently. Every PR carries an `AND-xxxxx` **except** an + explicit **Technical PR** (the one no-task exception, described below); do not offer a generic + "no task / standalone" option outside that. You may pre-fill a *suggestion* (from `$ARGUMENTS`, or + an `AND-\d+` found in the current branch name) as the recommended answer, but the user must confirm + or override it. Do not assume the current branch's task id applies to the pending changes — they + are often unrelated (e.g. you're on another task's branch). If the user gives no valid `AND-\d+`, + keep asking — do not proceed without one. + + When asking, also offer a **"Create a new Jira Task"** option. If the user picks it, run the + **`create-jira-task`** skill (it creates the Task from the local changes), then use the newly + created `AND-xxxxx` as this PR's task id and continue. (Offer the Story-equivalent only if the work + clearly warrants a Story; default to a Task.) **In `--dry-run`, do NOT actually run + `create-jira-task`** — it's a real write; instead use a placeholder task id (e.g. `AND-NEW`) and + note that the Task would be created. + + Also offer a **"Technical PR"** option (the one exception to the mandatory-task rule): a chore / + tooling PR with **no Jira task**. If chosen, the change type becomes `tech`, the branch is + `tech/` (no `AND-xxxxx`), and the commit subject + PR title have **no `AND-xxxxx` prefix** + (just the plain English title). + + Options to present: the suggested existing key (if any), **Create a new Jira Task**, **Technical + PR**, and free-text Other for an existing key. Outside of the Technical PR choice, never proceed + without a valid `AND-\d+`. +- **Title** (English, required) — the PR/commit description. If absent, propose one generated from + the staged/working changes (`git diff --stat`, `git log`) and ask the user to approve or edit. + Must be English. +- **Change type** — `feature`, `bugfix`, or `tech` (drives the branch prefix). `tech` is set + automatically when the user chose the **Technical PR** option above. Otherwise infer from the + title/task; default `feature`. +- **Base branch** — default `develop`. Only change for hotfixes (`releases/x.xx`). Ask only if the + current branch is itself a `releases/*` branch (then the base is likely that release line). +- **Files to include** — show `git status --porcelain` and let the user choose. Default to all + tracked changes **except** unrelated submodule pointer bumps and stray edits; call out anything + you exclude. If the user named specific files in `$ARGUMENTS` / the prompt (e.g. via `@path`), + include exactly those. + +## Phase 1b — Classify complexity & choose labels + +Every PR gets exactly **one complexity label**. Count the **files chosen in Phase 1** (the planned +PR contents — not `git diff --cached`, since nothing is staged until Phase 5) and judge the nature of +the change. Propose a level +(via `AskUserQuestion`, recommending the one you judged) and let the user confirm or override: + +| Label | Level | When | File limit | +|---|---|---|---| +| `deep` | 🔴 Red | Complex changes, or touching important/core logic | **≤ 15 files** | +| `complex` | 🟡 Yellow | Not deep and/or does not touch important core logic | **≤ 20 files** | +| `easy` | ⚪ White | Uniform/mechanical changes (rename, package move, formatting) | **no limit** | + +Rules: +1. **Over the limit** → the PR body **must** include an explanation/justification of why the change + could not be split or kept smaller. If the count exceeds the level's limit, ask the user for that + justification and append it to the PR body under a `## Why this exceeds the