diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index bc3d6cf51b..db2c4f6dca 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -231,6 +231,8 @@ dependencies {
implementation(projects.common.ui)
/** Features */
+ implementation(projects.features.addressBook.api)
+ implementation(projects.features.addressBook.impl)
implementation(projects.features.rating.impl)
implementation(projects.features.referral.impl)
implementation(projects.features.referral.domain)
diff --git a/core/ui/src/main/res/drawable/ic_contact_20.xml b/core/ui/src/main/res/drawable/ic_contact_20.xml
new file mode 100644
index 0000000000..b6d762b21b
--- /dev/null
+++ b/core/ui/src/main/res/drawable/ic_contact_20.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/features/address-book/api/.gitignore b/features/address-book/api/.gitignore
new file mode 100644
index 0000000000..567609b123
--- /dev/null
+++ b/features/address-book/api/.gitignore
@@ -0,0 +1 @@
+build/
diff --git a/features/address-book/api/build.gradle.kts b/features/address-book/api/build.gradle.kts
new file mode 100644
index 0000000000..425593dae2
--- /dev/null
+++ b/features/address-book/api/build.gradle.kts
@@ -0,0 +1,22 @@
+plugins {
+ alias(deps.plugins.android.library)
+ alias(deps.plugins.kotlin.android)
+ id("configuration")
+}
+
+android {
+ namespace = "com.tangem.features.addressbook.api"
+}
+
+dependencies {
+
+ /* Project - Domain */
+ implementation(projects.domain.models)
+
+ /* Project - Core */
+ implementation(projects.core.decompose)
+ implementation(projects.core.ui)
+
+ /* Compose */
+ implementation(deps.compose.runtime)
+}
\ No newline at end of file
diff --git a/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/AddressBookFeatureToggles.kt b/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/AddressBookFeatureToggles.kt
new file mode 100644
index 0000000000..73e8810fbe
--- /dev/null
+++ b/features/address-book/api/src/main/kotlin/com/tangem/features/addressbook/AddressBookFeatureToggles.kt
@@ -0,0 +1,5 @@
+package com.tangem.features.addressbook
+
+interface AddressBookFeatureToggles {
+ val isAddressBookEnabled: Boolean
+}
\ No newline at end of file
diff --git a/features/address-book/impl/.gitignore b/features/address-book/impl/.gitignore
new file mode 100644
index 0000000000..567609b123
--- /dev/null
+++ b/features/address-book/impl/.gitignore
@@ -0,0 +1 @@
+build/
diff --git a/features/address-book/impl/build.gradle.kts b/features/address-book/impl/build.gradle.kts
new file mode 100644
index 0000000000..f91cde71b1
--- /dev/null
+++ b/features/address-book/impl/build.gradle.kts
@@ -0,0 +1,28 @@
+plugins {
+ alias(deps.plugins.android.library)
+ alias(deps.plugins.kotlin.android)
+ alias(deps.plugins.kotlin.kapt)
+ alias(deps.plugins.hilt.android)
+ id("configuration")
+}
+
+android {
+ namespace = "com.tangem.features.addressbook.impl"
+}
+
+dependencies {
+ /** Api */
+ implementation(projects.features.addressBook.api)
+
+ /** Core modules */
+ implementation(projects.core.configToggles)
+ implementation(projects.core.decompose)
+ implementation(projects.core.ui)
+
+ /** Compose */
+ implementation(deps.compose.runtime)
+
+ /** DI */
+ implementation(deps.hilt.android)
+ kapt(deps.hilt.kapt)
+}
\ 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/DefaultAddressBookFeatureToggles.kt
new file mode 100644
index 0000000000..08247d086c
--- /dev/null
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/DefaultAddressBookFeatureToggles.kt
@@ -0,0 +1,11 @@
+package com.tangem.features.addressbook
+
+import com.tangem.core.configtoggle.FeatureToggles
+import com.tangem.core.configtoggle.feature.FeatureTogglesManager
+
+internal class DefaultAddressBookFeatureToggles(
+ private val featureTogglesManager: FeatureTogglesManager,
+) : AddressBookFeatureToggles {
+ override val isAddressBookEnabled: Boolean
+ get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.TWI_83_ADDRESS_BOOK_ENABLED)
+}
\ 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
new file mode 100644
index 0000000000..4594968b09
--- /dev/null
+++ b/features/address-book/impl/src/main/kotlin/com/tangem/features/addressbook/di/AddressBookModule.kt
@@ -0,0 +1,21 @@
+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 dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+internal object AddressBookModule {
+
+ @Provides
+ @Singleton
+ fun provideAddressBookFeatureToggles(featureTogglesManager: FeatureTogglesManager): AddressBookFeatureToggles {
+ return DefaultAddressBookFeatureToggles(featureTogglesManager)
+ }
+}
\ No newline at end of file
diff --git a/features/details/impl/build.gradle.kts b/features/details/impl/build.gradle.kts
index 0b61f5c022..3b6e587c78 100644
--- a/features/details/impl/build.gradle.kts
+++ b/features/details/impl/build.gradle.kts
@@ -19,6 +19,7 @@ dependencies {
implementation(projects.features.tester.api)
implementation(projects.features.createWalletSelection.api)
implementation(projects.features.onboardingV2.api)
+ implementation(projects.features.addressBook.api)
/* Project - Core */
implementation(projects.core.decompose)
diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewDetailsComponent.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewDetailsComponent.kt
index 15b152f01b..6b1b9e2655 100644
--- a/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewDetailsComponent.kt
+++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/component/preview/PreviewDetailsComponent.kt
@@ -29,6 +29,7 @@ internal class PreviewDetailsComponent : DetailsComponent {
},
).buildAll(
isWalletConnectAvailable = true,
+ isAddressBookAvailable = true,
isSupportChatAvailable = true,
hasAnyMobileWallet = true,
userWalletId = UserWalletId(""),
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 5e7ebe0773..e7849eebed 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,6 +25,15 @@ internal sealed class DetailsItemUM {
override val id: String = "wallet_connect"
}
+ data class WalletConnectAddressBookBlock(val items: List- ) : 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)
+ }
+ }
+
data object UserWalletList : DetailsItemUM() {
override val id: String = "user_wallet_list"
}
diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt
index b359828377..e1538a81b3 100644
--- a/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt
+++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/model/DetailsModel.kt
@@ -26,6 +26,7 @@ import com.tangem.domain.walletconnect.CheckIsWalletConnectAvailableUseCase
import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
+import com.tangem.features.addressbook.AddressBookFeatureToggles
import com.tangem.features.details.component.DetailsComponent
import com.tangem.features.details.entity.DetailsFooterUM
import com.tangem.features.details.entity.DetailsItemUM
@@ -51,6 +52,7 @@ internal class DetailsModel @Inject constructor(
socialsBuilder: SocialsBuilder,
paramsContainer: ParamsContainer,
feedbackFeatureToggles: FeedbackFeatureToggles,
+ addressBookFeatureToggles: AddressBookFeatureToggles,
private val itemsBuilder: ItemsBuilder,
private val appInfoProvider: AppInfoProvider,
private val checkIsWalletConnectAvailableUseCase: CheckIsWalletConnectAvailableUseCase,
@@ -86,6 +88,7 @@ internal class DetailsModel @Inject constructor(
items = MutableStateFlow(
itemsBuilder.buildAll(
isWalletConnectAvailable = isWalletConnectAvailable,
+ isAddressBookAvailable = addressBookFeatureToggles.isAddressBookEnabled,
isSupportChatAvailable = feedbackFeatureToggles.isUsedeskEnabled,
hasAnyMobileWallet = getWalletsUseCase.invokeSync().any { it is UserWallet.Hot },
userWalletId = params.userWalletId,
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 0afe3711b2..5d99380d75 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,10 +2,10 @@ 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.layout.Arrangement
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
@@ -13,17 +13,21 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
-import androidx.compose.runtime.*
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.key
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.util.fastForEach
import com.tangem.core.ui.components.SpacerH16
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.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference
@@ -155,10 +159,44 @@ private fun Block(
onClick = model.onClick,
)
}
+ is DetailsItemUM.WalletConnectAddressBookBlock -> {
+ BlockCard {
+ WalletConnectAddressBookBlockItems(
+ items = model.items,
+ modifier = itemModifier.padding(12.dp),
+ )
+ }
+ }
is DetailsItemUM.UserWalletList -> {
userWalletListBlockContent.Content(modifier = itemModifier)
}
- is DetailsItemUM.UnderSectionText -> { /* Handled above */ }
+ is DetailsItemUM.UnderSectionText -> { /* Handled above */
+ }
+ }
+ }
+}
+
+@Composable
+private fun WalletConnectAddressBookBlockItems(
+ items: List,
+ modifier: Modifier = Modifier,
+) {
+ items.fastForEach { item ->
+ when (item) {
+ is DetailsItemUM.WalletConnectAddressBookBlock.Item.WalletConnect -> InputRowImageBase(
+ modifier = modifier.clickable(onClick = item.onClick),
+ 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.WalletConnectAddressBookBlock.Item.AddressBook -> InputRowImageBase(
+ modifier = modifier.clickable(onClick = item.onClick),
+ 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),
+ )
}
}
}
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 7bd5b7bd35..c309485bf6 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
@@ -26,6 +26,7 @@ internal class ItemsBuilder @Inject constructor(
@Suppress("LongParameterList")
fun buildAll(
isWalletConnectAvailable: Boolean,
+ isAddressBookAvailable: Boolean,
isSupportChatAvailable: Boolean,
hasAnyMobileWallet: Boolean,
userWalletId: UserWalletId,
@@ -33,7 +34,11 @@ internal class ItemsBuilder @Inject constructor(
onSupportChatClick: () -> Unit,
onBuyClick: () -> Unit,
): ImmutableList = buildList {
- buildWalletConnectBlock(isWalletConnectAvailable, userWalletId)?.let(::add)
+ if (isAddressBookAvailable) {
+ buildWalletConnectAddressBookBlock(isWalletConnectAvailable, userWalletId)
+ } else {
+ buildWalletConnectBlock(isWalletConnectAvailable, userWalletId)?.let(::add)
+ }
buildUserWalletListBlock().let(::add)
if (hotWalletRestrictionManager.isCreationEnabledSync() && hasAnyMobileWallet) {
@@ -86,6 +91,33 @@ internal class ItemsBuilder @Inject constructor(
}
}
+ private fun MutableList.buildWalletConnectAddressBookBlock(
+ isWalletConnectAvailable: Boolean,
+ userWalletId: UserWalletId,
+ ) {
+ val walletConnectAddressBookItems = buildList {
+ if (isWalletConnectAvailable) add(buildWalletConnectButton(userWalletId))
+ add(buildAddressBookButton())
+ }
+ if (walletConnectAddressBookItems.isNotEmpty()) {
+ add(DetailsItemUM.WalletConnectAddressBookBlock(walletConnectAddressBookItems))
+ }
+ }
+
+ private fun buildWalletConnectButton(
+ userWalletId: UserWalletId,
+ ): DetailsItemUM.WalletConnectAddressBookBlock.Item.WalletConnect {
+ return DetailsItemUM.WalletConnectAddressBookBlock.Item.WalletConnect(
+ onClick = { router.push(AppRoute.WalletConnectSessions(userWalletId)) },
+ )
+ }
+
+ private fun buildAddressBookButton(): DetailsItemUM.WalletConnectAddressBookBlock.Item.AddressBook {
+ return DetailsItemUM.WalletConnectAddressBookBlock.Item.AddressBook(
+ onClick = { },
+ )
+ }
+
private fun buildUserWalletListBlock(): DetailsItemUM = DetailsItemUM.UserWalletList
private fun buildShopBlock(onBuyClick: () -> Unit): DetailsItemUM = DetailsItemUM.Basic(
diff --git a/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelFeedbackTest.kt b/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelFeedbackTest.kt
new file mode 100644
index 0000000000..bf6f2a1888
--- /dev/null
+++ b/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelFeedbackTest.kt
@@ -0,0 +1,191 @@
+package com.tangem.features.details.model
+
+import arrow.core.left
+import arrow.core.right
+import com.google.common.truth.Truth.assertThat
+import com.tangem.core.analytics.models.Basic
+import com.tangem.domain.feedback.models.FeedbackEmailType
+import com.tangem.domain.feedback.models.WalletMetaInfo
+import com.tangem.domain.models.wallet.UserWallet
+import com.tangem.features.details.entity.SelectEmailFeedbackTypeBS
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.verify
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+internal class DetailsModelFeedbackTest : DetailsModelTestBase() {
+
+ @Test
+ fun `GIVEN all wallets hot WHEN support email clicked THEN DirectUserRequest sent`() = runTest {
+ // Arrange
+ val wallet = hotWallet(wallet1)
+ val meta = metaInfo(wallet1, isVisa = false)
+ every { getWalletsUseCase.invokeSync() } returns listOf(wallet)
+ every { getSelectedWalletSyncUseCase() } returns wallet.right()
+ coEvery { getWalletMetaInfoUseCase(wallet1) } returns meta.right()
+ every { getTangemPayCustomerIdUseCase(wallet1) } returns "".right()
+
+ // Act
+ val model = createModel(this)
+ advanceUntilIdle()
+ onEmailSlot.captured.invoke()
+ advanceUntilIdle()
+
+ // Assert
+ verify { analyticsEventHandler.send(any()) }
+ coVerify { sendFeedbackEmailUseCase(FeedbackEmailType.DirectUserRequest(meta)) }
+ model.onDestroy()
+ }
+
+ @Test
+ fun `GIVEN all wallets cold visa with customerId WHEN support email clicked THEN Visa request sent`() = runTest {
+ val wallet = coldWallet(wallet1, isVisa = true)
+ val meta = metaInfo(wallet1, isVisa = true)
+ every { getWalletsUseCase.invokeSync() } returns listOf(wallet)
+ every { getSelectedWalletSyncUseCase() } returns wallet.right()
+ coEvery { getWalletMetaInfoUseCase(wallet1) } returns meta.right()
+ every { getTangemPayCustomerIdUseCase(wallet1) } returns customerId.right()
+
+ val model = createModel(this)
+ advanceUntilIdle()
+ onEmailSlot.captured.invoke()
+ advanceUntilIdle()
+
+ verify { analyticsEventHandler.send(any()) }
+ coVerify { sendFeedbackEmailUseCase(FeedbackEmailType.Visa.DirectUserRequest(meta, customerId)) }
+ model.onDestroy()
+ }
+
+ @Test
+ fun `GIVEN mixed wallets WHEN support email clicked THEN bottom sheet shown and no email sent`() = runTest {
+ val selected = hotWallet(wallet1)
+ val meta = metaInfo(wallet1, isVisa = false)
+ every { getWalletsUseCase.invokeSync() } returns listOf(selected, coldWallet(wallet2, isVisa = true))
+ every { getSelectedWalletSyncUseCase() } returns selected.right()
+ coEvery { getWalletMetaInfoUseCase(wallet1) } returns meta.right()
+ every { getTangemPayCustomerIdUseCase(wallet1) } returns customerId.right()
+
+ val model = createModel(this)
+ advanceUntilIdle()
+ onEmailSlot.captured.invoke()
+ advanceUntilIdle()
+
+ val bsConfig = model.state.value.selectFeedbackEmailTypeBSConfig
+ assertThat(bsConfig.isShown).isTrue()
+ assertThat(bsConfig.content).isInstanceOf(SelectEmailFeedbackTypeBS::class.java)
+ coVerify(exactly = 0) { sendFeedbackEmailUseCase(any()) }
+ model.onDestroy()
+ }
+
+ @Test
+ fun `GIVEN meta info missing WHEN support email clicked THEN no email sent`() = runTest {
+ val wallet = hotWallet(wallet1)
+ every { getWalletsUseCase.invokeSync() } returns listOf(wallet)
+ every { getSelectedWalletSyncUseCase() } returns wallet.right()
+ coEvery { getWalletMetaInfoUseCase(wallet1) } returns Throwable().left()
+ every { getTangemPayCustomerIdUseCase(wallet1) } returns "".right()
+
+ val model = createModel(this)
+ advanceUntilIdle()
+ onEmailSlot.captured.invoke()
+ advanceUntilIdle()
+
+ coVerify(exactly = 0) { sendFeedbackEmailUseCase(any()) }
+ model.onDestroy()
+ }
+
+ @Test
+ fun `GIVEN General option AND selected meta not visa WHEN selected THEN DirectUserRequest with selected meta`() =
+ runTest {
+ val selectedMeta = metaInfo(wallet1, isVisa = false)
+ val content = openBottomSheet(selectedMeta = selectedMeta)
+
+ content.onOptionClick(SelectEmailFeedbackTypeBS.Option.General)
+ advanceUntilIdle()
+
+ coVerify { sendFeedbackEmailUseCase(FeedbackEmailType.DirectUserRequest(selectedMeta)) }
+ assertThat(currentModel.state.value.selectFeedbackEmailTypeBSConfig.isShown).isFalse()
+ verify { analyticsEventHandler.send(any()) }
+ currentModel.onDestroy()
+ }
+
+ @Test
+ fun `GIVEN General option AND selected meta visa WHEN selected THEN picks non-visa wallet meta`() = runTest {
+ val selectedMeta = metaInfo(wallet1, isVisa = true)
+ val nonVisaMeta = metaInfo(wallet2, isVisa = false)
+ val content = openBottomSheet(
+ selectedMeta = selectedMeta,
+ wallets = listOf(coldWallet(wallet1, isVisa = true), hotWallet(wallet2)),
+ )
+ coEvery { getWalletMetaInfoUseCase(wallet2) } returns nonVisaMeta.right()
+
+ content.onOptionClick(SelectEmailFeedbackTypeBS.Option.General)
+ advanceUntilIdle()
+
+ coVerify { sendFeedbackEmailUseCase(FeedbackEmailType.DirectUserRequest(nonVisaMeta)) }
+ currentModel.onDestroy()
+ }
+
+ @Test
+ fun `GIVEN Visa option AND selected meta visa with customerId WHEN selected THEN Visa request with selected`() =
+ runTest {
+ val selectedMeta = metaInfo(wallet1, isVisa = true)
+ val content = openBottomSheet(selectedMeta = selectedMeta, customerId = customerId)
+
+ content.onOptionClick(SelectEmailFeedbackTypeBS.Option.Visa)
+ advanceUntilIdle()
+
+ coVerify {
+ sendFeedbackEmailUseCase(FeedbackEmailType.Visa.DirectUserRequest(selectedMeta, customerId))
+ }
+ currentModel.onDestroy()
+ }
+
+ @Test
+ fun `GIVEN Visa option AND selected meta not visa WHEN selected THEN picks cold visa wallet meta`() = runTest {
+ val selectedMeta = metaInfo(wallet1, isVisa = false)
+ val visaMeta = metaInfo(wallet2, isVisa = true)
+ val content = openBottomSheet(
+ selectedMeta = selectedMeta,
+ wallets = listOf(hotWallet(wallet1), coldWallet(wallet2, isVisa = true)),
+ )
+ coEvery { getWalletMetaInfoUseCase(wallet2) } returns visaMeta.right()
+ every { getTangemPayCustomerIdUseCase(wallet2) } returns customerId.right()
+
+ content.onOptionClick(SelectEmailFeedbackTypeBS.Option.Visa)
+ advanceUntilIdle()
+
+ coVerify { sendFeedbackEmailUseCase(FeedbackEmailType.Visa.DirectUserRequest(visaMeta, customerId)) }
+ currentModel.onDestroy()
+ }
+
+ private lateinit var currentModel: DetailsModel
+
+ /**
+ * Drives the model into the "mixed wallets" state so the bottom sheet is shown, then returns its content.
+ * [selectedMeta] is what [getWalletMetaInfoUseCase] returns for the selected wallet ([wallet1]).
+ */
+ private fun TestScope.openBottomSheet(
+ selectedMeta: WalletMetaInfo,
+ wallets: List = listOf(hotWallet(wallet1), coldWallet(wallet2, isVisa = true)),
+ customerId: String = "",
+ ): SelectEmailFeedbackTypeBS {
+ every { getWalletsUseCase.invokeSync() } returns wallets
+ every { getSelectedWalletSyncUseCase() } returns wallets.first().right()
+ coEvery { getWalletMetaInfoUseCase(wallet1) } returns selectedMeta.right()
+ every { getTangemPayCustomerIdUseCase(wallet1) } returns customerId.right()
+
+ currentModel = createModel(this)
+ advanceUntilIdle()
+ onEmailSlot.captured.invoke()
+ advanceUntilIdle()
+
+ return currentModel.state.value.selectFeedbackEmailTypeBSConfig.content as SelectEmailFeedbackTypeBS
+ }
+}
\ No newline at end of file
diff --git a/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelInitTest.kt b/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelInitTest.kt
new file mode 100644
index 0000000000..42af4c4f40
--- /dev/null
+++ b/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelInitTest.kt
@@ -0,0 +1,172 @@
+package com.tangem.features.details.model
+
+import arrow.core.left
+import arrow.core.right
+import com.google.common.truth.Truth.assertThat
+import com.tangem.features.details.entity.DetailsFooterUM
+import com.tangem.features.details.entity.DetailsItemUM
+import io.mockk.coEvery
+import io.mockk.every
+import io.mockk.verify
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+internal class DetailsModelInitTest : DetailsModelTestBase() {
+
+ @Test
+ fun `GIVEN walletConnect available WHEN init THEN buildAll receives isWalletConnectAvailable true`() = runTest {
+ // Arrange
+ coEvery { checkIsWalletConnectAvailableUseCase(userWalletId) } returns true.right()
+
+ // Act
+ createModel(this).also { advanceUntilIdle() }.onDestroy()
+
+ // Assert
+ assertThat(wcSlot.captured).isTrue()
+ }
+
+ @Test
+ fun `GIVEN walletConnect unavailable WHEN init THEN buildAll receives isWalletConnectAvailable false`() = runTest {
+ coEvery { checkIsWalletConnectAvailableUseCase(userWalletId) } returns false.right()
+
+ createModel(this).also { advanceUntilIdle() }.onDestroy()
+
+ assertThat(wcSlot.captured).isFalse()
+ }
+
+ @Test
+ fun `GIVEN walletConnect check fails WHEN init THEN isWalletConnectAvailable falls back to false`() = runTest {
+ coEvery { checkIsWalletConnectAvailableUseCase(userWalletId) } returns Throwable("boom").left()
+
+ createModel(this).also { advanceUntilIdle() }.onDestroy()
+
+ assertThat(wcSlot.captured).isFalse()
+ }
+
+ @Test
+ fun `GIVEN addressBook enabled WHEN init THEN buildAll receives isAddressBookAvailable true`() = runTest {
+ every { addressBookFeatureToggles.isAddressBookEnabled } returns true
+
+ createModel(this).also { advanceUntilIdle() }.onDestroy()
+
+ assertThat(abSlot.captured).isTrue()
+ }
+
+ @Test
+ fun `GIVEN addressBook disabled WHEN init THEN buildAll receives isAddressBookAvailable false`() = runTest {
+ every { addressBookFeatureToggles.isAddressBookEnabled } returns false
+
+ createModel(this).also { advanceUntilIdle() }.onDestroy()
+
+ assertThat(abSlot.captured).isFalse()
+ }
+
+ @Test
+ fun `GIVEN usedesk enabled WHEN init THEN buildAll receives isSupportChatAvailable true`() = runTest {
+ every { feedbackFeatureToggles.isUsedeskEnabled } returns true
+
+ createModel(this).also { advanceUntilIdle() }.onDestroy()
+
+ assertThat(chatSlot.captured).isTrue()
+ }
+
+ @Test
+ fun `GIVEN usedesk disabled WHEN init THEN buildAll receives isSupportChatAvailable false`() = runTest {
+ every { feedbackFeatureToggles.isUsedeskEnabled } returns false
+
+ createModel(this).also { advanceUntilIdle() }.onDestroy()
+
+ assertThat(chatSlot.captured).isFalse()
+ }
+
+ @Test
+ fun `GIVEN a hot wallet present WHEN init THEN buildAll receives hasAnyMobileWallet true`() = runTest {
+ every { getWalletsUseCase.invokeSync() } returns listOf(hotWallet(wallet1))
+
+ createModel(this).also { advanceUntilIdle() }.onDestroy()
+
+ assertThat(mobileSlot.captured).isTrue()
+ }
+
+ @Test
+ fun `GIVEN only cold wallets WHEN init THEN buildAll receives hasAnyMobileWallet false`() = runTest {
+ every { getWalletsUseCase.invokeSync() } returns listOf(coldWallet(wallet1, isVisa = false))
+
+ createModel(this).also { advanceUntilIdle() }.onDestroy()
+
+ assertThat(mobileSlot.captured).isFalse()
+ }
+
+ @Test
+ fun `GIVEN params userWalletId WHEN init THEN buildAll receives it`() = runTest {
+ createModel(this).also { advanceUntilIdle() }.onDestroy()
+
+ assertThat(walletIdSlot.captured).isEqualTo(userWalletId)
+ }
+
+ @Test
+ fun `GIVEN app info WHEN init THEN footer appVersion combines version and code`() = runTest {
+ val model = createModel(this)
+ advanceUntilIdle()
+
+ assertThat(model.state.value.footer.appVersion).isEqualTo("1.2.3 (456)")
+
+ model.onDestroy()
+ }
+
+ @Test
+ fun `GIVEN socials WHEN init THEN footer socials come from SocialsBuilder`() = runTest {
+ val socials = persistentListOf(DetailsFooterUM.Social(id = "tw", iconResId = 0, onClick = {}))
+ every { socialsBuilder.buildAll() } returns socials
+
+ val model = createModel(this)
+ advanceUntilIdle()
+
+ assertThat(model.state.value.footer.socials).isEqualTo(socials)
+
+ model.onDestroy()
+ }
+
+ @Test
+ fun `GIVEN popBack WHEN invoked THEN router pop is called`() = runTest {
+ val model = createModel(this)
+ advanceUntilIdle()
+
+ model.state.value.popBack()
+
+ verify { router.pop(onComplete = any()) }
+ model.onDestroy()
+ }
+
+ @Test
+ fun `GIVEN buildAll result WHEN not eligible for tangem pay THEN state items equal buildAll result`() = runTest {
+ val items = persistentListOf(DetailsItemUM.UserWalletList)
+ stubBuildAllReturns(items)
+ coEvery { tangemPayEligibilityManager.getEligibleWallets(any(), any()) } returns emptyList()
+
+ val model = createModel(this)
+ advanceUntilIdle()
+
+ assertThat(model.state.value.items).isEqualTo(items)
+ model.onDestroy()
+ }
+
+ @Test
+ fun `GIVEN items flow updates WHEN tangem pay item added THEN new items propagate to state`() = runTest {
+ val initial = persistentListOf()
+ val withTangemPay = persistentListOf(DetailsItemUM.UserWalletList)
+ stubBuildAllReturns(initial)
+ every { itemsBuilder.addTangemPayItem(any(), any()) } returns withTangemPay
+ coEvery { tangemPayEligibilityManager.getEligibleWallets(any(), any()) } returns listOf(hotWallet(wallet1))
+
+ val model = createModel(this)
+ advanceUntilIdle()
+
+ assertThat(model.state.value.items).isEqualTo(withTangemPay)
+ model.onDestroy()
+ }
+}
\ No newline at end of file
diff --git a/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelNavigationTest.kt b/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelNavigationTest.kt
new file mode 100644
index 0000000000..3ec4b6770b
--- /dev/null
+++ b/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelNavigationTest.kt
@@ -0,0 +1,68 @@
+package com.tangem.features.details.model
+
+import arrow.core.left
+import arrow.core.right
+import com.tangem.common.routing.AppRoute
+import com.tangem.core.analytics.models.Basic
+import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase
+import io.mockk.coEvery
+import io.mockk.every
+import io.mockk.verify
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+internal class DetailsModelNavigationTest : DetailsModelTestBase() {
+
+ @Test
+ fun `GIVEN selected wallet and meta WHEN support chat clicked THEN router pushes Usedesk`() = runTest {
+ // Arrange
+ val wallet = hotWallet(wallet1)
+ val meta = metaInfo(wallet1)
+ every { getSelectedWalletSyncUseCase() } returns wallet.right()
+ coEvery { getWalletMetaInfoUseCase(wallet1) } returns meta.right()
+
+ // Act
+ val model = createModel(this)
+ advanceUntilIdle()
+ onChatSlot.captured.invoke()
+ advanceUntilIdle()
+
+ // Assert
+ verify { router.push(route = AppRoute.Usedesk(meta), onComplete = any()) }
+ model.onDestroy()
+ }
+
+ @Test
+ fun `GIVEN meta info missing WHEN support chat clicked THEN no navigation`() = runTest {
+ val wallet = hotWallet(wallet1)
+ every { getSelectedWalletSyncUseCase() } returns wallet.right()
+ coEvery { getWalletMetaInfoUseCase(wallet1) } returns Throwable().left()
+
+ val model = createModel(this)
+ advanceUntilIdle()
+ onChatSlot.captured.invoke()
+ advanceUntilIdle()
+
+ verify(exactly = 0) { router.push(route = any(), onComplete = any()) }
+ model.onDestroy()
+ }
+
+ @Test
+ fun `GIVEN buy link WHEN buy clicked THEN opens url and sends analytics`() = runTest {
+ coEvery {
+ generateBuyTangemCardLinkUseCase(GenerateBuyTangemCardLinkUseCase.Source.Settings)
+ } returns buyUrl
+
+ val model = createModel(this)
+ advanceUntilIdle()
+ onBuySlot.captured.invoke()
+ advanceUntilIdle()
+
+ verify { analyticsEventHandler.send(any()) }
+ verify { urlOpener.openUrl(buyUrl) }
+ model.onDestroy()
+ }
+}
\ No newline at end of file
diff --git a/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelTangemPayTest.kt b/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelTangemPayTest.kt
new file mode 100644
index 0000000000..8bf88ba4b5
--- /dev/null
+++ b/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelTangemPayTest.kt
@@ -0,0 +1,82 @@
+package com.tangem.features.details.model
+
+import com.tangem.common.routing.AppRoute
+import com.tangem.domain.pay.model.TangemPayEntryPoint
+import com.tangem.domain.tangempay.TangemPayAnalyticsEvents
+import io.mockk.coEvery
+import io.mockk.verify
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+internal class DetailsModelTangemPayTest : DetailsModelTestBase() {
+
+ @Test
+ fun `GIVEN eligible wallets WHEN init THEN tangem pay item added and analytics sent`() = runTest {
+ // Arrange
+ coEvery {
+ tangemPayEligibilityManager.getEligibleWallets(
+ shouldExcludePaeraCustomers = true,
+ entryPoint = TangemPayEntryPoint.DETAILS,
+ )
+ } returns listOf(hotWallet(wallet1))
+
+ // Act
+ val model = createModel(this)
+ advanceUntilIdle()
+
+ // Assert
+ verify { analyticsEventHandler.send(any()) }
+ verify { itemsBuilder.addTangemPayItem(any(), any()) }
+ model.onDestroy()
+ }
+
+ @Test
+ fun `GIVEN no eligible wallets WHEN init THEN tangem pay item not added`() = runTest {
+ coEvery { tangemPayEligibilityManager.getEligibleWallets(any(), any()) } returns emptyList()
+
+ val model = createModel(this)
+ advanceUntilIdle()
+
+ verify(exactly = 0) { itemsBuilder.addTangemPayItem(any(), any()) }
+ verify(exactly = 0) { analyticsEventHandler.send(any()) }
+ model.onDestroy()
+ }
+
+ @Test
+ fun `GIVEN tangem pay available WHEN item clicked THEN navigates to onboarding`() = runTest {
+ coEvery { tangemPayEligibilityManager.getEligibleWallets(any(), any()) } returns listOf(hotWallet(wallet1))
+ coEvery { tangemPayEligibilityManager.getTangemPayAvailability(TangemPayEntryPoint.DETAILS) } returns true
+
+ val model = createModel(this)
+ advanceUntilIdle()
+ onTangemPaySlot.captured.invoke()
+ advanceUntilIdle()
+
+ verify { analyticsEventHandler.send(any()) }
+ verify {
+ router.push(
+ route = AppRoute.TangemPayOnboarding(AppRoute.TangemPayOnboarding.Mode.FromBannerInSettings),
+ onComplete = any(),
+ )
+ }
+ model.onDestroy()
+ }
+
+ @Test
+ fun `GIVEN tangem pay unavailable WHEN item clicked THEN item removed and no navigation`() = runTest {
+ coEvery { tangemPayEligibilityManager.getEligibleWallets(any(), any()) } returns listOf(hotWallet(wallet1))
+ coEvery { tangemPayEligibilityManager.getTangemPayAvailability(TangemPayEntryPoint.DETAILS) } returns false
+
+ val model = createModel(this)
+ advanceUntilIdle()
+ onTangemPaySlot.captured.invoke()
+ advanceUntilIdle()
+
+ verify { itemsBuilder.removeTangemPayItem(any()) }
+ verify(exactly = 0) { router.push(route = any(), onComplete = any()) }
+ model.onDestroy()
+ }
+}
\ No newline at end of file
diff --git a/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelTestBase.kt b/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelTestBase.kt
new file mode 100644
index 0000000000..6c48d43c14
--- /dev/null
+++ b/features/details/impl/src/test/kotlin/com/tangem/features/details/model/DetailsModelTestBase.kt
@@ -0,0 +1,170 @@
+package com.tangem.features.details.model
+
+import arrow.core.right
+import com.tangem.core.analytics.api.AnalyticsEventHandler
+import com.tangem.core.decompose.model.MutableParamsContainer
+import com.tangem.core.decompose.navigation.Router
+import com.tangem.core.navigation.url.UrlOpener
+import com.tangem.domain.card.common.visa.VisaUtilities
+import com.tangem.domain.feedback.GetWalletMetaInfoUseCase
+import com.tangem.domain.feedback.SendFeedbackEmailUseCase
+import com.tangem.domain.feedback.models.WalletMetaInfo
+import com.tangem.domain.feedback.repository.FeedbackFeatureToggles
+import com.tangem.domain.models.scan.CardDTO
+import com.tangem.domain.models.scan.ScanResponse
+import com.tangem.domain.models.wallet.UserWallet
+import com.tangem.domain.models.wallet.UserWalletId
+import com.tangem.domain.pay.TangemPayEligibilityManager
+import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase
+import com.tangem.domain.walletconnect.CheckIsWalletConnectAvailableUseCase
+import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase
+import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
+import com.tangem.domain.wallets.usecase.GetWalletsUseCase
+import com.tangem.features.addressbook.AddressBookFeatureToggles
+import com.tangem.features.details.component.DetailsComponent
+import com.tangem.features.details.entity.DetailsItemUM
+import com.tangem.features.details.utils.ItemsBuilder
+import com.tangem.features.details.utils.SocialsBuilder
+import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
+import com.tangem.utils.info.AppInfoProvider
+import io.mockk.coEvery
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkObject
+import io.mockk.slot
+import io.mockk.unmockkObject
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestScope
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+
+@OptIn(ExperimentalCoroutinesApi::class)
+internal abstract class DetailsModelTestBase {
+
+ protected val userWalletId = UserWalletId("011")
+ protected val wallet1 = UserWalletId("01")
+ protected val wallet2 = UserWalletId("02")
+ protected val customerId = "customer-1"
+ protected val buyUrl = "https://tangem.com/buy"
+
+ protected val socialsBuilder: SocialsBuilder = mockk()
+ protected val feedbackFeatureToggles: FeedbackFeatureToggles = mockk()
+ protected val addressBookFeatureToggles: AddressBookFeatureToggles = mockk()
+ protected val itemsBuilder: ItemsBuilder = mockk()
+ protected val appInfoProvider: AppInfoProvider = mockk()
+ protected val checkIsWalletConnectAvailableUseCase: CheckIsWalletConnectAvailableUseCase = mockk()
+ protected val router: Router = mockk(relaxUnitFun = true)
+ protected val urlOpener: UrlOpener = mockk(relaxUnitFun = true)
+ protected val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase = mockk()
+ protected val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase = mockk()
+ protected val getTangemPayCustomerIdUseCase: GetTangemPayCustomerIdUseCase = mockk()
+ protected val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase = mockk(relaxUnitFun = true)
+ protected val getWalletsUseCase: GetWalletsUseCase = mockk()
+ protected val generateBuyTangemCardLinkUseCase: GenerateBuyTangemCardLinkUseCase = mockk()
+ protected val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxUnitFun = true)
+ protected val tangemPayEligibilityManager: TangemPayEligibilityManager = mockk()
+
+ // Captured from itemsBuilder.buildAll(...) so the feature buttons can be driven.
+ protected val wcSlot = slot()
+ protected val abSlot = slot()
+ protected val chatSlot = slot()
+ protected val mobileSlot = slot()
+ protected val walletIdSlot = slot()
+ protected val onEmailSlot = slot<() -> Unit>()
+ protected val onChatSlot = slot<() -> Unit>()
+ protected val onBuySlot = slot<() -> Unit>()
+ protected val onTangemPaySlot = slot<() -> Unit>()
+
+ @BeforeEach
+ fun setUp() {
+ mockkObject(VisaUtilities)
+
+ // Defaults sufficient for model construction (init block).
+ coEvery { checkIsWalletConnectAvailableUseCase(any()) } returns false.right()
+ every { addressBookFeatureToggles.isAddressBookEnabled } returns false
+ every { feedbackFeatureToggles.isUsedeskEnabled } returns false
+ every { getWalletsUseCase.invokeSync() } returns emptyList()
+ every { socialsBuilder.buildAll() } returns persistentListOf()
+ every { appInfoProvider.appVersion } returns "1.2.3"
+ every { appInfoProvider.appVersionCode } returns 456
+ coEvery { tangemPayEligibilityManager.getEligibleWallets(any(), any()) } returns emptyList()
+
+ every {
+ itemsBuilder.buildAll(
+ isWalletConnectAvailable = capture(wcSlot),
+ isAddressBookAvailable = capture(abSlot),
+ isSupportChatAvailable = capture(chatSlot),
+ hasAnyMobileWallet = capture(mobileSlot),
+ userWalletId = capture(walletIdSlot),
+ onSupportEmailClick = capture(onEmailSlot),
+ onSupportChatClick = capture(onChatSlot),
+ onBuyClick = capture(onBuySlot),
+ )
+ } returns persistentListOf()
+ every { itemsBuilder.addTangemPayItem(any(), capture(onTangemPaySlot)) } answers { firstArg() }
+ every { itemsBuilder.removeTangemPayItem(any()) } answers { firstArg() }
+ }
+
+ @AfterEach
+ fun tearDown() {
+ unmockkObject(VisaUtilities)
+ }
+
+ protected fun createModel(testScope: TestScope): DetailsModel = DetailsModel(
+ socialsBuilder = socialsBuilder,
+ paramsContainer = MutableParamsContainer(DetailsComponent.Params(userWalletId)),
+ feedbackFeatureToggles = feedbackFeatureToggles,
+ addressBookFeatureToggles = addressBookFeatureToggles,
+ itemsBuilder = itemsBuilder,
+ appInfoProvider = appInfoProvider,
+ checkIsWalletConnectAvailableUseCase = checkIsWalletConnectAvailableUseCase,
+ router = router,
+ urlOpener = urlOpener,
+ getSelectedWalletSyncUseCase = getSelectedWalletSyncUseCase,
+ getWalletMetaInfoUseCase = getWalletMetaInfoUseCase,
+ getTangemPayCustomerIdUseCase = getTangemPayCustomerIdUseCase,
+ sendFeedbackEmailUseCase = sendFeedbackEmailUseCase,
+ getWalletsUseCase = getWalletsUseCase,
+ dispatchers = testScope.createTestingCoroutineDispatcherProvider(),
+ generateBuyTangemCardLinkUseCase = generateBuyTangemCardLinkUseCase,
+ analyticsEventHandler = analyticsEventHandler,
+ tangemPayEligibilityManager = tangemPayEligibilityManager,
+ )
+
+ protected fun stubBuildAllReturns(list: ImmutableList) {
+ every {
+ itemsBuilder.buildAll(any(), any(), any(), any(), any(), any(), any(), any())
+ } returns list
+ }
+
+ protected fun metaInfo(id: UserWalletId = userWalletId, isVisa: Boolean? = null): WalletMetaInfo =
+ WalletMetaInfo(userWalletId = id, isVisa = isVisa)
+
+ protected fun hotWallet(id: UserWalletId): UserWallet.Hot = mockk {
+ every { walletId } returns id
+ }
+
+ protected fun coldWallet(id: UserWalletId, isVisa: Boolean): UserWallet.Cold {
+ val cardMock = mockk()
+ every { VisaUtilities.isVisaCard(cardMock) } returns isVisa
+ val scan = mockk { every { card } returns cardMock }
+ return mockk {
+ every { walletId } returns id
+ every { scanResponse } returns scan
+ }
+ }
+
+ private fun TestScope.createTestingCoroutineDispatcherProvider(): TestingCoroutineDispatcherProvider {
+ val testDispatcher = StandardTestDispatcher(testScheduler)
+ return TestingCoroutineDispatcherProvider(
+ main = testDispatcher,
+ mainImmediate = testDispatcher,
+ io = testDispatcher,
+ default = testDispatcher,
+ single = testDispatcher,
+ )
+ }
+}
\ No newline at end of file
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
new file mode 100644
index 0000000000..5aa6f04da9
--- /dev/null
+++ b/features/details/impl/src/test/kotlin/com/tangem/features/details/utils/ItemsBuilderTest.kt
@@ -0,0 +1,240 @@
+package com.tangem.features.details.utils
+
+import com.google.common.truth.Truth.assertThat
+import com.tangem.core.decompose.navigation.Router
+import com.tangem.core.ui.components.block.model.BlockUM
+import com.tangem.core.ui.extensions.resourceReference
+import com.tangem.domain.models.wallet.UserWalletId
+import com.tangem.domain.settings.HotWalletRestrictionManager
+import com.tangem.features.details.entity.DetailsItemUM
+import com.tangem.features.details.impl.R
+import io.mockk.clearMocks
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+
+internal class ItemsBuilderTest {
+
+ private val router: Router = mockk(relaxUnitFun = true)
+ private val hotWalletRestrictionManager: HotWalletRestrictionManager = mockk()
+
+ private val itemsBuilder = ItemsBuilder(
+ router = router,
+ hotWalletRestrictionManager = hotWalletRestrictionManager,
+ )
+
+ @BeforeEach
+ fun setUp() {
+ clearMocks(router, hotWalletRestrictionManager)
+ every { hotWalletRestrictionManager.isCreationEnabledSync() } returns false
+ }
+
+ @Test
+ fun `GIVEN walletConnect available AND addressBook unavailable WHEN buildAll THEN standalone WalletConnect block`() {
+ // Act
+ val result = buildAll(isWalletConnectAvailable = true, isAddressBookAvailable = false)
+
+ // Assert
+ assertThat(result.map { it.id }).containsExactly(
+ "wallet_connect",
+ "user_wallet_list",
+ "shop",
+ "settings",
+ "support",
+ ).inOrder()
+ assertThat(result.first()).isInstanceOf(DetailsItemUM.WalletConnect::class.java)
+ }
+
+ @Test
+ fun `GIVEN walletConnect AND addressBook available WHEN buildAll THEN combined block with both items`() {
+ // Act
+ val result = buildAll(isWalletConnectAvailable = true, isAddressBookAvailable = true)
+
+ // Assert
+ assertThat(result.map { it.id }).containsExactly(
+ "wallet_connect_address_book",
+ "user_wallet_list",
+ "shop",
+ "settings",
+ "support",
+ ).inOrder()
+
+ val block = result.first() as DetailsItemUM.WalletConnectAddressBookBlock
+ assertThat(block.items.map { it::class.java }).containsExactly(
+ DetailsItemUM.WalletConnectAddressBookBlock.Item.WalletConnect::class.java,
+ DetailsItemUM.WalletConnectAddressBookBlock.Item.AddressBook::class.java,
+ ).inOrder()
+ }
+
+ @Test
+ fun `GIVEN walletConnect unavailable AND addressBook available WHEN buildAll THEN combined block with addressBook only`() {
+ // Act
+ val result = buildAll(isWalletConnectAvailable = false, isAddressBookAvailable = true)
+
+ // Assert
+ assertThat(result.map { it.id }).containsExactly(
+ "wallet_connect_address_book",
+ "user_wallet_list",
+ "shop",
+ "settings",
+ "support",
+ ).inOrder()
+
+ val block = result.first() as DetailsItemUM.WalletConnectAddressBookBlock
+ assertThat(block.items.map { it::class.java }).containsExactly(
+ DetailsItemUM.WalletConnectAddressBookBlock.Item.AddressBook::class.java,
+ )
+ }
+
+ @Test
+ fun `GIVEN walletConnect AND addressBook unavailable WHEN buildAll THEN no walletConnect block`() {
+ // Act
+ val result = buildAll(isWalletConnectAvailable = false, isAddressBookAvailable = false)
+
+ // Assert
+ assertThat(result.map { it.id }).containsExactly(
+ "user_wallet_list",
+ "shop",
+ "settings",
+ "support",
+ ).inOrder()
+ }
+
+ @Test
+ fun `GIVEN creation enabled AND has mobile wallet WHEN buildAll THEN under section text is added`() {
+ // Arrange
+ every { hotWalletRestrictionManager.isCreationEnabledSync() } returns true
+
+ // Act
+ val result = buildAll(hasAnyMobileWallet = true)
+
+ // Assert
+ val underSectionText = result.filterIsInstance().single()
+ assertThat(underSectionText).isEqualTo(
+ DetailsItemUM.UnderSectionText(
+ id = "only_one_mobile_wallet_explanation",
+ text = resourceReference(R.string.only_one_mobile_wallet_explanation),
+ ),
+ )
+ }
+
+ @Test
+ fun `GIVEN creation disabled OR no mobile wallet WHEN buildAll THEN no under section text`() {
+ // Arrange — creation enabled but no mobile wallet
+ every { hotWalletRestrictionManager.isCreationEnabledSync() } returns true
+
+ // Act
+ val noMobileWallet = buildAll(hasAnyMobileWallet = false)
+
+ // Arrange — has mobile wallet but creation disabled
+ every { hotWalletRestrictionManager.isCreationEnabledSync() } returns false
+ val creationDisabled = buildAll(hasAnyMobileWallet = true)
+
+ // Assert
+ assertThat(noMobileWallet.filterIsInstance()).isEmpty()
+ assertThat(creationDisabled.filterIsInstance()).isEmpty()
+ }
+
+ @Test
+ fun `GIVEN any flags WHEN buildAll THEN unconditional blocks are always present`() {
+ // Act — most restrictive combination: nothing optional is added
+ val result = buildAll(
+ isWalletConnectAvailable = false,
+ isAddressBookAvailable = false,
+ isSupportChatAvailable = false,
+ hasAnyMobileWallet = false,
+ )
+
+ // Assert
+ assertThat(result.map { it.id }).containsAtLeast(
+ "user_wallet_list",
+ "shop",
+ "settings",
+ "support",
+ )
+ assertThat(result.filterIsInstance()).hasSize(1)
+
+ val shop = result.single { it.id == "shop" } as DetailsItemUM.Basic
+ assertThat(shop.items.map { it.id }).containsExactly("buy_tangem_wallet")
+
+ val support = result.single { it.id == "support" } as DetailsItemUM.Basic
+ assertThat(support.items.map { it.id }).containsExactly("support_email", "disclaimer").inOrder()
+ }
+
+ @Test
+ fun `GIVEN shop block WHEN addTangemPayItem THEN tangem pay item appended to shop only`() {
+ // Arrange
+ val onClick: () -> Unit = {}
+ val items: ImmutableList = persistentListOf(
+ basicBlock(id = "shop", itemIds = listOf("buy_tangem_wallet")),
+ basicBlock(id = "settings", itemIds = listOf("app_settings")),
+ )
+
+ // Act
+ val result = itemsBuilder.addTangemPayItem(items = items, onClick = onClick)
+
+ // Assert
+ val shop = result.single { it.id == "shop" } as DetailsItemUM.Basic
+ assertThat(shop.items.map { it.id }).containsExactly("buy_tangem_wallet", "get_tangem_pay").inOrder()
+
+ val settings = result.single { it.id == "settings" } as DetailsItemUM.Basic
+ assertThat(settings.items.map { it.id }).containsExactly("app_settings")
+ }
+
+ @Test
+ fun `GIVEN shop block with tangem pay item WHEN removeTangemPayItem THEN item removed from shop only`() {
+ // Arrange
+ val items: ImmutableList = persistentListOf(
+ basicBlock(id = "shop", itemIds = listOf("buy_tangem_wallet", "get_tangem_pay")),
+ basicBlock(id = "settings", itemIds = listOf("app_settings")),
+ )
+
+ // Act
+ val result = itemsBuilder.removeTangemPayItem(items = items)
+
+ // Assert
+ val shop = result.single { it.id == "shop" } as DetailsItemUM.Basic
+ assertThat(shop.items.map { it.id }).containsExactly("buy_tangem_wallet")
+
+ val settings = result.single { it.id == "settings" } as DetailsItemUM.Basic
+ assertThat(settings.items.map { it.id }).containsExactly("app_settings")
+ }
+
+ private fun buildAll(
+ isWalletConnectAvailable: Boolean = false,
+ isAddressBookAvailable: Boolean = false,
+ isSupportChatAvailable: Boolean = false,
+ hasAnyMobileWallet: Boolean = false,
+ ): ImmutableList = itemsBuilder.buildAll(
+ isWalletConnectAvailable = isWalletConnectAvailable,
+ isAddressBookAvailable = isAddressBookAvailable,
+ isSupportChatAvailable = isSupportChatAvailable,
+ hasAnyMobileWallet = hasAnyMobileWallet,
+ userWalletId = USER_WALLET_ID,
+ onSupportEmailClick = {},
+ onSupportChatClick = {},
+ onBuyClick = {},
+ )
+
+ private fun basicBlock(id: String, itemIds: List): DetailsItemUM.Basic = DetailsItemUM.Basic(
+ id = id,
+ items = itemIds.map { itemId ->
+ DetailsItemUM.Basic.Item(
+ id = itemId,
+ block = BlockUM(
+ text = resourceReference(R.string.common_unknown_error),
+ iconRes = R.drawable.ic_tangem_24,
+ onClick = {},
+ ),
+ )
+ }.toImmutableList(),
+ )
+
+ private companion object {
+ val USER_WALLET_ID = UserWalletId("011")
+ }
+}
\ No newline at end of file
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 19a72b0acf..1e394ee095 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -188,6 +188,9 @@ include(":libs:tangem-sdk-api")
// endregion Libs modules
// region Feature modules
+include(":features:address-book:api")
+include(":features:address-book:impl")
+
include(":features:onboarding-v2:api")
include(":features:onboarding-v2:impl")