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/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt b/app/src/main/java/com/tangem/tap/routing/utils/ChildFactory.kt
index c43ead18f2..832eab2ad7 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
@@ -759,7 +759,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 3b46824c4d..3beaf7322c 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/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/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
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
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")