Updated on 2026-08-14
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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) =
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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" />
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
package com.tangem.core.ui.test
|
||||
|
||||
object WalletNotificationTestTags {
|
||||
const val ASSETS_DISCOVERY_BANNER = "WALLET_NOTIFICATION_ASSETS_DISCOVERY_BANNER"
|
||||
}
|
||||
27
core/ui/src/main/res/drawable/ic_replace_20.xml
Normal 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>
|
||||
29
core/ui/src/main/res/drawable/ic_visa_logo.xml
Normal 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>
|
||||
|
|
@ -18,4 +18,5 @@ object StringsSigns {
|
|||
const val PASSWORD_VISUAL_CHAR = '\u2022'
|
||||
const val APPROXIMATE = "≈"
|
||||
const val WHITE_SPACE = " "
|
||||
const val LIGHTNING = "⚡"
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ internal class DefaultOnrampComponent @AssistedInject constructor(
|
|||
),
|
||||
)
|
||||
},
|
||||
initialFiatAmount = params.initialFiatAmount,
|
||||
),
|
||||
)
|
||||
is OnrampChild.RedirectPage -> onrampRedirectComponentFactory.create(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
14
features/survey/api/build.gradle.kts
Normal 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)
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.tangem.features.survey
|
||||
|
||||
interface SurveyFeatureToggles {
|
||||
|
||||
val areSurveysEnabled: Boolean
|
||||
}
|
||||
|
|
@ -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>,
|
||||
)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.tangem.features.survey.deeplink
|
||||
|
||||
interface SurveyDeepLinkHandler {
|
||||
|
||||
interface Factory {
|
||||
fun create(queryParams: Map<String, String>): SurveyDeepLinkHandler
|
||||
}
|
||||
}
|
||||
61
features/survey/impl/build.gradle.kts
Normal 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)
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,8 +281,14 @@ 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(
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)")
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -7,4 +7,5 @@ internal data class TangemPayDetailsTopBarConfig(
|
|||
val onBackClick: () -> Unit,
|
||||
val onOpenMenu: () -> Unit,
|
||||
val items: ImmutableList<TangemDropdownMenuItem>,
|
||||
val itemsV2: ImmutableList<TangemPayDropDownItemUM>,
|
||||
)
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,38 +30,15 @@ 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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(
|
||||
title = setting.title,
|
||||
iconRes = setting.iconRes,
|
||||
tintReference = { TangemTheme.colors3.icon.primary },
|
||||
),
|
||||
isLoading = setting.isLoading,
|
||||
onClick = setting.onClick,
|
||||
isEnabled = setting.isEnabled,
|
||||
isLoading = setting.isLoading,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = setting.title.resolveAnnotatedReference(),
|
||||
style = TangemTheme.typography3.subheading.medium,
|
||||
color = TangemTheme.colors3.text.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 3 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.tangem.features.tokendetails
|
||||
|
||||
interface TokenDetailsFeatureToggles {
|
||||
val isQuickTopUpEnabled: Boolean
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -15,4 +15,5 @@ internal data class TokenDetailsState(
|
|||
val pullToRefreshConfig: PullToRefreshConfig,
|
||||
val isBalanceHidden: Boolean,
|
||||
val isMarketPriceAvailable: Boolean,
|
||||
val quickTopUpBlock: QuickTopUpBlockUM? = null,
|
||||
)
|
||||
|
|
@ -24,6 +24,7 @@ internal data class TokenDetailsUM(
|
|||
val addFundsUM: AddFundsUM,
|
||||
val transferUM: TransferUM,
|
||||
val zeroBalanceActionsUM: ZeroBalanceActionsUM,
|
||||
val quickTopUpBlock: QuickTopUpBlockUM? = null,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||