Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-05 15:12:57 +01:00
parent 4e05219c25
commit fd2fcc4735
22 changed files with 1114 additions and 4 deletions

View file

@ -231,6 +231,8 @@ dependencies {
implementation(projects.common.ui) implementation(projects.common.ui)
/** Features */ /** Features */
implementation(projects.features.addressBook.api)
implementation(projects.features.addressBook.impl)
implementation(projects.features.rating.impl) implementation(projects.features.rating.impl)
implementation(projects.features.referral.impl) implementation(projects.features.referral.impl)
implementation(projects.features.referral.domain) implementation(projects.features.referral.domain)

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<path
android:pathData="M9.149,16.445C9.427,16.482 9.711,16.5 10,16.5V18C9.646,18 9.297,17.974 8.955,17.93L9.149,16.445ZM10.947,17.188L11.007,17.641L11.044,17.93C10.702,17.974 10.354,18 10,18V16.5C10.289,16.5 10.573,16.482 10.851,16.445L10.947,17.188ZM6.044,15.158C6.492,15.502 6.985,15.789 7.513,16.008L6.938,17.392C6.287,17.122 5.681,16.769 5.131,16.346L6.044,15.158ZM14.868,16.346C14.318,16.769 13.712,17.122 13.061,17.392L12.997,17.234L12.487,16.008C13.015,15.789 13.508,15.502 13.956,15.158L14.868,16.346ZM3.992,12.487C4.211,13.015 4.498,13.508 4.842,13.956L4.247,14.413L3.653,14.868C3.231,14.318 2.877,13.712 2.607,13.061L2.772,12.994L3.992,12.487ZM17.228,12.994L17.392,13.061C17.122,13.712 16.769,14.318 16.346,14.868L15.158,13.956C15.502,13.508 15.789,13.015 16.008,12.487L17.228,12.994ZM2,10C2,9.646 2.025,9.297 2.069,8.955L2.812,9.052L3.555,9.149C3.518,9.427 3.5,9.711 3.5,10C3.5,10.289 3.518,10.573 3.555,10.851L2.069,11.044C2.025,10.702 2,10.354 2,10ZM18,10C18,10.354 17.974,10.702 17.93,11.044L17.188,10.948L16.445,10.851C16.482,10.573 16.5,10.289 16.5,10C16.5,9.711 16.482,9.427 16.445,9.149L17.93,8.955C17.974,9.297 18,9.646 18,10ZM4.842,6.044C4.498,6.492 4.211,6.985 3.992,7.513L2.607,6.938C2.877,6.287 3.231,5.681 3.653,5.131L4.842,6.044ZM16.346,5.131C16.769,5.681 17.122,6.287 17.392,6.938L16.008,7.513C15.789,6.985 15.502,6.492 15.158,6.044L16.346,5.131ZM7.513,3.992C6.985,4.211 6.492,4.498 6.044,4.842L5.131,3.653C5.681,3.231 6.287,2.877 6.938,2.607L7.513,3.992ZM13.061,2.607C13.712,2.877 14.318,3.231 14.868,3.653L13.956,4.842C13.508,4.498 13.015,4.211 12.487,3.992L13.061,2.607ZM10,2C10.354,2 10.702,2.025 11.044,2.069L10.948,2.812L10.947,2.811L10.851,3.555C10.573,3.518 10.289,3.5 10,3.5C9.711,3.5 9.427,3.518 9.149,3.555L8.955,2.069C9.297,2.025 9.646,2 10,2Z"
android:fillColor="#0090F9"/>
</vector>

1
features/address-book/api/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
build/

View file

@ -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)
}

View file

@ -0,0 +1,5 @@
package com.tangem.features.addressbook
interface AddressBookFeatureToggles {
val isAddressBookEnabled: Boolean
}

1
features/address-book/impl/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
build/

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
}
}

View file

@ -19,6 +19,7 @@ dependencies {
implementation(projects.features.tester.api) implementation(projects.features.tester.api)
implementation(projects.features.createWalletSelection.api) implementation(projects.features.createWalletSelection.api)
implementation(projects.features.onboardingV2.api) implementation(projects.features.onboardingV2.api)
implementation(projects.features.addressBook.api)
/* Project - Core */ /* Project - Core */
implementation(projects.core.decompose) implementation(projects.core.decompose)

View file

@ -29,6 +29,7 @@ internal class PreviewDetailsComponent : DetailsComponent {
}, },
).buildAll( ).buildAll(
isWalletConnectAvailable = true, isWalletConnectAvailable = true,
isAddressBookAvailable = true,
isSupportChatAvailable = true, isSupportChatAvailable = true,
hasAnyMobileWallet = true, hasAnyMobileWallet = true,
userWalletId = UserWalletId(""), userWalletId = UserWalletId(""),

View file

@ -25,6 +25,15 @@ internal sealed class DetailsItemUM {
override val id: String = "wallet_connect" override val id: String = "wallet_connect"
} }
data class WalletConnectAddressBookBlock(val items: List<Item>) : 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() { data object UserWalletList : DetailsItemUM() {
override val id: String = "user_wallet_list" override val id: String = "user_wallet_list"
} }

