Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-01 09:56:03 +02:00
commit a65b0ab877
120 changed files with 4083 additions and 255 deletions

View file

@ -283,6 +283,8 @@ dependencies {
implementation(projects.features.onboardingV2.impl)
implementation(projects.features.stories.api)
implementation(projects.features.stories.impl)
implementation(projects.features.survey.api)
implementation(projects.features.survey.impl)
implementation(projects.features.txhistory.api)
implementation(projects.features.txhistory.impl)
implementation(projects.features.biometry.api)

View file

@ -183,6 +183,7 @@ abstract class BaseTestCase : TestCase(
"GASLESS_APPROVAL_ENABLED" to true,
"MAIN_SCREEN_QR_SCANNING_ENABLED" to true,
"ADD_AND_MANAGE_TOKENS_ENABLED" to true,
"ASSETS_DISCOVERY_ENABLED" to true,
"VISA_ONBOARDING_ENABLED" to true,
"AND_15101_TANGEM_PAY_HOT_WALLET_ONBOARDING" to true,
"AND_15310_ADD_FUNDS_STAGE1" to true,

View file

@ -48,6 +48,10 @@ object TestConstants {
const val USER_TOKENS_API_SCENARIO = "user_tokens_api"
const val REFERRAL_API_SCENARIO = "referral_api"
const val QUOTES_API_SCENARIO = "quotes_api"
const val CREATE_USER_WALLET_API_SCENARIO = "create_user_wallet_api"
const val WALLET_TOKENS_API_SCENARIO = "wallet_tokens_api"
const val MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO = "moralis_evm_token_balances_api"
const val PROVIDERS_API_SCENARIO = "networks_providers"
const val SEED_PHRASE_12 = "they cram join fantasy unfair observe true theory buffalo bus exchange walk"
const val SEED_PHRASE_15 = "genuine try deer upset connect sausage diary rule price shallow fit faculty leopard " +
@ -60,6 +64,9 @@ object TestConstants {
"bread much nature basic fun iron benefit egg error prosper"
const val SVS_SEED_PHRASE_12 = "diagram thunder merit soup muscle amused refuse usual ring couch popular wash"
const val SEED_PHRASE_HAPPY_PATH =
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
const val TANGEM_PAY_ELIGIBILITY_SCENARIO = "tangem_pay_eligibility"
const val TANGEM_PAY_ACCESS_CODE = "517384"
}

View file

@ -4,6 +4,7 @@ import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.swipeUp
import com.tangem.common.BaseTestCase
@ -23,7 +24,7 @@ import androidx.compose.ui.test.hasText as withText
import com.tangem.core.res.R as CoreResR
import com.tangem.core.ui.R as CoreUiR
class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
class MainScreenPageObject(private val semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<MainScreenPageObject>(semanticsProvider = semanticsProvider) {
private val lazyList = KLazyListNode(
@ -100,6 +101,22 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
}
}
val restoringProgressText: KNode = child {
hasTestTag(MainScreenTestTags.SYNC_PROGRESS_TEXT)
useUnmergedTree = true
}
val walletImportedBanner: KNode = child {
hasTestTag(WalletNotificationTestTags.ASSETS_DISCOVERY_BANNER)
useUnmergedTree = true
}
val walletImportedBannerCheckHereButton: KNode = child {
hasAnyAncestor(withTestTag(WalletNotificationTestTags.ASSETS_DISCOVERY_BANNER))
hasText(getResourceString(CoreResR.string.main_manage_tokens))
useUnmergedTree = true
}
@OptIn(ExperimentalTestApi::class)
fun marketPriceBlock(): LazyListItemNode {
collapseHeader()
@ -253,6 +270,15 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
}
}
@OptIn(ExperimentalTestApi::class)
fun tokenRowWithTitle(tokenTitle: String): LazyListItemNode {
return lazyList.childWith<LazyListItemNode> {
hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM)
hasText(tokenTitle)
useUnmergedTree = true
}
}
/**
* Find token list item with title and address
*/
@ -350,6 +376,12 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
}
}
}
fun assertTokensCount(expectedCount: Int) {
semanticsProvider
.onAllNodes(withTestTag(TokenElementsTestTags.TOKEN_PRICE))
.assertCountEquals(expectedCount)
}
}
internal fun BaseTestCase.onMainScreen(function: MainScreenPageObject.() -> Unit) =

View file

@ -0,0 +1,251 @@
package com.tangem.tests.hotWallet
import androidx.test.InstrumentationRegistry.getTargetContext
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.CREATE_USER_WALLET_API_SCENARIO
import com.tangem.common.constants.TestConstants.MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO
import com.tangem.common.constants.TestConstants.PROVIDERS_API_SCENARIO
import com.tangem.common.constants.TestConstants.SEED_PHRASE_12
import com.tangem.common.constants.TestConstants.SEED_PHRASE_HAPPY_PATH
import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO
import com.tangem.common.constants.TestConstants.WALLET_TOKENS_API_SCENARIO
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.restartApp
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.openMainScreenWithExistingHotWallet
import com.tangem.screens.*
import com.tangem.screens.accounts.onAccountDetailsScreen
import dagger.hilt.android.testing.HiltAndroidTest
import io.github.kakaocup.kakao.common.utilities.getResourceString
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Test
import com.tangem.core.ui.R as CoreUiR
@HiltAndroidTest
class AssetsDiscoveryTest : BaseTestCase() {
private companion object {
const val DISCOVERY_TIMEOUT_MILLIS = 120_000L
const val SCENARIO_STATE_STARTED = "Started"
const val SCENARIO_STATE_EMPTY = "Empty"
const val SCENARIO_STATE_ALREADY_EXISTS = "AlreadyExists"
const val SCENARIO_STATE_ASSETS_DISCOVERY_REDIRECT = "AssetsDiscoveryRedirect"
const val SCENARIO_STATE_ASSETS_DISCOVERY_HAPPY_PATH = "AssetsDiscoveryHappyPath"
const val SCENARIO_STATE_NON_ZERO_EVM_BALANCES = "NonZeroEvmBalances"
const val SCENARIO_STATE_NON_ZERO_EVM_BALANCES_SLOW = "NonZeroEvmBalancesSlow"
val EXPECTED_DISCOVERED_TOKENS = listOf(
"Ethereum",
"Polygon",
"Tether",
)
val TOKENS_THAT_MUST_NOT_APPEAR = listOf(
"Solana",
"USDC",
)
val BACKEND_PRE_POPULATED_TOKENS = listOf(
"Bitcoin",
"Ethereum",
"Polygon",
)
}
@AllureId("9280")
@DisplayName("Hot wallet: new import — Discovery → Sync → Banner → Check here happy path")
@Test
fun newHotWalletImportHappyPathTest() {
val packageName = getTargetContext().packageName
setupHooks(
additionalBeforeAppLaunchSection = {
setWireMockScenarioState(PROVIDERS_API_SCENARIO, state = SCENARIO_STATE_ASSETS_DISCOVERY_REDIRECT)
setWireMockScenarioState(CREATE_USER_WALLET_API_SCENARIO, state = SCENARIO_STATE_STARTED)
setWireMockScenarioState(USER_TOKENS_API_SCENARIO, state = SCENARIO_STATE_ASSETS_DISCOVERY_HAPPY_PATH)
setWireMockScenarioState(WALLET_TOKENS_API_SCENARIO, state = SCENARIO_STATE_STARTED)
setWireMockScenarioState(MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO, state = SCENARIO_STATE_NON_ZERO_EVM_BALANCES)
},
additionalAfterSection = {
resetWireMockScenarioState(PROVIDERS_API_SCENARIO)
resetWireMockScenarioState(CREATE_USER_WALLET_API_SCENARIO)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(WALLET_TOKENS_API_SCENARIO)
resetWireMockScenarioState(MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO)
},
).run {
step("Import a new hot wallet from seed phrase") {
openMainScreenWithExistingHotWallet(SEED_PHRASE_HAPPY_PATH)
}
step("Assert 'Restoring' progress loader is shown (discovery is in flight)") {
onMainScreen { restoringProgressText.assertIsDisplayed() }
}
step("Wait for 'Wallet successfully imported' banner (discovery completes)") {
flakySafely(timeoutMs = DISCOVERY_TIMEOUT_MILLIS) {
onMainScreen { walletImportedBanner.assertIsDisplayed() }
}
}
step("Assert expected discovered tokens are visible in the assets list") {
onMainScreen {
EXPECTED_DISCOVERED_TOKENS.forEach { token ->
tokenRowWithTitle(token).assertIsDisplayed()
}
}
}
step("Tap 'Check here' (Manage tokens) on the banner") {
onMainScreen { walletImportedBannerCheckHereButton.clickWithAssertion() }
}
step("Assert 'Manage Tokens' screen is opened") {
onManageTokensScreen { searchField.assertIsDisplayed() }
}
step("Return to main screen") {
device.uiDevice.pressBack()
waitForIdle()
}
step("Assert banner is hidden after navigating into Manage Tokens") {
onMainScreen { walletImportedBanner.assertIsNotDisplayed() }
}
step("Force-close and re-launch the app") {
restartApp(packageName)
}
step("Assert banner is NOT shown again after relaunch") {
onMainScreen { walletImportedBanner.assertIsNotDisplayed() }
}
step("Assert previously discovered tokens still appear in the assets list") {
onMainScreen {
EXPECTED_DISCOVERED_TOKENS.forEach { token ->
tokenRowWithTitle(token).assertIsDisplayed()
}
}
}
step("Assert zero-balance and spam tokens are NOT shown in the assets list") {
onMainScreen {
TOKENS_THAT_MUST_NOT_APPEAR.forEach { token ->
assertTokenDoesNotExist(token)
}
}
}
}
}
@AllureId("9284")
@DisplayName("Hot wallet: token added manually during Discovery — no duplicate created")
@Test
fun manualTokenAddDuringDiscoveryNoDuplicateTest() {
val tetherTitle = "Tether"
val ethereumNetworkTitle = "ETHEREUM"
val accountName = getResourceString(CoreUiR.string.account_main_account_title)
val expectedTokensCount = 4
setupHooks(
additionalBeforeAppLaunchSection = {
setWireMockScenarioState(PROVIDERS_API_SCENARIO, state = SCENARIO_STATE_ASSETS_DISCOVERY_REDIRECT)
setWireMockScenarioState(CREATE_USER_WALLET_API_SCENARIO, state = SCENARIO_STATE_STARTED)
setWireMockScenarioState(USER_TOKENS_API_SCENARIO, state = SCENARIO_STATE_ASSETS_DISCOVERY_HAPPY_PATH)
setWireMockScenarioState(WALLET_TOKENS_API_SCENARIO, state = SCENARIO_STATE_STARTED)
setWireMockScenarioState(
MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO,
state = SCENARIO_STATE_NON_ZERO_EVM_BALANCES_SLOW,
)
},
additionalAfterSection = {
resetWireMockScenarioState(PROVIDERS_API_SCENARIO)
resetWireMockScenarioState(CREATE_USER_WALLET_API_SCENARIO)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(WALLET_TOKENS_API_SCENARIO)
resetWireMockScenarioState(MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO)
},
).run {
step("Import a new hot wallet from seed phrase") {
openMainScreenWithExistingHotWallet(SEED_PHRASE_HAPPY_PATH)
}
step("Assert 'Restoring' progress loader is shown (discovery is in flight)") {
onMainScreen { restoringProgressText.assertIsDisplayed() }
}
step("Open wallet details from top bar") {
onMainScreenTopBar { moreButton.clickWithAssertion() }
}
step("Open 'Wallet settings'") {
onDetailsScreen { walletNameButton.performClick() }
}
step("Open account: '$accountName'") {
onWalletSettingsScreen { accountItem(accountName).performClick() }
}
step("Open 'Manage Tokens' from account details") {
onAccountDetailsScreen { manageTokensButton.performClick() }
}
step("Search for '$tetherTitle' in Manage Tokens") {
onManageTokensScreen {
searchField.performClick()
searchField.performTextInput(tetherTitle)
}
device.uiDevice.pressBack()
waitForIdle()
}
step("Expand '$tetherTitle'") {
onManageTokensScreen { tokenItem(tetherTitle).clickWithAssertion() }
waitForIdle()
}
step("Enable the $ethereumNetworkTitle network") {
onManageTokensScreen { networkSwitch(ethereumNetworkTitle).clickWithAssertion() }
}
step("Save Manage Tokens changes") {
onManageTokensScreen { saveButton.clickWithAssertion() }
waitForIdle()
}
step("Navigate back to main screen") {
repeat(times = 3) {
device.uiDevice.pressBack()
waitForIdle()
}
}
step("Wait for 'Wallet successfully imported' banner (discovery completes after delay)") {
flakySafely(timeoutMs = DISCOVERY_TIMEOUT_MILLIS) {
onMainScreen { walletImportedBanner.assertIsDisplayed() }
}
}
step("Assert '$tetherTitle' is in the assets list (manual add + discovery merged)") {
onMainScreen { tokenRowWithTitle(tetherTitle).assertIsDisplayed() }
}
step("Assert assets list contains exactly $expectedTokensCount tokens (no duplicate after merge)") {
onMainScreen { assertTokensCount(expectedTokensCount) }
}
}
}
@AllureId("9282")
@DisplayName("Hot wallet: re-import existing wallet — 200 OK, no Discovery, tokens from backend")
@Test
fun reimportExistingHotWalletTest() {
setupHooks(
additionalBeforeAppLaunchSection = {
setWireMockScenarioState(CREATE_USER_WALLET_API_SCENARIO, state = SCENARIO_STATE_ALREADY_EXISTS)
setWireMockScenarioState(USER_TOKENS_API_SCENARIO, state = SCENARIO_STATE_STARTED)
setWireMockScenarioState(MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO, state = SCENARIO_STATE_EMPTY)
},
additionalAfterSection = {
resetWireMockScenarioState(CREATE_USER_WALLET_API_SCENARIO)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO)
},
).run {
step("Import an existing hot wallet from seed phrase") {
openMainScreenWithExistingHotWallet(SEED_PHRASE_12)
}
step("Assert tokens from backend are displayed immediately") {
BACKEND_PRE_POPULATED_TOKENS.forEach { token ->
onMainScreen { tokenRowWithTitle(token).assertIsDisplayed() }
}
}
step("Assert 'Restoring' loader is NOT displayed (discovery did not start)") {
onMainScreen { restoringProgressText.assertIsNotDisplayed() }
}
step("Assert 'Wallet successfully imported' banner is NOT displayed") {
onMainScreen { walletImportedBanner.assertIsNotDisplayed() }
}
}
}
}

View file

@ -210,6 +210,17 @@
android:scheme="tangem" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="survey"
android:scheme="tangem" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />

View file