View file

@ -26,6 +26,7 @@ import com.tangem.domain.walletconnect.CheckIsWalletConnectAvailableUseCase
import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase import com.tangem.domain.wallets.usecase.GenerateBuyTangemCardLinkUseCase
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase 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.component.DetailsComponent
import com.tangem.features.details.entity.DetailsFooterUM import com.tangem.features.details.entity.DetailsFooterUM
import com.tangem.features.details.entity.DetailsItemUM import com.tangem.features.details.entity.DetailsItemUM
@ -51,6 +52,7 @@ internal class DetailsModel @Inject constructor(
socialsBuilder: SocialsBuilder, socialsBuilder: SocialsBuilder,
paramsContainer: ParamsContainer, paramsContainer: ParamsContainer,
feedbackFeatureToggles: FeedbackFeatureToggles, feedbackFeatureToggles: FeedbackFeatureToggles,
addressBookFeatureToggles: AddressBookFeatureToggles,
private val itemsBuilder: ItemsBuilder, private val itemsBuilder: ItemsBuilder,
private val appInfoProvider: AppInfoProvider, private val appInfoProvider: AppInfoProvider,
private val checkIsWalletConnectAvailableUseCase: CheckIsWalletConnectAvailableUseCase, private val checkIsWalletConnectAvailableUseCase: CheckIsWalletConnectAvailableUseCase,
@ -86,6 +88,7 @@ internal class DetailsModel @Inject constructor(
items = MutableStateFlow( items = MutableStateFlow(
itemsBuilder.buildAll( itemsBuilder.buildAll(
isWalletConnectAvailable = isWalletConnectAvailable, isWalletConnectAvailable = isWalletConnectAvailable,
isAddressBookAvailable = addressBookFeatureToggles.isAddressBookEnabled,
isSupportChatAvailable = feedbackFeatureToggles.isUsedeskEnabled, isSupportChatAvailable = feedbackFeatureToggles.isUsedeskEnabled,
hasAnyMobileWallet = getWalletsUseCase.invokeSync().any { it is UserWallet.Hot }, hasAnyMobileWallet = getWalletsUseCase.invokeSync().any { it is UserWallet.Hot },
userWalletId = params.userWalletId, userWalletId = params.userWalletId,

View file

@ -2,10 +2,10 @@ package com.tangem.features.details.ui
import android.content.res.Configuration import android.content.res.Configuration
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.scrollable import androidx.compose.foundation.gestures.scrollable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@ -13,17 +13,21 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp 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.SpacerH16
import com.tangem.core.ui.components.appbar.TangemTopAppBar import com.tangem.core.ui.components.appbar.TangemTopAppBar
import com.tangem.core.ui.components.appbar.models.TopAppBarButtonUM 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.block.BlockItem
import com.tangem.core.ui.components.inputrow.InputRowImageBase
import com.tangem.core.ui.decompose.ComposableContentComponent import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveReference import com.tangem.core.ui.extensions.resolveReference
@ -155,10 +159,44 @@ private fun Block(
onClick = model.onClick, onClick = model.onClick,
) )
} }
is DetailsItemUM.WalletConnectAddressBookBlock -> {
BlockCard {
WalletConnectAddressBookBlockItems(
items = model.items,
modifier = itemModifier.padding(12.dp),
)
}
}
is DetailsItemUM.UserWalletList -> { is DetailsItemUM.UserWalletList -> {
userWalletListBlockContent.Content(modifier = itemModifier) userWalletListBlockContent.Content(modifier = itemModifier)
} }
is DetailsItemUM.UnderSectionText -> { /* Handled above */ } is DetailsItemUM.UnderSectionText -> { /* Handled above */
}
}
}
}
@Composable
private fun WalletConnectAddressBookBlockItems(
items: List<DetailsItemUM.WalletConnectAddressBookBlock.Item>,
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),
)
} }
} }
} }

View file

@ -26,6 +26,7 @@ internal class ItemsBuilder @Inject constructor(
@Suppress("LongParameterList") @Suppress("LongParameterList")
fun buildAll( fun buildAll(
isWalletConnectAvailable: Boolean, isWalletConnectAvailable: Boolean,
isAddressBookAvailable: Boolean,
isSupportChatAvailable: Boolean, isSupportChatAvailable: Boolean,
hasAnyMobileWallet: Boolean, hasAnyMobileWallet: Boolean,
userWalletId: UserWalletId, userWalletId: UserWalletId,
@ -33,7 +34,11 @@ internal class ItemsBuilder @Inject constructor(
onSupportChatClick: () -> Unit, onSupportChatClick: () -> Unit,
onBuyClick: () -> Unit, onBuyClick: () -> Unit,
): ImmutableList<DetailsItemUM> = buildList { ): ImmutableList<DetailsItemUM> = buildList {
if (isAddressBookAvailable) {
buildWalletConnectAddressBookBlock(isWalletConnectAvailable, userWalletId)
} else {
buildWalletConnectBlock(isWalletConnectAvailable, userWalletId)?.let(::add) buildWalletConnectBlock(isWalletConnectAvailable, userWalletId)?.let(::add)
}
buildUserWalletListBlock().let(::add) buildUserWalletListBlock().let(::add)
if (hotWalletRestrictionManager.isCreationEnabledSync() && hasAnyMobileWallet) { if (hotWalletRestrictionManager.isCreationEnabledSync() && hasAnyMobileWallet) {
@ -86,6 +91,33 @@ internal class ItemsBuilder @Inject constructor(
} }
} }
private fun MutableList<DetailsItemUM>.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 buildUserWalletListBlock(): DetailsItemUM = DetailsItemUM.UserWalletList
private fun buildShopBlock(onBuyClick: () -> Unit): DetailsItemUM = DetailsItemUM.Basic( private fun buildShopBlock(onBuyClick: () -> Unit): DetailsItemUM = DetailsItemUM.Basic(

View file

@ -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<Basic.ButtonSupport>()) }
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<Basic.ButtonSupport>()) }
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<Basic.ButtonSupport>()) }
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<UserWallet> = 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
}
}

View file

@ -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>(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<DetailsItemUM>()
val withTangemPay = persistentListOf<DetailsItemUM>(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()
}
}

View file

@ -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<Basic.ButtonBuy>()) }
verify { urlOpener.openUrl(buyUrl) }
model.onDestroy()
}
}

View file

@ -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<TangemPayAnalyticsEvents.PermanentButtonShowed>()) }
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<TangemPayAnalyticsEvents.PermanentButtonShowed>()) }
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<TangemPayAnalyticsEvents.DetailsVisaPermanentButtonClicked>()) }
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()
}
}

View file

@ -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<Boolean>()
protected val abSlot = slot<Boolean>()
protected val chatSlot = slot<Boolean>()
protected val mobileSlot = slot<Boolean>()
protected val walletIdSlot = slot<UserWalletId>()
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<DetailsItemUM>) {
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<CardDTO>()
every { VisaUtilities.isVisaCard(cardMock) } returns isVisa
val scan = mockk<ScanResponse> { 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,
)
}
}

View file

@ -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<DetailsItemUM.UnderSectionText>().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<DetailsItemUM.UnderSectionText>()).isEmpty()
assertThat(creationDisabled.filterIsInstance<DetailsItemUM.UnderSectionText>()).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<DetailsItemUM.UserWalletList>()).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<DetailsItemUM> = 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<DetailsItemUM> = 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<DetailsItemUM> = itemsBuilder.buildAll(
isWalletConnectAvailable = isWalletConnectAvailable,
isAddressBookAvailable = isAddressBookAvailable,
isSupportChatAvailable = isSupportChatAvailable,
hasAnyMobileWallet = hasAnyMobileWallet,
userWalletId = USER_WALLET_ID,
onSupportEmailClick = {},
onSupportChatClick = {},
onBuyClick = {},
)
private fun basicBlock(id: String, itemIds: List<String>): 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")
}
}

View file

@ -188,6 +188,9 @@ include(":libs:tangem-sdk-api")
// endregion Libs modules // endregion Libs modules
// region Feature modules // region Feature modules
include(":features:address-book:api")
include(":features:address-book:impl")
include(":features:onboarding-v2:api") include(":features:onboarding-v2:api")
include(":features:onboarding-v2:impl") include(":features:onboarding-v2:impl")