@ -5,6 +5,7 @@ import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.event.SignIn
import com.tangem.domain.card.analytics.IntroductionProcess
import com.tangem.domain.tokens.model.analytics.TokenScreenAnalyticsEvent
class HotWalletContextInterceptor(
val parent: ParamsInterceptor? = null,
@ -18,6 +19,7 @@ class HotWalletContextInterceptor(
is SignIn.ButtonAddWallet,
is SignIn.ButtonUnlockAllWithBiometric,
is IntroductionProcess.ButtonScanCard,
is TokenScreenAnalyticsEvent.ButtonQuickTopUp,
-> false
is SignIn.ErrorBiometricUpdated -> !event.isFromUnlockAll
else -> true

View file

@ -6,6 +6,8 @@ import android.net.Uri
import androidx.core.net.toUri
import com.tangem.common.routing.DeepLinkScheme
import com.tangem.common.uri.ExternalUrlValidator
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.core.analytics.models.ExceptionAnalyticsEvent
import com.tangem.core.navigation.deeplink.DeeplinkLauncher
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.utils.logging.TangemLogger
@ -17,6 +19,7 @@ import com.tangem.utils.logging.TangemLogger
internal class DefaultDeeplinkLauncher(
private val context: Context,
private val urlOpener: UrlOpener,
private val analyticsExceptionHandler: AnalyticsExceptionHandler,
) : DeeplinkLauncher {
override fun launch(link: String) {
@ -58,7 +61,26 @@ internal class DefaultDeeplinkLauncher(
}
private fun launchDeepLink(uri: Uri) {
context.startActivity(createDeepLinkIntent(uri))
val intent = createDeepLinkIntent(uri)
if (intent.resolveActivity(context.packageManager) != null) {
context.startActivity(intent)
} else {
TangemLogger.i(
"""
No match found for deep link
|- Received URI: $uri
""".trimIndent(),
)
analyticsExceptionHandler.sendException(
ExceptionAnalyticsEvent(
exception = UnresolvedDeeplinkException(uri),
params = mapOf(
"uri_scheme" to uri.scheme.orEmpty(),
"uri_host" to uri.host.orEmpty(),
),
),
)
}
}
private fun createDeepLinkIntent(uri: Uri): Intent = Intent(Intent.ACTION_VIEW, uri).apply {
@ -66,3 +88,6 @@ internal class DefaultDeeplinkLauncher(
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
}
internal class UnresolvedDeeplinkException(uri: Uri) :
RuntimeException("Deeplink has no matching activity: scheme=${uri.scheme}, host=${uri.host}")

View file

@ -1,6 +1,7 @@
package com.tangem.tap.di
import android.content.Context
import com.tangem.core.analytics.api.AnalyticsExceptionHandler
import com.tangem.tap.common.deeplink.DefaultDeeplinkLauncher
import com.tangem.core.navigation.deeplink.DeeplinkLauncher
import com.tangem.core.navigation.finisher.AppFinisher
@ -55,7 +56,10 @@ internal interface UtilsModule {
@Provides
@Singleton
fun provideDeeplinkLauncher(@ApplicationContext context: Context, urlOpener: UrlOpener): DeeplinkLauncher =
DefaultDeeplinkLauncher(context, urlOpener)
fun provideDeeplinkLauncher(
@ApplicationContext context: Context,
urlOpener: UrlOpener,
analyticsExceptionHandler: AnalyticsExceptionHandler,
): DeeplinkLauncher = DefaultDeeplinkLauncher(context, urlOpener, analyticsExceptionHandler)
}
}

View file

@ -21,6 +21,7 @@ import com.tangem.features.feed.entry.components.FeedEntryRoute
import com.tangem.features.home.api.HomeComponent
import com.tangem.features.hotwallet.*
import com.tangem.features.kyc.KycComponent
import com.tangem.features.survey.SurveyComponent
import com.tangem.features.managetokens.component.ChooseManagedTokensComponent
import com.tangem.features.managetokens.component.ManageTokensComponent
import com.tangem.features.managetokens.component.ManageTokensMode
@ -112,6 +113,7 @@ internal class ChildFactory @Inject constructor(
private val tangemPayOnboardingComponentFactory: TangemPayOnboardingComponent.Factory,
private val tangemPayWalletOnboardingComponentFactory: TangemPayHotWalletOnboardingComponent.Factory,
private val kycComponentFactory: KycComponent.Factory,
private val surveyComponentFactory: SurveyComponent.Factory,
private val yieldSupplyEntryComponentFactory: YieldSupplyEntryComponent.Factory,
private val feedEntryComponentFactory: FeedEntryComponent.Factory,
private val addFundsComponentFactory: AddFundsComponent.Factory,
@ -216,6 +218,7 @@ internal class ChildFactory @Inject constructor(
userWalletId = route.userWalletId,
cryptoCurrency = route.currency,
source = route.source,
initialFiatAmount = route.initialFiatAmount,
),
componentFactory = onrampComponentFactory,
)
@ -701,6 +704,13 @@ internal class ChildFactory @Inject constructor(
componentFactory = kycComponentFactory,
)
}
is AppRoute.Survey -> {
createComponentChild(
context = context,
params = SurveyComponent.Params(token = route.token, displayId = route.displayId),
componentFactory = surveyComponentFactory,
)
}
is AppRoute.YieldSupplyEntry -> {
createComponentChild(
context = context,

View file

@ -19,6 +19,7 @@ import com.tangem.features.onramp.deeplink.SellDeepLinkHandler
import com.tangem.features.onramp.deeplink.SwapDeepLinkHandler
import com.tangem.features.send.v2.api.deeplink.SellRedirectDeepLinkHandler
import com.tangem.features.staking.api.deeplink.StakingDeepLinkHandler
import com.tangem.features.survey.deeplink.SurveyDeepLinkHandler
import com.tangem.features.tangempay.deeplink.OnboardVisaDeepLinkHandler
import com.tangem.features.tangempay.deeplink.TangemPayMainDeepLinkHandler
import com.tangem.features.tokendetails.deeplink.TokenDetailsDeepLinkHandler
@ -62,6 +63,7 @@ internal class DeepLinkFactory @Inject constructor(
private val newsDeepLink: NewsDeepLinkHandler.Factory,
private val earnDeepLink: EarnDeepLinkHandler.Factory,
private val yieldDeepLink: YieldDeepLinkHandler.Factory,
private val surveyDeepLink: SurveyDeepLinkHandler.Factory,
) {
private val permittedAppRoute = MutableStateFlow(false)
@ -175,6 +177,7 @@ internal class DeepLinkFactory @Inject constructor(
DeepLinkRoute.Earn.host -> earnDeepLink.create(queryParams)
DeepLinkRoute.Yield.host -> yieldDeepLink.create(coroutineScope, queryParams)
DeepLinkRoute.PayAppMain.host -> tangemPayMainDeepLink.create(coroutineScope, queryParams)
DeepLinkRoute.Survey.host -> surveyDeepLink.create(queryParams)
else -> {
TangemLogger.i(
"""

View file

@ -11,6 +11,7 @@ import com.tangem.features.feed.entry.deeplink.MarketsTokenExchangesDeepLinkHand
import com.tangem.features.feed.entry.deeplink.NewsDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.NewsDetailsDeepLinkHandler
import com.tangem.features.feed.entry.deeplink.YieldDeepLinkHandler
import com.tangem.features.survey.deeplink.SurveyDeepLinkHandler
import com.tangem.features.onramp.deeplink.BuyDeepLinkHandler
import com.tangem.features.onramp.deeplink.OnrampDeepLinkHandler
import com.tangem.features.onramp.deeplink.SellDeepLinkHandler
@ -99,6 +100,10 @@ class DeepLinkFactoryTest {
every { create(any()) } returns mockk()
}
private val surveyDeepLinkFactory = mockk<SurveyDeepLinkHandler.Factory>(relaxed = true) {
every { create(any()) } returns mockk()
}
private val earnDeepLinkFactory = mockk<EarnDeepLinkHandler.Factory>(relaxed = true) {
every { create(any()) } returns mockk()
}
@ -140,6 +145,7 @@ class DeepLinkFactoryTest {
newsDeepLink = newsDeepLinkFactory,
earnDeepLink = earnDeepLinkFactory,
yieldDeepLink = yieldDeepLinkFactory,
surveyDeepLink = surveyDeepLinkFactory,
)
@OptIn(ExperimentalCoroutinesApi::class)

View file

@ -297,6 +297,7 @@ sealed class AppRoute(val path: String) : Route {
val source: OnrampSource,
val userWalletId: UserWalletId,
val currency: CryptoCurrency,
val initialFiatAmount: SerializedBigDecimal? = null,
) : AppRoute(path = "/onramp/${userWalletId.stringValue}/${currency.symbol}"), RouteBundleParams {
override fun getBundle(): Bundle = bundle(serializer())
}
@ -497,6 +498,9 @@ sealed class AppRoute(val path: String) : Route {
@Serializable
data class Kyc(val userWalletId: UserWalletId) : AppRoute(path = "/kyc")
@Serializable
data class Survey(val token: String, val displayId: String? = null) : AppRoute(path = "/survey")
@Serializable
data class YieldSupplyEntry(
val userWalletId: UserWalletId,

View file

@ -87,6 +87,10 @@ sealed class DeepLinkRoute {
data object PayAppMain : DeepLinkRoute() {
override val host: String = "pay-app-main"
}
data object Survey : DeepLinkRoute() {
override val host: String = "survey"
}
}
enum class DeepLinkScheme(val scheme: String) {

View file

@ -328,6 +328,7 @@ sealed class AnalyticsParam {
const val WALLET_TYPE = "Wallet Type"
const val BACKUPED = "Backuped"
const val MEMO = "Memo"
const val VALUE = "Value"
}
}

View file

@ -68,7 +68,7 @@
"version": "undefined"
},
{
"name": "SWAP_INTEGRATED_APPROVE",
"name": "AND_15120_SWAP_INTEGRATED_APPROVE",
"version": "undefined"
},
{
@ -114,5 +114,13 @@
{
"name": "AND_15438_BACKEND_AUTHENTICATION_ENABLED",
"version": "undefined"
},
{
"name": "AND_15482_SURVEYSPARROW_ENABLED",
"version": "undefined"
},
{
"name": "AND_15258_QUICK_TOP_UP_ENABLED",
"version": "undefined"
}
]

View file

@ -50,7 +50,6 @@ internal class FeatureTogglesNamingConventionTest {
"SOLANA_TX_HISTORY_ENABLED",
"STAKING_ETH_ENABLED",
"SWAP_AB_ENABLED",
"SWAP_INTEGRATED_APPROVE",
"USEDESK_ENABLED",
"VIRTUAL_ACCOUNTS_ENABLED",
"VISA_ONBOARDING_ENABLED",

View file

@ -14,21 +14,39 @@ class WireMockRedirectInterceptor : Interceptor {
val request = chain.request()
val url = request.url.toString()
val host = request.url.host
val sanitizedOverride = override.trimEnd('/')
if (url.contains(WIREMOCK_REMOTE_URL)) {
val newUrl = url.replace(WIREMOCK_REMOTE_URL, override.trimEnd('/'))
if (host == WIREMOCK_REMOTE_HOST) {
val newUrl = url.replace(WIREMOCK_REMOTE_URL, sanitizedOverride)
TangemLogger.d("WireMockRedirect: $url -> $newUrl")
val newRequest = request.newBuilder()
.url(newUrl)
.build()
return chain.proceed(newRequest)
return chain.proceed(request.newBuilder().url(newUrl).build())
}
if (host in REDIRECTABLE_THIRD_PARTY_HOSTS) {
val newUrl = url.replace("https://$host", "$sanitizedOverride/$host")
TangemLogger.d("WireMockRedirect (3p): $url -> $newUrl")
return chain.proceed(request.newBuilder().url(newUrl).build())
}
return chain.proceed(request)
}
companion object {
private const val WIREMOCK_REMOTE_URL = "[REDACTED_ENV_URL]"
private const val WIREMOCK_REMOTE_HOST = "wiremock.tests-d.com"
private const val WIREMOCK_REMOTE_URL = "https://$WIREMOCK_REMOTE_HOST"
/**
* Upstream hosts that have no other override knob and should be funnelled into WireMock
* when [overriddenBaseUrl] is set. Each matched URL becomes `<override>/<host>/<original-path>`,
* so mock mappings should live under that host-prefixed path in tangem-api-mocks. Matching
* is done against the request's parsed host (exact equality) substring matching would
* incorrectly redirect look-alikes such as `deep-index.moralis.io.evil.example`.
*/
private val REDIRECTABLE_THIRD_PARTY_HOSTS = setOf(
"deep-index.moralis.io",
"solana-gateway.moralis.io",
)
/**
* Override base URL for WireMock requests.

View file

@ -32,7 +32,7 @@ fun TangemThemeRedesign(content: @Composable () -> Unit) {
val tangemDimens3 = remember { TangemDimens3() }
val tangemTypography3 = remember { TangemTypography3(InterFamily) }
val tangemTypography2 = remember { TangemTypography2(InterFamily) }
val tangemTypography = remember { TangemTypography(InterFamily) }
val tangemTypography = remember { TangemTypography(InterFamily, useMediumForRegular = true) }
MaterialTheme(
colorScheme = tangemColorScheme(colors = rememberedColors),

View file

@ -19,7 +19,10 @@ internal val RobotoFamily = FontFamily(
@Immutable
class TangemTypography internal constructor(
fontFamily: FontFamily,
useMediumForRegular: Boolean = false,
) {
private val regularWeight: FontWeight = if (useMediumForRegular) FontWeight.Medium else FontWeight.Normal
val head: TextStyle = TextStyle(
fontFamily = fontFamily,
fontSize = 34.sp,
@ -34,7 +37,7 @@ class TangemTypography internal constructor(
val h1: TextStyle = TextStyle(
fontFamily = fontFamily,
fontSize = 34.sp,
fontWeight = FontWeight.Normal,
fontWeight = regularWeight,
letterSpacing = TextUnit(value = 0f, type = TextUnitType.Sp),
lineHeight = TextUnit(value = 44f, type = TextUnitType.Sp),
lineHeightStyle = LineHeightStyle(
@ -89,7 +92,7 @@ class TangemTypography internal constructor(
val body1: TextStyle = TextStyle(
fontFamily = fontFamily,
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
fontWeight = regularWeight,
letterSpacing = TextUnit(value = 0.5f, type = TextUnitType.Sp),
lineHeight = TextUnit(value = 24f, type = TextUnitType.Sp),
lineHeightStyle = LineHeightStyle(
@ -100,7 +103,7 @@ class TangemTypography internal constructor(
val body2: TextStyle = TextStyle(
fontFamily = fontFamily,
fontSize = 14.sp,
fontWeight = FontWeight.Normal,
fontWeight = regularWeight,
letterSpacing = TextUnit(value = 0.25f, type = TextUnitType.Sp),
lineHeight = TextUnit(value = 20f, type = TextUnitType.Sp),
lineHeightStyle = LineHeightStyle(
@ -133,7 +136,7 @@ class TangemTypography internal constructor(
val caption2: TextStyle = TextStyle(
fontFamily = fontFamily,
fontSize = 12.sp,
fontWeight = FontWeight.Normal,
fontWeight = regularWeight,
letterSpacing = TextUnit(value = 0.4f, type = TextUnitType.Sp),
lineHeight = TextUnit(value = 16f, type = TextUnitType.Sp),
lineHeightStyle = LineHeightStyle(

View file

@ -11,6 +11,7 @@ object MainScreenTestTags {
const val CARD_TITLE = "MAIN_SCREEN_CARD_TITLE"
const val CARD_IMAGE = "MAIN_SCREEN_CARD_IMAGE"
const val DEVICES_COUNT = "MAIN_SCREEN_DEVICES_COUNT"
const val SYNC_PROGRESS_TEXT = "MAIN_SCREEN_SYNC_PROGRESS_TEXT"
const val WALLET_BALANCE = "MAIN_SCREEN_WALLET_BALANCE"
const val TOTAL_BALANCE_MENU_ITEM = "MAIN_SCREEN_TOTAL_BALANCE_MENU_ITEM"

View file

@ -0,0 +1,5 @@
package com.tangem.core.ui.test
object WalletNotificationTestTags {
const val ASSETS_DISCOVERY_BANNER = "WALLET_NOTIFICATION_ASSETS_DISCOVERY_BANNER"
}

View file

@ -0,0 +1,27 @@
<!--
~ Copyright (C) 2026 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<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="M15.75,9.25C16.164,9.25 16.5,9.586 16.5,10C16.5,13.59 13.59,16.5 10,16.5C7.986,16.5 6.19,15.581 5,14.144V14.791C5,15.205 4.664,15.541 4.25,15.541C3.836,15.541 3.5,15.205 3.5,14.791V12.396C3.5,11.981 3.836,11.646 4.25,11.646H6.646C7.06,11.646 7.395,11.981 7.396,12.396C7.396,12.81 7.06,13.146 6.646,13.146H6.123C7.04,14.276 8.434,15 10,15C12.762,15 15,12.762 15,10C15,9.586 15.336,9.25 15.75,9.25Z"
android:fillColor="#ffffff"/>
<path
android:pathData="M10,3.5C12.014,3.5 13.81,4.419 15,5.855V5.208C15,4.794 15.336,4.458 15.75,4.458C16.164,4.458 16.5,4.794 16.5,5.208V7.604C16.5,8.018 16.164,8.354 15.75,8.354H13.354C12.94,8.353 12.604,8.018 12.604,7.604C12.605,7.19 12.941,6.854 13.354,6.854H13.876C12.96,5.723 11.565,5 10,5C7.238,5 5,7.238 5,10C5,10.414 4.664,10.75 4.25,10.75C3.836,10.75 3.5,10.414 3.5,10C3.5,6.41 6.41,3.5 10,3.5Z"
android:fillColor="#ffffff"/>
</vector>

View file

@ -0,0 +1,29 @@
<!--
~ Copyright (C) 2026 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="22dp"
android:height="8dp"
android:viewportWidth="22"
android:viewportHeight="8">
<group>
<clip-path
android:pathData="M0,0h22v8h-22z"/>
<path
android:pathData="M5.503,7.217H3.709L2.364,2.086C2.3,1.849 2.164,1.641 1.965,1.542C1.467,1.295 0.919,1.099 0.321,0.999V0.802H3.21C3.609,0.802 3.908,1.099 3.958,1.443L4.656,5.145L6.449,0.802H8.193L5.503,7.217ZM9.189,7.217H7.495L8.89,0.802H10.585L9.189,7.217ZM12.777,2.578C12.827,2.233 13.126,2.035 13.475,2.035C14.023,1.986 14.62,2.085 15.119,2.331L15.418,0.95C14.92,0.753 14.371,0.653 13.873,0.653C12.229,0.653 11.033,1.542 11.033,2.775C11.033,3.713 11.88,4.206 12.479,4.503C13.126,4.798 13.375,4.996 13.325,5.292C13.325,5.736 12.827,5.933 12.329,5.933C11.731,5.933 11.132,5.785 10.585,5.538L10.286,6.92C10.884,7.167 11.531,7.266 12.13,7.266C13.973,7.314 15.119,6.427 15.119,5.094C15.119,3.416 12.777,3.318 12.777,2.578ZM21.048,7.217L19.703,0.802H18.258C17.959,0.802 17.66,0.999 17.56,1.295L15.069,7.217H16.813L17.161,6.279H19.304L19.503,7.217H21.048ZM18.507,2.529L19.004,4.946H17.609L18.507,2.529Z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
</group>
</vector>

View file

@ -18,4 +18,5 @@ object StringsSigns {
const val PASSWORD_VISUAL_CHAR = '\u2022'
const val APPROXIMATE = ""
const val WHITE_SPACE = " "
const val LIGHTNING = ""
}

View file

@ -5,8 +5,10 @@ import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_FR
import com.tangem.core.analytics.models.AnalyticsParam.Key.ACTION
import com.tangem.core.analytics.models.AnalyticsParam.Key.BALANCE
import com.tangem.core.analytics.models.AnalyticsParam.Key.BLOCKCHAIN
import com.tangem.core.analytics.models.AnalyticsParam.Key.CURRENCY
import com.tangem.core.analytics.models.AnalyticsParam.Key.STATUS
import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM
import com.tangem.core.analytics.models.AnalyticsParam.Key.VALUE
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
/**
@ -181,6 +183,21 @@ sealed class TokenScreenAnalyticsEvent(
params = mapOf("Token" to token),
)
class ButtonQuickTopUp(
token: String,
blockchain: String,
currency: String,
value: String,
) : TokenScreenAnalyticsEvent(
event = "Quick Top Up Button",
params = mapOf(
TOKEN_PARAM to token,
BLOCKCHAIN to blockchain,
CURRENCY to currency,
VALUE to value,
),
)
companion object {
const val AVAILABLE = "Available"
private const val UNAVAILABLE = "Unavailable"

View file

@ -190,11 +190,18 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
bottomSheetState = bottomSheetState,
stackState = stackStack,
onHeaderSizeChange = onHeaderSizeChange,
onExpandSheet = onExpandSheet,
onExpandSheet = { onCollapsedSheetClick(onExpandSheet) },
isOpenedInBottomSheet = true,
)
}
private fun onCollapsedSheetClick(onExpandSheet: () -> Unit) {
if (stack.value.active.configuration is FeedEntryChildFactory.Child.Feed) {
clickIntents.openSearch(AnalyticsParam.ScreensSources.Markets.value)
}
onExpandSheet()
}
@Composable
override fun Content(modifier: Modifier) {
val bottomSheetState = remember {

View file

@ -5,6 +5,7 @@ import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.onramp.model.OnrampSource
import com.tangem.domain.models.wallet.UserWalletId
import java.math.BigDecimal
interface OnrampComponent : ComposableContentComponent {
@ -12,6 +13,7 @@ interface OnrampComponent : ComposableContentComponent {
val userWalletId: UserWalletId,
val cryptoCurrency: CryptoCurrency,
val source: OnrampSource,
val initialFiatAmount: BigDecimal? = null,
)
interface Factory : ComponentFactory<Params, OnrampComponent>

View file

@ -6,6 +6,7 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.onramp.model.OnrampProviderWithQuote
import com.tangem.domain.onramp.model.OnrampSource
import java.math.BigDecimal
internal interface OnrampMainComponent : ComposableContentComponent {
@ -15,6 +16,7 @@ internal interface OnrampMainComponent : ComposableContentComponent {
val source: OnrampSource,
val openSettings: () -> Unit,
val openRedirectPage: (quote: OnrampProviderWithQuote.Data) -> Unit,
val initialFiatAmount: BigDecimal? = null,
)
interface Factory : ComponentFactory<Params, OnrampMainComponent>

View file

@ -51,7 +51,7 @@ internal class OnrampStateFactory(
)
}
fun getReadyState(currency: OnrampCurrency): OnrampMainComponentUM.Content {
fun getReadyState(currency: OnrampCurrency, initialFiatAmount: BigDecimal? = null): OnrampMainComponentUM.Content {
val state = currentStateProvider()
val endButton = when (val button = state.topBarConfig.endButtonUM) {
@ -59,7 +59,7 @@ internal class OnrampStateFactory(
is TopAppBarButtonUM.Text -> button.copy(isEnabled = true)
}
val initialAmountBlockState = getInitialAmountBlockState(currency)
val initialAmountBlockState = getInitialAmountBlockState(currency, initialFiatAmount)
return OnrampMainComponentUM.Content(
topBarConfig = state.topBarConfig.copy(endButtonUM = endButton),
@ -136,7 +136,10 @@ internal class OnrampStateFactory(
)
}
private fun getInitialAmountBlockState(currency: OnrampCurrency): OnrampAmountBlockUM {
private fun getInitialAmountBlockState(
currency: OnrampCurrency,
initialFiatAmount: BigDecimal? = null,
): OnrampAmountBlockUM {
return OnrampAmountBlockUM(
currencyUM = OnrampCurrencyUM(
code = currency.code,
@ -146,8 +149,8 @@ internal class OnrampStateFactory(
unit = currency.unit,
),
amountFieldModel = AmountFieldModel(
value = "",
fiatValue = "",
value = initialFiatAmount?.toPlainString().orEmpty(),
fiatValue = initialFiatAmount?.toPlainString().orEmpty(),
onValueChange = onrampIntents::onAmountValueChanged,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.None,
@ -156,7 +159,7 @@ internal class OnrampStateFactory(
keyboardActions = KeyboardActions(),
isFiatValue = true,
cryptoAmount = BigDecimal.ZERO.convertToAmount(cryptoCurrency),
fiatAmount = BigDecimal.ZERO.convertToFiatAmount(currency),
fiatAmount = (initialFiatAmount ?: BigDecimal.ZERO).convertToFiatAmount(currency),
isError = false,
isWarning = false,
error = TextReference.EMPTY,

View file

@ -274,7 +274,7 @@ internal class OnrampMainComponentModel @Inject constructor(
amountStateFactory.getUpdatedCurrencyState(country.defaultCurrency)
}
is OnrampMainComponentUM.InitialLoading -> {
stateFactory.getReadyState(country.defaultCurrency)
stateFactory.getReadyState(country.defaultCurrency, params.initialFiatAmount)
}
}
}

View file

@ -82,6 +82,7 @@ internal class DefaultOnrampComponent @AssistedInject constructor(
),
)
},
initialFiatAmount = params.initialFiatAmount,
),
)
is OnrampChild.RedirectPage -> onrampRedirectComponentFactory.create(

View file

@ -1,5 +1,6 @@
package com.tangem.features.promobanners.impl.model
import androidx.core.net.toUri
import com.tangem.core.navigation.deeplink.DeeplinkLauncher
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.di.ModelScoped
@ -118,7 +119,18 @@ internal class PromoBannersBlockModel @Inject constructor(
private fun onButtonClick(displayId: Int, deeplink: String?) {
analyticsEventHandler.send(PromoBannerAnalyticsEvent.Clicked(displayId, placeholderName))
deeplink?.let { deeplinkLauncher.launch(it) }
deeplink?.let { deeplinkLauncher.launch(appendSurveyDisplayId(it, displayId)) }
}
private fun appendSurveyDisplayId(deeplink: String, displayId: Int): String {
val uri = deeplink.toUri()
val isSurveyDeeplink = uri.scheme == DEEPLINK_SCHEME_TANGEM && uri.host == DEEPLINK_HOST_SURVEY
if (!isSurveyDeeplink || uri.getQueryParameter(QUERY_DISPLAY_ID) != null) return deeplink
return uri.buildUpon()
.appendQueryParameter(QUERY_DISPLAY_ID, displayId.toString())
.build()
.toString()
}
private fun getInitialState() = PromoBannersBlockUM(
@ -152,4 +164,10 @@ internal class PromoBannersBlockModel @Inject constructor(
}
}
}
private companion object {
const val DEEPLINK_SCHEME_TANGEM = "tangem"
const val DEEPLINK_HOST_SURVEY = "survey"
const val QUERY_DISPLAY_ID = "display_id"
}
}

View file

@ -0,0 +1,14 @@
plugins {
alias(deps.plugins.android.library)
alias(deps.plugins.kotlin.android)
id("configuration")
}
android {
namespace = "com.tangem.features.survey.api"
}
dependencies {
implementation(projects.core.decompose)
implementation(projects.core.ui)
}

View file

@ -0,0 +1,11 @@
package com.tangem.features.survey
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
interface SurveyComponent : ComposableContentComponent {
data class Params(val token: String, val displayId: String?)
interface Factory : ComponentFactory<Params, SurveyComponent>
}

View file

@ -0,0 +1,6 @@
package com.tangem.features.survey
interface SurveyFeatureToggles {
val areSurveysEnabled: Boolean
}

View file

@ -0,0 +1,14 @@
package com.tangem.features.survey
import android.app.Activity
interface SurveySparrowLauncher {
fun present(activity: Activity, data: SurveyLaunchData)
}
data class SurveyLaunchData(
val domain: String,
val token: String,
val customParams: Map<String, String>,
)

View file

@ -0,0 +1,8 @@
package com.tangem.features.survey.deeplink
interface SurveyDeepLinkHandler {
interface Factory {
fun create(queryParams: Map<String, String>): SurveyDeepLinkHandler
}
}

View file

@ -0,0 +1,61 @@
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.survey.impl"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies {
/* Project - API */
implementation(projects.features.survey.api)
/* Domain */
implementation(projects.domain.common)
implementation(projects.domain.models)
implementation(projects.domain.wallets)
implementation(projects.domain.wallets.models)
/* Core */
implementation(projects.core.analytics)
implementation(projects.core.analytics.models)
implementation(projects.core.configToggles)
implementation(projects.core.datasource)
implementation(projects.core.decompose)
implementation(projects.core.ui)
implementation(projects.core.utils)
/* Common */
implementation(projects.common.routing)
/* DI */
implementation(deps.hilt.android)
kapt(deps.hilt.kapt)
/* Compose */
implementation(deps.compose.runtime)
implementation(deps.compose.ui)
/* Other */
implementation(deps.kotlin.coroutines)
implementation(deps.arrow.core)
/** Tangem libraries */
implementation(tangemDeps.card.core)
implementation(deps.surveysparrow)
/** Tests */
testImplementation(deps.test.junit5)
testRuntimeOnly(deps.test.junit5.engine)
testImplementation(deps.test.mockk)
testImplementation(deps.test.truth)
testImplementation(deps.test.coroutine)
}

View file

@ -0,0 +1,73 @@
package com.tangem.features.survey.impl
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.features.survey.SurveyComponent
import com.tangem.features.survey.SurveyLaunchData
import com.tangem.features.survey.SurveySparrowLauncher
import com.tangem.features.survey.impl.service.SurveyCustomParamsBuilder
import com.tangem.utils.logging.TangemLogger
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.launch
@Suppress("LongParameterList")
internal class DefaultSurveyComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: SurveyComponent.Params,
private val getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase,
private val customParamsBuilder: SurveyCustomParamsBuilder,
private val surveySparrowLauncher: SurveySparrowLauncher,
@Suppress("UnusedPrivateProperty") // TODO([REDACTED_TASK_KEY]): emit [Survey] analytics events
private val analyticsEventHandler: AnalyticsEventHandler,
) : SurveyComponent, AppComponentContext by appComponentContext {
init {
// componentScope runs on mainImmediate, so presenting the SDK is already on the main thread.
componentScope.launch {
val launchData = buildLaunchData()
if (launchData != null) {
surveySparrowLauncher.present(activity, launchData)
// TODO([REDACTED_TASK_KEY]): analyticsEventHandler.send(SurveyAnalyticsEvent.Shown(...))
}
router.pop()
}
}
private suspend fun buildLaunchData(): SurveyLaunchData? {
return getSelectedWalletSyncUseCase().fold(
ifLeft = { error ->
TangemLogger.e("$TAG: survey skipped, no available wallet ($error)")
null
},
ifRight = { userWallet ->
SurveyLaunchData(
domain = SURVEY_DOMAIN,
token = params.token,
customParams = customParamsBuilder.build(
userWallet = userWallet,
token = params.token,
displayId = params.displayId,
),
)
},
)
}
@Composable
override fun Content(modifier: Modifier) = Unit
@AssistedFactory
interface Factory : SurveyComponent.Factory {
override fun create(context: AppComponentContext, params: SurveyComponent.Params): DefaultSurveyComponent
}
private companion object {
const val TAG = "SurveyComponent"
const val SURVEY_DOMAIN = "tangem.surveysparrow.com"
}
}

View file

@ -0,0 +1,14 @@
package com.tangem.features.survey.impl
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.features.survey.SurveyFeatureToggles
import javax.inject.Inject
internal class DefaultSurveyFeatureToggles @Inject constructor(
private val featureTogglesManager: FeatureTogglesManager,
) : SurveyFeatureToggles {
override val areSurveysEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled(FeatureToggles.AND_15482_SURVEYSPARROW_ENABLED)
}

View file

@ -0,0 +1,38 @@
package com.tangem.features.survey.impl
import android.app.Activity
import com.surveysparrow.ss_android_sdk.SsSurvey
import com.surveysparrow.ss_android_sdk.SurveySparrow
import com.tangem.features.survey.SurveyLaunchData
import com.tangem.features.survey.SurveySparrowLauncher
import com.tangem.utils.logging.TangemLogger
import javax.inject.Inject
internal class DefaultSurveySparrowLauncher @Inject constructor() : SurveySparrowLauncher {
override fun present(activity: Activity, data: SurveyLaunchData) {
if (activity.isFinishing || activity.isDestroyed) {
TangemLogger.e("$TAG: cannot present survey, activity is finishing/destroyed")
return
}
val survey = try {
SsSurvey(data.domain, data.token).apply {
setSurveyType(SurveySparrow.CLASSIC)
data.customParams.forEach { (key, value) -> addCustomParam(key, value) }
}
} catch (e: Exception) {
TangemLogger.e("$TAG: failed to create SurveySparrow survey", e)
return
}
// Result handling (onActivityResult -> [Survey] Completed/Dismissed) is planned in [REDACTED_TASK_KEY]
SurveySparrow(activity, survey).startSurveyForResult(SURVEY_REQUEST_CODE)
TangemLogger.d("$TAG: survey started (requestCode=$SURVEY_REQUEST_CODE)")
}
private companion object {
const val TAG = "SurveySparrowPresenter"
const val SURVEY_REQUEST_CODE = 1001
}
}

View file

@ -0,0 +1,47 @@
package com.tangem.features.survey.impl.deeplink
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.features.survey.SurveyFeatureToggles
import com.tangem.features.survey.deeplink.SurveyDeepLinkHandler
import com.tangem.utils.logging.TangemLogger
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultSurveyDeepLinkHandler @AssistedInject constructor(
@Assisted private val queryParams: Map<String, String>,
private val surveyFeatureToggles: SurveyFeatureToggles,
private val appRouter: AppRouter,
) : SurveyDeepLinkHandler {
init {
handleDeepLink()
}
private fun handleDeepLink() {
if (!surveyFeatureToggles.areSurveysEnabled) {
TangemLogger.i("$TAG: survey deeplink ignored, feature is disabled")
return
}
val token = queryParams[QUERY_TOKEN]?.takeIf { it.isNotBlank() }
if (token == null) {
TangemLogger.e("$TAG: survey deeplink ignored, missing 'token' query param")
return
}
appRouter.push(AppRoute.Survey(token = token, displayId = queryParams[QUERY_DISPLAY_ID]))
}
@AssistedFactory
interface Factory : SurveyDeepLinkHandler.Factory {
override fun create(queryParams: Map<String, String>): DefaultSurveyDeepLinkHandler
}
private companion object {
const val TAG = "SurveyDeepLink"
const val QUERY_TOKEN = "token"
const val QUERY_DISPLAY_ID = "display_id"
}
}

View file

@ -0,0 +1,36 @@
package com.tangem.features.survey.impl.di
import com.tangem.features.survey.SurveyComponent
import com.tangem.features.survey.SurveyFeatureToggles
import com.tangem.features.survey.SurveySparrowLauncher
import com.tangem.features.survey.deeplink.SurveyDeepLinkHandler
import com.tangem.features.survey.impl.DefaultSurveyComponent
import com.tangem.features.survey.impl.DefaultSurveyFeatureToggles
import com.tangem.features.survey.impl.DefaultSurveySparrowLauncher
import com.tangem.features.survey.impl.deeplink.DefaultSurveyDeepLinkHandler
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal interface SurveyModule {
@Binds
@Singleton
fun bindSurveyFeatureToggles(impl: DefaultSurveyFeatureToggles): SurveyFeatureToggles
@Binds
@Singleton
fun bindSurveySparrowLauncher(impl: DefaultSurveySparrowLauncher): SurveySparrowLauncher
@Binds
@Singleton
fun bindSurveyComponentFactory(impl: DefaultSurveyComponent.Factory): SurveyComponent.Factory
@Binds
@Singleton
fun bindSurveyDeepLinkHandlerFactory(impl: DefaultSurveyDeepLinkHandler.Factory): SurveyDeepLinkHandler.Factory
}

View file

@ -0,0 +1,48 @@
package com.tangem.features.survey.impl.service
import com.tangem.common.extensions.calculateSha256
import com.tangem.common.extensions.hexToBytes
import com.tangem.common.extensions.toHexString
import com.tangem.core.analytics.AppInstanceIdProvider
import com.tangem.datasource.api.tangemTech.models.WalletType
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.utils.SupportedLanguages
import com.tangem.utils.info.AppInfoProvider
import javax.inject.Inject
internal class SurveyCustomParamsBuilder @Inject constructor(
private val appInstanceIdProvider: AppInstanceIdProvider,
private val appInfoProvider: AppInfoProvider,
) {
suspend fun build(userWallet: UserWallet, token: String, displayId: String?): Map<String, String> {
return buildMap {
put(KEY_SURVEY_KEY, token)
put(KEY_WALLET_ID, hashWalletId(userWallet))
WalletType.from(userWallet)?.let { put(KEY_WALLET_TYPE, it.name.lowercase()) }
displayId?.takeIf { it.isNotBlank() }?.let { put(KEY_DISPLAY_ID, it) }
appInstanceIdProvider.getAppInstanceId()?.let { put(KEY_DEVICE_ID, it) }
put(KEY_PLATFORM, appInfoProvider.platform.lowercase())
put(KEY_APP_VERSION, appInfoProvider.appVersion)
put(KEY_LANGUAGE, SupportedLanguages.getCurrentSupportedLanguageCode())
}
}
private fun hashWalletId(userWallet: UserWallet): String {
return userWallet.walletId.stringValue
.hexToBytes()
.calculateSha256()
.toHexString()
}
private companion object {
const val KEY_SURVEY_KEY = "survey_key"
const val KEY_WALLET_ID = "wallet_id"
const val KEY_WALLET_TYPE = "wallet_type"
const val KEY_DISPLAY_ID = "display_id"
const val KEY_DEVICE_ID = "device_id"
const val KEY_PLATFORM = "platform"
const val KEY_APP_VERSION = "app_version"
const val KEY_LANGUAGE = "language"
}
}

View file

@ -0,0 +1,81 @@
package com.tangem.features.survey.impl.deeplink
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.features.survey.SurveyFeatureToggles
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkObject
import io.mockk.verify
import com.tangem.utils.logging.TangemLogger
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
internal class DefaultSurveyDeepLinkHandlerTest {
private val featureToggles = mockk<SurveyFeatureToggles>()
private val appRouter = mockk<AppRouter>(relaxed = true)
@BeforeEach
fun setup() {
mockkObject(TangemLogger)
every { TangemLogger.i(any()) } just Runs
every { TangemLogger.e(any()) } just Runs
}
@AfterEach
fun tearDown() {
unmockkObject(TangemLogger)
}
@Test
fun `does not navigate when feature is disabled`() {
every { featureToggles.areSurveysEnabled } returns false
createHandler(mapOf("token" to TOKEN, "display_id" to DISPLAY_ID))
verify(exactly = 0) { appRouter.push(any(), any()) }
}
@Test
fun `does not navigate when token is missing`() {
every { featureToggles.areSurveysEnabled } returns true
createHandler(emptyMap())
verify(exactly = 0) { appRouter.push(any(), any()) }
}
@Test
fun `pushes survey route with token and display id on happy path`() {
every { featureToggles.areSurveysEnabled } returns true
createHandler(mapOf("token" to TOKEN, "display_id" to DISPLAY_ID))
verify { appRouter.push(route = AppRoute.Survey(token = TOKEN, displayId = DISPLAY_ID), onComplete = any()) }
}
@Test
fun `pushes survey route with null display id when absent`() {
every { featureToggles.areSurveysEnabled } returns true
createHandler(mapOf("token" to TOKEN))
verify { appRouter.push(route = AppRoute.Survey(token = TOKEN, displayId = null), onComplete = any()) }
}
private fun createHandler(queryParams: Map<String, String>) = DefaultSurveyDeepLinkHandler(
queryParams = queryParams,
surveyFeatureToggles = featureToggles,
appRouter = appRouter,
)
private companion object {
const val TOKEN = "ntt-84iF22PDajmervYneMW4kv"
const val DISPLAY_ID = "42"
}
}

View file

@ -0,0 +1,104 @@
package com.tangem.features.survey.impl.service
import com.google.common.truth.Truth.assertThat
import com.tangem.common.extensions.calculateSha256
import com.tangem.common.extensions.hexToBytes
import com.tangem.common.extensions.toHexString
import com.tangem.core.analytics.AppInstanceIdProvider
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.info.AppInfoProvider
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.util.Locale
internal class SurveyCustomParamsBuilderTest {
private val appInstanceIdProvider = mockk<AppInstanceIdProvider>()
private val appInfoProvider = mockk<AppInfoProvider>()
private val builder = SurveyCustomParamsBuilder(
appInstanceIdProvider = appInstanceIdProvider,
appInfoProvider = appInfoProvider,
)
@BeforeEach
fun setup() {
Locale.setDefault(Locale.ENGLISH)
every { appInfoProvider.platform } returns "Android"
every { appInfoProvider.appVersion } returns "5.40"
coEvery { appInstanceIdProvider.getAppInstanceId() } returns "device-123"
}
@Test
fun `builds all params for a cold wallet`() = runTest {
val wallet = coldWallet(WALLET_ID_HEX)
val params = builder.build(userWallet = wallet, token = TOKEN, displayId = "42")
assertThat(params).containsExactlyEntriesIn(
mapOf(
"survey_key" to TOKEN,
"wallet_id" to expectedWalletIdHash(WALLET_ID_HEX),
"wallet_type" to "cold",
"display_id" to "42",
"device_id" to "device-123",
"platform" to "android",
"app_version" to "5.40",
"language" to "en",
),
)
}
@Test
fun `wallet_id hash is uppercase hex`() = runTest {
val params = builder.build(userWallet = coldWallet(WALLET_ID_HEX), token = TOKEN, displayId = null)
val walletId = params.getValue("wallet_id")
assertThat(walletId).isEqualTo(walletId.uppercase())
assertThat(walletId).matches("[0-9A-F]+")
}
@Test
fun `wallet_type is hot for a hot wallet`() = runTest {
val wallet = mockk<UserWallet.Hot> { every { walletId } returns UserWalletId(WALLET_ID_HEX) }
val params = builder.build(userWallet = wallet, token = TOKEN, displayId = null)
assertThat(params["wallet_type"]).isEqualTo("hot")
}
@Test
fun `device_id is omitted when app instance id is null`() = runTest {
coEvery { appInstanceIdProvider.getAppInstanceId() } returns null
val params = builder.build(userWallet = coldWallet(WALLET_ID_HEX), token = TOKEN, displayId = "42")
assertThat(params).doesNotContainKey("device_id")
}
@Test
fun `display_id is omitted when null or blank`() = runTest {
val nullCase = builder.build(userWallet = coldWallet(WALLET_ID_HEX), token = TOKEN, displayId = null)
val blankCase = builder.build(userWallet = coldWallet(WALLET_ID_HEX), token = TOKEN, displayId = " ")
assertThat(nullCase).doesNotContainKey("display_id")
assertThat(blankCase).doesNotContainKey("display_id")
}
private fun coldWallet(walletIdHex: String): UserWallet.Cold = mockk {
every { walletId } returns UserWalletId(walletIdHex)
}
private fun expectedWalletIdHash(walletIdHex: String): String =
walletIdHex.hexToBytes().calculateSha256().toHexString()
private companion object {
const val TOKEN = "ntt-84iF22PDajmervYneMW4kv"
const val WALLET_ID_HEX = "0123456789ABCDEF"
}
}

View file

@ -6,6 +6,7 @@ import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.PreparedSwapConfigState
@ -36,6 +37,7 @@ sealed interface SwapState {
val userWallet: UserWallet,
val fromTokenInfo: TokenSwapInfo,
val toTokenInfo: TokenSwapInfo,
val cryptoCurrencyWarning: CryptoCurrencyWarning?,
val isInsufficientBalance: Boolean,
val appCurrency: AppCurrency,
val isBalanceHidden: Boolean,

View file

@ -30,13 +30,14 @@ interface SwapTransferInteractor {
suspend fun loadFee(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
fromTokenAmount: String,
fromTokenAmount: BigDecimal,
): Either<GetFeeError, TransactionFee>
suspend fun loadFeeExtended(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
fromTokenAmount: String,
fromTokenAmount: BigDecimal,
selectedToken: CryptoCurrencyStatus?,
): Either<GetFeeError, TransactionFeeExtended>
suspend fun sendTransfer(

View file

@ -22,9 +22,11 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.WithdrawalResult
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.tangempay.TangemPayWithdrawUseCase
import com.tangem.domain.tokens.GetBalanceNotEnoughForFeeWarningUseCase
import com.tangem.domain.tokens.GetCurrencyCheckUseCase
import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.domain.transaction.models.TransactionFeeExtended
@ -46,7 +48,7 @@ import kotlinx.coroutines.flow.first
import java.math.BigDecimal
import javax.inject.Inject
@Suppress("LongParameterList")
@Suppress("LongParameterList", "LargeClass")
class SwapTransferInteractorImpl @Inject constructor(
private val swapFeatureToggles: SwapFeatureToggles,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
@ -60,6 +62,7 @@ class SwapTransferInteractorImpl @Inject constructor(
private val getCurrencyCheckUseCase: GetCurrencyCheckUseCase,
private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase,
private val tangemPayWithdrawUseCase: TangemPayWithdrawUseCase,
private val getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase,
) : SwapTransferInteractor {
override suspend fun updateTransfer(
@ -110,10 +113,19 @@ class SwapTransferInteractorImpl @Inject constructor(
fee = fee,
currencyCheck = currencyCheck,
)
val cryptoCurrencyWarning = feePaidCurrencyStatus?.let { feeStatus ->
getCryptoCurrencyWarning(
feeValue = fee?.amount?.value.orZero(),
userWallet = userWallet,
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
feeStatus = feeStatus,
)
}
return SwapState.Transfer(
userWallet = userWallet,
fromTokenInfo = fromTokenInfo,
toTokenInfo = toTokenInfo,
cryptoCurrencyWarning = cryptoCurrencyWarning,
isInsufficientBalance = fromTokenAmountValue > fromTokenBalance,
appCurrency = appCurrency,
isBalanceHidden = isBalanceHidden,
@ -124,6 +136,20 @@ class SwapTransferInteractorImpl @Inject constructor(
)
}
private suspend fun getCryptoCurrencyWarning(
feeValue: BigDecimal,
userWallet: UserWallet,
fromSwapCurrencyStatus: SwapCurrencyStatus,
feeStatus: CryptoCurrencyStatus,
): CryptoCurrencyWarning? {
return getBalanceNotEnoughForFeeWarningUseCase(
fee = feeValue,
userWalletId = userWallet.walletId,
tokenStatus = fromSwapCurrencyStatus.status,
feeStatus = feeStatus,
).getOrNull()
}
private suspend fun getCoverageState(
fromTokenInfo: TokenSwapInfo,
userWallet: UserWallet,
@ -205,27 +231,36 @@ class SwapTransferInteractorImpl @Inject constructor(
override suspend fun loadFee(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
fromTokenAmount: String,
fromTokenAmount: BigDecimal,
): Either<GetFeeError, TransactionFee> {
val amount = fromTokenAmount.parseBigDecimalOrNull() ?: BigDecimal.ZERO
val destination = toSwapCurrencyStatus.destinationAddress() ?: return feeDataError(
message = "Destination address is null",
)
val userWallet = fromSwapCurrencyStatus.userWallet
val currency = fromSwapCurrencyStatus.currency
val transactionData = createTransferTransactionUseCase(
amount = fromTokenAmount.convertToSdkAmount(
cryptoCurrencyStatus = fromSwapCurrencyStatus.status,
),
memo = null,
destination = destination,
userWalletId = userWallet.walletId,
network = currency.network,
).getOrNull() ?: return feeDataError("Failed to build transfer transaction")
return getFeeUseCase(
amount = amount,
destination = destination,
userWallet = fromSwapCurrencyStatus.userWallet,
cryptoCurrency = fromSwapCurrencyStatus.currency,
network = fromSwapCurrencyStatus.currency.network,
transactionData = transactionData,
)
}
override suspend fun loadFeeExtended(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
fromTokenAmount: String,
fromTokenAmount: BigDecimal,
selectedToken: CryptoCurrencyStatus?,
): Either<GetFeeError, TransactionFeeExtended> {
val amount = fromTokenAmount.parseBigDecimalOrNull() ?: BigDecimal.ZERO
val destination = toSwapCurrencyStatus.destinationAddress() ?: return feeDataError(
message = "Destination address is null",
)
@ -233,7 +268,7 @@ class SwapTransferInteractorImpl @Inject constructor(
val currency = fromSwapCurrencyStatus.currency
val transactionData = createTransferTransactionUseCase(
amount = amount.convertToSdkAmount(
amount = fromTokenAmount.convertToSdkAmount(
cryptoCurrencyStatus = fromSwapCurrencyStatus.status,
),
memo = null,
@ -246,7 +281,13 @@ class SwapTransferInteractorImpl @Inject constructor(
userWallet = userWallet,
network = currency.network,
transactionData = transactionData,
)
).map { transactionFeeExtended ->
selectedToken ?: return@map transactionFeeExtended
val selectedTokenId = selectedToken.currency.id
transactionFeeExtended.copy(
feeTokenId = selectedTokenId,
)
}
}
override suspend fun sendTransfer(
@ -312,9 +353,9 @@ class SwapTransferInteractorImpl @Inject constructor(
transactionFeeResult: TransactionFeeResult,
txData: TransactionData,
): Either<SendTransactionError, String> {
val isToken = cryptoCurrencyStatus.currency is CryptoCurrency.Token
val isGaslessToken = isToken && transactionFeeResult is TransactionFeeResult.LoadedExtended
return if (isGaslessToken) {
val isFeeInTokenCurrency = transactionFeeResult is TransactionFeeResult.LoadedExtended &&
transactionFeeResult.fee.transactionFee.normal is Fee.Ethereum.TokenCurrency
return if (isFeeInTokenCurrency) {
createAndSendGaslessTransactionUseCase(
transactionData = txData,
userWallet = userWallet,

View file

@ -19,6 +19,7 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.WithdrawalResult
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.tangempay.TangemPayWithdrawUseCase
import com.tangem.domain.tokens.GetBalanceNotEnoughForFeeWarningUseCase
import com.tangem.domain.tokens.GetCurrencyCheckUseCase
import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck
@ -59,6 +60,7 @@ internal class SwapTransferInteractorImplTest {
private val getCurrencyCheckUseCase: GetCurrencyCheckUseCase = mockk()
private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase = mockk()
private val tangemPayWithdrawUseCase: TangemPayWithdrawUseCase = mockk()
private val getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase = mockk(relaxed = true)
private val sut = SwapTransferInteractorImpl(
swapFeatureToggles = swapFeatureToggles,
@ -73,6 +75,7 @@ internal class SwapTransferInteractorImplTest {
getCurrencyCheckUseCase = getCurrencyCheckUseCase,
isAmountSubtractAvailableUseCase = isAmountSubtractAvailableUseCase,
tangemPayWithdrawUseCase = tangemPayWithdrawUseCase,
getBalanceNotEnoughForFeeWarningUseCase = getBalanceNotEnoughForFeeWarningUseCase,
)
@AfterEach
@ -133,7 +136,17 @@ internal class SwapTransferInteractorImplTest {
every { getBalanceHidingSettingsUseCase.isBalanceHidden() } returns flowOf(true)
coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns true
val currencyCheck = buildCurrencyCheck()
coEvery { getCurrencyCheckUseCase(any(), any(), any(), any(), any(), any(), any()) } returns currencyCheck
coEvery {
getCurrencyCheckUseCase(
userWalletId = any(),
currencyStatus = any(),
feeCurrencyStatus = any(),
amount = any(),
fee = any(),
feeCurrencyBalanceAfterTransaction = any(),
recipientAddress = any(),
)
} returns currencyCheck
coEvery {
isAmountSubtractAvailableUseCase(any(), any(), any())
} returns false.right()
@ -160,6 +173,7 @@ internal class SwapTransferInteractorImplTest {
swapCurrencyStatus = toCurrencyStatus,
amountFiat = expectedFiat,
),
cryptoCurrencyWarning = null,
isInsufficientBalance = false,
appCurrency = appCurrency,
isBalanceHidden = true,
@ -193,7 +207,17 @@ internal class SwapTransferInteractorImplTest {
every { getBalanceHidingSettingsUseCase.isBalanceHidden() } returns flowOf(true)
coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns true
val currencyCheck = buildCurrencyCheck()
coEvery { getCurrencyCheckUseCase(any(), any(), any(), any(), any(), any(), any()) } returns currencyCheck
coEvery {
getCurrencyCheckUseCase(
userWalletId = any(),
currencyStatus = any(),
feeCurrencyStatus = any(),
amount = any(),
fee = any(),
feeCurrencyBalanceAfterTransaction = any(),
recipientAddress = any(),
)
} returns currencyCheck
coEvery {
isAmountSubtractAvailableUseCase(any(), any(), any())
} returns false.right()
@ -220,6 +244,7 @@ internal class SwapTransferInteractorImplTest {
swapCurrencyStatus = toCurrencyStatus,
amountFiat = expectedFiat,
),
cryptoCurrencyWarning = null,
isInsufficientBalance = true,
appCurrency = appCurrency,
isBalanceHidden = true,
@ -259,7 +284,15 @@ internal class SwapTransferInteractorImplTest {
every { getBalanceHidingSettingsUseCase.isBalanceHidden() } returns flowOf(false)
coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns false
coEvery {
getCurrencyCheckUseCase(any(), any(), any(), any(), any(), any(), any())
getCurrencyCheckUseCase(
userWalletId = any(),
currencyStatus = any(),
feeCurrencyStatus = any(),
amount = any(),
fee = any(),
feeCurrencyBalanceAfterTransaction = any(),
recipientAddress = any(),
)
} returns buildCurrencyCheck()
coEvery {
isAmountSubtractAvailableUseCase(any(), any(), any())
@ -285,40 +318,51 @@ internal class SwapTransferInteractorImplTest {
@Test
fun `GIVEN valid amount and destination WHEN loadFee THEN return TransactionFee from use case`() = runTest {
val userWallet: UserWallet = mockk()
val userWalletId: UserWalletId = mockk()
val userWallet: UserWallet = mockk { every { walletId } returns userWalletId }
val network: Network = mockk()
val fromCurrencyStatus = buildCurrencyStatus(
rawCurrencyId = FROM_RAW_CURRENCY_ID,
decimals = FROM_DECIMALS,
userWallet = userWallet,
network = network,
)
val toCurrencyStatus = buildCurrencyStatus(
rawCurrencyId = TO_RAW_CURRENCY_ID,
decimals = TO_DECIMALS,
destinationAddress = DESTINATION_ADDRESS,
)
val transactionData: TransactionData.Uncompiled = mockk()
val transactionFee: TransactionFee = mockk()
coEvery {
getFeeUseCase(
amount = BigDecimal("1.5"),
createTransferTransactionUseCase(
amount = any(),
memo = null,
destination = DESTINATION_ADDRESS,
userWalletId = userWalletId,
network = network,
)
} returns transactionData.right()
coEvery {
getFeeUseCase(
userWallet = userWallet,
cryptoCurrency = fromCurrencyStatus.currency,
network = network,
transactionData = transactionData,
)
} returns transactionFee.right()
val result = sut.loadFee(
fromSwapCurrencyStatus = fromCurrencyStatus,
toSwapCurrencyStatus = toCurrencyStatus,
fromTokenAmount = "1.5",
fromTokenAmount = BigDecimal("1.5"),
)
assertThat(result).isEqualTo(transactionFee.right())
coVerify {
getFeeUseCase(
amount = BigDecimal("1.5"),
destination = DESTINATION_ADDRESS,
userWallet = userWallet,
cryptoCurrency = fromCurrencyStatus.currency,
network = network,
transactionData = transactionData,
)
}
}
@ -365,7 +409,8 @@ internal class SwapTransferInteractorImplTest {
val result = sut.loadFeeExtended(
fromSwapCurrencyStatus = fromCurrencyStatus,
toSwapCurrencyStatus = toCurrencyStatus,
fromTokenAmount = "2.0",
fromTokenAmount = BigDecimal("2.0"),
selectedToken = null,
)
assertThat(result).isEqualTo(feeExtended.right())
@ -465,7 +510,7 @@ internal class SwapTransferInteractorImplTest {
}
@Test
fun `GIVEN token and LoadedExtended fee WHEN sendTransfer THEN route via createAndSendGaslessTransactionUseCase`() =
fun `GIVEN LoadedExtended fee with TokenCurrency normal fee WHEN sendTransfer THEN route via createAndSendGaslessTransactionUseCase`() =
runTest {
val userWalletId: UserWalletId = mockk()
val userWallet: UserWallet = mockk { every { walletId } returns userWalletId }
@ -483,7 +528,11 @@ internal class SwapTransferInteractorImplTest {
)
val fee: Fee = mockk()
val txData: TransactionData.Uncompiled = mockk()
val transactionFeeExtended: TransactionFeeExtended = mockk()
val transactionFeeExtended: TransactionFeeExtended = mockk {
every { transactionFee } returns mockk {
every { normal } returns mockk<Fee.Ethereum.TokenCurrency>()
}
}
val transactionFeeResult = TransactionFeeResult.LoadedExtended(transactionFeeExtended)
coEvery {
createTransferTransactionUseCase(
@ -574,6 +623,62 @@ internal class SwapTransferInteractorImplTest {
}
}
@Test
fun `GIVEN LoadedExtended fee with non-TokenCurrency normal fee WHEN sendTransfer THEN fall back to sendTransactionUseCase`() =
runTest {
val userWalletId: UserWalletId = mockk()
val userWallet: UserWallet = mockk { every { walletId } returns userWalletId }
val network: Network = mockk()
val fromCurrencyStatus = buildTokenCurrencyStatus(
rawCurrencyId = FROM_RAW_CURRENCY_ID,
decimals = FROM_DECIMALS,
userWallet = userWallet,
network = network,
)
val toCurrencyStatus = buildTokenCurrencyStatus(
rawCurrencyId = TO_RAW_CURRENCY_ID,
decimals = TO_DECIMALS,
destinationAddress = DESTINATION_ADDRESS,
)
val fee: Fee = mockk()
val txData: TransactionData.Uncompiled = mockk()
val transactionFeeExtended: TransactionFeeExtended = mockk {
every { transactionFee } returns mockk {
every { normal } returns mockk<Fee.Common>()
}
}
val transactionFeeResult = TransactionFeeResult.LoadedExtended(transactionFeeExtended)
coEvery {
createTransferTransactionUseCase(
amount = any(),
fee = fee,
memo = null,
destination = DESTINATION_ADDRESS,
userWalletId = userWalletId,
network = network,
)
} returns txData.right()
coEvery {
sendTransactionUseCase(txData = txData, userWallet = userWallet, network = network)
} returns TX_HASH.right()
val result = sut.sendTransfer(
fromSwapCurrencyStatus = fromCurrencyStatus,
toSwapCurrencyStatus = toCurrencyStatus,
sendingAmount = BigDecimal("1.0"),
fee = fee,
transactionFeeResult = transactionFeeResult,
)
assertThat(result).isEqualTo(TX_HASH.right())
coVerify {
sendTransactionUseCase(txData = txData, userWallet = userWallet, network = network)
}
coVerify(exactly = 0) {
createAndSendGaslessTransactionUseCase(any(), any(), any())
}
}
@Test
fun `GIVEN createTransferTransactionUseCase fails WHEN sendTransfer THEN return DataError`() = runTest {
val userWalletId: UserWalletId = mockk()

View file

@ -123,6 +123,7 @@ internal class DefaultSwapComponent @AssistedInject constructor(
analyticsCategoryName = CommonSendAnalyticEvents.SWAP_CATEGORY,
analyticsSendSource = CommonSendAnalyticEvents.CommonSendSource.Swap,
),
isTransferMode = config.isTransferMode,
),
)
}
@ -143,6 +144,7 @@ internal class DefaultSwapComponent @AssistedInject constructor(
data class FeeSelectorConfig(
val sendingCurrencyStatus: CryptoCurrencyStatus,
val feeCurrencyStatus: CryptoCurrencyStatus,
val isTransferMode: Boolean,
)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@ -151,6 +153,7 @@ internal class DefaultSwapComponent @AssistedInject constructor(
val dataState by model.dataStateStateFlow.collectAsStateWithLifecycle()
val fromCryptoCurrency by remember { derivedStateOf { dataState.fromSwapCurrencyStatus?.status } }
val feePaidCryptoCurrency by remember { derivedStateOf { dataState.feePaidCryptoCurrency } }
val isInTransferMode by remember { derivedStateOf { dataState.currentTransferState != null } }
val shouldHideBlock by remember {
derivedStateOf {
val isAmountEmptyOrZero = dataState.amount?.parseBigDecimalOrNull().isNullOrZero()
@ -158,7 +161,6 @@ internal class DefaultSwapComponent @AssistedInject constructor(
val isProviderMissing = dataState.selectedProvider == null
val loadedState = dataState.getCurrentLoadedSwapState()
val isPermissionNotReady = loadedState?.permissionState !is PermissionDataState.Empty
val isInTransferMode = dataState.currentTransferState != null
val isSwapNotReady = !isInTransferMode && (isProviderMissing || isPermissionNotReady)
val isTangemPayWithdrawal = model.isTangemPayWithdrawal()
@ -166,7 +168,7 @@ internal class DefaultSwapComponent @AssistedInject constructor(
}
}
LaunchedEffect(fromCryptoCurrency, feePaidCryptoCurrency, shouldHideBlock) {
LaunchedEffect(fromCryptoCurrency, feePaidCryptoCurrency, shouldHideBlock, isInTransferMode) {
if (shouldHideBlock) {
TangemLogger.e(
messageString = "Dismissing fee selector: " +
@ -194,6 +196,7 @@ internal class DefaultSwapComponent @AssistedInject constructor(
FeeSelectorConfig(
sendingCurrencyStatus = sendingCryptoCurrencyStatus,
feeCurrencyStatus = feeCurrencyStatus,
isTransferMode = isInTransferMode,
),
)
}

View file

@ -18,7 +18,7 @@ internal class DefaultSwapFeatureToggles @Inject constructor(
)
override val isSwapIntegratedApproveEnabled: Boolean = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.SWAP_INTEGRATED_APPROVE,
toggle = FeatureToggles.AND_15120_SWAP_INTEGRATED_APPROVE,
)
override val isSwapAbEnabled: Boolean = featureTogglesManager.isFeatureEnabled(

View file

@ -1,11 +1,11 @@
package com.tangem.feature.swap.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_FROM
import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_TO
import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_CODE
import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_MESSAGE
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.AnalyticsParam.Key.FEE_TOKEN
import com.tangem.core.analytics.models.AnalyticsParam.Key.PROVIDER
import com.tangem.core.analytics.models.AnalyticsParam.Key.RECEIVE_TOKEN
@ -13,6 +13,7 @@ import com.tangem.core.analytics.models.AnalyticsParam.Key.SEND_TOKEN
import com.tangem.core.analytics.models.AppsFlyerIncludedEvent
import com.tangem.core.analytics.models.getReferralParams
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.feature.swap.domain.models.domain.SwapProvider
import com.tangem.feature.swap.domain.models.ui.FeeBucket
@ -247,4 +248,46 @@ sealed class SwapEvents(
"Provider" to provider.name,
),
)
class TransferModeSwitched(
fromCurrency: CryptoCurrency?,
toCurrency: CryptoCurrency?,
) : SwapEvents(
event = "Transfer Mode Switched",
params = mapOf(
SEND_TOKEN to fromCurrency?.symbol.orEmpty(),
"Send Blockchain" to fromCurrency?.network?.name.orEmpty(),
RECEIVE_TOKEN to toCurrency?.symbol.orEmpty(),
"Receive Blockchain" to toCurrency?.network?.name.orEmpty(),
),
)
class ButtonTransferClicked(
fromCurrency: CryptoCurrency?,
toCurrency: CryptoCurrency?,
) : SwapEvents(
event = "Button - Transfer",
params = mapOf(
SEND_TOKEN to fromCurrency?.symbol.orEmpty(),
"Send Blockchain" to fromCurrency?.network?.name.orEmpty(),
RECEIVE_TOKEN to toCurrency?.symbol.orEmpty(),
"Receive Blockchain" to toCurrency?.network?.name.orEmpty(),
),
)
@Suppress("NullableToStringCall", "LongParameterList")
class TransferInProgressScreen(
fromCurrency: CryptoCurrency?,
toCurrency: CryptoCurrency?,
feeNetwork: Network,
) : SwapEvents(
event = "Transfer in Progress Screen Opened",
params = mapOf(
SEND_TOKEN to fromCurrency?.symbol.orEmpty(),
"Send Blockchain" to fromCurrency?.network?.name.orEmpty(),
RECEIVE_TOKEN to toCurrency?.symbol.orEmpty(),
"Receive Blockchain" to toCurrency?.network?.name.orEmpty(),
"Network fee" to feeNetwork.name,
),
), AppsFlyerIncludedEvent
}

View file

@ -19,11 +19,7 @@ import com.tangem.features.send.v2.api.params.FeeSelectorParams
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.*
class SwapFeeSelectorBlockComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@ -44,7 +40,11 @@ class SwapFeeSelectorBlockComponent @AssistedInject constructor(
null
},
feeDisplaySource = FeeSelectorParams.FeeDisplaySource.Screen,
feeStateConfiguration = FeeSelectorParams.FeeStateConfiguration.ExcludeLow,
feeStateConfiguration = if (params.isTransferMode) {
FeeSelectorParams.FeeStateConfiguration.None
} else {
FeeSelectorParams.FeeStateConfiguration.ExcludeLow
},
feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus,
cryptoCurrencyStatus = params.sendingCryptoCurrencyStatus,
analyticsCategoryName = params.analyticsParams.analyticsCategoryName,
@ -100,6 +100,7 @@ class SwapFeeSelectorBlockComponent @AssistedInject constructor(
val feeCryptoCurrencyStatus: CryptoCurrencyStatus,
val analyticsParams: AnalyticsParams,
val repository: ModelRepository,
val isTransferMode: Boolean,
)
@AssistedFactory

View file

@ -601,7 +601,15 @@ internal class SwapModel @Inject constructor(
toSwapCurrencyStatus = toSwapCurrencyStatus,
fromTokenAmount = lastAmount.value,
)
if (isUpdatedToTransferMode) return
if (isUpdatedToTransferMode) {
analyticsEventHandler.send(
event = SwapEvents.TransferModeSwitched(
fromCurrency = fromSwapCurrencyStatus.currency,
toCurrency = toSwapCurrencyStatus.currency,
),
)
return
}
dataState = dataState.copy(currentTransferState = null)
modelScope.launch {
uiState = stateBuilder.createInitialLoadingState(
@ -770,7 +778,10 @@ internal class SwapModel @Inject constructor(
feePaidCurrencyStatus = feePaidCryptoCurrencyStatus,
fee = fee,
) as? SwapState.Transfer ?: currentTransferState
dataState = dataState.copy(currentTransferState = refreshed)
dataState = dataState.copy(
currentTransferState = refreshed,
feePaidCryptoCurrency = feePaidCryptoCurrencyStatus ?: dataState.feePaidCryptoCurrency,
)
uiState = swapTransferStateBuilder.updateTransferButtonEnableState(
dataState = dataState,
transferState = refreshed,
@ -1291,6 +1302,12 @@ internal class SwapModel @Inject constructor(
private fun onTransferClick() {
val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus
val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus
analyticsEventHandler.send(
event = SwapEvents.ButtonTransferClicked(
fromCurrency = fromSwapCurrencyStatus?.currency,
toCurrency = toSwapCurrencyStatus?.currency,
),
)
val fee = (feeSelectorRepository.state.value as? FeeSelectorUM.Content)?.selectedFeeItem?.fee
if (fromSwapCurrencyStatus == null || toSwapCurrencyStatus == null) {
TangemLogger.e("onTransferClick: missing currency status, aborting")
@ -1332,6 +1349,7 @@ internal class SwapModel @Inject constructor(
TangemLogger.e(
messageString = "onTransferClick: withdrawTangemPay failed: ${error.getAnalyticsDescription()}",
)
startLoadingQuotesFromLastState()
showAlert()
}
.onRight { result ->
@ -1343,9 +1361,11 @@ internal class SwapModel @Inject constructor(
}
private fun updateTransferModeTangemPayState() {
sendTransferInProgressEvent()
uiState = swapTransferStateBuilder.createTangemPayWithdrawalSuccessState(
uiState = uiState,
dataState = dataState,
fee = getSelectedSwapFee()?.fee,
onExploreClick = {
val txUrl = uiState.successState?.txUrl.orEmpty()
if (txUrl.isNotEmpty()) {
@ -1373,6 +1393,7 @@ internal class SwapModel @Inject constructor(
).fold(
ifLeft = { error ->
TangemLogger.e("onTransferClick: transfer failed: ${error.getAnalyticsDescription()}")
startLoadingQuotesFromLastState()
showAlert()
},
ifRight = { txHash ->
@ -1384,14 +1405,13 @@ internal class SwapModel @Inject constructor(
""
}
updateWalletBalance()
sendTransferInProgressEvent()
uiState = swapTransferStateBuilder.createSuccessState(
uiState = uiState,
dataState = dataState,
appCurrency = selectedAppCurrencyFlow.value,
isAccountsMode = isAccountsMode,
txUrl = txUrl,
timestamp = System.currentTimeMillis(),
fee = null,
fee = getSelectedSwapFee()?.fee,
onExplorerClick = {
if (txUrl.isNotEmpty()) {
urlOpener.openUrl(txUrl)
@ -1403,6 +1423,18 @@ internal class SwapModel @Inject constructor(
)
}
private fun sendTransferInProgressEvent() {
val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus
val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus
analyticsEventHandler.send(
event = SwapEvents.TransferInProgressScreen(
fromCurrency = fromSwapCurrencyStatus?.currency,
toCurrency = toSwapCurrencyStatus?.currency,
feeNetwork = getFeeToken().network,
),
)
}
private suspend fun processTangemPayWithdrawal(
fromSwapCurrencyStatus: SwapCurrencyStatus,
swapTransactionState: SwapTransactionState.TangemPayWithdrawalData,
@ -2180,6 +2212,7 @@ internal class SwapModel @Inject constructor(
dataState.fromSwapCurrencyStatus ?: return Either.Left(GetFeeError.UnknownError)
val toSwapCurrencyStatus =
dataState.toSwapCurrencyStatus ?: return Either.Left(GetFeeError.UnknownError)
val amount = lastAmount.value.parseBigDecimalOrNull() ?: return Either.Left(GetFeeError.UnknownError)
val shouldTransferInsteadOfSwap = swapTransferInteractor.shouldTransferInsteadOfSwap(
fromSwapCurrencyStatus.currency,
toSwapCurrencyStatus.currency,
@ -2188,7 +2221,7 @@ internal class SwapModel @Inject constructor(
return swapTransferInteractor.loadFee(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
fromTokenAmount = lastAmount.value,
fromTokenAmount = amount,
).onLeft {
TangemLogger.e("loadFee[transfer]: Failed to load fee with error $it")
}.onRight {
@ -2202,9 +2235,7 @@ internal class SwapModel @Inject constructor(
return Either.Left(GetFeeError.UnknownError)
}
val amountDecimal = lastAmount.value.replace(",", ".").toBigDecimalOrNull()
?: return Either.Left(GetFeeError.UnknownError)
val swapAmount = SwapAmount(amountDecimal, fromSwapCurrencyStatus.currency.decimals)
val swapAmount = SwapAmount(amount, fromSwapCurrencyStatus.currency.decimals)
val swapDataForCall = when (quoteState.swapProvider.type) {
ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> {
quoteState.swapDataModel ?: return Either.Left(GetFeeError.UnknownError)
@ -2235,6 +2266,8 @@ internal class SwapModel @Inject constructor(
dataState.fromSwapCurrencyStatus ?: return Either.Left(GetFeeError.UnknownError)
val toSwapCurrencyStatus =
dataState.toSwapCurrencyStatus ?: return Either.Left(GetFeeError.UnknownError)
val amount = lastAmount.value.parseBigDecimalOrNull() ?: return Either.Left(GetFeeError.UnknownError)
val shouldTransferInsteadOfSwap = swapTransferInteractor.shouldTransferInsteadOfSwap(
fromSwapCurrencyStatus.currency,
toSwapCurrencyStatus.currency,
@ -2243,7 +2276,8 @@ internal class SwapModel @Inject constructor(
return swapTransferInteractor.loadFeeExtended(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
fromTokenAmount = lastAmount.value,
fromTokenAmount = amount,
selectedToken = selectedToken,
)
}
val quoteState = dataState.getCurrentLoadedSwapState() ?: return Either.Left(GetFeeError.UnknownError)
@ -2252,8 +2286,7 @@ internal class SwapModel @Inject constructor(
return Either.Left(GetFeeError.UnknownError)
}
val amountDecimal = lastAmount.value.parseBigDecimalOrNull() ?: return Either.Left(GetFeeError.UnknownError)
val swapAmount = SwapAmount(amountDecimal, fromSwapCurrencyStatus.currency.decimals)
val swapAmount = SwapAmount(amount, fromSwapCurrencyStatus.currency.decimals)
// DEX path requires a SwapDataModel.
val swapDataForCall = when (quoteState.swapProvider.type) {
@ -2300,10 +2333,6 @@ internal class SwapModel @Inject constructor(
modelScope.launch { forceUpdateState.emit(newState.copy(isHidden = true)) }
return
}
refreshTransferUIStateIfNeeded(
feePaidCryptoCurrencyStatus = dataState.feePaidCryptoCurrency,
fee = (newState as? FeeSelectorUM.Content)?.selectedFeeItem?.fee,
)
val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus
val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus
@ -2312,7 +2341,13 @@ internal class SwapModel @Inject constructor(
fromSwapCurrencyStatus?.currency,
toSwapCurrencyStatus?.currency,
)
if (shouldTransferInsteadOfSwap) return
if (shouldTransferInsteadOfSwap) {
refreshTransferUIStateIfNeeded(
feePaidCryptoCurrencyStatus = getSelectedSwapFee()?.selectedFeeToken,
fee = (newState as? FeeSelectorUM.Content)?.selectedFeeItem?.fee,
)
return
}
val quoteState = dataState.getCurrentLoadedSwapState() ?: return
val swapFee = getSelectedSwapFee() ?: return

View file

@ -3,12 +3,14 @@ package com.tangem.feature.swap.ui.transfer
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.common.ui.notifications.NotificationsFactory.addDustWarningNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addExceedsBalanceNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addExistentialWarningNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addFeeCoverageNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addReserveAmountErrorNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addTransactionLimitErrorNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addValidateTransactionNotifications
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.ui.SwapState
@ -24,12 +26,14 @@ import javax.inject.Inject
internal class SwapTransferNotificationsFactory @Inject constructor() {
@Suppress("LongParameterList")
fun getNotifications(
transferState: SwapState.Transfer,
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
fee: Fee?,
onReduceByAmount: (SwapAmount, BigDecimal) -> Unit,
onReduceToAmount: (SwapAmount) -> Unit,
onBuyClick: (CryptoCurrency) -> Unit,
): ImmutableList<NotificationUM> {
return buildList {
maybeAddRentExemptionError(transferState)
@ -41,6 +45,7 @@ internal class SwapTransferNotificationsFactory @Inject constructor() {
onReduceToAmount = onReduceToAmount,
)
maybeAddNeedReserveToCreateAccountWarning(transferState)
maybeAddExceedsBalanceNotification(transferState, onBuyClick)
}.toPersistentList()
}
@ -172,4 +177,21 @@ internal class SwapTransferNotificationsFactory @Inject constructor() {
)
}
}
private fun MutableList<NotificationUM>.maybeAddExceedsBalanceNotification(
transferState: SwapState.Transfer,
onBuyClick: (CryptoCurrency) -> Unit,
) {
val cryptoCurrencyStatus = transferState.fromTokenInfo.swapCurrencyStatus.status
addExceedsBalanceNotification(
cryptoCurrencyWarning = transferState.cryptoCurrencyWarning,
cryptoCurrencyStatus = cryptoCurrencyStatus,
shouldMergeFeeNetworkName = BlockchainUtils.isArbitrum(
networkId = cryptoCurrencyStatus.currency.network.rawId,
),
onClick = onBuyClick,
onAnalyticsEvent = {},
onResetAnalyticsEvent = {},
)
}
}

View file

@ -59,6 +59,7 @@ internal class SwapTransferStateBuilder @Inject constructor(
transferState = transferState,
feeCryptoCurrencyStatus = feePaidCryptoCurrencyStatus,
fee = fee,
onBuyClick = actions.openTokenDetailsScreen,
onReduceByAmount = actions.onReduceByAmount,
onReduceToAmount = actions.onReduceToAmount,
)
@ -227,6 +228,7 @@ internal class SwapTransferStateBuilder @Inject constructor(
transferState = transferState,
feeCryptoCurrencyStatus = feePaidCryptoCurrencyStatus,
fee = fee,
onBuyClick = actions.openTokenDetailsScreen,
onReduceByAmount = actions.onReduceByAmount,
onReduceToAmount = actions.onReduceToAmount,
)
@ -325,13 +327,12 @@ internal class SwapTransferStateBuilder @Inject constructor(
fun createSuccessState(
uiState: SwapStateHolder,
dataState: SwapProcessDataState,
appCurrency: AppCurrency,
isAccountsMode: Boolean,
fee: Fee?,
txUrl: String,
timestamp: Long,
fee: TextReference?,
onExplorerClick: () -> Unit,
): SwapStateHolder {
val transferState = requireNotNull(dataState.currentTransferState)
val fromSwapCurrencyStatus = requireNotNull(dataState.fromSwapCurrencyStatus)
val toSwapCurrencyStatus = requireNotNull(dataState.toSwapCurrencyStatus)
val amount = dataState.amount?.parseBigDecimalOrNull() ?: BigDecimal.ZERO
@ -341,11 +342,11 @@ internal class SwapTransferStateBuilder @Inject constructor(
val fromAmountText = amount.format { crypto(fromCurrency.symbol, fromCurrency.decimals) }
val toAmountText = amount.format { crypto(toCurrency.symbol, toCurrency.decimals) }
val fromFiatAmount = getFormattedFiatAmount(
appCurrency = appCurrency,
appCurrency = transferState.appCurrency,
amount = fromSwapCurrencyStatus.status.value.fiatRate?.multiply(amount),
)
val toFiatAmount = getFormattedFiatAmount(
appCurrency = appCurrency,
appCurrency = transferState.appCurrency,
amount = toSwapCurrencyStatus.status.value.fiatRate?.multiply(amount),
)
@ -359,15 +360,15 @@ internal class SwapTransferStateBuilder @Inject constructor(
isTransferMode = true,
providerIcon = "",
rate = TextReference.EMPTY,
fee = fee,
fee = fee?.let { formatFeeForSuccess(transferState = transferState, fee = it) },
fromTitle = getCardAccountTitle(
account = fromSwapCurrencyStatus.account,
isAccountsMode = isAccountsMode,
isAccountsMode = transferState.isAccountsMode,
isFromCard = true,
),
toTitle = getCardAccountTitle(
account = toSwapCurrencyStatus.account,
isAccountsMode = isAccountsMode,
isAccountsMode = transferState.isAccountsMode,
isFromCard = false,
),
fromTokenAmount = stringReference(fromAmountText),
@ -385,6 +386,7 @@ internal class SwapTransferStateBuilder @Inject constructor(
fun createTangemPayWithdrawalSuccessState(
uiState: SwapStateHolder,
dataState: SwapProcessDataState,
fee: Fee?,
onExploreClick: () -> Unit,
): SwapStateHolder {
val fromSwapCurrencyStatus = requireNotNull(dataState.fromSwapCurrencyStatus)
@ -407,7 +409,7 @@ internal class SwapTransferStateBuilder @Inject constructor(
isTransferMode = true,
providerIcon = "",
rate = TextReference.EMPTY,
fee = null,
fee = fee?.let { formatFeeForSuccess(transferState = transferState, fee = it) },
fromTitle = getCardAccountTitle(
account = fromSwapCurrencyStatus.account,
isAccountsMode = transferState.isAccountsMode,
@ -429,4 +431,19 @@ internal class SwapTransferStateBuilder @Inject constructor(
),
)
}
private fun formatFeeForSuccess(transferState: SwapState.Transfer, fee: Fee): TextReference {
val feeAmount = fee.amount
val totalFeeValue = feeAmount.value ?: BigDecimal.ZERO
val cryptoFormatted = totalFeeValue.format {
crypto(symbol = feeAmount.currencySymbol, decimals = feeAmount.decimals)
}
val appCurrency = transferState.appCurrency
val swapCurrencyStatus = transferState.fromTokenInfo.swapCurrencyStatus
val fiatRate = swapCurrencyStatus.status.value.fiatRate
val fiatFormatted = fiatRate?.multiply(totalFeeValue).format {
fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol)
}
return stringReference("$cryptoFormatted ($fiatFormatted)")
}
}

View file

@ -43,6 +43,7 @@ internal class SwapTransferNotificationsFactoryTest {
fee = null,
onReduceByAmount = { _, _ -> },
onReduceToAmount = {},
onBuyClick = {},
)
assertThat(result).isEmpty()
@ -65,6 +66,7 @@ internal class SwapTransferNotificationsFactoryTest {
fee = null,
onReduceByAmount = { _, _ -> },
onReduceToAmount = {},
onBuyClick = {},
)
assertThat(result.filterIsInstance<NotificationUM.Solana.RentInfo>()).hasSize(1)
@ -91,6 +93,7 @@ internal class SwapTransferNotificationsFactoryTest {
fee = fee,
onReduceByAmount = { _, _ -> },
onReduceToAmount = {},
onBuyClick = {},
)
assertThat(result.filterIsInstance<NotificationUM.Error.ExistentialDeposit>()).hasSize(1)
@ -113,6 +116,7 @@ internal class SwapTransferNotificationsFactoryTest {
fee = null,
onReduceByAmount = { _, _ -> },
onReduceToAmount = {},
onBuyClick = {},
)
assertThat(result.filterIsInstance<NotificationUM.Error.MinimumAmountError>()).hasSize(1)
@ -131,6 +135,7 @@ internal class SwapTransferNotificationsFactoryTest {
fee = null,
onReduceByAmount = { _, _ -> },
onReduceToAmount = {},
onBuyClick = {},
)
assertThat(result.filterIsInstance<NotificationUM.Cardano.MinAdaValueCharged>()).hasSize(1)
@ -154,6 +159,7 @@ internal class SwapTransferNotificationsFactoryTest {
fee = null,
onReduceByAmount = { _, _ -> },
onReduceToAmount = {},
onBuyClick = {},
)
assertThat(result.filterIsInstance<NotificationUM.Warning.FeeCoverageNotification>()).hasSize(1)
@ -176,6 +182,7 @@ internal class SwapTransferNotificationsFactoryTest {
fee = null,
onReduceByAmount = { _, _ -> },
onReduceToAmount = {},
onBuyClick = {},
)
val reserve = result.filterIsInstance<SwapNotificationUM.Warning.NeedReserveToCreateAccount>()
@ -199,15 +206,39 @@ internal class SwapTransferNotificationsFactoryTest {
fee = null,
onReduceByAmount = { _, _ -> },
onReduceToAmount = {},
onBuyClick = {},
)
assertThat(result.filterIsInstance<SwapNotificationUM.Warning.ReduceAmount>()).hasSize(1)
}
@Test
fun `GIVEN BalanceNotEnoughForFee warning WHEN getNotifications THEN TokenExceedsBalance is added`() = runTest {
val warning = CryptoCurrencyWarning.BalanceNotEnoughForFee(
tokenCurrency = buildCoin(),
coinCurrency = buildCoin(),
)
val transferState = buildTransferState(
cryptoCurrencyWarning = warning,
)
val result = sut.getNotifications(
transferState = transferState,
feeCryptoCurrencyStatus = null,
fee = null,
onReduceByAmount = { _, _ -> },
onReduceToAmount = {},
onBuyClick = {},
)
assertThat(result.filterIsInstance<NotificationUM.Error.TokenExceedsBalance>()).hasSize(1)
}
@Suppress("LongParameterList")
private fun buildTransferState(
fromTokenInfo: TokenSwapInfo = buildTokenInfo(buildCoinStatus()),
toTokenInfo: TokenSwapInfo = buildTokenInfo(buildCoinStatus()),
cryptoCurrencyWarning: CryptoCurrencyWarning? = null,
currencyCheck: CryptoCurrencyCheck? = null,
validationResult: Throwable? = null,
minAdaValue: BigDecimal? = null,
@ -217,6 +248,7 @@ internal class SwapTransferNotificationsFactoryTest {
userWallet = coldWallet,
fromTokenInfo = fromTokenInfo,
toTokenInfo = toTokenInfo,
cryptoCurrencyWarning = cryptoCurrencyWarning,
isInsufficientBalance = false,
appCurrency = AppCurrency.Default,
isBalanceHidden = false,

View file

@ -56,6 +56,7 @@ internal class SwapTransferStateBuilderTest {
fee = any(),
onReduceByAmount = any(),
onReduceToAmount = any(),
onBuyClick = any(),
)
} returns persistentListOf()
}
@ -129,6 +130,7 @@ internal class SwapTransferStateBuilderTest {
fee = null,
onReduceByAmount = any(),
onReduceToAmount = any(),
onBuyClick = any(),
)
}
}
@ -170,6 +172,7 @@ internal class SwapTransferStateBuilderTest {
fee = null,
onReduceByAmount = any(),
onReduceToAmount = any(),
onBuyClick = any(),
)
}
}
@ -212,6 +215,7 @@ internal class SwapTransferStateBuilderTest {
fee = null,
onReduceByAmount = any(),
onReduceToAmount = any(),
onBuyClick = any(),
)
}
}
@ -260,6 +264,7 @@ internal class SwapTransferStateBuilderTest {
fee = null,
onReduceByAmount = any(),
onReduceToAmount = any(),
onBuyClick = any(),
)
}
}
@ -307,6 +312,7 @@ internal class SwapTransferStateBuilderTest {
fee = fee,
onReduceByAmount = any(),
onReduceToAmount = any(),
onBuyClick = any(),
)
} returns persistentListOf()
@ -330,6 +336,7 @@ internal class SwapTransferStateBuilderTest {
fee = fee,
onReduceByAmount = any(),
onReduceToAmount = any(),
onBuyClick = any(),
)
}
}
@ -468,25 +475,40 @@ internal class SwapTransferStateBuilderTest {
@Test
fun `GIVEN dataState with from-to currencies WHEN createSuccessState THEN success holder is built in transfer mode with given fee and txUrl`() {
val appCurrency = AppCurrency(code = "USD", name = "US Dollar", symbol = "$")
val amount = BigDecimal("1.5")
val transferState = buildTransferState(
fromAmount = amount,
toAmount = amount,
isAccountsMode = true,
)
val dataState = SwapProcessDataState(
fromSwapCurrencyStatus = fromCurrencyStatus,
toSwapCurrencyStatus = toCurrencyStatus,
amount = amount.toPlainString(),
currentTransferState = transferState,
)
val feeValue = BigDecimal("0.001")
val fee = Fee.Common(
amount = Amount(currencySymbol = "ETH", value = feeValue, decimals = 18),
)
val appCurrency = transferState.appCurrency
val expectedFee = stringReference(
"${feeValue.format { crypto(symbol = "ETH", decimals = 18) }} " +
"(${
fromCurrencyStatus.status.value.fiatRate!!.multiply(feeValue).format {
fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol)
}
})",
)
val fee: TextReference = stringReference("0.001 ETH")
val txUrl = "https://explorer.example/tx/0xabc"
val timestamp = 1_700_000_000_000L
val result = sut.createSuccessState(
uiState = baseStateHolder(),
dataState = dataState,
appCurrency = appCurrency,
isAccountsMode = true,
fee = fee,
txUrl = txUrl,
timestamp = timestamp,
fee = fee,
onExplorerClick = {},
)
@ -495,7 +517,7 @@ internal class SwapTransferStateBuilderTest {
assertThat(success.shouldShowStatusButton).isFalse()
assertThat(success.timestamp).isEqualTo(timestamp)
assertThat(success.txUrl).isEqualTo(txUrl)
assertThat(success.fee).isEqualTo(fee)
assertThat(success.fee).isEqualTo(expectedFee)
assertThat(success.providerName).isEqualTo(TextReference.EMPTY)
assertThat(success.providerType).isEqualTo(TextReference.EMPTY)
assertThat(success.providerIcon).isEmpty()
@ -613,6 +635,7 @@ internal class SwapTransferStateBuilderTest {
val result = sut.createTangemPayWithdrawalSuccessState(
uiState = baseStateHolder(),
dataState = dataState,
fee = null,
onExploreClick = onExploreClick,
)
val after = System.currentTimeMillis()
@ -707,6 +730,7 @@ internal class SwapTransferStateBuilderTest {
userWallet = coldWallet,
fromTokenInfo = fromInfo,
toTokenInfo = toInfo,
cryptoCurrencyWarning = null,
isInsufficientBalance = isInsufficientBalance,
appCurrency = AppCurrency.Default,
isBalanceHidden = false,

View file

@ -16,11 +16,13 @@ import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.components.NavigationBar3ButtonsScrim
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.features.tangempay.components.txHistory.DefaultTangemPayTxHistoryComponent
import com.tangem.features.tangempay.components.txHistory.TangemPayTxHistoryDetailsComponent
import com.tangem.features.tangempay.entity.TangemPayDetailsNavigation
import com.tangem.features.tangempay.model.TangemPayDetailsModel
import com.tangem.features.tangempay.ui.TangemPayDetailsScreen
import com.tangem.features.tangempay.ui.TangemPayDetailsScreenV2
import com.tangem.features.tangempay.utils.requireLoaded
import com.tangem.features.tangempay.utils.userWalletId
import com.tangem.features.tokendetails.ExpressTransactionsComponent
@ -72,12 +74,21 @@ internal class TangemPayDetailsComponent(
val bottomSheet by bottomSheetSlot.subscribeAsState()
NavigationBar3ButtonsScrim()
TangemPayDetailsScreen(
state = state,
txHistoryComponent = txHistoryComponent,
expressTransactionsComponent = expressTransactionsComponent,
modifier = modifier,
)
if (LocalRedesignEnabled.current) {
TangemPayDetailsScreenV2(
state = state,
txHistoryComponent = txHistoryComponent,
expressTransactionsComponent = expressTransactionsComponent,
modifier = modifier,
)
} else {
TangemPayDetailsScreen(
state = state,
txHistoryComponent = txHistoryComponent,
expressTransactionsComponent = expressTransactionsComponent,
modifier = modifier,
)
}
bottomSheet.child?.instance?.BottomSheet()
}

View file

@ -17,6 +17,7 @@ internal data class TangemPayCardPageUM(
val dailyLimitState: TangemPayDailyLimitBlockState,
val addToWalletBlockState: AddToWalletBlockState? = null,
val isReissueInProgress: Boolean = false,
val menuItems: ImmutableList<TangemPayDropDownItemUM>,
) {
companion object {
fun stub(
@ -40,6 +41,7 @@ internal data class TangemPayCardPageUM(
onBackClick = {},
isReissueInProgress = isReissueInProgress,
dailyLimitState = dailyLimitState,
menuItems = persistentListOf(),
)
}
}

View file

@ -4,9 +4,12 @@ import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig
import com.tangem.core.ui.components.containers.pullToRefresh.PullToRefreshConfig
import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenuItem
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.themedColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_document_20
import com.tangem.domain.models.pay.TangemPayCardFrozenState
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.utils.TangemPayDetailIntents
@ -31,6 +34,7 @@ internal class TangemPayDetailsStateFactory(
onBackClick = onBack,
onOpenMenu = onOpenMenu,
items = getTopBarMenuItems(isTangemPayDeactivated),
itemsV2 = getTopBarMenuItemsV2(isTangemPayDeactivated),
),
pullToRefreshConfig = PullToRefreshConfig(
isRefreshing = false,
@ -89,4 +93,31 @@ internal class TangemPayDetailsStateFactory(
),
)
}
private fun getTopBarMenuItemsV2(isTangemPayDeactivated: Boolean): ImmutableList<TangemPayDropDownItemUM> {
if (isTangemPayDeactivated) return persistentListOf()
return persistentListOf(
TangemPayDropDownItemUM(
title = resourceReference(R.string.tangem_pay_terms_limits),
onClick = intents::onClickTermsAndLimits,
icon = TangemIconUM.Icon(
imageVector = Icons.ic_document_20,
tintReference = {
TangemTheme.colors3.icon.primary
},
),
),
TangemPayDropDownItemUM(
title = resourceReference(R.string.tangempay_pay_support),
onClick = intents::onContactSupportClicked,
icon = TangemIconUM.Icon(
imageVector = Icons.ic_document_20,
tintReference = {
TangemTheme.colors3.icon.primary
},
), // TODO change when the icon will be ready in design
),
)
}
}

View file

@ -7,4 +7,5 @@ internal data class TangemPayDetailsTopBarConfig(
val onBackClick: () -> Unit,
val onOpenMenu: () -> Unit,
val items: ImmutableList<TangemDropdownMenuItem>,
val itemsV2: ImmutableList<TangemPayDropDownItemUM>,
)

View file

@ -77,7 +77,7 @@ internal sealed class TangemPayDetailsBalanceBlockState {
data class Content(
override val actionButtons: ImmutableList<ActionButtonConfig>,
override val cardsBlockState: CardsBlockState?,
val fiatBalance: String,
val fiatBalance: TextReference,
val isBalanceFlickering: Boolean,
) : TangemPayDetailsBalanceBlockState()

View file

@ -0,0 +1,10 @@
package com.tangem.features.tangempay.entity
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.extensions.TextReference
internal data class TangemPayDropDownItemUM(
val onClick: () -> Unit,
val title: TextReference,
val icon: TangemIconUM,
)

View file

@ -13,6 +13,7 @@ import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.ui.DesignFeatureToggles
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.format.bigdecimal.fiat
@ -20,6 +21,7 @@ import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.getJavaCurrencyByCode
import com.tangem.core.ui.format.bigdecimal.optionalDecimals
import com.tangem.core.ui.message.SnackbarMessage
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.test.TangemPayTestTags
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.TokenReceiveConfig
@ -53,7 +55,7 @@ import kotlinx.coroutines.launch
import javax.inject.Inject
import com.tangem.core.ui.R as CoreUiR
@Suppress("LongParameterList")
@Suppress("LongParameterList", "LargeClass")
@Stable
@ModelScoped
internal class TangemPayCardPageModel @Inject constructor(
@ -89,6 +91,7 @@ internal class TangemPayCardPageModel @Inject constructor(
dailyLimitState = TangemPayDailyLimitBlockState.Loading,
settings = persistentListOf(),
settingsV2 = persistentListOf(),
menuItems = buildMenuItems(),
),
)
@ -210,6 +213,21 @@ internal class TangemPayCardPageModel @Inject constructor(
)
}
private fun buildMenuItems(): ImmutableList<TangemPayDropDownItemUM> {
return persistentListOf(
TangemPayDropDownItemUM(
title = TextReference.Res(R.string.tangempay_card_details_reissue_card),
onClick = ::onClickReissueCard,
icon = TangemIconUM.Icon(
iconRes = CoreUiR.drawable.ic_replace_20,
tintReference = {
TangemTheme.colors3.icon.primary
},
),
),
)
}
private fun onClickViewDetails() {
modelScope.launch(dispatchers.default) {
cardDetailsEventListener.send(CardDetailsEvent.Show)

View file

@ -2,8 +2,10 @@ package com.tangem.features.tangempay.model.transformers
import arrow.core.Either
import com.tangem.core.error.UniversalError
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.formatStyled
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.pay.model.TangemPayCardBalance
import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState
import com.tangem.features.tangempay.entity.TangemPayDetailsUM
@ -35,10 +37,14 @@ internal class DetailsBalanceTransformer(
return prevState.copy(balanceBlockState = balance)
}
private fun getFiatBalanceText(balance: TangemPayCardBalance): String {
private fun getFiatBalanceText(balance: TangemPayCardBalance): TextReference {
val currency = Currency.getInstance(balance.currencyCode)
return balance.fiatBalance.format {
fiat(fiatCurrencyCode = currency.currencyCode, fiatCurrencySymbol = currency.symbol)
return balance.fiatBalance.formatStyled {
fiat(
fiatCurrencyCode = currency.currencyCode,
fiatCurrencySymbol = currency.symbol,
spanStyleReference = { TangemTheme.typography3.heading.medium.toSpanStyle() },
)
}
}
}

View file

@ -15,9 +15,8 @@ import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
@ -25,6 +24,9 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.util.fastForEach
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.LocalRedesignEnabled
@ -36,7 +38,9 @@ import com.tangem.features.tangempay.components.cardDetails.PreviewTangemPayCard
import com.tangem.features.tangempay.components.cardDetails.TangemPayCardDetailsBlockComponent
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.entity.*
import com.tangem.features.tangempay.ui.components.PayContextMenuBlock
import kotlinx.collections.immutable.ImmutableList
import com.tangem.core.ui.R as CoreUiR
private const val CONTENT_FADE_DURATION_MS = 300
@ -51,8 +55,8 @@ internal fun TangemPayCardPageScreen(
Scaffold(
modifier = modifier,
topBar = {
AppBarWithBackButton(
modifier = Modifier.statusBarsPadding(),
CardPageTopBar(
items = state.menuItems,
onBackClick = state.onBackClick,
)
},
@ -170,6 +174,48 @@ private fun TangemPayCardPageSettingRow(
}
}
@Composable
private fun CardPageTopBar(
onBackClick: () -> Unit,
items: ImmutableList<TangemPayDropDownItemUM>,
modifier: Modifier = Modifier,
) {
if (LocalRedesignEnabled.current) {
var isDropdownMenuShown by rememberSaveable { mutableStateOf(false) }
TangemTopBar(
modifier = modifier.statusBarsPadding(),
startContent = {
TangemButton(
iconStart = TangemIconUM.Icon(iconRes = R.drawable.ic_arrow_back_28),
onClick = onBackClick,
size = TangemButton.Size.X11,
variant = TangemButton.Variant.Material,
)
},
endContent = {
Box {
TangemButton(
iconStart = TangemIconUM.Icon(iconRes = CoreUiR.drawable.ic_more_default_24),
onClick = { isDropdownMenuShown = true },
size = TangemButton.Size.X11,
variant = TangemButton.Variant.Material,
)
PayContextMenuBlock(
items = items,
onMenuDismiss = { isDropdownMenuShown = false },
isDropdownMenuShown = isDropdownMenuShown,
)
}
},
)
} else {
AppBarWithBackButton(
modifier = modifier,
onBackClick = onBackClick,
)
}
}
private fun LazyListScope.cardPageItem(
key: Any? = null,
contentType: Any? = null,

View file

@ -2,20 +2,20 @@ package com.tangem.features.tangempay.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.util.fastForEach
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.extensions.resolveAnnotatedReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.features.tangempay.entity.TangemPayCardPageSettingV2
import com.tangem.features.tangempay.ui.components.TangemPayActionButton
import kotlinx.collections.immutable.ImmutableList
@Composable
@ -30,41 +30,18 @@ internal fun TangemPayCardPageSettingsButtonsBlock(
horizontalArrangement = Arrangement.Center,
) {
settings.fastForEach { setting ->
TangemPaySettingButton(
setting = setting,
TangemPayActionButton(
modifier = Modifier.then(if (setting.testTag != null) Modifier.testTag(setting.testTag) else Modifier),
title = setting.title,
iconRes = setting.iconRes,
onClick = setting.onClick,
isEnabled = setting.isEnabled,
isLoading = setting.isLoading,
)
}
}
}
@Composable
private fun TangemPaySettingButton(setting: TangemPayCardPageSettingV2, modifier: Modifier = Modifier) {
Column(
modifier = modifier.padding(horizontal = TangemTheme.dimens2.x6),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
horizontalAlignment = Alignment.CenterHorizontally,
) {
TangemButton(
variant = TangemButton.Variant.Material,
size = TangemButton.Size.X14,
onClick = setting.onClick,
iconStart = TangemIconUM.Icon(
iconRes = setting.iconRes,
tintReference = { TangemTheme.colors3.icon.primary },
),
isLoading = setting.isLoading,
isEnabled = setting.isEnabled,
)
Text(
text = setting.title.resolveAnnotatedReference(),
style = TangemTheme.typography3.subheading.medium,
color = TangemTheme.colors3.text.primary,
)
}
}
@Preview(showBackground = true)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable

View file

@ -25,6 +25,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
@ -45,9 +46,7 @@ import com.tangem.core.ui.components.dropdownmenu.TangemDropdownItem
import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenu
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.components.text.applyBladeBrush
import com.tangem.core.ui.extensions.orMaskWithStars
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.TangemPayTestTags
@ -343,7 +342,7 @@ private fun FiatBalance(
)
is TangemPayDetailsBalanceBlockState.Content -> Text(
modifier = modifier.testTag(TangemPayTestTags.PAYMENT_ACCOUNT_BALANCE),
text = state.fiatBalance.orMaskWithStars(isBalanceHidden),
text = state.fiatBalance.orMaskWithStars(isBalanceHidden).resolveReference(),
style = TangemTheme.typography.h2.applyBladeBrush(
isEnabled = state.isBalanceFlickering,
textColor = TangemTheme.colors.text.primary1,
@ -432,10 +431,15 @@ private fun TangemPayDetailsScreenPreview(
}
}
private class TangemPayDetailsUMProvider : CollectionPreviewParameterProvider<TangemPayDetailsUM>(
internal class TangemPayDetailsUMProvider : CollectionPreviewParameterProvider<TangemPayDetailsUM>(
collection = listOf(
TangemPayDetailsUM(
topBarConfig = TangemPayDetailsTopBarConfig(onBackClick = {}, onOpenMenu = {}, items = persistentListOf()),
topBarConfig = TangemPayDetailsTopBarConfig(
onBackClick = {},
onOpenMenu = {},
items = persistentListOf(),
itemsV2 = persistentListOf(),
),
pullToRefreshConfig = PullToRefreshConfig(isRefreshing = false, onRefresh = {}),
balanceBlockState = TangemPayDetailsBalanceBlockState.Content(
actionButtons = persistentListOf(
@ -445,7 +449,14 @@ private class TangemPayDetailsUMProvider : CollectionPreviewParameterProvider<Ta
onClick = {},
),
),
fiatBalance = "$1234.56",
fiatBalance = combinedReference(
stringReference("1,234"),
styledStringReference(
".56",
{ SpanStyle(color = TangemTheme.colors3.text.primary) },
),
stringReference(" $"),
),
isBalanceFlickering = false,
cardsBlockState = TangemPayDetailsBalanceBlockState.CardsBlockState(
cards = persistentListOf(
@ -465,7 +476,12 @@ private class TangemPayDetailsUMProvider : CollectionPreviewParameterProvider<Ta
accountDeactivatedNotificationConfig = null,
),
TangemPayDetailsUM(
topBarConfig = TangemPayDetailsTopBarConfig(onBackClick = {}, onOpenMenu = {}, items = persistentListOf()),
topBarConfig = TangemPayDetailsTopBarConfig(
onBackClick = {},
onOpenMenu = {},
items = persistentListOf(),
itemsV2 = persistentListOf(),
),
pullToRefreshConfig = PullToRefreshConfig(isRefreshing = false, onRefresh = {}),
balanceBlockState = TangemPayDetailsBalanceBlockState.Loading(
actionButtons = persistentListOf(),
@ -502,7 +518,7 @@ private fun TangemPayDetailsTxHistoryScreenPreview(
}
}
private class TangemPayDetailsTxHistoryProvider : CollectionPreviewParameterProvider<TangemPayTxHistoryUM>(
internal class TangemPayDetailsTxHistoryProvider : CollectionPreviewParameterProvider<TangemPayTxHistoryUM>(
collection = listOf(
PreviewTangemPayTxHistoryComponent.loadingUM,
PreviewTangemPayTxHistoryComponent.contentUM,

View file

@ -0,0 +1,404 @@
package com.tangem.features.tangempay.ui
import android.content.res.Configuration
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastForEach
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.core.ui.components.SpacerW
import com.tangem.core.ui.components.buttons.actions.ActionButtonConfig
import com.tangem.core.ui.components.containers.pullToRefresh.TangemPullToRefreshSlidingContainer
import com.tangem.core.ui.components.text.applyBladeBrush
import com.tangem.core.ui.components.topFade
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.message.TangemMessage
import com.tangem.core.ui.ds.message.TangemMessageEffect
import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.ds2.shimmers.TextShimmer
import com.tangem.core.ui.ds2.shimmers.TextShimmerStyle
import com.tangem.core.ui.extensions.clickableSingle
import com.tangem.core.ui.extensions.orMaskWithStars
import com.tangem.core.ui.extensions.resolveAnnotatedReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.test.TangemPayTestTags
import com.tangem.features.tangempay.components.express.PreviewEmptyExpressTransactionsComponent
import com.tangem.features.tangempay.components.txHistory.PreviewTangemPayTxHistoryComponent
import com.tangem.features.tangempay.components.txHistory.TangemPayTxHistoryComponent
import com.tangem.features.tangempay.details.impl.R
import com.tangem.features.tangempay.entity.TangemPayDetailsBalanceBlockState
import com.tangem.features.tangempay.entity.TangemPayDetailsTopBarConfig
import com.tangem.features.tangempay.entity.TangemPayDetailsUM
import com.tangem.features.tangempay.entity.TangemPayTxHistoryUM
import com.tangem.features.tangempay.ui.components.PayContextMenuBlock
import com.tangem.features.tangempay.ui.components.TangemPayActionButton
import com.tangem.features.tangempay.ui.components.TangemPayAddCardView
import com.tangem.features.tangempay.ui.components.TangemPayCardView
import com.tangem.features.tokendetails.ExpressTransactionsComponent
import com.tangem.utils.StringsSigns.DASH_SIGN
import kotlinx.collections.immutable.ImmutableList
import com.tangem.core.ui.R as CoreUiR
private val InitialTopBarHeight: Dp = 64.dp
private val BgImageFadeDistance: Dp = 96.dp
private const val TOP_FADE_MID_STOP = 0.8f
private const val TOP_FADE_MID_ALPHA = 0.8f
@Suppress("LongMethod")
@Composable
internal fun TangemPayDetailsScreenV2(
state: TangemPayDetailsUM,
txHistoryComponent: TangemPayTxHistoryComponent,
expressTransactionsComponent: ExpressTransactionsComponent,
modifier: Modifier = Modifier,
) {
val listState = rememberLazyListState()
val density = LocalDensity.current
val statusBarHeight = with(density) { WindowInsets.systemBars.getTop(this).toDp() }
val bottomBarHeight = with(density) { WindowInsets.systemBars.getBottom(this).toDp() }
var topBarTotalHeight by remember { mutableStateOf(InitialTopBarHeight + statusBarHeight) }
val rootBackground = TangemTheme.colors3.bg.primary
val fadeDistancePx = with(LocalDensity.current) { BgImageFadeDistance.toPx() }
val bgImageAlpha by remember(fadeDistancePx) {
derivedStateOf {
val scrollOffsetPx = when (listState.firstVisibleItemIndex) {
0 -> listState.firstVisibleItemScrollOffset.toFloat()
else -> fadeDistancePx
}.coerceAtLeast(0f)
(1f - scrollOffsetPx / fadeDistancePx).coerceIn(0f, 1f)
}
}
val txHistoryState by txHistoryComponent.state.collectAsStateWithLifecycle()
val expressState by expressTransactionsComponent.state.collectAsStateWithLifecycle()
val expressTransactionsBottomSheetState = expressState.bottomSheetSlot
Box(
modifier = modifier
.fillMaxSize()
.background(rootBackground),
) {
Image(
modifier = Modifier
.fillMaxWidth(),
painter = painterResource(R.drawable.img_bg_pay_details),
contentDescription = null,
contentScale = ContentScale.FillWidth,
alpha = bgImageAlpha,
)
TangemPullToRefreshSlidingContainer(
config = state.pullToRefreshConfig,
indicatorOffset = topBarTotalHeight,
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.topFade(
height = topBarTotalHeight,
0f to rootBackground.copy(alpha = 1f - bgImageAlpha),
TOP_FADE_MID_STOP to rootBackground.copy(alpha = TOP_FADE_MID_ALPHA * (1f - bgImageAlpha)),
1f to Color.Transparent,
),
horizontalAlignment = Alignment.CenterHorizontally,
state = listState,
contentPadding = PaddingValues(
top = topBarTotalHeight,
bottom = TangemTheme.dimens2.x4 + bottomBarHeight,
),
) {
payDetailsBody(state)
with(expressTransactionsComponent) {
expressTransactionsContent(
state = expressState.transactionsToDisplay,
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(top = 12.dp)
.fillMaxWidth(),
)
}
with(txHistoryComponent) { txHistoryContent(listState = listState, state = txHistoryState) }
}
}
PayDetailsTopBar(
config = state.topBarConfig,
onHeightChange = { measuredHeight ->
if (topBarTotalHeight != measuredHeight) topBarTotalHeight = measuredHeight
},
)
}
expressTransactionsBottomSheetState?.content(null)
}
private fun LazyListScope.payDetailsBody(state: TangemPayDetailsUM) {
item("balanceBlock") {
BalanceBlock(
state = state.balanceBlockState,
isBalanceHidden = state.isBalanceHidden,
)
}
state.balanceBlockState.cardsBlockState?.let { cardsState ->
item("cardsBlock") {
CardsBlock(cardsBlockState = cardsState)
}
}
if (state.balanceBlockState.actionButtons.isNotEmpty()) {
item("actionButtonsBlock") {
ActionBlock(actionButtons = state.balanceBlockState.actionButtons)
}
}
when {
state.balanceBlockState.cardsBlockState?.cards?.fastAny { it.isReissuing } == true -> {
item("reissuingBannerBlock") {
TangemMessage(
modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x4),
title = resourceReference(R.string.tangempay_reissue_card_in_progress),
subtitle = resourceReference(R.string.tangempay_reissue_card_in_progress_description),
)
}
}
else -> {
if (state.addToWalletBlockState != null) {
item("addToWalletBannerBlock") {
TangemPayAddToWalletBlock(
state = state.addToWalletBlockState,
modifier = Modifier.padding(horizontal = TangemTheme.dimens2.x4),
)
}
}
if (state.accountDeactivatedNotificationConfig != null) {
item("deactivationBannerBlock") {
TangemMessage(
modifier = Modifier
.padding(horizontal = TangemTheme.dimens2.x4)
.clickableSingle(
onClick = { state.accountDeactivatedNotificationConfig.onClick?.invoke() },
),
title = state.accountDeactivatedNotificationConfig.title,
subtitle = state.accountDeactivatedNotificationConfig.subtitle,
messageEffect = TangemMessageEffect.Warning,
)
}
}
}
}
}
@Composable
private fun PayDetailsTopBar(
config: TangemPayDetailsTopBarConfig,
onHeightChange: (Dp) -> Unit,
modifier: Modifier = Modifier,
) {
val density = LocalDensity.current
TangemTopBar(
modifier = modifier
.onSizeChanged { size ->
onHeightChange(with(density) { size.height.toDp() })
}
.statusBarsPadding(),
title = resourceReference(R.string.tangempay_payment_account),
subtitle = resourceReference(R.string.tangempay_usdc_on_polygon_network),
startContent = {
TangemButton(
iconStart = TangemIconUM.Icon(iconRes = CoreUiR.drawable.ic_arrow_back_28),
onClick = config.onBackClick,
size = TangemButton.Size.X11,
variant = TangemButton.Variant.Material,
)
},
endContent = if (config.itemsV2.isNotEmpty()) {
{
var isDropdownMenuShown by rememberSaveable { mutableStateOf(false) }
Box {
TangemButton(
iconStart = TangemIconUM.Icon(iconRes = CoreUiR.drawable.ic_more_default_24),
onClick = {
config.onOpenMenu()
isDropdownMenuShown = true
},
size = TangemButton.Size.X11,
variant = TangemButton.Variant.Material,
)
PayContextMenuBlock(
items = config.itemsV2,
onMenuDismiss = { isDropdownMenuShown = false },
isDropdownMenuShown = isDropdownMenuShown,
)
}
}
} else {
null
},
)
}
@Composable
private fun BalanceBlock(
state: TangemPayDetailsBalanceBlockState,
isBalanceHidden: Boolean,
modifier: Modifier = Modifier,
) {
Column(
modifier
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens2.x4)
.padding(top = TangemTheme.dimens2.x12),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AnimatedContent(
targetState = state,
label = "Updating the balance",
transitionSpec = {
fadeIn(animationSpec = tween(durationMillis = 220, delayMillis = 90)) togetherWith
fadeOut(animationSpec = tween(durationMillis = 90))
},
) { animatedState ->
when (animatedState) {
is TangemPayDetailsBalanceBlockState.Loading -> TextShimmer(
modifier = Modifier.size(width = 160.dp, height = 56.dp),
text = "1234.00",
style = TextShimmerStyle.HEADING_MEDIUM,
radius = TangemTheme.dimens2.x25,
)
is TangemPayDetailsBalanceBlockState.Content -> Text(
modifier = Modifier.testTag(TangemPayTestTags.PAYMENT_ACCOUNT_BALANCE),
text = animatedState.fiatBalance.orMaskWithStars(isBalanceHidden).resolveAnnotatedReference(),
style = TangemTheme.typography3.display.medium.applyBladeBrush(
isEnabled = animatedState.isBalanceFlickering,
textColor = TangemTheme.colors3.text.primary,
),
color = TangemTheme.colors3.text.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
autoSize = TextAutoSize.StepBased(
minFontSize = TangemTheme.typography3.heading.medium.fontSize,
maxFontSize = TangemTheme.typography3.display.medium.fontSize,
),
)
is TangemPayDetailsBalanceBlockState.Error -> Text(
modifier = Modifier.testTag(TangemPayTestTags.PAYMENT_ACCOUNT_BALANCE),
text = DASH_SIGN.orMaskWithStars(isBalanceHidden),
style = TangemTheme.typography3.display.medium,
color = TangemTheme.colors3.text.primary,
)
}
}
}
}
@Composable
private fun CardsBlock(
cardsBlockState: TangemPayDetailsBalanceBlockState.CardsBlockState,
modifier: Modifier = Modifier,
) {
LazyRow(
modifier = modifier.fillMaxWidth(),
state = rememberLazyListState(),
contentPadding = PaddingValues(
horizontal = TangemTheme.dimens2.x4,
vertical = TangemTheme.dimens2.x6,
),
horizontalArrangement = Arrangement.Center,
) {
items(items = cardsBlockState.cards) { item ->
TangemPayCardView(
isReissuing = item.isReissuing,
lastDigits = item.lastDigits,
onClick = item.onClick,
)
SpacerW(TangemTheme.dimens2.x2)
}
item {
TangemPayAddCardView(onClick = cardsBlockState.onAddCardClick)
}
}
}
@Composable
private fun LazyItemScope.ActionBlock(
actionButtons: ImmutableList<ActionButtonConfig>,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(vertical = TangemTheme.dimens2.x6),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
actionButtons.fastForEach { actionConfig ->
TangemPayActionButton(
iconRes = actionConfig.iconResId,
onClick = actionConfig.onClick,
isEnabled = actionConfig.isEnabled,
isLoading = actionConfig.isInProgress,
title = actionConfig.text,
)
}
}
}
// region preview
@Preview(device = Devices.PIXEL_7_PRO)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.PIXEL_7_PRO)
@Composable
private fun TangemPayDetailsScreenPreview(
@PreviewParameter(TangemPayDetailsUMProvider::class) state: TangemPayDetailsUM,
) {
TangemThemePreviewRedesign {
TangemPayDetailsScreenV2(
state = state,
txHistoryComponent = PreviewTangemPayTxHistoryComponent(
txHistoryUM = PreviewTangemPayTxHistoryComponent.contentUM,
),
expressTransactionsComponent = PreviewEmptyExpressTransactionsComponent(),
)
}
}
@Preview(device = Devices.PIXEL_7_PRO)
@Composable
private fun TangemPayDetailsTxHistoryScreenPreview(
@PreviewParameter(TangemPayDetailsTxHistoryProvider::class) state: TangemPayTxHistoryUM,
) {
TangemThemePreviewRedesign {
TangemPayDetailsScreenV2(
state = TangemPayDetailsUMProvider().values.first(),
txHistoryComponent = PreviewTangemPayTxHistoryComponent(txHistoryUM = state),
expressTransactionsComponent = PreviewEmptyExpressTransactionsComponent(),
)
}
}
// end region preview

View file

@ -0,0 +1,57 @@
package com.tangem.features.tangempay.ui.components
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.util.fastForEach
import com.tangem.core.ui.ds.contextmenu.TangemContextMenu
import com.tangem.core.ui.ds.image.TangemIcon
import com.tangem.core.ui.extensions.clickableSingle
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.tangempay.entity.TangemPayDropDownItemUM
import kotlinx.collections.immutable.ImmutableList
@Composable
internal fun PayContextMenuBlock(
items: ImmutableList<TangemPayDropDownItemUM>,
isDropdownMenuShown: Boolean,
onMenuDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
TangemContextMenu(
expanded = isDropdownMenuShown,
onDismissRequest = onMenuDismiss,
offset = DpOffset.Zero,
modifier = modifier,
) {
items.fastForEach { item ->
Column {
Row(
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
modifier = Modifier
.clickableSingle(
onClick = {
item.onClick()
onMenuDismiss()
},
)
.padding(vertical = TangemTheme.dimens2.x3, horizontal = TangemTheme.dimens2.x4),
) {
TangemIcon(
modifier = Modifier.size(TangemTheme.dimens2.x5),
tangemIconUM = item.icon,
)
Text(
text = item.title.resolveReference(),
style = TangemTheme.typography3.body.medium,
color = TangemTheme.colors3.text.primary,
maxLines = 1,
)
}
}
}
}
}

View file

@ -0,0 +1,66 @@
package com.tangem.features.tangempay.ui.components
import android.content.res.Configuration
import androidx.annotation.DrawableRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.R
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveAnnotatedReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
@Composable
internal fun TangemPayActionButton(
title: TextReference,
@DrawableRes iconRes: Int,
onClick: () -> Unit,
modifier: Modifier = Modifier,
isEnabled: Boolean = true,
isLoading: Boolean = false,
) {
Column(
modifier = modifier.padding(horizontal = TangemTheme.dimens2.x4),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
horizontalAlignment = Alignment.CenterHorizontally,
) {
TangemButton(
variant = TangemButton.Variant.Material,
size = TangemButton.Size.X14,
onClick = onClick,
iconStart = TangemIconUM.Icon(
iconRes = iconRes,
tintReference = { TangemTheme.colors3.icon.primary },
),
isLoading = isLoading,
isEnabled = isEnabled,
)
Text(
text = title.resolveAnnotatedReference(),
style = TangemTheme.typography3.subheading.medium,
color = TangemTheme.colors3.text.primary,
)
}
}
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun TangemPayActionButtonPreview() {
TangemThemePreviewRedesign {
TangemPayActionButton(
title = stringReference("Action"),
iconRes = R.drawable.ic_arrow_down_24,
onClick = {},
)
}
}

View file

@ -0,0 +1,215 @@
package com.tangem.features.tangempay.ui.components
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.extensions.clickableSingle
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_clock_12
import com.tangem.core.ui.res.generated.icons.ic_cloud_12
import com.tangem.core.ui.test.TangemPayTestTags
private const val DEFAULT_CARD_BG = 0xFF1C1F29
private const val REISSUING_CARD_BG = 0xFF1E1E1E
@Composable
internal fun TangemPayCardView(
isReissuing: Boolean,
lastDigits: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
CardBackground(
modifier = modifier
.size(
height = TangemTheme.dimens2.x10,
width = TangemTheme.dimens2.x14,
)
.testTag(TangemPayTestTags.PAYMENT_ACCOUNT_CARD_BUTTON),
isReissuing = isReissuing,
onClick = onClick,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = TangemTheme.dimens2.x1)
.padding(top = TangemTheme.dimens2.x1),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Icon(
modifier = Modifier.size(TangemTheme.dimens2.x3),
imageVector = if (isReissuing) {
Icons.ic_clock_12
} else {
Icons.ic_cloud_12
},
tint = TangemTheme.colors3.icon.staticDark,
contentDescription = null,
)
Icon(
modifier = Modifier.size(height = TangemTheme.dimens2.x2, width = 22.dp),
imageVector = ImageVector.vectorResource(R.drawable.ic_visa_logo),
tint = TangemTheme.colors3.icon.staticDark,
contentDescription = null,
)
}
Text(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = TangemTheme.dimens2.x1, bottom = TangemTheme.dimens2.x1),
text = lastDigits,
style = TangemTheme.typography3.caption.medium.copy(fontSize = 10.sp),
color = TangemTheme.colors3.text.staticDark.primary,
)
}
}
@Composable
internal fun TangemPayAddCardView(onClick: () -> Unit, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.size(
height = TangemTheme.dimens2.x10,
width = TangemTheme.dimens2.x14,
)
.clip(RoundedCornerShape(TangemTheme.dimens3.borderRadius.b075))
.background(TangemTheme.colors3.bg.opaque.primary)
.clickableSingle(onClick = onClick),
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier.size(20.dp),
imageVector = ImageVector.vectorResource(R.drawable.ic_plus_default_24),
contentDescription = null,
tint = TangemTheme.colors3.icon.secondary,
)
}
}
@Suppress("MagicNumber")
@Composable
private fun CardBackground(
isReissuing: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable BoxScope.() -> Unit,
) {
val bgColor = remember(isReissuing) {
if (isReissuing) {
Color(REISSUING_CARD_BG)
} else {
Color(DEFAULT_CARD_BG)
}
}
Box(
modifier = modifier
.clip(RoundedCornerShape(TangemTheme.dimens3.borderRadius.b075))
.drawBehind {
drawRect(bgColor)
val w = size.width
val h = size.height
val radiusScaleRightCorner = h / 2f
val radiusScaleLeftCorner = h / 1.27f
drawRect(
brush = Brush.radialGradient(
colors = listOf(
Color(
if (isReissuing) 0xFFB0B4BC else 0xFF38587F,
).copy(
if (isReissuing) .1f else .41f,
),
Color.Transparent,
),
center = Offset(w - 20f, h * .05f),
radius = radiusScaleRightCorner,
tileMode = TileMode.Clamp,
),
)
if (!isReissuing) {
drawRect(
brush = Brush.radialGradient(
colors = listOf(
Color(0xFF2881FF).copy(.25f),
Color.Transparent,
),
center = Offset(0f, h + h * .1f),
radius = radiusScaleLeftCorner,
),
)
}
}
.border(
width = 1.dp,
color = TangemTheme.colors3.border.primary,
shape = RoundedCornerShape(TangemTheme.dimens3.borderRadius.b075),
)
.clickableSingle(onClick = onClick),
content = content,
)
}
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.PIXEL_7_PRO)
@Composable
private fun CardBackgroundPreview() {
TangemThemePreviewRedesign {
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.Center) {
CardBackground(
modifier = Modifier.size(
height = TangemTheme.dimens2.x10,
width = TangemTheme.dimens2.x14,
),
isReissuing = false,
onClick = {},
content = {},
)
SpacerH(TangemTheme.dimens2.x4)
CardBackground(
modifier = Modifier.size(
height = TangemTheme.dimens2.x10,
width = TangemTheme.dimens2.x14,
),
isReissuing = true,
onClick = {},
content = {},
)
SpacerH(TangemTheme.dimens2.x4)
TangemPayCardView(isReissuing = false, onClick = {}, lastDigits = "1234")
SpacerH(TangemTheme.dimens2.x4)
TangemPayCardView(isReissuing = true, onClick = {}, lastDigits = "")
SpacerH(TangemTheme.dimens2.x4)
TangemPayAddCardView(onClick = {})
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View file

@ -47,7 +47,6 @@ dependencies {
/** Other libraries */
implementation(deps.arrow.core)
implementation(deps.kotlin.immutable.collections)
implementation(deps.surveysparrow)
/** Core modules */
implementation(projects.core.datasource)
@ -60,6 +59,7 @@ dependencies {
/** Feature Apis */
implementation(projects.features.tester.api)
implementation(projects.features.pushNotifications.api)
implementation(projects.features.survey.api)
/* SDK */
implementation(tangemDeps.blockchain)

View file

@ -1,6 +1,5 @@
package com.tangem.feature.tester.presentation
import android.widget.Toast
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -18,7 +17,6 @@ import com.tangem.core.navigation.finisher.AppFinisher
import com.tangem.core.ui.UiDependencies
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.screen.ComposeActivity
import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.feature.tester.presentation.accounts.ui.AccountsScreen
import com.tangem.feature.tester.presentation.accounts.viewmodel.TesterAccountsViewModel
import com.tangem.feature.tester.presentation.actions.TesterActionsScreen
@ -40,9 +38,10 @@ import com.tangem.feature.tester.presentation.providers.ui.BlockchainProvidersSc
import com.tangem.feature.tester.presentation.providers.viewmodel.BlockchainProvidersViewModel
import com.tangem.feature.tester.presentation.storybook.ui.StoryBookScreen
import com.tangem.feature.tester.presentation.storybook.viewmodel.StoryBookViewModel
import com.tangem.feature.tester.presentation.surveysparrow.SurveySparrowManager
import com.tangem.feature.tester.presentation.testpush.ui.TestPushScreen
import com.tangem.feature.tester.presentation.testpush.viewmodel.TestPushViewModel
import com.tangem.features.survey.SurveyLaunchData
import com.tangem.features.survey.SurveySparrowLauncher
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.collections.immutable.persistentSetOf
import javax.inject.Inject
@ -64,7 +63,7 @@ internal class TesterActivity : ComposeActivity() {
lateinit var appRouter: AppRouter
@Inject
lateinit var environmentConfig: EnvironmentConfig
lateinit var surveySparrowLauncher: SurveySparrowLauncher
@Composable
override fun ScreenContent(modifier: Modifier) {
@ -217,29 +216,16 @@ internal class TesterActivity : ComposeActivity() {
}
private fun startSurveySparrow(): Boolean {
val token = environmentConfig.surveySparrowToken
if (token.isNullOrEmpty()) {
val toast = Toast.makeText(
this,
"Survey Sparrow is not configured. Token is missing.",
Toast.LENGTH_LONG,
)
toast.show()
return false
}
SurveySparrowManager(domain = DOMAIN, token = token).startSurveyForResult(
surveySparrowLauncher.present(
activity = this,
requestCode = SURVEY_SPARROW_REQUEST_CODE,
data = SurveyLaunchData(domain = DOMAIN, token = TEST_SHARE_TOKEN, customParams = emptyMap()),
)
return true
}
private companion object {
const val DOMAIN = "tangem.com"
const val SURVEY_SPARROW_REQUEST_CODE = 1001
const val DOMAIN = "tangem.surveysparrow.com"
const val TEST_SHARE_TOKEN = "ntt-84iF22PDajmervYneMW4kv"
}
}

View file

@ -1,55 +0,0 @@
package com.tangem.feature.tester.presentation.surveysparrow
import android.app.Activity
import com.surveysparrow.ss_android_sdk.SsSurvey
import com.surveysparrow.ss_android_sdk.SurveySparrow
import com.tangem.utils.logging.TangemLogger
/**
* Manager for Survey Sparrow SDK.
*
* @param domain Survey Sparrow domain (e.g., "yourcompany")
* @param token Survey Sparrow SDK token
*/
class SurveySparrowManager(
private val domain: String,
private val token: String,
) {
/**
* Create a SurveySparrow instance to start a survey.
*
* @param activity The activity context
* @param customVariables Optional custom variables to pass to the survey
* @return SurveySparrow instance ready to start
*/
fun createSurvey(activity: Activity, customVariables: Map<String, String>? = null): SurveySparrow? {
return try {
val survey = SsSurvey(domain, token).apply {
customVariables?.forEach { (key, value) ->
addCustomParam(key, value)
}
}
SurveySparrow(activity, survey)
} catch (e: Exception) {
TangemLogger.e("Failed to create SurveySparrow survey", e)
null
}
}
/**
* Start a survey for result.
*
* @param activity The activity context
* @param requestCode The request code for onActivityResult
* @param customVariables Optional custom variables to pass to the survey
*/
fun startSurveyForResult(activity: Activity, requestCode: Int, customVariables: Map<String, String>? = null) {
val surveySparrow = createSurvey(activity, customVariables)
if (surveySparrow != null) {
surveySparrow.startSurveyForResult(requestCode)
TangemLogger.d("SurveySparrow survey started with requestCode: $requestCode")
}
}
}

View file

@ -0,0 +1,5 @@
package com.tangem.features.tokendetails
interface TokenDetailsFeatureToggles {
val isQuickTopUpEnabled: Boolean
}

View file

@ -0,0 +1,15 @@
package com.tangem.feature.tokendetails
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.features.tokendetails.TokenDetailsFeatureToggles
import javax.inject.Inject
internal class DefaultTokenDetailsFeatureToggles @Inject constructor(
featureTogglesManager: FeatureTogglesManager,
) : TokenDetailsFeatureToggles {
override val isQuickTopUpEnabled: Boolean = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.AND_15258_QUICK_TOP_UP_ENABLED,
)
}

View file

@ -0,0 +1,21 @@
package com.tangem.feature.tokendetails.di
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.feature.tokendetails.DefaultTokenDetailsFeatureToggles
import com.tangem.features.tokendetails.TokenDetailsFeatureToggles
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 TokenDetailsFeatureModule {
@Provides
@Singleton
fun provideTokenDetailsFeatureToggles(featureTogglesManager: FeatureTogglesManager): TokenDetailsFeatureToggles {
return DefaultTokenDetailsFeatureToggles(featureTogglesManager)
}
}

View file

@ -5,6 +5,7 @@ import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.tokens.model.ScenarioUnavailabilityReason
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenBalanceSegmentedButtonConfig
import java.math.BigDecimal
@Suppress("TooManyFunctions")
interface TokenDetailsClickIntents {
@ -71,6 +72,8 @@ interface TokenDetailsClickIntents {
fun onBalanceSelect(config: TokenBalanceSegmentedButtonConfig)
fun onQuickTopUpClick(amount: BigDecimal, currencyCode: String)
fun onYieldInfoClick()
// region Clore migration
@ -168,6 +171,8 @@ internal class EmptyTokenDetailsClickIntents : TokenDetailsClickIntents {
override fun onYieldInfoClick() { /* no op */ }
override fun onQuickTopUpClick(amount: BigDecimal, currencyCode: String) { /* no op */ }
override fun onCopyAddress(): TextReference? {
/* no op */
return null

View file

@ -64,6 +64,7 @@ import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.offramp.GetOfframpUrlUseCase
import com.tangem.domain.onramp.CheckOnrampAvailabilityUseCase
import com.tangem.domain.onramp.model.OnrampSource
import com.tangem.domain.staking.GetStakingAvailabilityUseCase
import com.tangem.domain.staking.GetStakingEntryInfoUseCase
@ -84,6 +85,7 @@ import com.tangem.domain.transaction.error.OpenTrustlineError
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.domain.transaction.usecase.*
import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
import com.tangem.domain.txhistory.usecase.GetTxHistoryItemsCountUseCase
import com.tangem.domain.wallets.usecase.GetExploreUrlUseCase
import com.tangem.domain.wallets.usecase.GetWalletIconUseCase
import com.tangem.domain.wallets.usecase.GetExtendedPublicKeyForCurrencyUseCase
@ -103,6 +105,7 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDeta
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsStateController
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TransferUM
import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.QuickTopUpBlockFactory
import com.tangem.feature.tokendetails.presentation.tokendetails.state.factory.TokenDetailsStateFactory
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.InitializeWithCryptoCurrencyTransformer
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.SetBalanceTransformer
@ -132,6 +135,7 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.math.BigDecimal
import javax.inject.Inject
@Suppress("LongParameterList", "LargeClass", "TooManyFunctions", "PropertyUsedBeforeDeclaration")
@ -192,6 +196,9 @@ internal class TokenDetailsModel @Inject constructor(
private val redesignStateController: TokenDetailsStateController,
private val swapFeedbackUseCase: SwapFeedbackUseCase,
private val swapFeatureToggles: SwapFeatureToggles,
private val quickTopUpBlockFactory: QuickTopUpBlockFactory,
private val getTxHistoryItemsCountUseCase: GetTxHistoryItemsCountUseCase,
private val checkOnrampAvailabilityUseCase: CheckOnrampAvailabilityUseCase,
) : Model(),
TokenDetailsClickIntents,
YieldSupplyDepositedWarningComponent.ModelCallback {
@ -304,6 +311,7 @@ internal class TokenDetailsModel @Inject constructor(
handleBalanceHiding()
checkForActionUpdates()
handleNavigationParam()
observeQuickTopUpBlock()
}
private fun initButtons() {
@ -584,6 +592,43 @@ internal class TokenDetailsModel @Inject constructor(
}
}
override fun onQuickTopUpClick(amount: BigDecimal, currencyCode: String) {
analyticsEventsHandler.send(
TokenScreenAnalyticsEvent.ButtonQuickTopUp(
token = cryptoCurrency.symbol,
blockchain = cryptoCurrency.network.name,
currency = currencyCode,
value = amount.toInt().toString(),
),
)
appRouter.push(
AppRoute.Onramp(
source = OnrampSource.TOKEN_DETAILS,
userWalletId = userWalletId,
currency = cryptoCurrency,
initialFiatAmount = amount,
),
)
}
private fun onQuickTopUpOtherClick() {
analyticsEventsHandler.send(
TokenScreenAnalyticsEvent.ButtonWithParams.ButtonBuy(
token = cryptoCurrency.symbol,
blockchain = cryptoCurrency.network.name,
status = ScenarioUnavailabilityReason.None.toReasonAnalyticsText(),
derivationIndex = getAccountIndexOrNull(),
),
)
appRouter.push(
AppRoute.Onramp(
source = OnrampSource.TOKEN_DETAILS,
userWalletId = userWalletId,
currency = cryptoCurrency,
),
)
}
override fun onBuyCoinClick(cryptoCurrency: CryptoCurrency) {
analyticsEventsHandler.send(
TokenScreenAnalyticsEvent.ButtonWithParams.ButtonBuy(
@ -1363,6 +1408,38 @@ internal class TokenDetailsModel @Inject constructor(
observeRedesignStakingNotification()
}
private fun observeQuickTopUpBlock() {
getAccountCryptoCurrencyStatusUseCase(userWalletId, cryptoCurrency)
.map { it.status }
.distinctUntilChanged()
.flatMapLatest { status ->
flow {
val amount = status.value.amount
if (amount == null || !amount.isZero()) {
emit(null)
return@flow
}
val txCount = getTxHistoryItemsCountUseCase(userWalletId, cryptoCurrency)
val availability = checkOnrampAvailabilityUseCase(userWallet)
emit(
quickTopUpBlockFactory.build(
currencyStatus = status,
isTxHistoryEmpty = txCount,
onrampAvailability = availability,
onPresetClick = ::onQuickTopUpClick,
onOtherClick = ::onQuickTopUpOtherClick,
),
)
}
}
.onEach { block ->
uiState.value = uiState.value.copy(quickTopUpBlock = block)
redesignStateController.update { state -> state.copy(quickTopUpBlock = block) }
}
.flowOn(dispatchers.default)
.launchIn(modelScope)
}
private fun observeRedesignStakingNotification() {
val statusFlow = getAccountCryptoCurrencyStatusUseCase(userWalletId, cryptoCurrency)
.map { it.status }

View file

@ -0,0 +1,17 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.extensions.TextReference
import kotlinx.collections.immutable.ImmutableList
@Immutable
internal data class QuickTopUpBlockUM(
val amounts: ImmutableList<QuickTopUpAmountUM>,
) {
@Immutable
data class QuickTopUpAmountUM(
val displayValue: TextReference,
val onClick: () -> Unit,
val isOther: Boolean = false,
)
}

View file

@ -15,4 +15,5 @@ internal data class TokenDetailsState(
val pullToRefreshConfig: PullToRefreshConfig,
val isBalanceHidden: Boolean,
val isMarketPriceAvailable: Boolean,
val quickTopUpBlock: QuickTopUpBlockUM? = null,
)

View file

@ -24,6 +24,7 @@ internal data class TokenDetailsUM(
val addFundsUM: AddFundsUM,
val transferUM: TransferUM,
val zeroBalanceActionsUM: ZeroBalanceActionsUM,
val quickTopUpBlock: QuickTopUpBlockUM? = null,
)
@Immutable

View file

@ -0,0 +1,79 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory
import arrow.core.Either
import com.tangem.core.res.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.onramp.model.OnrampAvailability
import com.tangem.domain.onramp.model.error.OnrampError
import com.tangem.domain.txhistory.models.TxHistoryStateError
import com.tangem.feature.tokendetails.presentation.tokendetails.state.QuickTopUpBlockUM
import com.tangem.features.tokendetails.TokenDetailsFeatureToggles
import com.tangem.utils.extensions.isZero
import kotlinx.collections.immutable.toImmutableList
import java.math.BigDecimal
import javax.inject.Inject
internal class QuickTopUpBlockFactory @Inject constructor(
private val featureToggles: TokenDetailsFeatureToggles,
) {
fun build(
currencyStatus: CryptoCurrencyStatus,
isTxHistoryEmpty: Either<TxHistoryStateError, Int>,
onrampAvailability: Either<OnrampError, OnrampAvailability>,
onPresetClick: (BigDecimal, String) -> Unit,
onOtherClick: () -> Unit,
): QuickTopUpBlockUM? {
if (!featureToggles.isQuickTopUpEnabled) return null
val amount = currencyStatus.value.amount
if (amount == null || !amount.isZero()) return null
val isHistoryEmpty = isTxHistoryEmpty.fold(
ifLeft = { it is TxHistoryStateError.EmptyTxHistories },
ifRight = { it == 0 },
)
if (!isHistoryEmpty) return null
val currency = when (val availability = onrampAvailability.getOrNull()) {
is OnrampAvailability.Available -> availability.currency
is OnrampAvailability.ConfirmResidency -> {
if (!availability.country.onrampAvailable) return null
availability.country.defaultCurrency
}
else -> return null
}
val presets = when (currency.code) {
USD_CODE -> USD_PRESETS
EUR_CODE -> EUR_PRESETS
else -> return null
}
val presetAmounts = presets.map { value ->
QuickTopUpBlockUM.QuickTopUpAmountUM(
displayValue = stringReference("${currency.unit}$value"),
onClick = { onPresetClick(BigDecimal(value), currency.code) },
)
}
val otherAmount = QuickTopUpBlockUM.QuickTopUpAmountUM(
displayValue = resourceReference(R.string.quick_top_up_chip_other),
onClick = onOtherClick,
isOther = true,
)
return QuickTopUpBlockUM(
amounts = (presetAmounts + otherAmount).toImmutableList(),
)
}
private companion object {
const val USD_CODE = "USD"
const val EUR_CODE = "EUR"
val USD_PRESETS = listOf(50, 200, 700)
val EUR_PRESETS = listOf(50, 200, 650)
}
}

View file

@ -0,0 +1,135 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.util.fastForEach
import com.tangem.core.res.R
import com.tangem.core.ui.extensions.combinedReference
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.utils.StringsSigns
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.feature.tokendetails.presentation.tokendetails.state.QuickTopUpBlockUM
import kotlinx.collections.immutable.persistentListOf
private val quickTopUpGradientBrush = Brush.linearGradient(
colors = listOf(Color(0xFFEDE5F3), Color(0xFFD7EDD9)),
start = Offset(0f, 0f),
end = Offset(Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY),
)
private val quickTopUpBorderBrush = Brush.sweepGradient(
listOf(
Color(0x0D000000),
Color(0x26000000),
Color(0x0D000000),
Color(0x26000000),
),
)
@Composable
internal fun QuickTopUpBlock(state: QuickTopUpBlockUM, modifier: Modifier = Modifier) {
val shape = RoundedCornerShape(TangemTheme.dimens.radius20)
Box(
modifier = modifier
.fillMaxWidth()
.clip(shape)
.background(brush = quickTopUpGradientBrush)
.border(width = 1.dp, brush = quickTopUpBorderBrush, shape = shape),
) {
Column(
modifier = Modifier.padding(TangemTheme.dimens.spacing12),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing16),
) {
val textColor = TangemTheme.colors.text.primary1
Text(
text = combinedReference(
stringReference("${StringsSigns.LIGHTNING} "),
resourceReference(R.string.quick_top_up_title),
).resolveReference(),
style = TangemTheme.typography.subtitle1.copy(fontWeight = FontWeight.SemiBold),
modifier = Modifier.graphicsLayer {
colorFilter = ColorFilter.tint(textColor, BlendMode.SrcIn)
},
)
Row(
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing6),
) {
state.amounts.fastForEach { amountUM ->
Button(
onClick = amountUM.onClick,
shape = CircleShape,
contentPadding = PaddingValues(
horizontal = TangemTheme.dimens.spacing12,
vertical = TangemTheme.dimens.spacing0,
),
colors = ButtonDefaults.buttonColors(
containerColor = TangemTheme.colors.background.primary,
contentColor = TangemTheme.colors.text.primary1,
),
modifier = Modifier.heightIn(TangemTheme.dimens.size36),
elevation = null,
) {
Text(
text = amountUM.displayValue.resolveReference(),
style = TangemTheme.typography.subtitle1.copy(fontWeight = FontWeight.SemiBold),
)
}
}
}
}
}
}
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_NO)
@Composable
private fun QuickTopUpBlock_Preview() {
TangemThemePreviewRedesign {
QuickTopUpBlock(
state = QuickTopUpBlockUM(
amounts = persistentListOf(
QuickTopUpBlockUM.QuickTopUpAmountUM(
displayValue = stringReference("\$50"),
onClick = {},
),
QuickTopUpBlockUM.QuickTopUpAmountUM(
displayValue = stringReference("\$200"),
onClick = {},
),
QuickTopUpBlockUM.QuickTopUpAmountUM(
displayValue = stringReference("\$700"),
onClick = {},
),
QuickTopUpBlockUM.QuickTopUpAmountUM(
displayValue = resourceReference(R.string.quick_top_up_chip_other),
onClick = {},
isOther = true,
),
),
),
modifier = Modifier.padding(TangemTheme.dimens.spacing12),
)
}
}

View file

@ -226,6 +226,14 @@ private fun TokenDetailsBody(
modifier = expressTransactionModifier,
)
}
tokenDetailsUM.quickTopUpBlock?.let { quickTopUpBlock ->
item(key = "quick_top_up_block") {
QuickTopUpBlock(
state = quickTopUpBlock,
modifier = itemModifier.padding(vertical = TangemTheme.dimens2.x0),
)
}
}
with(txHistoryComponent) {
txHistoryContent(listState = listState, state = txHistoryState)
}

View file

@ -160,6 +160,15 @@ internal fun TokenDetailsScreenLegacy(
)
}
state.quickTopUpBlock?.let { quickTopUpBlock ->
item(key = "quick_top_up_block") {
QuickTopUpBlock(
state = quickTopUpBlock,
modifier = itemModifier,
)
}
}
with(txHistoryComponent) {
txHistoryContentLegacy(listState = listState, state = txHistoryComponentState)
}

View file

@ -0,0 +1,347 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.factory
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.core.res.R
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.onramp.model.OnrampAvailability
import com.tangem.domain.onramp.model.OnrampCountry
import com.tangem.domain.onramp.model.OnrampCurrency
import com.tangem.domain.onramp.model.error.OnrampError
import com.tangem.domain.txhistory.models.TxHistoryStateError
import com.tangem.features.tokendetails.TokenDetailsFeatureToggles
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Test
import java.math.BigDecimal
internal class QuickTopUpBlockFactoryTest {
private val featureToggles: TokenDetailsFeatureToggles = mockk {
every { isQuickTopUpEnabled } returns true
}
private val factory = QuickTopUpBlockFactory(featureToggles)
private val zeroBalanceStatus: CryptoCurrencyStatus = mockk {
every { value } returns mockk<CryptoCurrencyStatus.Loaded> {
every { amount } returns BigDecimal.ZERO
}
}
private val nonZeroBalanceStatus: CryptoCurrencyStatus = mockk {
every { value } returns mockk<CryptoCurrencyStatus.Loaded> {
every { amount } returns BigDecimal.TEN
}
}
private val usdCurrency = OnrampCurrency(
name = "US Dollar",
code = "USD",
image = null,
precision = 2,
unit = "$",
)
private val eurCurrency = OnrampCurrency(
name = "Euro",
code = "EUR",
image = null,
precision = 2,
unit = "",
)
private val gbpCurrency = OnrampCurrency(
name = "British Pound",
code = "GBP",
image = null,
precision = 2,
unit = "£",
)
private val countryMock: OnrampCountry = mockk(relaxed = true)
private val availableUsd: OnrampAvailability = OnrampAvailability.Available(
country = countryMock,
currency = usdCurrency,
)
private val notSupported: OnrampAvailability = OnrampAvailability.NotSupported(country = countryMock)
private val emptyHistory = TxHistoryStateError.EmptyTxHistories.left()
private val histWithItems = 5.right()
private val histRightZero = 0.right()
@Test
fun `returns null when feature toggle is disabled`() {
val disabledToggles: TokenDetailsFeatureToggles = mockk {
every { isQuickTopUpEnabled } returns false
}
val disabledFactory = QuickTopUpBlockFactory(disabledToggles)
val result = disabledFactory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = emptyHistory,
onrampAvailability = availableUsd.right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNull()
}
@Test
fun `returns null when balance is non-zero`() {
val result = factory.build(
currencyStatus = nonZeroBalanceStatus,
isTxHistoryEmpty = emptyHistory,
onrampAvailability = availableUsd.right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNull()
}
@Test
fun `returns null when history has transactions`() {
val result = factory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = histWithItems,
onrampAvailability = availableUsd.right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNull()
}
@Test
fun `returns null when onramp is not available`() {
val result = factory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = emptyHistory,
onrampAvailability = notSupported.right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNull()
}
@Test
fun `returns null when currency is not USD or EUR`() {
val result = factory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = emptyHistory,
onrampAvailability = OnrampAvailability.Available(
country = countryMock,
currency = gbpCurrency,
).right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNull()
}
@Test
fun `returns block with USD presets when all conditions met`() {
val result = factory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = emptyHistory,
onrampAvailability = availableUsd.right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNotNull()
val amounts = result!!.amounts
assertThat(amounts.map { it.displayValue }).containsExactly(
stringReference("$50"),
stringReference("$200"),
stringReference("$700"),
resourceReference(R.string.quick_top_up_chip_other),
).inOrder()
assertThat(amounts.last().isOther).isTrue()
assertThat(amounts.take(3).all { !it.isOther }).isTrue()
}
@Test
fun `returns block with EUR presets`() {
val availableEur = OnrampAvailability.Available(
country = countryMock,
currency = eurCurrency,
)
val result = factory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = emptyHistory,
onrampAvailability = availableEur.right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNotNull()
val amounts = result!!.amounts
assertThat(amounts.map { it.displayValue }).containsExactly(
stringReference("€50"),
stringReference("€200"),
stringReference("€650"),
resourceReference(R.string.quick_top_up_chip_other),
).inOrder()
assertThat(amounts.last().isOther).isTrue()
}
@Test
fun `returns block when history count is right zero (boundary case)`() {
val result = factory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = histRightZero,
onrampAvailability = availableUsd.right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNotNull()
}
@Test
fun `returns block when ConfirmResidency and country supports onramp with USD`() {
val usdCountry = OnrampCountry(
id = "us",
name = "United States",
code = "US",
image = "",
alpha3 = "USA",
continent = "America",
defaultCurrency = usdCurrency,
onrampAvailable = true,
)
val confirmResidency = OnrampAvailability.ConfirmResidency(country = usdCountry)
val result = factory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = emptyHistory,
onrampAvailability = confirmResidency.right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNotNull()
val amounts = result!!.amounts
assertThat(amounts.map { it.displayValue }).containsExactly(
stringReference("$50"),
stringReference("$200"),
stringReference("$700"),
resourceReference(R.string.quick_top_up_chip_other),
).inOrder()
}
@Test
fun `returns null when onramp availability is error`() {
val result = factory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = emptyHistory,
onrampAvailability = OnrampError.DataError(code = "error", description = null).left(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNull()
}
@Test
fun `returns null when balance is loading (amount is null)`() {
val loadingStatus: CryptoCurrencyStatus = mockk {
every { value } returns CryptoCurrencyStatus.Loading
}
val result = factory.build(
currencyStatus = loadingStatus,
isTxHistoryEmpty = emptyHistory,
onrampAvailability = availableUsd.right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNull()
}
@Test
fun `returns null when tx history is not implemented`() {
val result = factory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = TxHistoryStateError.TxHistoryNotImplemented.left(),
onrampAvailability = availableUsd.right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNull()
}
@Test
fun `returns null when tx history fetch fails with data error`() {
val result = factory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = TxHistoryStateError.DataError(RuntimeException("network error")).left(),
onrampAvailability = availableUsd.right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNull()
}
@Test
fun `returns null when ConfirmResidency with non-USD or EUR default currency`() {
val gbpCountry = OnrampCountry(
id = "gb",
name = "United Kingdom",
code = "GB",
image = "",
alpha3 = "GBR",
continent = "Europe",
defaultCurrency = gbpCurrency,
onrampAvailable = true,
)
val result = factory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = emptyHistory,
onrampAvailability = OnrampAvailability.ConfirmResidency(country = gbpCountry).right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNull()
}
@Test
fun `returns null when ConfirmResidency but country does not support onramp`() {
val restrictedCountry = OnrampCountry(
id = "kp",
name = "North Korea",
code = "KP",
image = "",
alpha3 = "PRK",
continent = "Asia",
defaultCurrency = usdCurrency,
onrampAvailable = false,
)
val confirmResidency = OnrampAvailability.ConfirmResidency(country = restrictedCountry)
val result = factory.build(
currencyStatus = zeroBalanceStatus,
isTxHistoryEmpty = emptyHistory,
onrampAvailability = confirmResidency.right(),
onPresetClick = { _, _ -> },
onOtherClick = {},
)
assertThat(result).isNull()
}
}

View file

@ -337,6 +337,7 @@ private fun AdditionalInfo(
id = R.string.initial_wallet_sync_restore_progress,
formatArgs = wrappedList(animatedContent.progressPercent),
),
testTag = MainScreenTestTags.SYNC_PROGRESS_TEXT,
)
CircularProgressIndicator(
modifier = Modifier.size(TangemTheme.dimens.size16),
@ -353,14 +354,14 @@ private fun AdditionalInfo(
}
@Composable
private fun AdditionalInfoText(text: TextReference) {
private fun AdditionalInfoText(text: TextReference, testTag: String = MainScreenTestTags.DEVICES_COUNT) {
Text(
text = text.resolveReference(),
color = TangemTheme.colors.text.tertiary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = TangemTheme.typography.caption2,
modifier = Modifier.testTag(MainScreenTestTags.DEVICES_COUNT),
modifier = Modifier.testTag(testTag),
)
}

Some files were not shown because too many files have changed in this diff Show more