Updated on 2026-08-14
This commit is contained in:
commit
4d2ec09d2d
100 changed files with 1972 additions and 1112 deletions
|
|
@ -189,8 +189,12 @@ abstract class BaseTestCase : TestCase(
|
|||
"ADD_AND_MANAGE_TOKENS_ENABLED" to true,
|
||||
"ASSETS_DISCOVERY_ENABLED" to true,
|
||||
"VISA_ONBOARDING_ENABLED" to true,
|
||||
// Toggles released in 5.39+ — forced on so tests run against the actual build even when the
|
||||
// app version resolves to 1.0.0-SNAPSHOT on CI (then 1.0.0 < 5.39 would disable them).
|
||||
// Version-gated toggles released in versions <= 6.0 — forced on so tests run against the actual
|
||||
// build even when the app version resolves to 1.0.0-SNAPSHOT on CI (then 1.0.0 < x.xx would
|
||||
// disable them). On the releases/6.0 branch every toggle with version <= 6.0 ships enabled.
|
||||
// 5.37
|
||||
"HEDERA_ERC20_ENABLED" to true,
|
||||
// 5.39
|
||||
"STAKING_ETH_ENABLED" to true,
|
||||
"DYNAMIC_ADDRESSES_ENABLED" to true,
|
||||
"SOLANA_TX_HISTORY_ENABLED" to true,
|
||||
|
|
@ -203,8 +207,22 @@ abstract class BaseTestCase : TestCase(
|
|||
"AND_15103_SWAP_RATE_EXPERIENCE_ENABLED" to true,
|
||||
"AND_15122_SWAP_PREDEFINED_BUTTONS_ENABLED" to true,
|
||||
"TWI_1512_HIDE_STORIES_FOR_REFERRAL_ENABLED" to true,
|
||||
// Toggles released in 5.40
|
||||
// 5.39.2
|
||||
"AND_15154_YIELD_PROMO_ENABLED" to true,
|
||||
// 5.40
|
||||
"TWI_1377_MANAGE_FUNDS" to true,
|
||||
// 6.0
|
||||
"APP_REDESIGN_ENABLED" to true,
|
||||
"TWI_1326_YIELD_MODE_SWAP_ENABLED" to true,
|
||||
"AND_15207_SWAP_SWITCH_TO_TRANSFER_ENABLED" to true,
|
||||
"AND_15120_SWAP_INTEGRATED_APPROVE" to true,
|
||||
"AND_15596_ONBOARDING_PUSH_NOTIFICATION_DOUBLE_ASK_AB_ENABLED" to true,
|
||||
"AND_15258_QUICK_TOP_UP_ENABLED" to true,
|
||||
"AND_15368_VISA_PAY_REDESIGN" to true,
|
||||
"AND_15364_VISA_PAY_CARD_CLOSE" to true,
|
||||
"AND_15489_EXPRESS_SHARE_BUTTON_ENABLED" to true,
|
||||
"AND_15235_VISA_MULTIPLE_CARDS" to true,
|
||||
"AND_15715_SWAP_BEST_DEX_RATE_ENABLED" to true,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ fun BaseTestCase.checkSingleCurrencyMainScreen(cardTitle: String) {
|
|||
step("Assert 'Add funds' button is displayed") {
|
||||
onMainScreen { addFundsButton.assertIsDisplayed() }
|
||||
}
|
||||
step("Assert 'Sell' button is displayed") {
|
||||
onMainScreen { sellButton.assertIsDisplayed() }
|
||||
step("Assert 'Transfer' button is displayed") {
|
||||
onMainScreen { transferButton.assertIsDisplayed() }
|
||||
}
|
||||
step("Assert 'Swap' button is not displayed") {
|
||||
onMainScreen { swapButton.assertIsNotDisplayed() }
|
||||
|
|
@ -39,8 +39,8 @@ fun BaseTestCase.checkMultiCurrencyMainScreen(
|
|||
step("Assert 'Swap' button is displayed") {
|
||||
onMainScreen { swapButton.assertIsDisplayed() }
|
||||
}
|
||||
step("Assert 'Sell' button is displayed") {
|
||||
onMainScreen { sellButton.assertIsDisplayed() }
|
||||
step("Assert 'Transfer' button is displayed") {
|
||||
onMainScreen { transferButton.assertIsDisplayed() }
|
||||
}
|
||||
step("Assert 'Send' button is not displayed") {
|
||||
onMainScreen { sendButton.assertIsNotDisplayed() }
|
||||
|
|
@ -61,8 +61,8 @@ fun BaseTestCase.assertActionButtonsForMultiCurrencyWallet(isEnabled: Boolean =
|
|||
step("Assert 'Swap' button is enabled") {
|
||||
onMainScreen { swapButton.assertIsEnabled() }
|
||||
}
|
||||
step("Assert 'Sell' button is enabled") {
|
||||
onMainScreen { sellButton.assertIsEnabled() }
|
||||
step("Assert 'Transfer' button is enabled") {
|
||||
onMainScreen { transferButton.assertIsEnabled() }
|
||||
}
|
||||
} else {
|
||||
step("Assert 'Add funds' button is not enabled") {
|
||||
|
|
@ -71,8 +71,8 @@ fun BaseTestCase.assertActionButtonsForMultiCurrencyWallet(isEnabled: Boolean =
|
|||
step("Assert 'Swap' button is not enabled") {
|
||||
onMainScreen { swapButton.assertIsNotEnabled() }
|
||||
}
|
||||
step("Assert 'Sell' button is not enabled") {
|
||||
onMainScreen { sellButton.assertIsNotEnabled() }
|
||||
step("Assert 'Transfer' button is not enabled") {
|
||||
onMainScreen { transferButton.assertIsNotEnabled() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_VERY_LONG
|
|||
import com.tangem.common.extensions.assertVisibility
|
||||
import com.tangem.common.extensions.clickWhenEnabled
|
||||
import com.tangem.common.extensions.clickWithAssertion
|
||||
import com.tangem.common.extensions.extractText
|
||||
import com.tangem.core.ui.R as CoreUiR
|
||||
import com.tangem.core.ui.test.BaseButtonTestTags
|
||||
import com.tangem.core.ui.test.HotWalletAccessCodeTestTags
|
||||
|
|
@ -185,6 +186,25 @@ fun BaseTestCase.selectFeeType(feeType: FeeType, selectedFeeAmount: String) {
|
|||
}
|
||||
}
|
||||
|
||||
fun BaseTestCase.selectFeeTypeAndReadFee(feeType: FeeType): String {
|
||||
step("Click on 'Select fee' icon") {
|
||||
onSwapTokenScreen { selectFeeIcon.performClick() }
|
||||
}
|
||||
step("Click on '$feeType' item") {
|
||||
onSwapSelectNetworkFeeBottomSheet {
|
||||
when (feeType) {
|
||||
FeeType.Market -> marketSelectorItem.clickWithAssertion()
|
||||
FeeType.Fast -> fastSelectorItem.clickWithAssertion()
|
||||
}
|
||||
}
|
||||
}
|
||||
var fee = ""
|
||||
step("Read displayed '$feeType' fee amount") {
|
||||
onSwapTokenScreen { fee = feeAmount.extractText() }
|
||||
}
|
||||
return fee
|
||||
}
|
||||
|
||||
fun BaseTestCase.chackUnableToCoverFeeNotification(networkName: String, currencySymbol: String) {
|
||||
step("Assert 'Unable to cover '$networkName' fee notification title is displayed'") {
|
||||
onSwapTokenScreen { unableToCoverFeeNotificationTitle(networkName).assertIsDisplayed() }
|
||||
|
|
|
|||
|
|
@ -20,14 +20,14 @@ import com.tangem.core.res.R as CoreResR
|
|||
* After the onramp redesign this is a [BaseBottomSheetTestTags.CONTAINER] bottom sheet
|
||||
* (centered title + close icon), not a full screen with a top app bar.
|
||||
*/
|
||||
class ChooseTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
|
||||
ComposeScreen<ChooseTokenPageObject>(
|
||||
class ChooseTokenBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
|
||||
ComposeScreen<ChooseTokenBottomSheetPageObject>(
|
||||
semanticsProvider = semanticsProvider,
|
||||
viewBuilderAction = { hasTestTag(BaseBottomSheetTestTags.CONTAINER) },
|
||||
) {
|
||||
|
||||
val topAppBarTitle: KNode = child {
|
||||
hasText(getResourceString(CoreResR.string.common_add_funds))
|
||||
val title: KNode = child {
|
||||
hasText(getResourceString(CoreResR.string.common_choose_token))
|
||||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
|
|
@ -44,5 +44,5 @@ class ChooseTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider
|
|||
}
|
||||
}
|
||||
|
||||
internal fun BaseTestCase.onChooseTokenScreen(function: ChooseTokenPageObject.() -> Unit) =
|
||||
internal fun BaseTestCase.onChooseTokenBottomSheet(function: ChooseTokenBottomSheetPageObject.() -> Unit) =
|
||||
onComposeScreen(composeTestRule, function)
|
||||
|
|
@ -68,9 +68,9 @@ class MainScreenPageObject(private val semanticsProvider: SemanticsNodeInteracti
|
|||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
val sellButton: KNode = child {
|
||||
val transferButton: KNode = child {
|
||||
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
|
||||
hasAnyDescendant(withText(getResourceString(R.string.common_sell)))
|
||||
hasAnyDescendant(withText(getResourceString(R.string.common_transfer)))
|
||||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ class TangemPayCardPagePageObject(semanticsProvider: SemanticsNodeInteractionsPr
|
|||
}
|
||||
|
||||
val showDetailsButton: KNode = child {
|
||||
hasTestTag(TangemPayTestTags.CARD_DETAILS_SHOW_BUTTON)
|
||||
hasTestTag(TangemPayTestTags.SHOW_DETAILS_ROW)
|
||||
useUnmergedTree = true
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,8 +44,8 @@ class BuyTokenTest : BaseTestCase() {
|
|||
onMainScreen { addFundsButton.clickWithAssertion() }
|
||||
}
|
||||
step("Click on token with name: '$tokenTitle'") {
|
||||
onChooseTokenScreen {
|
||||
topAppBarTitle.assertIsDisplayed()
|
||||
onChooseTokenBottomSheet {
|
||||
title.assertIsDisplayed()
|
||||
tokenWithTitle(tokenTitle).clickWithAssertion()
|
||||
}
|
||||
}
|
||||
|
|
@ -91,8 +91,8 @@ class BuyTokenTest : BaseTestCase() {
|
|||
onMainScreen { addFundsButton.clickWithAssertion() }
|
||||
}
|
||||
step("Click on token with name: '$tokenTitle'") {
|
||||
onChooseTokenScreen {
|
||||
topAppBarTitle.assertIsDisplayed()
|
||||
onChooseTokenBottomSheet {
|
||||
title.assertIsDisplayed()
|
||||
tokenWithTitle(tokenTitle).clickWithAssertion()
|
||||
}
|
||||
}
|
||||
|
|
@ -165,8 +165,8 @@ class BuyTokenTest : BaseTestCase() {
|
|||
onMainScreen { addFundsButton.clickWithAssertion() }
|
||||
}
|
||||
step("Click on token with name: '$tokenTitle'") {
|
||||
onChooseTokenScreen {
|
||||
topAppBarTitle.assertIsDisplayed()
|
||||
onChooseTokenBottomSheet {
|
||||
title.assertIsDisplayed()
|
||||
tokenWithTitle(tokenTitle).clickWithAssertion()
|
||||
}
|
||||
}
|
||||
|
|
@ -251,8 +251,8 @@ class BuyTokenTest : BaseTestCase() {
|
|||
onMainScreen { addFundsButton.clickWithAssertion() }
|
||||
}
|
||||
step("Click on token with name: '$tokenTitle'") {
|
||||
onChooseTokenScreen {
|
||||
topAppBarTitle.assertIsDisplayed()
|
||||
onChooseTokenBottomSheet {
|
||||
title.assertIsDisplayed()
|
||||
tokenWithTitle(tokenTitle).clickWithAssertion()
|
||||
}
|
||||
}
|
||||
|
|
@ -336,8 +336,8 @@ class BuyTokenTest : BaseTestCase() {
|
|||
onMainScreen { addFundsButton.clickWithAssertion() }
|
||||
}
|
||||
step("Click on token with name: '$tokenTitle'") {
|
||||
onChooseTokenScreen {
|
||||
topAppBarTitle.assertIsDisplayed()
|
||||
onChooseTokenBottomSheet {
|
||||
title.assertIsDisplayed()
|
||||
tokenWithTitle(tokenTitle).clickWithAssertion()
|
||||
}
|
||||
}
|
||||
|
|
@ -425,8 +425,8 @@ class BuyTokenTest : BaseTestCase() {
|
|||
onMainScreen { addFundsButton.clickWithAssertion() }
|
||||
}
|
||||
step("Click on token with name: '$tokenTitle'") {
|
||||
onChooseTokenScreen {
|
||||
topAppBarTitle.assertIsDisplayed()
|
||||
onChooseTokenBottomSheet {
|
||||
title.assertIsDisplayed()
|
||||
tokenWithTitle(tokenTitle).clickWithAssertion()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -306,8 +306,8 @@ class DetailsTest : BaseTestCase() {
|
|||
step("Assert 'Buy' button is not displayed") {
|
||||
buyButton.assertIsNotDisplayed()
|
||||
}
|
||||
step("Assert 'Sell' button is not displayed") {
|
||||
sellButton.assertIsNotDisplayed()
|
||||
step("Assert 'Transfer' button is not displayed") {
|
||||
transferButton.assertIsNotDisplayed()
|
||||
}
|
||||
step("Assert 'Swap' button is not displayed") {
|
||||
swapButton.assertIsNotDisplayed()
|
||||
|
|
|
|||
|
|
@ -389,8 +389,11 @@ class MainScreenActionButtonsTest : BaseTestCase() {
|
|||
step("Click on 'Add funds' button") {
|
||||
onMainScreen { addFundsButton.performClick() }
|
||||
}
|
||||
step("Click on '$tokenTitle'") {
|
||||
onAddFundsBottomSheet { userTokenWithTitle(tokenTitle).clickWithAssertion() }
|
||||
}
|
||||
step("Click on 'Buy' button in bottom sheet") {
|
||||
onAddFundsBottomSheet { buyButton.clickWithAssertion() }
|
||||
onGetTokenBottomSheet { buyButton.performClick() }
|
||||
}
|
||||
step("Click on 'Confirm' button in 'Dialog'") {
|
||||
waitForIdle()
|
||||
|
|
@ -429,10 +432,10 @@ class MainScreenActionButtonsTest : BaseTestCase() {
|
|||
onMainScreen { addFundsButton.performClick() }
|
||||
}
|
||||
step("Assert 'Choose token' screen title is displayed") {
|
||||
onChooseTokenScreen { topAppBarTitle.assertIsDisplayed() }
|
||||
onChooseTokenBottomSheet { title.assertIsDisplayed() }
|
||||
}
|
||||
step("Assert token with title: '$tokenTitle' is displayed") {
|
||||
onChooseTokenScreen { tokenWithTitle(tokenTitle).assertIsDisplayed() }
|
||||
onChooseTokenBottomSheet { tokenWithTitle(tokenTitle).assertIsDisplayed() }
|
||||
}
|
||||
step("Press 'Back' button") {
|
||||
device.uiDevice.pressBack()
|
||||
|
|
@ -452,14 +455,14 @@ class MainScreenActionButtonsTest : BaseTestCase() {
|
|||
step("Press 'Back' button") {
|
||||
device.uiDevice.pressBack()
|
||||
}
|
||||
step("Assert 'Sell' button is displayed") {
|
||||
onMainScreen { sellButton.assertIsDisplayed() }
|
||||
step("Assert 'Transfer' button is displayed") {
|
||||
onMainScreen { transferButton.assertIsDisplayed() }
|
||||
}
|
||||
step("Click on 'Sell' button") {
|
||||
onMainScreen { sellButton.performClick() }
|
||||
step("Click on 'Transfer' button") {
|
||||
onMainScreen { transferButton.performClick() }
|
||||
}
|
||||
step("Assert 'Sell' token screen title is displayed") {
|
||||
onSellScreen { title.assertIsDisplayed() }
|
||||
step("Assert 'Choose token' title is displayed") {
|
||||
onChooseTokenBottomSheet { title.assertIsDisplayed() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -488,7 +491,7 @@ class MainScreenActionButtonsTest : BaseTestCase() {
|
|||
onMainScreen { addFundsButton.performClick() }
|
||||
}
|
||||
step("Assert 'Choose token' screen opens (Add funds is always available)") {
|
||||
onChooseTokenScreen { topAppBarTitle.assertIsDisplayed() }
|
||||
onChooseTokenBottomSheet { title.assertIsDisplayed() }
|
||||
}
|
||||
step("Press 'Back' to return to main screen") {
|
||||
device.uiDevice.pressBack()
|
||||
|
|
@ -508,17 +511,14 @@ class MainScreenActionButtonsTest : BaseTestCase() {
|
|||
step("Click on 'Ok' button") {
|
||||
onDialog { okButton.performClick() }
|
||||
}
|
||||
step("Assert 'Sell' button is displayed") {
|
||||
onMainScreen { sellButton.assertIsDisplayed() }
|
||||
step("Assert 'Transfer' button is displayed") {
|
||||
onMainScreen { transferButton.assertIsDisplayed() }
|
||||
}
|
||||
step("Click on 'Sell' button") {
|
||||
onMainScreen { sellButton.performClick() }
|
||||
step("Click on 'Transfer' button") {
|
||||
onMainScreen { transferButton.performClick() }
|
||||
}
|
||||
step("Check 'Action is unavailable' dialog") {
|
||||
checkActionIsUnavailableDialog()
|
||||
}
|
||||
step("Click on 'Ok' button") {
|
||||
onDialog { okButton.performClick() }
|
||||
step("Assert 'Choose token' title is displayed") {
|
||||
onChooseTokenBottomSheet { title.assertIsDisplayed() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -548,7 +548,7 @@ class MainScreenActionButtonsTest : BaseTestCase() {
|
|||
onMainScreen { addFundsButton.performClick() }
|
||||
}
|
||||
step("Assert 'Choose token' screen opens (Add funds is always available)") {
|
||||
onChooseTokenScreen { topAppBarTitle.assertIsDisplayed() }
|
||||
onChooseTokenBottomSheet { title.assertIsDisplayed() }
|
||||
}
|
||||
step("Press 'Back' to return to main screen") {
|
||||
device.uiDevice.pressBack()
|
||||
|
|
@ -566,17 +566,14 @@ class MainScreenActionButtonsTest : BaseTestCase() {
|
|||
step("Click on 'Ok' button") {
|
||||
onDialog { okButton.performClick() }
|
||||
}
|
||||
step("Assert 'Sell' button is displayed") {
|
||||
onMainScreen { sellButton.assertIsDisplayed() }
|
||||
step("Assert 'Transfer' button is displayed") {
|
||||
onMainScreen { transferButton.assertIsDisplayed() }
|
||||
}
|
||||
step("Click on 'Sell' button") {
|
||||
onMainScreen { sellButton.performClick() }
|
||||
step("Click on 'Transfer' button") {
|
||||
onMainScreen { transferButton.performClick() }
|
||||
}
|
||||
step("Check 'Action is unavailable' dialog") {
|
||||
checkActionIsUnavailableDialog()
|
||||
}
|
||||
step("Click on 'Ok' button") {
|
||||
onDialog { okButton.performClick() }
|
||||
step("Assert 'Choose token' title is displayed") {
|
||||
onChooseTokenBottomSheet { title.assertIsDisplayed() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import com.tangem.screens.*
|
|||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.qameta.allure.kotlin.AllureId
|
||||
import io.qameta.allure.kotlin.junit4.DisplayName
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
@HiltAndroidTest
|
||||
|
|
@ -545,11 +546,11 @@ class SwapTokenScreenTest : BaseTestCase() {
|
|||
val inputAmount = "0.99"
|
||||
val market = "Market"
|
||||
val fast = "Fast"
|
||||
val marketFeeAmount = "$1.12"
|
||||
val fastFeeAmount = "$1.43"
|
||||
|
||||
setupHooks().run {
|
||||
|
||||
var marketFee = 0.0
|
||||
|
||||
step("Open 'Main Screen'") {
|
||||
openMainScreen()
|
||||
}
|
||||
|
|
@ -575,19 +576,28 @@ class SwapTokenScreenTest : BaseTestCase() {
|
|||
textInput.performTextReplacement(inputAmount)
|
||||
}
|
||||
}
|
||||
step("Select '$market' fee type") {
|
||||
step("Select '$market' fee type and capture its amount") {
|
||||
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
|
||||
selectFeeType(FeeType.Market, selectedFeeAmount = marketFeeAmount)
|
||||
marketFee = parseFeeAmount(selectFeeTypeAndReadFee(FeeType.Market))
|
||||
}
|
||||
}
|
||||
step("Select '$fast' fee type") {
|
||||
step("Select '$fast' fee type and assert it exceeds the '$market' fee") {
|
||||
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
|
||||
selectFeeType(FeeType.Fast, selectedFeeAmount = fastFeeAmount)
|
||||
val fastFee = parseFeeAmount(selectFeeTypeAndReadFee(FeeType.Fast))
|
||||
assertTrue(
|
||||
"Expected '$fast' fee ($fastFee) to be greater than '$market' fee ($marketFee)",
|
||||
fastFee > marketFee,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseFeeAmount(raw: String): Double = raw
|
||||
.replace(Regex("[^0-9.]"), "")
|
||||
.toDoubleOrNull()
|
||||
?: error("Could not parse a numeric fee amount from '$raw'")
|
||||
|
||||
@AllureId("8536")
|
||||
@DisplayName("Swap: check switch fee type (unable to cover 'Market' and 'Fast' fee)")
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.tangem.tests.tangempay
|
|||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.tangem.common.BaseTestCase
|
||||
import com.tangem.common.constants.TestConstants.TANGEM_PAY_ELIGIBILITY_SCENARIO
|
||||
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
|
||||
import com.tangem.common.extensions.assertTextContainsSafe
|
||||
import com.tangem.common.extensions.clickWithAssertion
|
||||
import com.tangem.common.extensions.extractText
|
||||
|
|
@ -53,11 +54,10 @@ class TangemPayTest : BaseTestCase() {
|
|||
step("Enter PIN '$newPin'") {
|
||||
onTangemPayChangePinScreen { inputField.performTextInput(newPin) }
|
||||
}
|
||||
step("Click on 'Submit' button") {
|
||||
onTangemPayChangePinScreen { submitButton.performClick() }
|
||||
}
|
||||
step("Assert success screen is displayed") {
|
||||
onTangemPayChangePinScreen { successTitle.assertIsDisplayed() }
|
||||
step("Assert success screen title is displayed") {
|
||||
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
|
||||
onTangemPayChangePinScreen { successTitle.assertIsDisplayed() }
|
||||
}
|
||||
}
|
||||
step("Click on 'Done' button") {
|
||||
onTangemPayChangePinScreen { doneButton.clickWithAssertion() }
|
||||
|
|
@ -156,9 +156,11 @@ class TangemPayTest : BaseTestCase() {
|
|||
).run {
|
||||
openTangemPay()
|
||||
step("Click on card button") {
|
||||
waitForIdle()
|
||||
onTangemPayMainScreen { cardButton.clickWithAssertion() }
|
||||
}
|
||||
step("Click on 'Show details' button") {
|
||||
waitForIdle()
|
||||
onTangemPayCardPageScreen { showDetailsButton.clickWithAssertion() }
|
||||
}
|
||||
step("Assert number, expiration and CVC values are visible") {
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ class TangemPayTopUpTest : BaseTestCase() {
|
|||
onTangemPayMainScreen { balance.assertTextContainsSafe("10", substring = true) }
|
||||
}
|
||||
step("Click on 'Top Up' action chip") {
|
||||
waitForIdle()
|
||||
onTangemPayMainScreen { topUpButton.clickWithAssertion() }
|
||||
}
|
||||
step("Assert 'Add Funds' sheet is displayed") {
|
||||
|
|
|
|||
|
|
@ -55,18 +55,6 @@ internal object CardDomainModule {
|
|||
return IsNeedToBackupUseCase(userWalletsListRepository = userWalletsListRepository)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideHasSingleWalletSignedHashesUseCase(
|
||||
cardRepository: CardRepository,
|
||||
walletManagersFacade: WalletManagersFacade,
|
||||
): HasSingleWalletSignedHashesUseCase {
|
||||
return HasSingleWalletSignedHashesUseCase(
|
||||
cardRepository = cardRepository,
|
||||
walletManagersFacade = walletManagersFacade,
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideGetExtendedPublicKeyForCurrencyUseCase(
|
||||
|
|
|
|||
|
|
@ -69,6 +69,20 @@ internal object TransactionDomainModule {
|
|||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSignAndBroadcastPsbtUseCase(
|
||||
cardSdkConfigRepository: CardSdkConfigRepository,
|
||||
walletManagersFacade: WalletManagersFacade,
|
||||
tangemHotWalletSignerFactory: TangemHotWalletSigner.Factory,
|
||||
): SignAndBroadcastPsbtUseCase {
|
||||
return SignAndBroadcastPsbtUseCase(
|
||||
cardSdkConfigRepository = cardSdkConfigRepository,
|
||||
walletManagersFacade = walletManagersFacade,
|
||||
getHotTransactionSigner = tangemHotWalletSignerFactory::create,
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAssociateAssetUseCase(
|
||||
|
|
|
|||
|
|
@ -27,13 +27,13 @@ import com.tangem.domain.notifications.SendPushTokenUseCase
|
|||
import com.tangem.domain.notifications.models.ApplicationId
|
||||
import com.tangem.domain.notifications.models.NotificationsError
|
||||
import com.tangem.domain.onramp.FetchHotCryptoUseCase
|
||||
import com.tangem.domain.stories.GetStoryContentUseCase
|
||||
import com.tangem.domain.stories.models.StoryContentIds
|
||||
import com.tangem.domain.quotes.multi.MultiQuoteUpdater
|
||||
import com.tangem.domain.settings.DeleteDeprecatedLogsUseCase
|
||||
import com.tangem.domain.settings.IncrementAppLaunchCounterUseCase
|
||||
import com.tangem.domain.settings.usercountry.FetchUserCountryUseCase
|
||||
import com.tangem.domain.staking.FetchStakingOptionsUseCase
|
||||
import com.tangem.domain.stories.GetStoryContentUseCase
|
||||
import com.tangem.domain.stories.models.StoryContentIds
|
||||
import com.tangem.domain.wallets.usecase.AssociateWalletsWithApplicationIdUseCase
|
||||
import com.tangem.domain.wallets.usecase.GetSavedWalletsCountUseCase
|
||||
import com.tangem.domain.wallets.usecase.UpdateRemoteWalletsInfoUseCase
|
||||
|
|
@ -268,8 +268,12 @@ internal class MainViewModel @Inject constructor(
|
|||
onShownBalanceToastAction()
|
||||
}
|
||||
},
|
||||
startIconId = if (settings.isBalanceHidden) {
|
||||
R.drawable.ic_eye_off_outline_24
|
||||
} else {
|
||||
R.drawable.ic_eye_outline_24
|
||||
},
|
||||
)
|
||||
|
||||
messageSender.send(message)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,8 +105,7 @@ class TokenActionsHandler @AssistedInject constructor(
|
|||
|
||||
private fun isTopUpBlockedByBackupError(action: TokenActionsBSContentUM.Action, userWallet: UserWallet): Boolean {
|
||||
val isBlockedAction = action == TokenActionsBSContentUM.Action.Buy ||
|
||||
action == TokenActionsBSContentUM.Action.Receive ||
|
||||
action == TokenActionsBSContentUM.Action.Exchange
|
||||
action == TokenActionsBSContentUM.Action.Receive
|
||||
if (!isBlockedAction) return false
|
||||
if (!isWalletBackupProblematicUseCase(userWallet)) return false
|
||||
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@
|
|||
<item quantity="one">Adresse</item>
|
||||
<item quantity="other">Adressen</item>
|
||||
</plurals>
|
||||
<string name="address_book_choose_address">Adresse auswählen</string>
|
||||
<string name="address_book_contact">Kontakt</string>
|
||||
<string name="address_book_contact_name">Name der Kontaktperson</string>
|
||||
<string name="address_book_copy_address">Adresse kopieren</string>
|
||||
|
|
@ -112,6 +113,7 @@
|
|||
<string name="address_book_discard">Verwerfen</string>
|
||||
<string name="address_book_edit_address">Adresse bearbeiten</string>
|
||||
<string name="address_book_enter_address">Adresse eingeben</string>
|
||||
<string name="address_book_invalid_address_error">Ungültige Adresse</string>
|
||||
<string name="address_book_keep_editing">Weiter bearbeiten</string>
|
||||
<string name="address_book_new_contact">Neuer Kontakt</string>
|
||||
<string name="address_book_no_contacts">Noch keine Kontakte</string>
|
||||
|
|
@ -274,6 +276,7 @@
|
|||
<string name="common_attention">Achtung</string>
|
||||
<string name="common_available_networks">Verfügbare Netzwerke</string>
|
||||
<string name="common_backup">Sicherungskopie</string>
|
||||
<string name="common_backup_error">Sicherungsfehler</string>
|
||||
<string name="common_balance">Bilanz: %s</string>
|
||||
<string name="common_balance_title">Saldo</string>
|
||||
<string name="common_biometric_authentication">biometrische Authentifizierung</string>
|
||||
|
|
@ -429,6 +432,7 @@
|
|||
<string name="common_send">Senden</string>
|
||||
<string name="common_send_colon">Senden:</string>
|
||||
<string name="common_send_tx_error">Absenden der Transaktion fehlgeschlagen</string>
|
||||
<string name="common_send_with_swap">Senden & Tauschen</string>
|
||||
<string name="common_sending">Senden</string>
|
||||
<string name="common_sent">Gesendet</string>
|
||||
<string name="common_server_unavailable">Der Server ist nicht verfügbar. Bitte versuche es später erneut.</string>
|
||||
|
|
@ -646,6 +650,7 @@
|
|||
<string name="express_legal_two_placeholders">Durch die Nutzung der Swap-Funktionalität erklärst du dich mit des Anbieters %1$s und %2$s einverstanden.</string>
|
||||
<string name="express_more_providers_soon">Weitere Anbieter folgen in Kürze</string>
|
||||
<string name="express_provider">Anbieter</string>
|
||||
<string name="express_provider_best_dex_rate">Bester DEX-Kurs</string>
|
||||
<string name="express_provider_best_rate">Bester Preis</string>
|
||||
<string name="express_provider_fca_warning_list">Warnliste der FCA</string>
|
||||
<string name="express_provider_fixed_rate_is_unavailable">Der Festzins ist nicht verfügbar</string>
|
||||
|
|
@ -658,6 +663,9 @@
|
|||
<string name="express_provider_permission_needed">Erlaubnis erforderlich</string>
|
||||
<string name="express_provider_permission_needed_v2">Genehmigung erforderlich</string>
|
||||
<string name="express_provider_recommended">Empfohlen</string>
|
||||
<string name="express_send_with_swap_not_supported_button">Tauschen</string>
|
||||
<string name="express_send_with_swap_not_supported_text">Tauschen Sie es manuell aus und senden Sie es anschließend an den Empfänger.</string>
|
||||
<string name="express_send_with_swap_not_supported_title">%s wird in Swap & Send nicht unterstützt.</string>
|
||||
<string name="express_status_bought">Gekauft %s</string>
|
||||
<string name="express_status_buying">Kauf %s</string>
|
||||
<string name="express_status_buying_active">Kauf %s…</string>
|
||||
|
|
@ -692,9 +700,14 @@
|
|||
<string name="feedback_subject_support_tangem">Feedback zu Tangem</string>
|
||||
<string name="feedback_subject_tx_failed">Eine Transaktion kann nicht gesendet werden</string>
|
||||
<string name="feedback_token_description_error">Fehler in der Coinbeschreibung</string>
|
||||
<string name="force_update_action">Jetzt aktualisieren</string>
|
||||
<string name="force_update_banner_message">Aktualisiere die Anwendung auf die neueste Version, um die ordnungsgemäße Funktionalität zu gewährleisten</string>
|
||||
<string name="force_update_banner_title">Aktualisierung erforderlich</string>
|
||||
<string name="force_update_brick_description">Diese Version der App wird nicht mehr unterstützt und kann auf diesem Gerät nicht aktualisiert werden.</string>
|
||||
<string name="force_update_brick_title">Update nicht verfügbar</string>
|
||||
<string name="force_update_button">Update</string>
|
||||
<string name="force_update_os_description">Ihr Betriebssystem ist veraltet. Bitte aktualisieren Sie es, um die App weiterhin nutzen zu können.</string>
|
||||
<string name="force_update_os_title">Aktualisieren Sie Ihr Betriebssystem</string>
|
||||
<string name="force_update_warning_message">Bitte aktualisiere die Anwendung auf die neueste Version, um eine einwandfreie Funktion zu gewährleisten.</string>
|
||||
<string name="force_update_warning_title">Aktualisierung erforderlich</string>
|
||||
<string name="gasless_not_enough_funds_to_cover_token_fee">Nicht genügend Mittel</string>
|
||||
|
|
@ -1281,6 +1294,7 @@
|
|||
<string name="quick_action_buy_description">Kreditkarte oder Bankkonto</string>
|
||||
<string name="quick_action_receive_description">Teilen deine Adresse oder dein QR-Code</string>
|
||||
<string name="quick_action_sell_description">Sicherer Verkauf von Kryptowährungen</string>
|
||||
<string name="quick_action_send_and_swap_description">Senden Sie mit Tausch an ein anderes Token</string>
|
||||
<string name="quick_action_send_description">An eine andere Wallet senden</string>
|
||||
<string name="quick_action_swap_description">Zwischen deinen Portfolios</string>
|
||||
<string name="quick_top_up_chip_other">Andere</string>
|
||||
|
|
@ -1511,6 +1525,8 @@
|
|||
<string name="staking_details_apr">Effektiver Jahreszins</string>
|
||||
<string name="staking_details_apy">APY</string>
|
||||
<string name="staking_details_auto_claiming_rewards_daily_text">Belohnungen sammeln sich täglich automatisch in deinem Staking-Konto an.</string>
|
||||
<string name="staking_details_autocompound_funds_earned">Erzielte Einnahmen: %s</string>
|
||||
<string name="staking_details_autocompound_rewards_compounded">Die Prämien werden Ihrem Staking-Guthaben gutgeschrieben.</string>
|
||||
<string name="staking_details_autocompound_rewards_earned">Die Belohnungen werden auf Dein Stakingguthaben aufgezinst. Verdiente Gelder: %s</string>
|
||||
<string name="staking_details_available">Verfügbar</string>
|
||||
<string name="staking_details_average_reward_rate">Durchschnittliche Belohnungsquote</string>
|
||||
|
|
@ -1613,6 +1629,8 @@
|
|||
<string name="staking_title_stake">Stake %s</string>
|
||||
<string name="staking_title_unstake">Staking beenden %s</string>
|
||||
<string name="staking_transaction_in_progress_text">Die Transaktion wird bearbeitet! Derzeit findet eine Validierung in der Blockchain statt. Dies kann einige Minuten dauern.</string>
|
||||
<string name="staking_transaction_validation_unsafe">%s Staking aufgrund von Sicherheitsüberprüfungen untersagt</string>
|
||||
<string name="staking_transaction_validation_warning">%s Das Staking erscheint verdächtig. Bitte staken Sie auf eigenes Risiko.</string>
|
||||
<string name="staking_unbonding">Lösen der Bindungen</string>
|
||||
<string name="staking_unlocked_locked">Gelocktes unlocken</string>
|
||||
<string name="staking_unlocking">Entsperren</string>
|
||||
|
|
@ -1641,6 +1659,8 @@
|
|||
<string name="story_web3_title">Web 3.0-kompatibel</string>
|
||||
<string name="sui_not_enough_coin_for_fee_description">Zum Fortfahren ist eine eingehende Transaktion von mindestens %1$s erforderlich</string>
|
||||
<string name="sui_not_enough_coin_for_fee_title">Unzureichende Mittel</string>
|
||||
<string name="support_chat_screen_title">Support-Chat</string>
|
||||
<string name="support_chat_share_logs_button">App-Protokolle anhängen</string>
|
||||
<string name="support_chat_swap_prefilled_message">SWAP-Operationsdaten:\nAus: %1$s %2$s\nZu: %3$s %4$s\nVon %5$s - %6$s</string>
|
||||
<string name="support_selector_view_chat_button">Chat öffnen</string>
|
||||
<string name="support_selector_view_email_button">Mail öffnen</string>
|
||||
|
|
@ -1693,7 +1713,7 @@
|
|||
<string name="swapping_insufficient_funds_description">Nicht genügend Geldmittel, um diese Transaktion abzuschließen. Verringern Sie den zu erhaltenden Betrag oder fügen Sie weitere Mittel hinzu.</string>
|
||||
<string name="swapping_permission_header">Erlaubnis erteilen</string>
|
||||
<string name="swapping_rate_experience_title">Bewerten deine Erfahrung mit dem Anbieter</string>
|
||||
<string name="swapping_rate_feedback_placeholder">Geben dein Feedback ein</string>
|
||||
<string name="swapping_rate_feedback_placeholder">Geben dein Feedback</string>
|
||||
<string name="swapping_rate_feedback_submit">Feedback senden</string>
|
||||
<string name="swapping_rate_feedback_title">Was waren deine Erfahrungen?</string>
|
||||
<string name="swapping_swap_action">Tauschen</string>
|
||||
|
|
@ -1711,6 +1731,7 @@
|
|||
<string name="tangem_pay_card_details_unable_to_rename_card_title">Karte kann nicht umbenannt werden</string>
|
||||
<string name="tangem_pay_card_frozen">Karte eingefroren</string>
|
||||
<string name="tangem_pay_card_payment">Kartenzahlung</string>
|
||||
<string name="tangem_pay_close_card_disabled_last_card">Die letzte Karte kann nicht geschlossen werden.</string>
|
||||
<string name="tangem_pay_close_card_popup_description">Es wird aus der App verschwinden</string>
|
||||
<string name="tangem_pay_close_card_popup_primary_button_title">Karte schließen</string>
|
||||
<string name="tangem_pay_close_card_popup_secondary_button_title">Geh zurück</string>
|
||||
|
|
@ -1823,6 +1844,7 @@
|
|||
</plurals>
|
||||
<string name="tangempay_change_pin_code">PIN-Code ändern</string>
|
||||
<string name="tangempay_come_back_if_forget_pin">Kehren Sie zur App zurück, falls Sie ihn vergessen.</string>
|
||||
<string name="tangempay_common_card">Karte</string>
|
||||
<string name="tangempay_daily_limit_hint" formatted="false">Limit von %s bis %s festlegen</string>
|
||||
<string name="tangempay_daily_limit_set_button">Limits festlegen</string>
|
||||
<string name="tangempay_declined_reason_1">Unzureichendes Guthaben</string>
|
||||
|
|
@ -1924,6 +1946,9 @@
|
|||
<string name="tangempay_reissue_card_insufficient_funds_subtitle">Zahlen Sie USDC auf das Zahlungskonto ein, um die Ausstellungsgebühr zu decken</string>
|
||||
<string name="tangempay_reissue_card_insufficient_funds_title">Gebühr kann nicht gedeckt werden</string>
|
||||
<string name="tangempay_reissue_card_title">Ihre Karte neu ausstellen?</string>
|
||||
<string name="tangempay_remove_account">Konto löschen</string>
|
||||
<string name="tangempay_remove_account_alert_description">Tangem Pay wird vom Hauptbildschirm entfernt und erscheint auch nach einer Neuinstallation der App nicht wieder.</string>
|
||||
<string name="tangempay_remove_account_alert_title">Konto löschen?</string>
|
||||
<string name="tangempay_service_unavailable_description">Wir beheben ein technisches Problem. Bitte versuchen Sie es später erneut.</string>
|
||||
<string name="tangempay_service_unavailable_title">Service vorübergehend nicht verfügbar</string>
|
||||
<string name="tangempay_service_unreachable_try_later">Daten können derzeit nicht angezeigt werden, Kartenzahlungen funktionieren jedoch weiterhin.</string>
|
||||
|
|
@ -2031,6 +2056,8 @@
|
|||
<string name="twins_recreate_toolbar">Tangem Twin</string>
|
||||
<string name="twins_recreate_warning">Diese Aktion ist unumkehrbar. Du hast keinen Zugriff mehr auf die alte Wallet.</string>
|
||||
<string name="twins_scan_twin_with_number">Tippe auf die Doppelkarte oder Ring mit der Nummer %s und entferne sie erst am Ende des Vorgangs.</string>
|
||||
<string name="tx_history_onramp_top_up">Aufladen</string>
|
||||
<string name="tx_history_onramp_topped_up">Aufgeladen</string>
|
||||
<string name="unexpected_error_description">Bitte versuche es später noch einmal. Sollte das Problem weiterhin bestehen, wende Dich bitte an den Support.</string>
|
||||
<string name="unexpected_error_title">Etwas ist schiefgelaufen!</string>
|
||||
<string name="universal_error">Es ist ein Fehler aufgetreten. Fehlercode: %s. Bitte kontaktiere unseren Support.</string>
|
||||
|
|
@ -2062,6 +2089,8 @@
|
|||
<string name="user_wallet_list_rename_popup_title">Wallet umbenennen</string>
|
||||
<string name="user_wallet_list_unlock_all">Alle freischalten</string>
|
||||
<string name="user_wallet_list_unlock_all_with">Alle mit %s freischalten</string>
|
||||
<string name="virtual_account_title">Virtuelles Konto</string>
|
||||
<string name="virtual_account_transactions_empty">Noch keine Transaktionen. Beginnen Sie mit dem Einkaufen und sehen Sie sich hier den Verlauf an</string>
|
||||
<string name="visa_balance_limits_details_aml_verified">AML-geprüft</string>
|
||||
<string name="visa_balance_limits_details_available">Verfügbar</string>
|
||||
<string name="visa_balance_limits_details_blocked">Gesperrt</string>
|
||||
|
|
@ -2368,6 +2397,7 @@
|
|||
<string name="wc_all_dapps_disconnected">Alle dApps getrennt</string>
|
||||
<string name="wc_allow_to_spend">Erlaubnis auszugeben</string>
|
||||
<string name="wc_approve_description">Durch die Genehmigung erlaubst Du dApps oder Smart Contracts, Token in zukünftigen Transaktionen zu verwenden.</string>
|
||||
<string name="wc_change_address">Adresse ändern</string>
|
||||
<string name="wc_common_address">Vertragsadresse</string>
|
||||
<string name="wc_common_connect">Verbinden</string>
|
||||
<string name="wc_common_loading">Laden</string>
|
||||
|
|
|
|||
|
|
@ -1542,6 +1542,7 @@
|
|||
<string name="staking_enabled">Staking habilitado</string>
|
||||
<string name="staking_error_no_validators_message">No hay validadores disponibles en este momento. Por favor, inténtelo de nuevo más tarde.</string>
|
||||
<string name="staking_error_no_validators_title">Staking no disponible</string>
|
||||
<string name="staking_error_unavailable_region">Staking no está disponible en tu región.</string>
|
||||
<string name="staking_give_permission_fee_footer">La red cobrará una tarifa de aprobación de token para verificar que usted está autorizando el uso de su token para el staking.</string>
|
||||
<string name="staking_legal">Al utilizar la función de staking, usted acepta %1$s y %2$s del proveedor</string>
|
||||
<string name="staking_locked">Bloqueado</string>
|
||||
|
|
@ -1621,6 +1622,8 @@
|
|||
<string name="staking_title_stake">Stake %s</string>
|
||||
<string name="staking_title_unstake">Unstaking de %s</string>
|
||||
<string name="staking_transaction_in_progress_text">¡La transacción se está procesando! La validación está en curso en la cadena de bloques. Esto puede tardar unos minutos.</string>
|
||||
<string name="staking_transaction_validation_unsafe">%s Prohibido el staking debido a controles de seguridad</string>
|
||||
<string name="staking_transaction_validation_warning">%s staking sospechoso. Por favor, realice el staking bajo su propia responsabilidad.</string>
|
||||
<string name="staking_unbonding">Desunión</string>
|
||||
<string name="staking_unlocked_locked">Desbloquear</string>
|
||||
<string name="staking_unlocking">Desbloqueando</string>
|
||||
|
|
@ -1701,7 +1704,7 @@
|
|||
<string name="swapping_insufficient_funds_description">No hay fondos suficientes para completar esta transacción. Reduzca el importe a recibir o añada más fondos.</string>
|
||||
<string name="swapping_permission_header">Dar autorización</string>
|
||||
<string name="swapping_rate_experience_title">Valore su experiencia con el proveedor</string>
|
||||
<string name="swapping_rate_feedback_placeholder">Escriba sus comentarios</string>
|
||||
<string name="swapping_rate_feedback_placeholder">Escriba sus comentarios (opcional)</string>
|
||||
<string name="swapping_rate_feedback_submit">Enviar comentarios</string>
|
||||
<string name="swapping_rate_feedback_title">¿Qué influyó en su \nexperiencia?</string>
|
||||
<string name="swapping_swap_action">Intercambiar</string>
|
||||
|
|
|
|||
|
|
@ -1500,6 +1500,8 @@
|
|||
<string name="staking_title_stake">Stake %s</string>
|
||||
<string name="staking_title_unstake">déstaker %s</string>
|
||||
<string name="staking_transaction_in_progress_text">La transaction est en cours de traitement ! La validation est actuellement en cours dans la blockchain. Cela peut prendre quelques minutes.</string>
|
||||
<string name="staking_transaction_validation_unsafe">%s staking interdit en raison de contrôles de sécurité</string>
|
||||
<string name="staking_transaction_validation_warning">%s staking suspicieux. Continuez à vos propres risques.</string>
|
||||
<string name="staking_unbonding">Dissociation</string>
|
||||
<string name="staking_unlocked_locked">Débloquer</string>
|
||||
<string name="staking_unlocking">Déverrouillage</string>
|
||||
|
|
@ -1528,6 +1530,8 @@
|
|||
<string name="story_web3_title">Compatible avec Web 3.0</string>
|
||||
<string name="sui_not_enough_coin_for_fee_description">Une transaction entrante d\'au moins de %1$s est requise pour continuer</string>
|
||||
<string name="sui_not_enough_coin_for_fee_title">Fonds insuffisants</string>
|
||||
<string name="support_chat_screen_title">Discussion avec le support</string>
|
||||
<string name="support_chat_share_logs_button">Joindre les logs de l\'application</string>
|
||||
<string name="support_chat_swap_prefilled_message">Données du SWAP :\nDepuis :%1$s%2$s\nVers :%3$s%4$s\nPar :%5$s-%6$s</string>
|
||||
<string name="support_selector_view_chat_button">Accéder au chat</string>
|
||||
<string name="support_selector_view_email_button">Ouvrir un email</string>
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@
|
|||
<string name="addfunds_you_receive_title">受け取る</string>
|
||||
<string name="address_book_add_address">アドレスを追加</string>
|
||||
<string name="address_book_add_address_description">アドレスを追加し、ネットワークを選択してください。</string>
|
||||
<string name="address_book_add_contact">連絡先を追加</string>
|
||||
<plurals name="address_book_addresses">
|
||||
<item quantity="other">%d件のアドレス</item>
|
||||
</plurals>
|
||||
|
|
@ -112,10 +113,13 @@
|
|||
<string name="address_book_enter_address">アドレスを入力</string>
|
||||
<string name="address_book_invalid_address_error">無効なアドレス</string>
|
||||
<string name="address_book_keep_editing">編集を続ける</string>
|
||||
<string name="address_book_new_contact">新しい連絡先</string>
|
||||
<string name="address_book_no_contacts">連絡先はまだありません</string>
|
||||
<string name="address_book_no_contacts_description">追加した連絡先はここに表示されます。</string>
|
||||
<string name="address_book_remove_address">アドレスを削除</string>
|
||||
<string name="address_book_save_wallet_to_description">この連絡先は、このウォレットのアドレス帳に紐付けられます。</string>
|
||||
<string name="address_book_select_network">ネットワークを選択</string>
|
||||
<string name="address_book_title">連絡先</string>
|
||||
<string name="address_book_unsaved_changes">保存されていない変更</string>
|
||||
<string name="address_book_unsaved_changes_description">編集内容を破棄してもよろしいですか?</string>
|
||||
<string name="address_qr_code_message_format">このアドレスには%3$s ネットワークから%1$s (%2$s) のみを送信してください。他のトークンやネットワークを使用すると、資金を失う可能性があります。</string>
|
||||
|
|
@ -685,7 +689,11 @@
|
|||
<string name="feedback_token_description_error">コインの説明エラー</string>
|
||||
<string name="force_update_banner_message">正常に動作するよう、アプリを最新バージョンに更新してください。</string>
|
||||
<string name="force_update_banner_title">アップデートが必要です</string>
|
||||
<string name="force_update_brick_description">このバージョンのアプリはサポート対象外となっており、このデバイスでは更新できません。</string>
|
||||
<string name="force_update_brick_title">更新できません</string>
|
||||
<string name="force_update_button">アップデート</string>
|
||||
<string name="force_update_os_description">使用中のOSが古くなっています。アプリを引き続き使用するには、OSを更新してください。</string>
|
||||
<string name="force_update_os_title">OSの更新が必要です</string>
|
||||
<string name="force_update_warning_message">正常に動作するよう、アプリを最新バージョンに更新してください。</string>
|
||||
<string name="force_update_warning_title">アップデートが必要です</string>
|
||||
<string name="gasless_not_enough_funds_to_cover_token_fee">残高不足</string>
|
||||
|
|
@ -1591,6 +1599,8 @@
|
|||
<string name="staking_title_stake">%sをステーキングする</string>
|
||||
<string name="staking_title_unstake">%sのステーキング解除</string>
|
||||
<string name="staking_transaction_in_progress_text">取引を処理中です。現在、ブロックチェーンで検証が行われています。これには数分かかる場合があります。</string>
|
||||
<string name="staking_transaction_validation_unsafe">セキュリティチェックにより、%sのステーキングは禁止されています</string>
|
||||
<string name="staking_transaction_validation_warning">%sのステーキングにはリスクが伴う可能性があります。ご自身の判断と責任でご利用ください</string>
|
||||
<string name="staking_unbonding">ステーキング解約中</string>
|
||||
<string name="staking_unlocked_locked">ロック解除</string>
|
||||
<string name="staking_unlocking">ロック解除中</string>
|
||||
|
|
@ -1619,6 +1629,8 @@
|
|||
<string name="story_web3_title">Web3.0対応</string>
|
||||
<string name="sui_not_enough_coin_for_fee_description">続行するには少なくとも%1$sの受信取引が必要です</string>
|
||||
<string name="sui_not_enough_coin_for_fee_title">残高不足</string>
|
||||
<string name="support_chat_screen_title">サポートチャット</string>
|
||||
<string name="support_chat_share_logs_button">アプリログを添付</string>
|
||||
<string name="support_chat_swap_prefilled_message">SWAP操作データ:\n交換元:%1$s%2$s\n交換先:%3$s%4$s\n提供元:%5$s - %6$s</string>
|
||||
<string name="support_selector_view_chat_button">チャットを開く</string>
|
||||
<string name="support_selector_view_email_button">メールを開く</string>
|
||||
|
|
@ -1671,7 +1683,7 @@
|
|||
<string name="swapping_insufficient_funds_description">この取引を完了するには残高が不足しています。受け取り額を減らすか、資金を追加してください。</string>
|
||||
<string name="swapping_permission_header">許可を与える</string>
|
||||
<string name="swapping_rate_experience_title">プロバイダーの利用体験を評価してください</string>
|
||||
<string name="swapping_rate_feedback_placeholder">フィードバックを入力してください</string>
|
||||
<string name="swapping_rate_feedback_placeholder">フィードバックを入力(任意)</string>
|
||||
<string name="swapping_rate_feedback_submit">フィードバックを送信</string>
|
||||
<string name="swapping_rate_feedback_title">ご利用中に気になった点を\n教えてください</string>
|
||||
<string name="swapping_swap_action">スワップ</string>
|
||||
|
|
@ -1690,7 +1702,7 @@
|
|||
<string name="tangem_pay_card_frozen">カードが凍結されています</string>
|
||||
<string name="tangem_pay_card_payment">カード決済</string>
|
||||
<string name="tangem_pay_close_card_disabled_last_card">最後のカードは停止できません</string>
|
||||
<string name="tangem_pay_close_card_popup_description">支払いアカウントから削除されます。</string>
|
||||
<string name="tangem_pay_close_card_popup_description">アプリに表示されなくなります</string>
|
||||
<string name="tangem_pay_close_card_popup_primary_button_title">カードを解約する</string>
|
||||
<string name="tangem_pay_close_card_popup_secondary_button_title">戻る</string>
|
||||
<string name="tangem_pay_close_card_popup_title">カードを解約しますか?</string>
|
||||
|
|
@ -1890,7 +1902,7 @@
|
|||
<string name="tangempay_onboarding_title">Tangem Pay カードをすぐに手に入れよう</string>
|
||||
<string name="tangempay_pay_support">Payサポート</string>
|
||||
<string name="tangempay_payment_account">支払いアカウント</string>
|
||||
<string name="tangempay_payment_account_sync_needed">Tangem Pay セッションの有効期限が切れました</string>
|
||||
<string name="tangempay_payment_account_sync_needed">セッションの有効期限が切れました</string>
|
||||
<string name="tangempay_pin_validation_error_message">無効な暗証番号:連続や繰り返しを避けてください</string>
|
||||
<string name="tangempay_reissue_card_confirm">カードを交換</string>
|
||||
<string name="tangempay_reissue_card_description">これにより、新しいカード情報が発行されます。現在のカード情報は使えなくなります。この操作は元に戻せません。</string>
|
||||
|
|
@ -1918,12 +1930,12 @@
|
|||
<string name="tangempay_sync_needed_button">セッションを更新</string>
|
||||
<string name="tangempay_sync_needed_title">Tangem Pay セッションの有効期限が切れました</string>
|
||||
<string name="tangempay_tangem_visa_card">日常の支払いにUSDCを利用</string>
|
||||
<string name="tangempay_temporarily_unavailable">Tangem Payは現在一時的に利用できません。</string>
|
||||
<string name="tangempay_temporarily_unavailable">Tangem Payは一時的に利用できません</string>
|
||||
<string name="tangempay_title">Tangem Pay</string>
|
||||
<string name="tangempay_topup_receive_body">USDC Polygon をアカウントのアドレスに送信</string>
|
||||
<string name="tangempay_topup_receive_title">別のウォレットまたは取引所から</string>
|
||||
<string name="tangempay_topup_swap_body">ウォレットの暗号資産を使って、決済アカウントにチャージできます</string>
|
||||
<string name="tangempay_topup_swap_title">Tangemウォレットからスワップ</string>
|
||||
<string name="tangempay_topup_swap_title">Tangemウォレットから</string>
|
||||
<string name="tangempay_usdc_on_polygon_network">Polygonネットワーク上のUSDC</string>
|
||||
<string name="tangempay_withdrawal_note_description">返金分はオンチェーンのPolygon残高には戻らず、出金にも利用できません。ただし、カード残高として残り、支払いに利用できます。</string>
|
||||
<string name="tangempay_withdrawal_note_title">ご注意ください</string>
|
||||
|
|
|
|||
|
|
@ -1593,6 +1593,7 @@
|
|||
<string name="staking_enabled">Стейкинг включен</string>
|
||||
<string name="staking_error_no_validators_message">В данный момент нет доступных валидаторов. Попробуйте позже.</string>
|
||||
<string name="staking_error_no_validators_title">Стейкинг недоступен</string>
|
||||
<string name="staking_error_unavailable_region">Стейкинг недоступен в вашем регионе</string>
|
||||
<string name="staking_give_permission_fee_footer">Комиссия сети за одобрение токена будет взиматься за подтверждение того, что именно вы разрешаете использовать ваш токен для стейкинга.</string>
|
||||
<string name="staking_legal">Пользуясь стейкинг сервисом, вы соглашаетесь с %1$s и %2$s</string>
|
||||
<string name="staking_locked">Заблокировано</string>
|
||||
|
|
@ -1672,6 +1673,8 @@
|
|||
<string name="staking_title_stake">Застейкать %s</string>
|
||||
<string name="staking_title_unstake">Вывести %s</string>
|
||||
<string name="staking_transaction_in_progress_text">Транзакция обрабатывается! В настоящее время идет проверка в блокчейне. Это может занять несколько минут.</string>
|
||||
<string name="staking_transaction_validation_unsafe">%s стейкинг запрещен по результатам проверок безопасности</string>
|
||||
<string name="staking_transaction_validation_warning">%s стейкинг вызывает сомнения. Пожалуйста, стейкайте на свой страх и риск</string>
|
||||
<string name="staking_unbonding">Отзыв</string>
|
||||
<string name="staking_unlocked_locked">Разблокировать</string>
|
||||
<string name="staking_unlocking">Разблокировка</string>
|
||||
|
|
@ -1700,6 +1703,8 @@
|
|||
<string name="story_web3_title">Поддержка Web 3.0</string>
|
||||
<string name="sui_not_enough_coin_for_fee_description">Для отправки требуется входящая транзакция на сумму не менее %1$s</string>
|
||||
<string name="sui_not_enough_coin_for_fee_title">Недостаточно средств</string>
|
||||
<string name="support_chat_screen_title">Чат поддержки</string>
|
||||
<string name="support_chat_share_logs_button">Прикрепить логи</string>
|
||||
<string name="support_chat_swap_prefilled_message">Данные об операции обмена:\nОткуда: %1$s %2$s\nКуда: %3$s %4$s\nЧерез: %5$s - %6$s</string>
|
||||
<string name="support_selector_view_chat_button">Открыть чат</string>
|
||||
<string name="support_selector_view_email_button">Открыть почту</string>
|
||||
|
|
@ -1752,7 +1757,7 @@
|
|||
<string name="swapping_insufficient_funds_description">Недостаточно средств для завершения транзакции. Уменьшите сумму получения или добавьте больше средств</string>
|
||||
<string name="swapping_permission_header">Дать разрешение</string>
|
||||
<string name="swapping_rate_experience_title">Оцените ваш опыт взаимодействия с провайдером</string>
|
||||
<string name="swapping_rate_feedback_placeholder">Напишите ваш отзыв</string>
|
||||
<string name="swapping_rate_feedback_placeholder">Оставьте отзыв (необязательно)</string>
|
||||
<string name="swapping_rate_feedback_submit">Отправить отзыв</string>
|
||||
<string name="swapping_rate_feedback_title">Что повлияло на вашу оценку?</string>
|
||||
<string name="swapping_swap_action">Обменять</string>
|
||||
|
|
@ -1951,11 +1956,11 @@
|
|||
<string name="tangempay_newonboard_Q4_body">Баланс карты работает на USDC в сети Polygon, но пополнить его можно любым активом (USDT, SOL, ETH, BTC, XRP и др.) через удобные встроенные свопы Tangem.</string>
|
||||
<string name="tangempay_newonboard_Q4_title">Какую крипту можно тратить?</string>
|
||||
<string name="tangempay_newonboard_body">Тратьте крипту где угодно — без банков, бирж и посредников. Сила self-custody для повседневных платежей.</string>
|
||||
<string name="tangempay_newonboard_bottomleft_body">Платите онлайн и c Apple Pay</string>
|
||||
<string name="tangempay_newonboard_bottomleft_body">Онлайн и c Apple Pay</string>
|
||||
<string name="tangempay_newonboard_bottomleft_title">Принимается везде</string>
|
||||
<string name="tangempay_newonboard_bottomright_body">За покупки не в USD</string>
|
||||
<string name="tangempay_newonboard_bottomright_title">FX-комиссия 1%</string>
|
||||
<string name="tangempay_newonboard_title">Откройте карту Tangem Pay</string>
|
||||
<string name="tangempay_newonboard_title">Откройте Tangem Pay</string>
|
||||
<string name="tangempay_newonboard_topleft_body">1 USDC = 1 USD</string>
|
||||
<string name="tangempay_newonboard_topleft_title">Без комиссии за покупки</string>
|
||||
<string name="tangempay_newonboard_topright_body">Без сюрпризов</string>
|
||||
|
|
@ -1999,7 +2004,7 @@
|
|||
<string name="tangempay_sync_needed_button">Обновить сессию</string>
|
||||
<string name="tangempay_sync_needed_title">Tangem Pay · Cессия истекла</string>
|
||||
<string name="tangempay_tangem_visa_card">Оплачивайте ежедневные покупки в USDC</string>
|
||||
<string name="tangempay_temporarily_unavailable">Tangem Pay временно недоступен</string>
|
||||
<string name="tangempay_temporarily_unavailable">Проблема с соединением</string>
|
||||
<string name="tangempay_title">Tangem Pay</string>
|
||||
<string name="tangempay_topup_receive_body">Отправьте USDC Polygon на адрес вашего аккаунта</string>
|
||||
<string name="tangempay_topup_receive_title">С другого кошелька или биржи</string>
|
||||
|
|
|
|||
|
|
@ -1593,6 +1593,7 @@
|
|||
<string name="staking_enabled">Стейкінг увімкнено</string>
|
||||
<string name="staking_error_no_validators_message">Наразі немає вільних валідаторів. Повторіть спробу пізніше.</string>
|
||||
<string name="staking_error_no_validators_title">Стейкінг недоступний</string>
|
||||
<string name="staking_error_unavailable_region">Стейкінг недоступний у вашому регіоні</string>
|
||||
<string name="staking_give_permission_fee_footer">Комісія за схвалення токену, щоб верифікувати, що саме ви дозволяєте використання токена для стейкінгу.</string>
|
||||
<string name="staking_legal">Використовуючи функцію стейкінгу, ви погоджуєтесь з %1$s та %2$s</string>
|
||||
<string name="staking_locked">Заблоковано</string>
|
||||
|
|
@ -1672,6 +1673,8 @@
|
|||
<string name="staking_title_stake">Застейкати %s</string>
|
||||
<string name="staking_title_unstake">Зняти зі стейкінгу %s</string>
|
||||
<string name="staking_transaction_in_progress_text">Транзакція обробляється! Наразі триває перевірка в блокчейні. Це може зайняти кілька хвилин.</string>
|
||||
<string name="staking_transaction_validation_unsafe">%s стейкінг заборонено за результатами перевірок безпеки</string>
|
||||
<string name="staking_transaction_validation_warning">%s стейкінг викликає сумніви. Будь ласка, стейкайте на свій страх і ризик</string>
|
||||
<string name="staking_unbonding">Розблокування</string>
|
||||
<string name="staking_unlocked_locked">Розблокувати</string>
|
||||
<string name="staking_unlocking">Розблокування</string>
|
||||
|
|
@ -1700,6 +1703,8 @@
|
|||
<string name="story_web3_title">Web 3.0 сумісність</string>
|
||||
<string name="sui_not_enough_coin_for_fee_description">Для відправки потрібна вхідна транзакція на суму не менше %1$s</string>
|
||||
<string name="sui_not_enough_coin_for_fee_title">Недостатньо коштів</string>
|
||||
<string name="support_chat_screen_title">Чат підтримки</string>
|
||||
<string name="support_chat_share_logs_button">Прикріпити логи</string>
|
||||
<string name="support_chat_swap_prefilled_message">Дані про операцію обміну:\nЗвідки: %1$s %2$s\nКуди: %3$s %4$s\nЧерез: %5$s - %6$s</string>
|
||||
<string name="support_selector_view_chat_button">Відкрити чат</string>
|
||||
<string name="support_selector_view_email_button">Відкрити пошту</string>
|
||||
|
|
@ -1752,7 +1757,7 @@
|
|||
<string name="swapping_insufficient_funds_description">Недостатньо коштів для завершення транзакції. Зменште суму отримання або додайте більше коштів</string>
|
||||
<string name="swapping_permission_header">Надати дозвіл</string>
|
||||
<string name="swapping_rate_experience_title">Оцініть ваш досвід взаємодії з провайдером</string>
|
||||
<string name="swapping_rate_feedback_placeholder">Напишіть ваш відгук</string>
|
||||
<string name="swapping_rate_feedback_placeholder">Залиште відгук (необов\'язково)</string>
|
||||
<string name="swapping_rate_feedback_submit">Надіслати відгук</string>
|
||||
<string name="swapping_rate_feedback_title">Що вплинуло на вашу оцінку?</string>
|
||||
<string name="swapping_swap_action">Обміняти</string>
|
||||
|
|
|
|||
|
|
@ -1591,6 +1591,8 @@
|
|||
<string name="staking_title_stake">质押%s</string>
|
||||
<string name="staking_title_unstake">取消抵押 %s</string>
|
||||
<string name="staking_transaction_in_progress_text">交易正在处理中!区块链正在进行验证,这可能需要几分钟时间。</string>
|
||||
<string name="staking_transaction_validation_unsafe">因安全检查问题,禁止质押%s </string>
|
||||
<string name="staking_transaction_validation_warning">%s 质押行为可疑。请自行承担风险进行质押</string>
|
||||
<string name="staking_unbonding">解除绑定</string>
|
||||
<string name="staking_unlocked_locked">解锁</string>
|
||||
<string name="staking_unlocking">解锁</string>
|
||||
|
|
@ -1671,7 +1673,7 @@
|
|||
<string name="swapping_insufficient_funds_description">账户余额不足,无法完成此交易。请减少收款金额或增加余额。</string>
|
||||
<string name="swapping_permission_header">给予许可</string>
|
||||
<string name="swapping_rate_experience_title">请评价您与服务提供商的互动体验</string>
|
||||
<string name="swapping_rate_feedback_placeholder">请输入您的反馈</string>
|
||||
<string name="swapping_rate_feedback_placeholder">请输入您的反馈(可选)</string>
|
||||
<string name="swapping_rate_feedback_submit">发送反馈</string>
|
||||
<string name="swapping_rate_feedback_title">是什么影响了您的\n体验?</string>
|
||||
<string name="swapping_swap_action">兑换</string>
|
||||
|
|
|
|||
|
|
@ -333,7 +333,6 @@
|
|||
<string name="common_error">Error</string>
|
||||
<string name="common_estimated_fee">Top-up network fee</string>
|
||||
<string name="common_exchange">Swap</string>
|
||||
<string name="common_send_with_swap">Send&Swap</string>
|
||||
<string name="common_explore">Explore</string>
|
||||
<string name="common_explore_transaction_history">Explore transaction history</string>
|
||||
<string name="common_explorer">Explorer</string>
|
||||
|
|
@ -433,6 +432,7 @@
|
|||
<string name="common_send">Send</string>
|
||||
<string name="common_send_colon">Send:</string>
|
||||
<string name="common_send_tx_error">Failed to send transaction</string>
|
||||
<string name="common_send_with_swap">Send&Swap</string>
|
||||
<string name="common_sending">Sending</string>
|
||||
<string name="common_sent">Sent</string>
|
||||
<string name="common_server_unavailable">The server is not available, please try again later</string>
|
||||
|
|
@ -701,6 +701,7 @@
|
|||
<string name="feedback_subject_support_tangem">Tangem feedback</string>
|
||||
<string name="feedback_subject_tx_failed">Can\'t send a transaction</string>
|
||||
<string name="feedback_token_description_error">Coin description error</string>
|
||||
<string name="force_update_action">Update now</string>
|
||||
<string name="force_update_banner_message">Update the app to its latest version to ensure proper functionality</string>
|
||||
<string name="force_update_banner_title">Update needed</string>
|
||||
<string name="force_update_brick_description">This version of the app is no longer supported and can\'t be updated on this device.</string>
|
||||
|
|
@ -1294,9 +1295,9 @@
|
|||
<string name="quick_action_buy_description">Credit card or bank account</string>
|
||||
<string name="quick_action_receive_description">Share your address or QR-code</string>
|
||||
<string name="quick_action_sell_description">Sell crypto securely</string>
|
||||
<string name="quick_action_send_and_swap_description">Send with swap to another token</string>
|
||||
<string name="quick_action_send_description">Send to another wallet</string>
|
||||
<string name="quick_action_swap_description">Between your portfolios</string>
|
||||
<string name="quick_action_send_and_swap_description">Send with swap to another token</string>
|
||||
<string name="quick_top_up_chip_other">Other</string>
|
||||
<string name="quick_top_up_title">Quick top up</string>
|
||||
<string name="receive_bottom_sheet_no_memo_required_message">No memo required</string>
|
||||
|
|
@ -1550,6 +1551,7 @@
|
|||
<string name="staking_enabled">Staking enabled</string>
|
||||
<string name="staking_error_no_validators_message">No available validators at the moment. Please try again later.</string>
|
||||
<string name="staking_error_no_validators_title">Staking Unavailable</string>
|
||||
<string name="staking_error_unavailable_region">Staking is unavailable in your region</string>
|
||||
<string name="staking_give_permission_fee_footer">The network will charge a token approval fee to verify that you are authorizing the use of your token for the staking.</string>
|
||||
<string name="staking_legal">By using staking functionality, you agree with provider’s %1$s and %2$s</string>
|
||||
<string name="staking_locked">Locked</string>
|
||||
|
|
@ -1629,6 +1631,8 @@
|
|||
<string name="staking_title_stake">Stake %s</string>
|
||||
<string name="staking_title_unstake">Unstake %s</string>
|
||||
<string name="staking_transaction_in_progress_text">The transaction is being processed! Validation is currently underway in the blockchain. This may take a few minutes.</string>
|
||||
<string name="staking_transaction_validation_unsafe">%s staking prohibited due to security checks</string>
|
||||
<string name="staking_transaction_validation_warning">%s staking suspicious. Please stake at your own risk</string>
|
||||
<string name="staking_unbonding">Unbonding</string>
|
||||
<string name="staking_unlocked_locked">Unlock</string>
|
||||
<string name="staking_unlocking">Unlocking</string>
|
||||
|
|
@ -1657,6 +1661,8 @@
|
|||
<string name="story_web3_title">Web 3.0 Compatible</string>
|
||||
<string name="sui_not_enough_coin_for_fee_description">An incoming transaction of at least %1$s is required to proceed</string>
|
||||
<string name="sui_not_enough_coin_for_fee_title">Insufficient funds</string>
|
||||
<string name="support_chat_screen_title">Support chat</string>
|
||||
<string name="support_chat_share_logs_button">Attach app logs</string>
|
||||
<string name="support_chat_swap_prefilled_message">SWAP operation data:\nFrom: %1$s %2$s\nTo: %3$s %4$s\nBy %5$s - %6$s</string>
|
||||
<string name="support_selector_view_chat_button">Open chat</string>
|
||||
<string name="support_selector_view_email_button">Open mail</string>
|
||||
|
|
@ -1709,7 +1715,7 @@
|
|||
<string name="swapping_insufficient_funds_description">Not enough funds to complete this transaction. Reduce the amount to receive or add more funds.</string>
|
||||
<string name="swapping_permission_header">Give Permission</string>
|
||||
<string name="swapping_rate_experience_title">Rate your experience with provider</string>
|
||||
<string name="swapping_rate_feedback_placeholder">Type your feedback</string>
|
||||
<string name="swapping_rate_feedback_placeholder">Type your feedback(optional)</string>
|
||||
<string name="swapping_rate_feedback_submit">Send feedback</string>
|
||||
<string name="swapping_rate_feedback_title">What affected your \nexperience?</string>
|
||||
<string name="swapping_swap_action">Swap</string>
|
||||
|
|
@ -1840,6 +1846,7 @@
|
|||
</plurals>
|
||||
<string name="tangempay_change_pin_code">Change PIN-code</string>
|
||||
<string name="tangempay_come_back_if_forget_pin">Come back to the app if you forget it.</string>
|
||||
<string name="tangempay_common_card">Card</string>
|
||||
<string name="tangempay_daily_limit_hint" formatted="false">Set a limit from %s to %s</string>
|
||||
<string name="tangempay_daily_limit_set_button">Set limits</string>
|
||||
<string name="tangempay_declined_reason_1">insufficient funds</string>
|
||||
|
|
@ -1957,7 +1964,7 @@
|
|||
<string name="tangempay_sync_needed_button">Renew session</string>
|
||||
<string name="tangempay_sync_needed_title">Payment account session expired</string>
|
||||
<string name="tangempay_tangem_visa_card">Use USDC for everyday payments</string>
|
||||
<string name="tangempay_temporarily_unavailable">Tangem Pay is temporarily unavailable</string>
|
||||
<string name="tangempay_temporarily_unavailable">Connection issues</string>
|
||||
<string name="tangempay_title">Tangem Pay</string>
|
||||
<string name="tangempay_topup_receive_body">Send USDC Polygon to your account’s address </string>
|
||||
<string name="tangempay_topup_receive_title">From another wallet or exchange</string>
|
||||
|
|
@ -2084,6 +2091,8 @@
|
|||
<string name="user_wallet_list_rename_popup_title">Rename wallet</string>
|
||||
<string name="user_wallet_list_unlock_all">Unlock all</string>
|
||||
<string name="user_wallet_list_unlock_all_with">Unlock all with %s</string>
|
||||
<string name="virtual_account_title">Virtual account</string>
|
||||
<string name="virtual_account_transactions_empty">No transactions yet. Start spending and see history here</string>
|
||||
<string name="visa_balance_limits_details_aml_verified">AML verifired</string>
|
||||
<string name="visa_balance_limits_details_available">Available</string>
|
||||
<string name="visa_balance_limits_details_blocked">Blocked</string>
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@ private fun BottomSheetIcon(icon: MessageBottomSheetUM.Icon, modifier: Modifier
|
|||
}
|
||||
|
||||
val backgroundColor = when (icon.backgroundType) {
|
||||
MessageBottomSheetUM.Icon.BackgroundType.Unspecified -> Color.Unspecified
|
||||
MessageBottomSheetUM.Icon.BackgroundType.Unspecified -> TangemTheme.colors3.bg.tertiary
|
||||
MessageBottomSheetUM.Icon.BackgroundType.SameAsTint -> {
|
||||
if (tint == Color.Unspecified) Color.Unspecified else tint.copy(alpha = 0.1f)
|
||||
}
|
||||
|
|
@ -217,7 +217,7 @@ private fun BottomSheetVector(vector: MessageBottomSheetUM.Vector, modifier: Mod
|
|||
}
|
||||
|
||||
val backgroundColor = when (vector.backgroundType) {
|
||||
MessageBottomSheetUM.Vector.BackgroundType.Unspecified -> Color.Unspecified
|
||||
MessageBottomSheetUM.Vector.BackgroundType.Unspecified -> TangemTheme.colors3.bg.tertiary
|
||||
MessageBottomSheetUM.Vector.BackgroundType.SameAsTint -> tint
|
||||
MessageBottomSheetUM.Vector.BackgroundType.Accent -> TangemTheme.colors3.bg.status.infoSubtle
|
||||
MessageBottomSheetUM.Vector.BackgroundType.Informative -> TangemTheme.colors3.bg.status.infoSubtle
|
||||
|
|
|
|||
|
|
@ -11,9 +11,11 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.components.SpacerW
|
||||
|
|
@ -37,17 +39,20 @@ import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
|||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
fun TangemTopSnackbar(snackbarMessage: SnackbarMessage, modifier: Modifier = Modifier) {
|
||||
val density = LocalDensity.current
|
||||
val fixedFontScaleDensity = remember(density) {
|
||||
Density(density = density.density, fontScale = 1f)
|
||||
}
|
||||
val actionLabel = snackbarMessage.actionLabel
|
||||
val action = snackbarMessage.action
|
||||
val hasAction = actionLabel != null && action != null
|
||||
|
||||
var isTextOverflowing by remember { mutableStateOf(false) }
|
||||
val shape = if (isTextOverflowing) {
|
||||
RoundedCornerShape(TangemTheme.dimens2.x5)
|
||||
} else {
|
||||
TangemTheme.shapes.roundedCornersXLarge
|
||||
}
|
||||
|
||||
var isTextOverflowing by remember(
|
||||
snackbarMessage.message,
|
||||
snackbarMessage.startIconId,
|
||||
hasAction,
|
||||
) { mutableStateOf(false) }
|
||||
val shape = RoundedCornerShape(24.dp)
|
||||
Column(
|
||||
modifier = modifier
|
||||
.shadow(elevation = TangemTheme.dimens.elevation4, shape = shape, clip = false)
|
||||
|
|
@ -55,8 +60,8 @@ fun TangemTopSnackbar(snackbarMessage: SnackbarMessage, modifier: Modifier = Mod
|
|||
.clip(shape)
|
||||
.hazeEffectTangem()
|
||||
.sizeIn(minHeight = TangemTheme.dimens2.x11)
|
||||
.padding(start = TangemTheme.dimens2.x5, end = TangemTheme.dimens2.x1)
|
||||
.padding(vertical = TangemTheme.dimens2.x1),
|
||||
.padding(start = TangemTheme.dimens2.x4, end = TangemTheme.dimens2.x3)
|
||||
.padding(vertical = TangemTheme.dimens2.x2),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Row(
|
||||
|
|
@ -74,15 +79,20 @@ fun TangemTopSnackbar(snackbarMessage: SnackbarMessage, modifier: Modifier = Mod
|
|||
SpacerW(TangemTheme.dimens2.x2)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = snackbarMessage.message.resolveReference(),
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
color = TangemTheme.colors.text.secondary,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
style = TangemTheme.typography.body2,
|
||||
onTextLayout = { if (hasAction) isTextOverflowing = it.hasVisualOverflow },
|
||||
)
|
||||
CompositionLocalProvider(LocalDensity provides fixedFontScaleDensity) {
|
||||
Text(
|
||||
text = snackbarMessage.message.resolveReference(),
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
color = TangemTheme.colors2.text.neutral.secondary,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 2,
|
||||
style = TangemTheme.typography2.captionMedium13,
|
||||
onTextLayout = { layoutResult ->
|
||||
val isOverflowing = hasAction && layoutResult.hasVisualOverflow
|
||||
if (isTextOverflowing != isOverflowing) isTextOverflowing = isOverflowing
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
SpacerW(TangemTheme.dimens2.x4)
|
||||
|
||||
|
|
@ -170,6 +180,7 @@ private fun Preview_TangemTopSnackbar_NoAction() {
|
|||
)
|
||||
TangemTopSnackbar(
|
||||
snackbarMessage = SnackbarMessage(
|
||||
startIconId = R.drawable.ic_eye_off_outline_24,
|
||||
message = stringReference(
|
||||
"Operation completed long long text that should be truncated with ellipsis at the end",
|
||||
),
|
||||
|
|
|
|||
|
|
@ -200,7 +200,7 @@ fun TangemMessage(
|
|||
)
|
||||
Column(
|
||||
horizontalAlignment = alignment,
|
||||
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1),
|
||||
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x3),
|
||||
modifier = Modifier
|
||||
.padding(TangemTheme.dimens2.x3)
|
||||
.fillMaxWidth(),
|
||||
|
|
|
|||
|
|
@ -10,14 +10,17 @@ import androidx.compose.runtime.*
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.draw.innerShadow
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.rotate
|
||||
import androidx.compose.ui.graphics.shadow.Shadow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.components.haze.hazeForegroundEffectTangem
|
||||
import com.tangem.core.ui.extensions.conditionalCompose
|
||||
|
|
@ -141,7 +144,6 @@ enum class TangemMessageEffect(val isAnimatable: Boolean) {
|
|||
listOf(
|
||||
HazeTint(
|
||||
color = Color(0x4D7F7F7F),
|
||||
|
||||
blendMode = BlendMode.Luminosity,
|
||||
),
|
||||
HazeTint(
|
||||
|
|
@ -233,15 +235,28 @@ enum class TangemMessageEffect(val isAnimatable: Boolean) {
|
|||
None -> if (isInDarkTheme) Color(0x1AFFFFFF) else Color.Transparent
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Color of the inner shadow that glows inward evenly from every edge over the card body,
|
||||
* or `null` when the effect doesn't have one. [None] uses a subtle white 15% highlight
|
||||
* (Figma: inner shadow X0 Y0 blur 74 spread 0, #FFFFFF · 15%).
|
||||
*/
|
||||
@Suppress("MagicNumber")
|
||||
fun getInnerGlowColor(isInDarkTheme: Boolean): Color? = when (this) {
|
||||
None -> if (isInDarkTheme) Color(0x26FFFFFF) else null
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies a message effect background to the [Modifier] based on the provided [messageEffect] and [radius] */
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
fun Modifier.messageEffectBackground(messageEffect: TangemMessageEffect, radius: Dp, contentColor: Color): Modifier {
|
||||
val isInDarkTheme = LocalIsInDarkTheme.current
|
||||
val borderGradientColors = remember(messageEffect, isInDarkTheme) { messageEffect.getBorderGradient(isInDarkTheme) }
|
||||
val gradientColors = remember(messageEffect, isInDarkTheme) { messageEffect.getColorGradient(isInDarkTheme) }
|
||||
val gradientTint = remember(messageEffect, isInDarkTheme) { messageEffect.getGradientTint(isInDarkTheme) }
|
||||
val innerGlowColor = remember(messageEffect, isInDarkTheme) { messageEffect.getInnerGlowColor(isInDarkTheme) }
|
||||
|
||||
val angle by if (messageEffect.isAnimatable) {
|
||||
LocalMessageEffectAnimation.current.offsetState
|
||||
|
|
@ -287,15 +302,35 @@ fun Modifier.messageEffectBackground(messageEffect: TangemMessageEffect, radius:
|
|||
blendMode = BlendMode.SrcIn,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
drawCircle(
|
||||
brush = brush,
|
||||
radius = size.width,
|
||||
blendMode = BlendMode.SrcIn,
|
||||
)
|
||||
}
|
||||
// None ignores contentColor — its body is the white-10% frosted haze, not a solid fill
|
||||
if (messageEffect != TangemMessageEffect.None) {
|
||||
drawRect(
|
||||
color = contentColor,
|
||||
topLeft = Offset(padding, padding),
|
||||
size = Size(size.width - 2 * padding, size.height - 2 * padding),
|
||||
)
|
||||
}
|
||||
drawRect(
|
||||
color = contentColor,
|
||||
topLeft = Offset(padding, padding),
|
||||
size = Size(size.width - 2 * padding, size.height - 2 * padding),
|
||||
)
|
||||
drawContent()
|
||||
}
|
||||
}
|
||||
.conditionalCompose(innerGlowColor != null) {
|
||||
innerShadow(
|
||||
shape = RoundedCornerShape(0.dp),
|
||||
shadow = Shadow(
|
||||
radius = 10.dp,
|
||||
spread = 10.dp,
|
||||
color = innerGlowColor ?: Color.Transparent,
|
||||
offset = DpOffset(0.dp, 0.dp),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
|
|
@ -27,7 +28,6 @@ import androidx.compose.ui.unit.coerceAtLeast
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.fastForEachIndexed
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.core.ui.extensions.conditional
|
||||
import com.tangem.core.ui.extensions.resolveReference
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
|
|
@ -108,47 +108,121 @@ fun TangemSegmentedPicker(
|
|||
segmentHeight = segmentHeight.value,
|
||||
separatorWidth = SEPARATOR_WIDTH,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
items.fastForEachIndexed { index, item ->
|
||||
Segment(
|
||||
item = item,
|
||||
index = index,
|
||||
isFixed = isFixed,
|
||||
minSegmentWidth = minSegmentWidth,
|
||||
selectedIndex = selectedIndex,
|
||||
onClick = { onClick(item) },
|
||||
modifier = Modifier
|
||||
.onGloballyPositioned {
|
||||
with(density) {
|
||||
itemsWidths[index] = it.size.width.toDp()
|
||||
segmentHeight.value = it.size.height.toDp().coerceAtLeast(segmentHeight.value)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if (index != items.lastIndex) {
|
||||
val selected by selectedIndex
|
||||
val alpha by animateFloatAsState(
|
||||
targetValue = if (selected == index || selected == index + 1) 0f else 1f,
|
||||
animationSpec = tween(durationMillis = 300),
|
||||
label = "separatorAlpha",
|
||||
SegmentsRow(
|
||||
isFixed = isFixed,
|
||||
minSegmentWidth = minSegmentWidth,
|
||||
segmentCount = items.size,
|
||||
content = {
|
||||
items.fastForEachIndexed { index, item ->
|
||||
Segment(
|
||||
item = item,
|
||||
index = index,
|
||||
minSegmentWidth = minSegmentWidth,
|
||||
selectedIndex = selectedIndex,
|
||||
onClick = { onClick(item) },
|
||||
modifier = Modifier
|
||||
.onGloballyPositioned {
|
||||
with(density) {
|
||||
itemsWidths[index] = it.size.width.toDp()
|
||||
segmentHeight.value = it.size.height.toDp().coerceAtLeast(segmentHeight.value)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.alpha(alpha)
|
||||
.width(SEPARATOR_WIDTH)
|
||||
.height(20.dp)
|
||||
.background(
|
||||
color = TangemTheme.colors2.border.neutral.tertiary.copy(alpha = 0.1f),
|
||||
),
|
||||
)
|
||||
if (index != items.lastIndex) {
|
||||
Separator(index = index, selectedIndex = selectedIndex)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lays out segments (with the inter-segment separators interleaved) in a single row.
|
||||
*
|
||||
* - When [isFixed] is `true` and the parent width is bounded, every segment is sized to at least its
|
||||
* content width, and the remaining free space is distributed equally between segments — so the row
|
||||
* fills the full width without clipping any segment's text.
|
||||
* - Otherwise segments wrap their content.
|
||||
*
|
||||
* Children must be supplied as `segment, separator, segment, separator, … , segment` (even indices are
|
||||
* segments, odd indices are separators).
|
||||
*/
|
||||
@Composable
|
||||
private fun SegmentsRow(
|
||||
isFixed: Boolean,
|
||||
minSegmentWidth: Dp,
|
||||
segmentCount: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Layout(modifier = modifier, content = content) { measurables, constraints ->
|
||||
val segMeasurables = measurables.filterIndexed { i, _ -> i % 2 == 0 }
|
||||
val sepMeasurables = measurables.filterIndexed { i, _ -> i % 2 == 1 }
|
||||
|
||||
val sepPlaceables = sepMeasurables.map { it.measure(constraints.copy(minWidth = 0)) }
|
||||
val totalSeparators = sepPlaceables.sumOf { it.width }
|
||||
|
||||
val minPx = if (minSegmentWidth != Dp.Unspecified) minSegmentWidth.roundToPx() else 0
|
||||
val baseWidths = segMeasurables.map { maxOf(it.maxIntrinsicWidth(constraints.maxHeight), minPx) }
|
||||
|
||||
val widths = if (isFixed && constraints.hasBoundedWidth) {
|
||||
val leftover = constraints.maxWidth - baseWidths.sum() - totalSeparators
|
||||
if (leftover > 0) {
|
||||
val extra = leftover / segmentCount
|
||||
val remainder = leftover % segmentCount
|
||||
baseWidths.mapIndexed { i, w -> w + extra + if (i < remainder) 1 else 0 }
|
||||
} else {
|
||||
baseWidths
|
||||
}
|
||||
} else {
|
||||
baseWidths
|
||||
}
|
||||
|
||||
val segPlaceables = segMeasurables.mapIndexed { i, m ->
|
||||
m.measure(constraints.copy(minWidth = widths[i], maxWidth = widths[i]))
|
||||
}
|
||||
|
||||
val layoutWidth = (widths.sum() + totalSeparators)
|
||||
.coerceIn(constraints.minWidth, constraints.maxWidth)
|
||||
val layoutHeight = (segPlaceables + sepPlaceables).maxOf { it.height }
|
||||
.coerceIn(constraints.minHeight, constraints.maxHeight)
|
||||
|
||||
layout(layoutWidth, layoutHeight) {
|
||||
var x = 0
|
||||
segPlaceables.forEachIndexed { i, seg ->
|
||||
seg.placeRelative(x, (layoutHeight - seg.height) / 2)
|
||||
x += seg.width
|
||||
sepPlaceables.getOrNull(i)?.let { sep ->
|
||||
sep.placeRelative(x, (layoutHeight - sep.height) / 2)
|
||||
x += sep.width
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Separator(index: Int, selectedIndex: MutableState<Int>) {
|
||||
val selected by selectedIndex
|
||||
val alpha by animateFloatAsState(
|
||||
targetValue = if (selected == index || selected == index + 1) 0f else 1f,
|
||||
animationSpec = tween(durationMillis = 300),
|
||||
label = "separatorAlpha",
|
||||
)
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.alpha(alpha)
|
||||
.width(SEPARATOR_WIDTH)
|
||||
.height(20.dp)
|
||||
.background(
|
||||
color = TangemTheme.colors2.border.neutral.tertiary.copy(alpha = 0.1f),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private val SEPARATOR_WIDTH = 0.5.dp
|
||||
|
||||
@Composable
|
||||
|
|
@ -199,10 +273,9 @@ private fun SegmentSelection(
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun RowScope.Segment(
|
||||
private fun Segment(
|
||||
item: TangemSegmentUM,
|
||||
index: Int,
|
||||
isFixed: Boolean,
|
||||
selectedIndex: MutableState<Int>,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
|
|
@ -211,9 +284,6 @@ private fun RowScope.Segment(
|
|||
Box(
|
||||
modifier = modifier
|
||||
.defaultMinSize(minWidth = minSegmentWidth)
|
||||
.conditional(isFixed) {
|
||||
weight(1f)
|
||||
}
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ fun BigDecimalFiatFormat.defaultAmount(): BigDecimalFormat = BigDecimalFormat {
|
|||
)
|
||||
}
|
||||
} else {
|
||||
formatter.format(value)
|
||||
formatter.format(value.zeroIfRoundsToZero(FIAT_MARKET_DEFAULT_DIGITS))
|
||||
.replace(formatterCurrency.getSymbol(locale), fiatCurrencySymbol)
|
||||
}
|
||||
}
|
||||
|
|
@ -94,7 +94,7 @@ fun BigDecimalFiatFormatStyled.defaultAmount(spanStyleReference: SpanStyleRefere
|
|||
val formattingAmount = if (value.isLessThanThreshold()) {
|
||||
FIAT_FORMAT_THRESHOLD
|
||||
} else {
|
||||
value
|
||||
value.zeroIfRoundsToZero(FIAT_MARKET_DEFAULT_DIGITS)
|
||||
}
|
||||
|
||||
val decimalSeparator = (formatter as? DecimalFormat)?.decimalFormatSymbols?.decimalSeparator
|
||||
|
|
@ -273,6 +273,9 @@ fun BigDecimalFiatFormat.optionalDecimals(): BigDecimalFormat = BigDecimalFormat
|
|||
|
||||
private fun BigDecimal.isLessThanThreshold() = this > BigDecimal.ZERO && this < FIAT_FORMAT_THRESHOLD
|
||||
|
||||
private fun BigDecimal.zeroIfRoundsToZero(scale: Int): BigDecimal =
|
||||
if (setScale(scale, RoundingMode.HALF_UP).signum() == 0) BigDecimal.ZERO else this
|
||||
|
||||
/**
|
||||
* Returns amount with correct scale
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ object TangemPayTestTags {
|
|||
const val CARD_DETAILS_COPY_CVC = "TANGEM_PAY_CARD_DETAILS_COPY_CVC"
|
||||
|
||||
// Card management (card page settings)
|
||||
const val SHOW_DETAILS_ROW = "TANGEM_PAY_SHOW_DETAILS_ROW"
|
||||
const val CHANGE_PIN_ROW = "TANGEM_PAY_CHANGE_PIN_ROW"
|
||||
const val FREEZE_CARD_ROW = "TANGEM_PAY_FREEZE_CARD_ROW"
|
||||
const val CARD_FROZEN_BADGE = "TANGEM_PAY_CARD_FROZEN_BADGE"
|
||||
|
|
|
|||
9
core/ui/src/main/res/drawable/ic_swap_28.xml
Normal file
9
core/ui/src/main/res/drawable/ic_swap_28.xml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="28dp"
|
||||
android:height="28dp"
|
||||
android:viewportWidth="28"
|
||||
android:viewportHeight="28">
|
||||
<path
|
||||
android:pathData="M18.221,10.776C22.333,10.776 25.667,14.11 25.667,18.222C25.667,22.334 22.333,25.667 18.221,25.667C14.109,25.667 10.775,22.333 10.775,18.222C10.775,14.11 14.109,10.777 18.221,10.776ZM17.152,17.301L15.115,17.81L15.418,19.024L16.653,18.715L15.859,20.967C15.792,21.158 15.821,21.37 15.939,21.535C16.056,21.701 16.246,21.8 16.448,21.8H21.177V20.55H17.332L18.107,18.351L20.146,17.842L19.843,16.629L18.606,16.938L19.401,14.686L18.223,14.269L17.152,17.301ZM9.779,2.333C13.861,2.333 17.173,5.617 17.223,9.687C15.365,9.902 13.689,10.708 12.386,11.914C12.441,11.727 12.47,11.525 12.47,11.307C12.47,10.301 11.878,9.682 10.618,9.389L10.15,9.285V7.188C10.814,7.268 11.267,7.687 11.287,8.22H12.337C12.306,7.139 11.452,6.367 10.15,6.258V5.585H9.461V6.258C8.108,6.377 7.28,7.124 7.28,8.244C7.28,9.195 7.892,9.847 9.014,10.121L9.461,10.236V12.451C8.715,12.366 8.231,11.963 8.18,11.386H7.115C7.12,12.516 8.026,13.277 9.461,13.372V14H10.15V13.367C10.608,13.328 11.005,13.23 11.336,13.076C10.451,14.259 9.865,15.679 9.687,17.222C5.617,17.172 2.333,13.86 2.333,9.779C2.333,5.667 5.667,2.333 9.779,2.333ZM10.15,10.36C11.004,10.525 11.4,10.863 11.4,11.421C11.4,12.038 10.942,12.417 10.15,12.462V10.36ZM9.461,9.146C8.741,9.006 8.355,8.657 8.355,8.159C8.356,7.627 8.818,7.219 9.461,7.184V9.146Z"
|
||||
android:fillColor="#ffffff"/>
|
||||
</vector>
|
||||
|
|
@ -100,6 +100,38 @@ internal class BigDecimalFiatFormatTest {
|
|||
.isEqualTo("<" + "0,01".addSymbolWithSpaceRight(usdSymbol))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `defaultAmount tiny negative rounding to zero is formatted without sign`() {
|
||||
val testValue = BigDecimal("-0.0001")
|
||||
|
||||
val formatted = testValue.format {
|
||||
fiat(
|
||||
fiatCurrencyCode = usdCurrencyCode,
|
||||
fiatCurrencySymbol = usdSymbol,
|
||||
locale = testLocale,
|
||||
).defaultAmount()
|
||||
}
|
||||
|
||||
Truth.assertThat(formatted)
|
||||
.isEqualTo("0.00".addUsdSymbolLeft())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `defaultAmount negative rounding away from zero keeps sign`() {
|
||||
val testValue = BigDecimal("-0.005")
|
||||
|
||||
val formatted = testValue.format {
|
||||
fiat(
|
||||
fiatCurrencyCode = usdCurrencyCode,
|
||||
fiatCurrencySymbol = usdSymbol,
|
||||
locale = testLocale,
|
||||
).defaultAmount()
|
||||
}
|
||||
|
||||
Truth.assertThat(formatted)
|
||||
.isEqualTo("-" + "0.01".addUsdSymbolLeft())
|
||||
}
|
||||
|
||||
// === approximateAmount() ===
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -255,10 +255,18 @@ internal interface TangemPayDataModule {
|
|||
}
|
||||
|
||||
@Provides
|
||||
fun provideRestoreActiveOrdersUseCase(
|
||||
fun provideRestoreActiveIssueOrdersUseCase(
|
||||
customerOrderRepository: CustomerOrderRepository,
|
||||
): RestoreActiveOrdersUseCase {
|
||||
return RestoreActiveOrdersUseCase(customerOrderRepository)
|
||||
issueCardRepository: TangemPayIssueCardRepository,
|
||||
startTangemPayOrderPollingUseCase: StartTangemPayOrderPollingUseCase,
|
||||
appCoroutineScope: AppCoroutineScope,
|
||||
): RestoreActiveIssueOrdersUseCase {
|
||||
return RestoreActiveIssueOrdersUseCase(
|
||||
customerOrderRepository = customerOrderRepository,
|
||||
issueCardRepository = issueCardRepository,
|
||||
startTangemPayOrderPollingUseCase = startTangemPayOrderPollingUseCase,
|
||||
appCoroutineScope = appCoroutineScope,
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
|
|
|
|||
|
|
@ -465,6 +465,17 @@ internal class DefaultWalletManagersFacade @Inject constructor(
|
|||
return getEnabledDynamicAddressesManagerOrNull(userWalletId, network) != null
|
||||
}
|
||||
|
||||
override suspend fun getPsbtFee(userWalletId: UserWalletId, network: Network, psbtBase64: String): BigDecimal? =
|
||||
withContext(dispatchers.io) {
|
||||
val walletManager = getOrCreateWalletManager(userWalletId = userWalletId, network = network)
|
||||
?: return@withContext null
|
||||
|
||||
when (val result = walletManager.getPsbtFee(psbtBase64)) {
|
||||
is Result.Success -> result.data.toBigDecimal()
|
||||
is Result.Failure -> null
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getDynamicAddressesReceiveAddress(userWalletId: UserWalletId, network: Network): String? {
|
||||
val dynamicAddressesManager = getEnabledDynamicAddressesManagerOrNull(userWalletId, network) ?: return null
|
||||
return dynamicAddressesManager.findFirstUnusedReceiveAddress()?.address
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
package com.tangem.domain.transaction.usecase
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import com.tangem.blockchain.common.TransactionSigner
|
||||
import com.tangem.blockchain.extensions.Result
|
||||
import com.tangem.domain.card.common.TapWorkarounds.isTangemTwins
|
||||
import com.tangem.domain.card.models.TwinKey
|
||||
import com.tangem.domain.card.repository.CardSdkConfigRepository
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.transaction.error.SendTransactionError
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
|
||||
/**
|
||||
* Signs and broadcasts a Bitcoin swap transaction supplied by a DEX provider as a Base64 PSBT.
|
||||
*
|
||||
* Unlike a normal send (where we build the transaction ourselves), the provider returns an
|
||||
* almost-complete transaction encoded as a PSBT in `txData`. This use case:
|
||||
* 1. derives which inputs belong to the wallet ([WalletManager.deriveSignInputs]),
|
||||
* 2. signs them with the card/hot signer ([WalletManager.signPsbt]),
|
||||
* 3. finalizes and broadcasts the transaction ([WalletManager.broadcastPsbt]),
|
||||
*
|
||||
* returning the resulting transaction hash. Errors from any step are mapped to [SendTransactionError]
|
||||
* via [SendTransactionUseCase.handleError].
|
||||
*/
|
||||
class SignAndBroadcastPsbtUseCase(
|
||||
private val cardSdkConfigRepository: CardSdkConfigRepository,
|
||||
private val walletManagersFacade: WalletManagersFacade,
|
||||
private val getHotTransactionSigner: (UserWallet.Hot) -> TransactionSigner,
|
||||
) {
|
||||
|
||||
suspend operator fun invoke(
|
||||
psbtBase64: String,
|
||||
userWallet: UserWallet,
|
||||
network: Network,
|
||||
): Either<SendTransactionError, String> {
|
||||
walletManagersFacade.update(
|
||||
userWalletId = userWallet.walletId,
|
||||
network = network,
|
||||
extraTokens = emptySet(),
|
||||
)
|
||||
|
||||
val walletManager = walletManagersFacade.getOrCreateWalletManager(userWallet.walletId, network)
|
||||
?: return SendTransactionError.UnknownError().left()
|
||||
|
||||
val signInputs = when (val result = walletManager.deriveSignInputs(psbtBase64)) {
|
||||
is Result.Success -> result.data
|
||||
is Result.Failure -> return SendTransactionUseCase.handleError(result).left()
|
||||
}
|
||||
|
||||
val signer = createSigner(userWallet)
|
||||
|
||||
val signedPsbt = when (val result = walletManager.signPsbt(psbtBase64, signInputs, signer)) {
|
||||
is Result.Success -> result.data
|
||||
is Result.Failure -> return SendTransactionUseCase.handleError(result).left()
|
||||
}
|
||||
|
||||
return when (val result = walletManager.broadcastPsbt(signedPsbt)) {
|
||||
is Result.Success -> result.data.right()
|
||||
is Result.Failure -> SendTransactionUseCase.handleError(result).left()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSigner(userWallet: UserWallet): TransactionSigner {
|
||||
return when (userWallet) {
|
||||
is UserWallet.Hot -> getHotTransactionSigner(userWallet)
|
||||
is UserWallet.Cold -> {
|
||||
val card = userWallet.scanResponse.card
|
||||
val isCardNotBackedUp = card.backupStatus?.isActive != true && !card.isTangemTwins
|
||||
cardSdkConfigRepository.getCommonSigner(
|
||||
cardId = card.cardId.takeIf { isCardNotBackedUp },
|
||||
twinKey = TwinKey.getOrNull(scanResponse = userWallet.scanResponse),
|
||||
userWalletId = userWallet.walletId,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -295,7 +295,7 @@ class CreateAndSendGaslessTransactionUseCase(
|
|||
private suspend fun getEIP7702DataForGasless(
|
||||
gaslessDataProvider: EthereumGaslessDataProvider,
|
||||
): EIP7702AuthorizationData {
|
||||
return when (val dataResult = gaslessDataProvider.prepareEIP7702AuthorizationData()) {
|
||||
return when (val dataResult = gaslessDataProvider.prepareEIP7702AuthorizationData(isV2 = false)) {
|
||||
is Result.Failure -> throw dataResult.error
|
||||
is Result.Success -> dataResult.data
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,116 @@
|
|||
package com.tangem.domain.transaction.usecase
|
||||
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.blockchain.blockchains.bitcoin.walletconnect.models.SignInput
|
||||
import com.tangem.blockchain.common.BlockchainSdkError
|
||||
import com.tangem.blockchain.common.TransactionSigner
|
||||
import com.tangem.blockchain.common.WalletManager
|
||||
import com.tangem.blockchain.extensions.Result
|
||||
import com.tangem.domain.card.repository.CardSdkConfigRepository
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.walletmanager.WalletManagersFacade
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
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
|
||||
|
||||
/**
|
||||
* Tests for [SignAndBroadcastPsbtUseCase] — the orchestration that derives the wallet's inputs from a
|
||||
* provider PSBT, signs them, and broadcasts the finalized transaction (Bitcoin swap flow).
|
||||
*/
|
||||
internal class SignAndBroadcastPsbtUseCaseTest {
|
||||
|
||||
private val walletManagersFacade: WalletManagersFacade = mockk()
|
||||
private val cardSdkConfigRepository: CardSdkConfigRepository = mockk(relaxed = true)
|
||||
private val signer: TransactionSigner = mockk(relaxed = true)
|
||||
private val walletManager: WalletManager = mockk()
|
||||
|
||||
private val useCase = SignAndBroadcastPsbtUseCase(
|
||||
cardSdkConfigRepository = cardSdkConfigRepository,
|
||||
walletManagersFacade = walletManagersFacade,
|
||||
getHotTransactionSigner = { signer },
|
||||
)
|
||||
|
||||
private val userWalletId = UserWalletId(stringValue = "deadbeef")
|
||||
private val network: Network = mockk(relaxed = true)
|
||||
private val userWallet: UserWallet = mockk<UserWallet.Hot>(relaxed = true) {
|
||||
every { walletId } returns userWalletId
|
||||
}
|
||||
|
||||
private val psbt = "psbt-base64"
|
||||
private val signInputs = listOf(SignInput(address = "addr", index = 0, sighashTypes = listOf(1)))
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
// The use case refreshes the wallet manager (fresh UTXO set) before signing the PSBT.
|
||||
coEvery { walletManagersFacade.update(userWalletId, network, emptySet()) } returns mockk(relaxed = true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN derive sign broadcast succeed WHEN invoke THEN returns tx hash`() = runTest {
|
||||
// Arrange
|
||||
coEvery { walletManagersFacade.getOrCreateWalletManager(userWalletId, network) } returns walletManager
|
||||
every { walletManager.deriveSignInputs(psbt) } returns Result.Success(signInputs)
|
||||
coEvery { walletManager.signPsbt(psbt, signInputs, signer) } returns Result.Success("signed-psbt")
|
||||
coEvery { walletManager.broadcastPsbt("signed-psbt") } returns Result.Success("tx-hash")
|
||||
|
||||
// Act
|
||||
val actual = useCase(psbtBase64 = psbt, userWallet = userWallet, network = network)
|
||||
|
||||
// Assert
|
||||
assertThat(actual).isEqualTo("tx-hash".right())
|
||||
coVerify(exactly = 1) { walletManagersFacade.update(userWalletId, network, emptySet()) }
|
||||
coVerify(exactly = 1) { walletManager.broadcastPsbt("signed-psbt") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN deriveSignInputs fails WHEN invoke THEN returns error and does not sign`() = runTest {
|
||||
// Arrange
|
||||
coEvery { walletManagersFacade.getOrCreateWalletManager(userWalletId, network) } returns walletManager
|
||||
every {
|
||||
walletManager.deriveSignInputs(psbt)
|
||||
} returns Result.Failure(BlockchainSdkError.CustomError("no inputs"))
|
||||
|
||||
// Act
|
||||
val actual = useCase(psbtBase64 = psbt, userWallet = userWallet, network = network)
|
||||
|
||||
// Assert
|
||||
assertThat(actual.isLeft()).isTrue()
|
||||
coVerify(exactly = 0) { walletManager.signPsbt(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN signPsbt fails WHEN invoke THEN returns error and does not broadcast`() = runTest {
|
||||
// Arrange
|
||||
coEvery { walletManagersFacade.getOrCreateWalletManager(userWalletId, network) } returns walletManager
|
||||
every { walletManager.deriveSignInputs(psbt) } returns Result.Success(signInputs)
|
||||
coEvery {
|
||||
walletManager.signPsbt(psbt, signInputs, signer)
|
||||
} returns Result.Failure(BlockchainSdkError.CustomError("sign fail"))
|
||||
|
||||
// Act
|
||||
val actual = useCase(psbtBase64 = psbt, userWallet = userWallet, network = network)
|
||||
|
||||
// Assert
|
||||
assertThat(actual.isLeft()).isTrue()
|
||||
coVerify(exactly = 0) { walletManager.broadcastPsbt(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN wallet manager missing WHEN invoke THEN returns error`() = runTest {
|
||||
// Arrange
|
||||
coEvery { walletManagersFacade.getOrCreateWalletManager(userWalletId, network) } returns null
|
||||
|
||||
// Act
|
||||
val actual = useCase(psbtBase64 = psbt, userWallet = userWallet, network = network)
|
||||
|
||||
// Assert
|
||||
assertThat(actual.isLeft()).isTrue()
|
||||
}
|
||||
}
|
||||
|
|
@ -49,7 +49,7 @@ object OrderConflictRules {
|
|||
private fun blocks(intent: OrderIntent, order: Order): Boolean {
|
||||
if (!order.isActive) return false
|
||||
return when (intent) {
|
||||
OrderIntent.IssueCard -> order.type.isIssuing()
|
||||
OrderIntent.IssueCard -> order.type.isIssuing
|
||||
OrderIntent.Withdraw -> order.type == OrderType.WITHDRAW
|
||||
is OrderIntent.Freeze -> sameProductInstance(order, intent.productInstanceId) &&
|
||||
order.type.isFreezeOrReissue()
|
||||
|
|
@ -65,15 +65,7 @@ object OrderConflictRules {
|
|||
return order.productInstanceId == productInstanceId
|
||||
}
|
||||
|
||||
private fun OrderType.isIssuing(): Boolean {
|
||||
return this == OrderType.CARD_ISSUE_ADDITIONAL ||
|
||||
this == OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC ||
|
||||
this == OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC_V2
|
||||
}
|
||||
|
||||
private fun OrderType.isFreezeOrReissue(): Boolean {
|
||||
return this == OrderType.CARD_FREEZE ||
|
||||
this == OrderType.CARD_UNFREEZE ||
|
||||
this == OrderType.CARD_REISSUE
|
||||
return this.isFreezingUnfreezing || this.isReissuing
|
||||
}
|
||||
}
|
||||
|
|
@ -8,8 +8,16 @@ enum class OrderStatus {
|
|||
;
|
||||
|
||||
/** An order is active while it is still being processed (NEW or PROCESSING). */
|
||||
val isActive: Boolean get() = this == NEW || this == PROCESSING
|
||||
val isActive: Boolean get() = activeStatuses.contains(this)
|
||||
|
||||
/** Terminal statuses (COMPLETED or CANCELED) — used to invalidate the local order hint. */
|
||||
val isTerminal: Boolean get() = this == COMPLETED || this == CANCELED
|
||||
val isTerminal: Boolean get() = terminalStatuses.contains(this)
|
||||
|
||||
companion object {
|
||||
/** Statuses of an in-flight order (still being processed). */
|
||||
val activeStatuses: Set<OrderStatus> = setOf(NEW, PROCESSING)
|
||||
|
||||
/** Statuses of a finished order — no further state changes are expected. */
|
||||
val terminalStatuses: Set<OrderStatus> = setOf(COMPLETED, CANCELED)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package com.tangem.domain.pay.model
|
||||
|
||||
import com.tangem.domain.pay.model.OrderType.Companion.fromString
|
||||
import com.tangem.domain.pay.model.OrderType.Companion.issueCardTypes
|
||||
|
||||
/**
|
||||
* Order type used for findOrders filtering and order-conflict checks.
|
||||
|
|
@ -10,8 +11,9 @@ import com.tangem.domain.pay.model.OrderType.Companion.fromString
|
|||
*/
|
||||
enum class OrderType(val wireValue: String) {
|
||||
CARD_ISSUE_ADDITIONAL("CARD_ISSUE_ADDITIONAL"),
|
||||
CARD_ISSUE_VIRTUAL_RAIN_KYC_V2("CARD_ISSUE_VIRTUAL_RAIN_KYC_V2"),
|
||||
CARD_ISSUE_VIRTUAL_RAIN("CARD_ISSUE_VIRTUAL_RAIN"),
|
||||
CARD_ISSUE_VIRTUAL_RAIN_KYC("CARD_ISSUE_VIRTUAL_RAIN_KYC"),
|
||||
CARD_ISSUE_VIRTUAL_RAIN_KYC_V2("CARD_ISSUE_VIRTUAL_RAIN_KYC_V2"),
|
||||
CARD_REISSUE("CARD_REISSUE"),
|
||||
CARD_FREEZE("CARD_FREEZE"),
|
||||
CARD_UNFREEZE("CARD_UNFREEZE"),
|
||||
|
|
@ -19,7 +21,29 @@ enum class OrderType(val wireValue: String) {
|
|||
UNKNOWN(""),
|
||||
;
|
||||
|
||||
/** `true` for any card-issuance order type — see [issueCardTypes]. */
|
||||
val isIssuing: Boolean get() = issueCardTypes.contains(this)
|
||||
|
||||
/** `true` for card freeze / unfreeze orders. */
|
||||
val isFreezingUnfreezing: Boolean get() = this == CARD_FREEZE || this == CARD_UNFREEZE
|
||||
|
||||
/** `true` for card reissue orders. */
|
||||
val isReissuing: Boolean get() = this == CARD_REISSUE
|
||||
|
||||
companion object {
|
||||
|
||||
/**
|
||||
* All order types that represent issuing a card: the first virtual card (and its KYC
|
||||
* variants) and an additional card. Used both to filter `findOrders` and to detect
|
||||
* issue-card conflicts.
|
||||
*/
|
||||
val issueCardTypes = setOf(
|
||||
CARD_ISSUE_ADDITIONAL,
|
||||
CARD_ISSUE_VIRTUAL_RAIN,
|
||||
CARD_ISSUE_VIRTUAL_RAIN_KYC,
|
||||
CARD_ISSUE_VIRTUAL_RAIN_KYC_V2,
|
||||
)
|
||||
|
||||
fun fromString(value: String?): OrderType {
|
||||
if (value.isNullOrBlank()) return UNKNOWN
|
||||
return entries.firstOrNull { it.wireValue == value || it.name == value } ?: UNKNOWN
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import arrow.core.raise.catch
|
|||
import arrow.core.raise.either
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pay.model.Offer
|
||||
import com.tangem.domain.pay.model.OrderStatus
|
||||
import com.tangem.domain.pay.model.OrderType
|
||||
import com.tangem.domain.pay.model.TangemPayOrderInfo
|
||||
import com.tangem.domain.pay.repository.CustomerOffersRepository
|
||||
|
|
@ -56,11 +57,8 @@ class IssueAdditionalCardUseCase(
|
|||
customerOrderRepository
|
||||
.findOrders(
|
||||
userWalletId = userWalletId,
|
||||
types = setOf(
|
||||
offer.data.orderType,
|
||||
OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC,
|
||||
OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC_V2,
|
||||
),
|
||||
types = setOf(offer.data.orderType) + OrderType.issueCardTypes,
|
||||
statuses = OrderStatus.activeStatuses,
|
||||
)
|
||||
.bind()
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
package com.tangem.domain.pay.usecase
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.raise.Raise
|
||||
import arrow.core.raise.catch
|
||||
import arrow.core.raise.either
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pay.model.OrderStatus
|
||||
import com.tangem.domain.pay.model.OrderType
|
||||
import com.tangem.domain.pay.model.TangemPayOrderInfo
|
||||
import com.tangem.domain.pay.repository.CustomerOrderRepository
|
||||
import com.tangem.domain.pay.repository.TangemPayIssueCardRepository
|
||||
import com.tangem.domain.visa.error.VisaApiError
|
||||
import com.tangem.utils.coroutines.AppCoroutineScope
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Restores in-flight card-issuance orders on app launch / when returning to the wallet screen.
|
||||
*
|
||||
* `findOrders` is the source of truth — a locally stored order id is only a hint that does not
|
||||
|
||||
* survive a force close. This use case re-discovers the active issue orders, persists their ids so
|
||||
* the payment-account state renders an "issuing" placeholder card, and (re)starts polling so the
|
||||
* placeholder is driven to its terminal state.
|
||||
*
|
||||
* Non-fatal exceptions are logged and collapsed to [VisaApiError.Unspecified]; the caller treats the
|
||||
* result as fire-and-forget.
|
||||
*
|
||||
* @property customerOrderRepository source of truth for active orders.
|
||||
* @property issueCardRepository persists issue-order ids for placeholder rendering.
|
||||
* @property startTangemPayOrderPollingUseCase drives a restored order to its terminal state.
|
||||
*/
|
||||
class RestoreActiveIssueOrdersUseCase(
|
||||
private val customerOrderRepository: CustomerOrderRepository,
|
||||
private val issueCardRepository: TangemPayIssueCardRepository,
|
||||
private val startTangemPayOrderPollingUseCase: StartTangemPayOrderPollingUseCase,
|
||||
private val appCoroutineScope: AppCoroutineScope,
|
||||
) {
|
||||
suspend operator fun invoke(userWalletId: UserWalletId): Either<VisaApiError, Unit> = either {
|
||||
val orders = catch(
|
||||
block = {
|
||||
customerOrderRepository.findOrders(
|
||||
userWalletId = userWalletId,
|
||||
types = OrderType.issueCardTypes,
|
||||
statuses = OrderStatus.activeStatuses,
|
||||
).bind()
|
||||
},
|
||||
catch = { handleError(it) },
|
||||
).filter { it.status.isActive }
|
||||
|
||||
orders.forEach { order ->
|
||||
issueCardRepository.storeIssueOrderId(userWalletId = userWalletId, orderId = order.id)
|
||||
|
||||
appCoroutineScope.launch {
|
||||
startTangemPayOrderPollingUseCase(
|
||||
order = TangemPayOrderInfo(orderId = order.id, orderStatus = order.status),
|
||||
userWalletId = userWalletId,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Raise<VisaApiError>.handleError(throwable: Throwable): Nothing {
|
||||
TangemLogger.e("Error in RestoreActiveIssueOrdersUseCase", throwable)
|
||||
raise(VisaApiError.Unspecified)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
package com.tangem.domain.pay.usecase
|
||||
|
||||
import arrow.core.Either
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pay.model.Order
|
||||
import com.tangem.domain.pay.model.OrderStatus
|
||||
import com.tangem.domain.pay.repository.CustomerOrderRepository
|
||||
import com.tangem.domain.visa.error.VisaApiError
|
||||
|
||||
/**
|
||||
|
||||
* same customer.
|
||||
*
|
||||
* Wraps `findOrders` (the source of truth) and filters to the active set (NEW / PROCESSING).
|
||||
* The caller decides how to dispatch each order to the appropriate flow.
|
||||
*/
|
||||
class RestoreActiveOrdersUseCase(
|
||||
private val customerOrderRepository: CustomerOrderRepository,
|
||||
) {
|
||||
suspend operator fun invoke(userWalletId: UserWalletId): Either<VisaApiError, List<Order>> {
|
||||
return customerOrderRepository.findOrders(
|
||||
userWalletId = userWalletId,
|
||||
statuses = ACTIVE_STATUSES,
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val ACTIVE_STATUSES: Set<OrderStatus> = setOf(OrderStatus.NEW, OrderStatus.PROCESSING)
|
||||
}
|
||||
}
|
||||
|
|
@ -6,25 +6,43 @@ import com.tangem.domain.pay.model.OrderStatus
|
|||
import com.tangem.domain.pay.model.TangemPayOrderInfo
|
||||
import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository
|
||||
import kotlinx.coroutines.delay
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class StartTangemPayOrderPollingUseCase(
|
||||
private val cardDetailsRepository: TangemPayCardDetailsRepository,
|
||||
private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Order keys (`walletId:orderId`) currently being polled. Keeps polling idempotent so callers that
|
||||
* may fire repeatedly for the same order (e.g. order restore on every wallet (re)load) never spawn a
|
||||
* second poller for it.
|
||||
*/
|
||||
private val activeOrders = ConcurrentHashMap.newKeySet<String>()
|
||||
|
||||
suspend operator fun invoke(order: TangemPayOrderInfo, userWalletId: UserWalletId): Boolean {
|
||||
while (true) {
|
||||
val newOrder = if (order.orderStatus.isTerminal) {
|
||||
order
|
||||
} else {
|
||||
cardDetailsRepository.getOrderInfo(userWalletId, order.orderId).getOrNull()
|
||||
}
|
||||
// A poller for this exact order is already running — `false` only reaches fire-and-forget issue
|
||||
// callers (restore / issue-additional); the awaiting freeze caller always polls a fresh order id.
|
||||
val key = "${userWalletId.stringValue}:${order.orderId}"
|
||||
if (!activeOrders.add(key)) return false
|
||||
|
||||
if (newOrder != null && newOrder.orderStatus.isTerminal) {
|
||||
paymentAccountStatusFetcher.invoke(userWalletId)
|
||||
return newOrder.orderStatus == OrderStatus.COMPLETED
|
||||
}
|
||||
try {
|
||||
while (true) {
|
||||
val newOrder = if (order.orderStatus.isTerminal) {
|
||||
order
|
||||
} else {
|
||||
cardDetailsRepository.getOrderInfo(userWalletId, order.orderId).getOrNull()
|
||||
}
|
||||
|
||||
delay(POLLING_DELAY)
|
||||
if (newOrder != null && newOrder.orderStatus.isTerminal) {
|
||||
paymentAccountStatusFetcher.invoke(userWalletId)
|
||||
return newOrder.orderStatus == OrderStatus.COMPLETED
|
||||
}
|
||||
|
||||
delay(POLLING_DELAY)
|
||||
}
|
||||
} finally {
|
||||
activeOrders.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,15 @@ internal class OrderConflictRulesTest {
|
|||
assertThat(resolution).isInstanceOf(ConflictResolution.Blocked::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IssueCard is blocked by an active plain virtual-card issue order`() {
|
||||
val active = listOf(order(type = OrderType.CARD_ISSUE_VIRTUAL_RAIN, status = OrderStatus.PROCESSING))
|
||||
|
||||
val resolution = OrderConflictRules.resolve(OrderIntent.IssueCard, active)
|
||||
|
||||
assertThat(resolution).isInstanceOf(ConflictResolution.Blocked::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IssueCard is allowed when only withdraw is active`() {
|
||||
val active = listOf(order(type = OrderType.WITHDRAW, status = OrderStatus.PROCESSING))
|
||||
|
|
|
|||
|
|
@ -67,10 +67,11 @@ internal class IssueAdditionalCardUseCaseTest {
|
|||
userWalletId,
|
||||
types = setOf(
|
||||
OrderType.CARD_ISSUE_ADDITIONAL,
|
||||
OrderType.CARD_ISSUE_VIRTUAL_RAIN,
|
||||
OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC,
|
||||
OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC_V2,
|
||||
),
|
||||
statuses = emptySet(),
|
||||
statuses = setOf(OrderStatus.NEW, OrderStatus.PROCESSING),
|
||||
)
|
||||
} returns listOf(existing).right()
|
||||
|
||||
|
|
@ -89,10 +90,11 @@ internal class IssueAdditionalCardUseCaseTest {
|
|||
userWalletId,
|
||||
types = setOf(
|
||||
OrderType.CARD_ISSUE_ADDITIONAL,
|
||||
OrderType.CARD_ISSUE_VIRTUAL_RAIN,
|
||||
OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC,
|
||||
OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC_V2,
|
||||
),
|
||||
statuses = emptySet(),
|
||||
statuses = setOf(OrderStatus.NEW, OrderStatus.PROCESSING),
|
||||
)
|
||||
} returns emptyList<Order>().right()
|
||||
coEvery {
|
||||
|
|
@ -117,10 +119,11 @@ internal class IssueAdditionalCardUseCaseTest {
|
|||
userWalletId,
|
||||
types = setOf(
|
||||
OrderType.CARD_ISSUE_ADDITIONAL,
|
||||
OrderType.CARD_ISSUE_VIRTUAL_RAIN,
|
||||
OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC,
|
||||
OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC_V2,
|
||||
),
|
||||
statuses = emptySet(),
|
||||
statuses = setOf(OrderStatus.NEW, OrderStatus.PROCESSING),
|
||||
)
|
||||
} returns emptyList<Order>().right()
|
||||
val newOrder = order(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,130 @@
|
|||
package com.tangem.domain.pay.usecase
|
||||
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pay.model.Order
|
||||
import com.tangem.domain.pay.model.OrderStatus
|
||||
import com.tangem.domain.pay.model.OrderType
|
||||
import com.tangem.domain.pay.model.TangemPayOrderInfo
|
||||
import com.tangem.domain.pay.repository.CustomerOrderRepository
|
||||
import com.tangem.domain.pay.repository.TangemPayIssueCardRepository
|
||||
import com.tangem.domain.visa.error.VisaApiError
|
||||
import com.tangem.test.core.TestAppCoroutineScope
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
internal class RestoreActiveIssueOrdersUseCaseTest {
|
||||
|
||||
private val orderRepository: CustomerOrderRepository = mockk()
|
||||
private val issueCardRepository: TangemPayIssueCardRepository = mockk(relaxed = true)
|
||||
private val startTangemPayOrderPollingUseCase: StartTangemPayOrderPollingUseCase = mockk(relaxed = true)
|
||||
private val useCase = RestoreActiveIssueOrdersUseCase(
|
||||
customerOrderRepository = orderRepository,
|
||||
issueCardRepository = issueCardRepository,
|
||||
startTangemPayOrderPollingUseCase = startTangemPayOrderPollingUseCase,
|
||||
appCoroutineScope = TestAppCoroutineScope(),
|
||||
)
|
||||
private val userWalletId = UserWalletId("1234567890ABCDEF")
|
||||
|
||||
@Test
|
||||
fun `GIVEN active issue orders WHEN invoke THEN each order is stored and polled`() = runTest {
|
||||
// Arrange
|
||||
val first = order(id = "first", type = OrderType.CARD_ISSUE_ADDITIONAL, status = OrderStatus.NEW)
|
||||
val second = order(id = "second", type = OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC, status = OrderStatus.PROCESSING)
|
||||
coEvery {
|
||||
orderRepository.findOrders(
|
||||
userWalletId = userWalletId,
|
||||
types = ISSUE_ORDER_TYPES,
|
||||
statuses = ACTIVE_STATUSES,
|
||||
)
|
||||
} returns listOf(first, second).right()
|
||||
|
||||
// Act
|
||||
val result = useCase(userWalletId)
|
||||
|
||||
// Assert
|
||||
assertThat(result.isRight()).isTrue()
|
||||
coVerify(exactly = 1) { issueCardRepository.storeIssueOrderId(userWalletId, first.id) }
|
||||
coVerify(exactly = 1) { issueCardRepository.storeIssueOrderId(userWalletId, second.id) }
|
||||
coVerify(exactly = 1) {
|
||||
startTangemPayOrderPollingUseCase(TangemPayOrderInfo(first.id, first.status), userWalletId)
|
||||
}
|
||||
coVerify(exactly = 1) {
|
||||
startTangemPayOrderPollingUseCase(TangemPayOrderInfo(second.id, second.status), userWalletId)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no active orders WHEN invoke THEN nothing is stored or polled`() = runTest {
|
||||
// Arrange
|
||||
coEvery {
|
||||
orderRepository.findOrders(userWalletId, types = ISSUE_ORDER_TYPES, statuses = ACTIVE_STATUSES)
|
||||
} returns emptyList<Order>().right()
|
||||
|
||||
// Act
|
||||
val result = useCase(userWalletId)
|
||||
|
||||
// Assert
|
||||
assertThat(result.isRight()).isTrue()
|
||||
coVerify(exactly = 0) { issueCardRepository.storeIssueOrderId(any(), any()) }
|
||||
coVerify(exactly = 0) { startTangemPayOrderPollingUseCase(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN a terminal order leaks through WHEN invoke THEN it is filtered out`() = runTest {
|
||||
// Arrange
|
||||
val completed = order(id = "done", type = OrderType.CARD_ISSUE_ADDITIONAL, status = OrderStatus.COMPLETED)
|
||||
coEvery {
|
||||
orderRepository.findOrders(userWalletId, types = ISSUE_ORDER_TYPES, statuses = ACTIVE_STATUSES)
|
||||
} returns listOf(completed).right()
|
||||
|
||||
// Act
|
||||
val result = useCase(userWalletId)
|
||||
|
||||
// Assert
|
||||
assertThat(result.isRight()).isTrue()
|
||||
coVerify(exactly = 0) { issueCardRepository.storeIssueOrderId(any(), any()) }
|
||||
coVerify(exactly = 0) { startTangemPayOrderPollingUseCase(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN findOrders fails WHEN invoke THEN returns Unspecified and stores nothing`() = runTest {
|
||||
// Arrange
|
||||
coEvery {
|
||||
orderRepository.findOrders(userWalletId, types = ISSUE_ORDER_TYPES, statuses = ACTIVE_STATUSES)
|
||||
} returns VisaApiError.Unspecified.left()
|
||||
|
||||
// Act
|
||||
val result = useCase(userWalletId)
|
||||
|
||||
// Assert
|
||||
assertThat(result.leftOrNull()).isEqualTo(VisaApiError.Unspecified)
|
||||
coVerify(exactly = 0) { issueCardRepository.storeIssueOrderId(any(), any()) }
|
||||
coVerify(exactly = 0) { startTangemPayOrderPollingUseCase(any(), any()) }
|
||||
}
|
||||
|
||||
private fun order(id: String, type: OrderType, status: OrderStatus): Order = Order(
|
||||
id = id,
|
||||
customerId = "customer",
|
||||
type = type,
|
||||
status = status,
|
||||
step = null,
|
||||
stepChangeCode = null,
|
||||
productInstanceId = null,
|
||||
paymentAccountId = null,
|
||||
cardId = null,
|
||||
withdrawTxHash = null,
|
||||
createdAt = null,
|
||||
updatedAt = null,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
val ISSUE_ORDER_TYPES = OrderType.issueCardTypes
|
||||
val ACTIVE_STATUSES = OrderStatus.activeStatuses
|
||||
}
|
||||
}
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
package com.tangem.domain.pay.usecase
|
||||
|
||||
import arrow.core.left
|
||||
import arrow.core.right
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.pay.model.Order
|
||||
import com.tangem.domain.pay.model.OrderStatus
|
||||
import com.tangem.domain.pay.model.OrderType
|
||||
import com.tangem.domain.pay.repository.CustomerOrderRepository
|
||||
import com.tangem.domain.visa.error.VisaApiError
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
internal class RestoreActiveOrdersUseCaseTest {
|
||||
|
||||
private val repository: CustomerOrderRepository = mockk()
|
||||
private val useCase = RestoreActiveOrdersUseCase(repository)
|
||||
private val userWalletId = UserWalletId("1234567890ABCDEF")
|
||||
|
||||
@Test
|
||||
fun `passes only NEW and PROCESSING statuses to findOrders`() = runTest {
|
||||
val expected = setOf(OrderStatus.NEW, OrderStatus.PROCESSING)
|
||||
coEvery { repository.findOrders(userWalletId, types = emptySet(), statuses = expected) } returns
|
||||
emptyList<Order>().right()
|
||||
|
||||
useCase(userWalletId)
|
||||
|
||||
coVerify(exactly = 1) { repository.findOrders(userWalletId, types = emptySet(), statuses = expected) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns the orders found by the repository`() = runTest {
|
||||
val orders = listOf(
|
||||
order(id = "issue", type = OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC, status = OrderStatus.PROCESSING),
|
||||
order(id = "withdraw", type = OrderType.WITHDRAW, status = OrderStatus.NEW),
|
||||
)
|
||||
coEvery { repository.findOrders(userWalletId, types = emptySet(), statuses = any()) } returns orders.right()
|
||||
|
||||
val result = useCase(userWalletId)
|
||||
|
||||
assertThat(result.getOrNull()).containsExactlyElementsIn(orders)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `surfaces repository errors`() = runTest {
|
||||
coEvery { repository.findOrders(userWalletId, types = emptySet(), statuses = any()) } returns
|
||||
VisaApiError.Unspecified.left()
|
||||
|
||||
val result = useCase(userWalletId)
|
||||
|
||||
assertThat(result.leftOrNull()).isEqualTo(VisaApiError.Unspecified)
|
||||
}
|
||||
|
||||
private fun order(id: String, type: OrderType, status: OrderStatus): Order = Order(
|
||||
id = id,
|
||||
customerId = "customer",
|
||||
type = type,
|
||||
status = status,
|
||||
step = null,
|
||||
stepChangeCode = null,
|
||||
productInstanceId = null,
|
||||
paymentAccountId = null,
|
||||
cardId = null,
|
||||
withdrawTxHash = null,
|
||||
createdAt = null,
|
||||
updatedAt = null,
|
||||
)
|
||||
}
|
||||
|
|
@ -9,7 +9,11 @@ import com.tangem.domain.pay.model.OrderStatus
|
|||
import com.tangem.domain.pay.model.TangemPayOrderInfo
|
||||
import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository
|
||||
import com.tangem.domain.visa.error.VisaApiError
|
||||
import io.mockk.*
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
|
|
@ -112,6 +116,28 @@ internal class StartTangemPayOrderPollingUseCaseTest {
|
|||
coVerify(exactly = 1) { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN order already being polled WHEN invoke again for same order THEN returns false without a second poll`() =
|
||||
runTest {
|
||||
// Arrange — first poller never reaches a terminal status, so it keeps polling.
|
||||
val order = TangemPayOrderInfo(ORDER_ID, OrderStatus.PROCESSING)
|
||||
coEvery { cardDetailsRepository.getOrderInfo(USER_WALLET_ID, ORDER_ID) } returns
|
||||
TangemPayOrderInfo(ORDER_ID, OrderStatus.PROCESSING).right()
|
||||
|
||||
// Act — start the first poller, let it register the order and park in its poll delay,
|
||||
// then invoke again for the same order.
|
||||
val firstPoller = launch { useCase(order, USER_WALLET_ID) }
|
||||
runCurrent()
|
||||
val secondResult = useCase(order, USER_WALLET_ID)
|
||||
|
||||
// Assert — the duplicate invoke is a no-op (no extra getOrderInfo, no status fetch).
|
||||
assertThat(secondResult).isFalse()
|
||||
coVerify(exactly = 1) { cardDetailsRepository.getOrderInfo(USER_WALLET_ID, ORDER_ID) }
|
||||
coVerify(exactly = 0) { paymentAccountStatusFetcher.invoke(USER_WALLET_ID) }
|
||||
|
||||
firstPoller.cancel()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val USER_WALLET_ID = UserWalletId("aabbcc112233")
|
||||
const val ORDER_ID = "order-test-1"
|
||||
|
|
|
|||
|
|
@ -237,6 +237,15 @@ interface WalletManagersFacade {
|
|||
derivationPath: String?,
|
||||
): BigDecimal
|
||||
|
||||
/**
|
||||
* Computes the on-chain miner fee embedded in a Bitcoin swap [psbtBase64], in satoshi.
|
||||
*
|
||||
* Swap providers return a "naked" PSBT whose fee is implied by `sum(inputs) - sum(outputs)`
|
||||
* rather than reported separately. Returns `null` if the wallet manager is unavailable, the
|
||||
* network is not a PSBT-capable Bitcoin chain, or the fee cannot be derived from the PSBT.
|
||||
*/
|
||||
suspend fun getPsbtFee(userWalletId: UserWalletId, network: Network, psbtBase64: String): BigDecimal?
|
||||
|
||||
/**
|
||||
* Get requirements for asset(currency)
|
||||
* @return null if there's no requirement, otherwise [AssetRequirementsCondition].
|
||||
|
|
|
|||
|
|
@ -7,11 +7,14 @@ import com.tangem.domain.models.wallet.UserWalletId
|
|||
import com.tangem.domain.yield.supply.models.YieldBoostPromo
|
||||
import com.tangem.domain.yield.supply.models.YieldBoostStatus
|
||||
import com.tangem.domain.yield.supply.promo.YieldPromoRepository
|
||||
import io.mockk.Deregisterable
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.mockk
|
||||
import io.mockk.registerInstanceFactory
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.datetime.Instant
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
|
|
@ -25,11 +28,23 @@ class IsYieldBoostPromoEnabledForTokenUseCaseTest {
|
|||
private val contractAddress = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"
|
||||
private val networkRawId = "ethereum"
|
||||
|
||||
// Stub concrete instances of the sealed return types so MockK doesn't subclass them while recording coEvery
|
||||
// (Objenesis on a JVM-sealed type throws InstantiationError flakily under full-suite CI runs).
|
||||
private val instanceFactories = mutableListOf<Deregisterable>()
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
instanceFactories += registerInstanceFactory { YieldBoostPromo.None }
|
||||
instanceFactories += registerInstanceFactory { YieldBoostStatus.NotStarted }
|
||||
useCase = IsYieldBoostPromoEnabledForTokenUseCase(repository = repository)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
instanceFactories.forEach { it.deregister() }
|
||||
instanceFactories.clear()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN currency is coin WHEN invoke THEN returns Right(false)`() = runTest {
|
||||
val coin = createCoin()
|
||||
|
|
|
|||
|
|
@ -5,11 +5,14 @@ import com.tangem.domain.models.wallet.UserWalletId
|
|||
import com.tangem.domain.yield.supply.models.YieldBoostPromo
|
||||
import com.tangem.domain.yield.supply.models.YieldBoostStatus
|
||||
import com.tangem.domain.yield.supply.promo.YieldPromoRepository
|
||||
import io.mockk.Deregisterable
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.mockk
|
||||
import io.mockk.registerInstanceFactory
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.datetime.Instant
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
|
|
@ -23,11 +26,23 @@ class ShouldShowYieldBoostMainBannerUseCaseTest {
|
|||
private val contractAddress = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"
|
||||
private val networkRawId = "ethereum"
|
||||
|
||||
// Stub concrete instances of the sealed return types so MockK doesn't subclass them while recording coEvery
|
||||
// (Objenesis on a JVM-sealed type throws InstantiationError flakily under full-suite CI runs).
|
||||
private val instanceFactories = mutableListOf<Deregisterable>()
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
instanceFactories += registerInstanceFactory { YieldBoostPromo.None }
|
||||
instanceFactories += registerInstanceFactory { YieldBoostStatus.NotStarted }
|
||||
useCase = ShouldShowYieldBoostMainBannerUseCase(repository = repository)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
instanceFactories.forEach { it.deregister() }
|
||||
instanceFactories.clear()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN promo repository throws WHEN invoke THEN returns Left`() = runTest {
|
||||
coEvery { repository.getYieldBoostPromo(userWalletId, false) } throws RuntimeException("net")
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ internal class PortfolioListBlockDelegate @AssistedInject constructor(
|
|||
|
||||
private val onTokenItemClick: Channel<Pair<AccountStatus, CryptoCurrencyStatus>> = Channel()
|
||||
|
||||
private val isOnlyMultiCurrency: Boolean get() = !featureSettings.isShowSingleCurrencyWallets
|
||||
|
||||
val onTokenChosen: Channel<ChooseTokenResult> = Channel()
|
||||
val tokenFilter: MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean> =
|
||||
MutableStateFlow { _, _ -> true }
|
||||
|
|
@ -53,7 +55,7 @@ internal class PortfolioListBlockDelegate @AssistedInject constructor(
|
|||
multiAccountStatusListSupplier.invokeAsMap()
|
||||
|
||||
val allWalletsFlow: StateFlow<LinkedHashMap<UserWalletId, UserWallet>> =
|
||||
getWalletsUseCase.invokeAsMap().stateIn(this)
|
||||
getWalletsUseCase.invokeAsMap(isOnlyMultiCurrency = isOnlyMultiCurrency).stateIn(this)
|
||||
|
||||
onTokenItemClick.receiveAsFlow()
|
||||
.onEach { (account, currencyStatus) ->
|
||||
|
|
|
|||
|
|
@ -307,7 +307,6 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
|
|||
preselectedNetworkId: String? = null,
|
||||
): DefaultEarnComponent.Params = DefaultEarnComponent.Params(
|
||||
onBackClick = onBack,
|
||||
onSearchClicked = clickIntents::openSearch,
|
||||
preselectedEarnType = preselectedEarnType,
|
||||
preselectedNetworkId = preselectedNetworkId,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -27,7 +27,10 @@ import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
|
|||
import com.tangem.core.ui.components.haze.hazeEffectTangem
|
||||
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
|
||||
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
|
||||
import com.tangem.core.ui.ds.topbar.TangemTopBar
|
||||
import com.tangem.core.ui.ds.topbar.TangemTopBarType
|
||||
import com.tangem.core.ui.extensions.clickableSingle
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.LocalMainBottomSheetColor
|
||||
import com.tangem.core.ui.res.LocalRedesignEnabled
|
||||
|
|
@ -36,7 +39,7 @@ import com.tangem.domain.models.earn.PreselectedEarnType
|
|||
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent
|
||||
import com.tangem.features.feed.components.feed.FeedBottomSheetRoute
|
||||
import com.tangem.features.feed.model.earn.EarnModel
|
||||
import com.tangem.features.feed.ui.components.FeedSearchBar
|
||||
import com.tangem.features.feed.ui.LocalIsOpenedInBottomSheet
|
||||
import com.tangem.features.feed.ui.earn.EarnContent
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
|
|
@ -60,9 +63,13 @@ internal class DefaultEarnComponent(
|
|||
val background = LocalMainBottomSheetColor.current.value
|
||||
val state by earnModel.state.collectAsStateWithLifecycle()
|
||||
if (LocalRedesignEnabled.current) {
|
||||
FeedSearchBar(
|
||||
isSearchBarClickable = bottomSheetState.value == BottomSheetState.EXPANDED,
|
||||
feedListSearchBar = state.feedListSearchBar,
|
||||
TangemTopBar(
|
||||
title = resourceReference(R.string.earn_title),
|
||||
type = if (LocalIsOpenedInBottomSheet.current) {
|
||||
TangemTopBarType.BottomSheet
|
||||
} else {
|
||||
TangemTopBarType.Default
|
||||
},
|
||||
startContent = {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.ic_arrow_back_28),
|
||||
|
|
@ -136,7 +143,6 @@ internal class DefaultEarnComponent(
|
|||
@Serializable
|
||||
data class Params(
|
||||
val onBackClick: () -> Unit,
|
||||
val onSearchClicked: (source: String) -> Unit,
|
||||
val preselectedEarnType: PreselectedEarnType? = null,
|
||||
val preselectedNetworkId: String? = null,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import com.arkivanov.decompose.router.slot.dismiss
|
|||
import com.tangem.common.routing.AppRoute
|
||||
import com.tangem.common.routing.AppRouter
|
||||
import com.tangem.core.analytics.api.AnalyticsEventHandler
|
||||
import com.tangem.core.analytics.models.AnalyticsParam
|
||||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.core.decompose.model.Model
|
||||
import com.tangem.core.decompose.model.ParamsContainer
|
||||
|
|
@ -22,8 +21,8 @@ import com.tangem.domain.markets.TokenMarketInfo
|
|||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.earn.EarnNetworks
|
||||
import com.tangem.domain.models.earn.EarnTokenWithCurrency
|
||||
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager
|
||||
import com.tangem.domain.models.earn.PreselectedEarnType
|
||||
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager
|
||||
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager.AnalyticsParams.Companion.CategoryEarn
|
||||
import com.tangem.features.feed.components.earn.DefaultEarnComponent
|
||||
import com.tangem.features.feed.components.earn.EarnNetworkFilterComponent
|
||||
|
|
@ -400,7 +399,6 @@ internal class EarnModel @Inject constructor(
|
|||
onNetworkFilterClick = ::onNetworkFilterClick,
|
||||
onTypeFilterClick = ::onTypeFilterClick,
|
||||
onScroll = ::onMostlyUsedScrolled,
|
||||
onSearchBarClicked = { params.onSearchClicked(AnalyticsParam.ScreensSources.Earn.value) },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
package com.tangem.features.feed.model.earn.state
|
||||
|
||||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.core.ui.extensions.TextReference
|
||||
import com.tangem.features.feed.model.earn.state.transformers.EarnUMTransformer
|
||||
import com.tangem.features.feed.ui.earn.state.*
|
||||
import com.tangem.features.feed.ui.feed.state.FeedListSearchBar
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
|
@ -36,10 +34,6 @@ internal class EarnStateController @Inject constructor() {
|
|||
onNetworkFilterClick = {},
|
||||
onTypeFilterClick = {},
|
||||
onSliderScroll = {},
|
||||
feedListSearchBar = FeedListSearchBar(
|
||||
placeholderText = TextReference.EMPTY,
|
||||
onBarClick = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +1,12 @@
|
|||
package com.tangem.features.feed.model.earn.state.transformers
|
||||
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.features.feed.impl.R
|
||||
import com.tangem.features.feed.ui.earn.state.EarnUM
|
||||
import com.tangem.features.feed.ui.feed.state.FeedListSearchBar
|
||||
|
||||
internal class UpdateEarnUMInitialStateTransformer(
|
||||
private val onBackClick: () -> Unit,
|
||||
private val onNetworkFilterClick: () -> Unit,
|
||||
private val onTypeFilterClick: () -> Unit,
|
||||
private val onScroll: () -> Unit,
|
||||
private val onSearchBarClicked: () -> Unit,
|
||||
) : EarnUMTransformer {
|
||||
|
||||
override fun transform(prevState: EarnUM): EarnUM {
|
||||
|
|
@ -19,10 +15,6 @@ internal class UpdateEarnUMInitialStateTransformer(
|
|||
onNetworkFilterClick = onNetworkFilterClick,
|
||||
onTypeFilterClick = onTypeFilterClick,
|
||||
onSliderScroll = onScroll,
|
||||
feedListSearchBar = FeedListSearchBar(
|
||||
onBarClick = onSearchBarClicked,
|
||||
placeholderText = resourceReference(id = R.string.markets_search_title_placeholder),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
package com.tangem.features.feed.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
|
||||
@Composable
|
||||
internal fun ContainerWithDivider(
|
||||
modifier: Modifier = Modifier,
|
||||
showDivider: Boolean = false,
|
||||
paddingValues: PaddingValues = PaddingValues(start = TangemTheme.dimens2.x3),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Box(modifier = modifier) {
|
||||
content()
|
||||
if (showDivider) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(paddingValues),
|
||||
color = TangemTheme.colors2.graphic.neutral.quaternary,
|
||||
thickness = 1.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -22,12 +22,14 @@ import com.tangem.core.ui.components.*
|
|||
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
|
||||
import com.tangem.core.ui.components.list.InfiniteListHandler
|
||||
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
|
||||
import com.tangem.core.ui.extensions.*
|
||||
import com.tangem.core.ui.extensions.conditional
|
||||
import com.tangem.core.ui.extensions.conditionalCompose
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.*
|
||||
import com.tangem.domain.models.earn.EarnType
|
||||
import com.tangem.features.feed.ui.earn.components.*
|
||||
import com.tangem.features.feed.ui.earn.state.*
|
||||
import com.tangem.features.feed.ui.feed.state.FeedListSearchBar
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
private const val EARN_LOAD_MORE_BUFFER = 3
|
||||
|
|
@ -663,10 +665,6 @@ private fun previewEarnUM(
|
|||
onNetworkFilterClick = {},
|
||||
onTypeFilterClick = {},
|
||||
onSliderScroll = {},
|
||||
feedListSearchBar = FeedListSearchBar(
|
||||
placeholderText = TextReference.Str("Search tokens & news"),
|
||||
onBarClick = {},
|
||||
),
|
||||
)
|
||||
|
||||
private const val PLACEHOLDER_ITEMS_COUNT = 8
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package com.tangem.features.feed.ui.earn.state
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.tangem.features.feed.ui.feed.state.FeedListSearchBar
|
||||
|
||||
@Immutable
|
||||
internal data class EarnUM(
|
||||
|
|
@ -12,5 +11,4 @@ internal data class EarnUM(
|
|||
val onNetworkFilterClick: () -> Unit,
|
||||
val onTypeFilterClick: () -> Unit,
|
||||
val onSliderScroll: () -> Unit,
|
||||
val feedListSearchBar: FeedListSearchBar,
|
||||
)
|
||||
|
|
@ -128,7 +128,7 @@ private fun InfoPointV2(infoPointUM: InfoPointUM, modifier: Modifier = Modifier)
|
|||
},
|
||||
),
|
||||
tint = when (infoPointUM.change) {
|
||||
InfoPointUM.ChangeType.UP -> TangemTheme.colors2.markers.iconGreen
|
||||
InfoPointUM.ChangeType.UP -> TangemTheme.colors2.markers.iconBlue
|
||||
InfoPointUM.ChangeType.DOWN -> TangemTheme.colors2.markers.iconRed
|
||||
},
|
||||
contentDescription = null,
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ import com.tangem.core.ui.res.TangemThemePreview
|
|||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.core.ui.utils.PreviewShimmerContainer
|
||||
import com.tangem.features.feed.impl.R
|
||||
import com.tangem.features.feed.ui.components.ContainerWithDivider
|
||||
import com.tangem.features.feed.ui.market.detailed.state.LinksUM
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
|
@ -105,7 +104,6 @@ private fun LinksBlockV2(state: LinksUM, modifier: Modifier = Modifier) {
|
|||
title = stringResourceSafe(id = R.string.markets_token_details_blockchain_site),
|
||||
links = state.blockchainSite,
|
||||
onLinkClick = state.onLinkClick,
|
||||
lastBlock = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -159,44 +157,38 @@ private fun SubBlockV2(
|
|||
links: ImmutableList<LinksUM.Link>,
|
||||
onLinkClick: (LinksUM.Link) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
lastBlock: Boolean = false,
|
||||
) {
|
||||
if (links.isEmpty()) return
|
||||
|
||||
ContainerWithDivider(
|
||||
modifier = modifier,
|
||||
showDivider = !lastBlock,
|
||||
Column(
|
||||
modifier = modifier.padding(vertical = TangemTheme.dimens2.x2),
|
||||
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = TangemTheme.dimens2.x2),
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 10.dp, top = TangemTheme.dimens2.x4),
|
||||
text = title,
|
||||
style = TangemTheme.typography2.headingSemibold20,
|
||||
color = TangemTheme.colors2.text.neutral.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
FlowRow(
|
||||
modifier = Modifier.padding(vertical = TangemTheme.dimens2.x2, horizontal = 3.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
|
||||
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 10.dp, top = TangemTheme.dimens2.x4),
|
||||
text = title,
|
||||
style = TangemTheme.typography2.headingSemibold20,
|
||||
color = TangemTheme.colors2.text.neutral.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
FlowRow(
|
||||
modifier = Modifier.padding(vertical = TangemTheme.dimens2.x2, horizontal = 3.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
|
||||
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
|
||||
) {
|
||||
links.fastForEach { link ->
|
||||
SecondaryTangemButton(
|
||||
onClick = { onLinkClick(link) },
|
||||
text = stringReference(link.title),
|
||||
iconPosition = TangemButtonIconPosition.Start,
|
||||
tangemIconUM = TangemIconUM.Icon(
|
||||
iconRes = link.iconRes,
|
||||
tintReference = { TangemTheme.colors2.graphic.neutral.primary },
|
||||
),
|
||||
size = TangemButtonSize.X9,
|
||||
shape = TangemButtonShape.Rounded,
|
||||
)
|
||||
}
|
||||
links.fastForEach { link ->
|
||||
SecondaryTangemButton(
|
||||
onClick = { onLinkClick(link) },
|
||||
text = stringReference(link.title),
|
||||
iconPosition = TangemButtonIconPosition.Start,
|
||||
tangemIconUM = TangemIconUM.Icon(
|
||||
iconRes = link.iconRes,
|
||||
tintReference = { TangemTheme.colors2.graphic.neutral.primary },
|
||||
),
|
||||
size = TangemButtonSize.X9,
|
||||
shape = TangemButtonShape.Rounded,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -237,7 +229,7 @@ private fun LinksBlockPlaceholderV2(modifier: Modifier = Modifier) {
|
|||
Column(modifier = modifier) {
|
||||
SubBlockPlaceholderV2()
|
||||
SubBlockPlaceholderV2()
|
||||
SubBlockPlaceholderV2(lastBlock = true)
|
||||
SubBlockPlaceholderV2()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -269,30 +261,25 @@ private fun SubBlockPlaceholderV1(modifier: Modifier = Modifier, lastBlock: Bool
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun SubBlockPlaceholderV2(modifier: Modifier = Modifier, lastBlock: Boolean = false) {
|
||||
ContainerWithDivider(
|
||||
modifier = modifier,
|
||||
showDivider = !lastBlock,
|
||||
private fun SubBlockPlaceholderV2(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier.padding(TangemTheme.dimens2.x2),
|
||||
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(TangemTheme.dimens2.x2),
|
||||
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
|
||||
TextShimmer(
|
||||
modifier = Modifier.width(56.dp),
|
||||
style = TangemTheme.typography2.bodySemibold16,
|
||||
radius = TangemTheme.dimens2.x25,
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
|
||||
) {
|
||||
TextShimmer(
|
||||
modifier = Modifier.width(56.dp),
|
||||
style = TangemTheme.typography2.bodySemibold16,
|
||||
radius = TangemTheme.dimens2.x25,
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
|
||||
) {
|
||||
repeat(times = 3) {
|
||||
ChipShimmer(
|
||||
modifier = Modifier
|
||||
.height(36.dp)
|
||||
.weight(1f),
|
||||
)
|
||||
}
|
||||
repeat(times = 3) {
|
||||
ChipShimmer(
|
||||
modifier = Modifier
|
||||
.height(36.dp)
|
||||
.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -311,9 +311,9 @@ private fun RatingChangeIndicator(change: MarketRatingChange24H) {
|
|||
when (change) {
|
||||
is MarketRatingChange24H.Up -> RatingChangeContent(
|
||||
iconRes = R.drawable.ic_arrow_up_8,
|
||||
iconTint = TangemTheme.colors2.markers.iconGreen,
|
||||
iconTint = TangemTheme.colors2.markers.iconBlue,
|
||||
changeValue = change.changeValue.toString(),
|
||||
textColor = TangemTheme.colors2.text.status.positive,
|
||||
textColor = TangemTheme.colors2.text.status.accent,
|
||||
)
|
||||
is MarketRatingChange24H.Down -> RatingChangeContent(
|
||||
iconRes = R.drawable.ic_arrow_down_8,
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import com.tangem.blockchain.common.transaction.TransactionFee
|
|||
import com.tangem.blockchain.yieldsupply.providers.ethereum.yield.EthereumYieldSupplySendCallData
|
||||
import com.tangem.blockchainsdk.utils.fromNetworkId
|
||||
import com.tangem.blockchainsdk.utils.toBlockchain
|
||||
import com.tangem.blockchainsdk.utils.toNetworkId
|
||||
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
|
||||
import com.tangem.core.ui.extensions.stringReference
|
||||
import com.tangem.core.ui.format.bigdecimal.fiat
|
||||
|
|
@ -63,6 +62,8 @@ import com.tangem.feature.swap.domain.models.domain.*
|
|||
import com.tangem.feature.swap.domain.models.toStringWithRightOffset
|
||||
import com.tangem.feature.swap.domain.models.ui.*
|
||||
import com.tangem.features.swap.SwapFeatureToggles
|
||||
import com.tangem.lib.crypto.BlockchainUtils.isBitcoin
|
||||
import com.tangem.lib.crypto.BlockchainUtils.isSolana
|
||||
import com.tangem.utils.coroutines.runSuspendCatching
|
||||
import com.tangem.utils.extensions.orZero
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
|
|
@ -81,6 +82,7 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
private val allowPermissionsHandler: AllowPermissionsHandler,
|
||||
private val cryptoCurrencyBalanceFetcher: CryptoCurrencyBalanceFetcher,
|
||||
private val sendTransactionUseCase: SendTransactionUseCase,
|
||||
private val signAndBroadcastPsbtUseCase: SignAndBroadcastPsbtUseCase,
|
||||
private val createTransactionUseCase: CreateTransactionUseCase,
|
||||
private val createTransferTransactionUseCase: CreateTransferTransactionUseCase,
|
||||
private val createApprovalTransactionUseCase: CreateApprovalTransactionUseCase,
|
||||
|
|
@ -685,28 +687,59 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
isTangemPayWithdrawal = isTangemPayWithdrawal,
|
||||
)
|
||||
}
|
||||
ResolvedFlow.DexLike -> {
|
||||
val networkId = fromSwapCurrencyStatus.currency.network.rawId
|
||||
if (isSolana(networkId)) {
|
||||
onSwapSolanaDex(
|
||||
provider = swapProvider,
|
||||
swapData = requireNotNull(swapData),
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
toSwapCurrencyStatus = toSwapCurrencyStatus,
|
||||
amountToSwap = amountToSwap,
|
||||
)
|
||||
} else {
|
||||
if (fee == null) return SwapTransactionState.Error.UnknownError
|
||||
onSwapDex(
|
||||
provider = swapProvider,
|
||||
swapData = requireNotNull(swapData),
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
toSwapCurrencyStatus = toSwapCurrencyStatus,
|
||||
swapFee = fee,
|
||||
amountToSwap = amountToSwap,
|
||||
integratedApproval = integratedApproval,
|
||||
)
|
||||
}
|
||||
ResolvedFlow.DexLike -> onSwapDexLike(
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
toSwapCurrencyStatus = toSwapCurrencyStatus,
|
||||
swapProvider = swapProvider,
|
||||
swapData = requireNotNull(swapData),
|
||||
amountToSwap = amountToSwap,
|
||||
fee = fee,
|
||||
integratedApproval = integratedApproval,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches a DEX-like swap by from-network: Solana (compiled tx) and Bitcoin (PSBT) get their
|
||||
* own signing paths; everything else goes through the EVM [onSwapDex] (which requires a [fee]).
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
private suspend fun onSwapDexLike(
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
toSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
swapProvider: SwapProvider,
|
||||
swapData: SwapDataModel,
|
||||
amountToSwap: String,
|
||||
fee: SwapFee?,
|
||||
integratedApproval: IntegratedApprovalData?,
|
||||
): SwapTransactionState {
|
||||
val networkId = fromSwapCurrencyStatus.currency.network.rawId
|
||||
return when {
|
||||
isSolana(networkId) -> onSwapSolanaDex(
|
||||
provider = swapProvider,
|
||||
swapData = swapData,
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
toSwapCurrencyStatus = toSwapCurrencyStatus,
|
||||
amountToSwap = amountToSwap,
|
||||
)
|
||||
isBitcoin(networkId) -> onSwapBitcoinPsbt(
|
||||
provider = swapProvider,
|
||||
swapData = swapData,
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
toSwapCurrencyStatus = toSwapCurrencyStatus,
|
||||
amountToSwap = amountToSwap,
|
||||
)
|
||||
else -> {
|
||||
if (fee == null) return SwapTransactionState.Error.UnknownError
|
||||
onSwapDex(
|
||||
provider = swapProvider,
|
||||
swapData = swapData,
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
toSwapCurrencyStatus = toSwapCurrencyStatus,
|
||||
swapFee = fee,
|
||||
amountToSwap = amountToSwap,
|
||||
integratedApproval = integratedApproval,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1042,6 +1075,44 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bitcoin DEX swap: the provider returns an almost-complete transaction as a Base64 PSBT in
|
||||
* `txData`. We derive our inputs, sign and broadcast it ourselves (see [SignAndBroadcastPsbtUseCase]),
|
||||
* then reuse the shared DEX success path. No fee handling: the fee is already embedded in the PSBT.
|
||||
*/
|
||||
private suspend fun onSwapBitcoinPsbt(
|
||||
provider: SwapProvider,
|
||||
swapData: SwapDataModel,
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
toSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
amountToSwap: String,
|
||||
): SwapTransactionState {
|
||||
val dexTransaction = swapData.transaction as? ExpressTransactionModel.DEX
|
||||
val amountDecimal = requireNotNull(toBigDecimalOrNull(amountToSwap)) { "wrong amount format" }
|
||||
val psbtBase64 = requireNotNull(dexTransaction?.txData) { "txData is null" }
|
||||
val amount = SwapAmount(amountDecimal, fromSwapCurrencyStatus.currency.decimals)
|
||||
|
||||
val result = signAndBroadcastPsbtUseCase(
|
||||
psbtBase64 = psbtBase64,
|
||||
userWallet = fromSwapCurrencyStatus.userWallet,
|
||||
network = fromSwapCurrencyStatus.currency.network,
|
||||
)
|
||||
return result.fold(
|
||||
ifRight = { txHash ->
|
||||
finalizeDexSwapSuccess(
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
toSwapCurrencyStatus = toSwapCurrencyStatus,
|
||||
provider = provider,
|
||||
swapData = swapData,
|
||||
amount = amount,
|
||||
txHash = txHash,
|
||||
payInAddress = swapData.transaction.txTo,
|
||||
)
|
||||
},
|
||||
ifLeft = { SwapTransactionState.Error.TransactionError(it) },
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun handleSwapResult(
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
toSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
|
|
@ -2283,10 +2354,6 @@ internal class SwapInteractorImpl @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun isSolana(networkId: String): Boolean {
|
||||
return networkId == Blockchain.Solana.toNetworkId()
|
||||
}
|
||||
|
||||
private fun getPayoutAddress(txData: TransactionData.Uncompiled): String {
|
||||
val ethereumCallData = (txData.extras as? EthereumTransactionExtras)?.callData
|
||||
return if (ethereumCallData is EthereumYieldSupplySendCallData) {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import com.tangem.blockchain.common.Amount
|
|||
import com.tangem.blockchain.common.Blockchain
|
||||
import com.tangem.blockchain.common.TransactionData
|
||||
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
|
||||
import com.tangem.blockchain.common.transaction.Fee
|
||||
import com.tangem.blockchain.common.transaction.TransactionFee
|
||||
import com.tangem.blockchain.yieldsupply.providers.YieldModuleUpgradeUnavailableException
|
||||
import com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionIndeterminateException
|
||||
|
|
@ -32,6 +33,7 @@ import com.tangem.domain.yield.supply.usecase.WrapYieldSwapCallDataWithUpgradeUs
|
|||
import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel
|
||||
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
|
||||
import com.tangem.lib.crypto.BlockchainUtils.SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES
|
||||
import com.tangem.lib.crypto.BlockchainUtils.isBitcoin
|
||||
import com.tangem.lib.crypto.BlockchainUtils.isSolana
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
import java.math.BigDecimal
|
||||
|
|
@ -55,7 +57,7 @@ import java.math.BigInteger
|
|||
*
|
||||
* @see DexFeeResult for the returned shape.
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
@Suppress("LongParameterList", "LargeClass", "TooManyFunctions")
|
||||
class DexSwapFeeCalculator(
|
||||
private val getFeeUseCase: GetFeeUseCase,
|
||||
private val getEthSpecificFeeUseCase: GetEthSpecificFeeUseCase,
|
||||
|
|
@ -79,56 +81,117 @@ class DexSwapFeeCalculator(
|
|||
?.movePointLeft(nativeCoinDecimals)
|
||||
?: BigDecimal.ZERO
|
||||
|
||||
if (isSolana(networkRawId)) {
|
||||
val transactionBytes = Base64.decode(transaction.txData, Base64.NO_WRAP)
|
||||
val formattedHash = getFormattedHash(transactionBytes)
|
||||
|
||||
// TODO Update after new firmware [REDACTED_JIRA]
|
||||
if (formattedHash.size > SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES &&
|
||||
fromSwapCurrencyStatus.userWallet is UserWallet.Cold
|
||||
) {
|
||||
raise(GetFeeError.BlockchainErrors.TooLargeSolanaTransactionError)
|
||||
}
|
||||
|
||||
val solanaFee = getFeeDataForSolanaDexSwap(
|
||||
when {
|
||||
isBitcoin(networkRawId) -> calculateBitcoinFee(
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
transactionBytes = transactionBytes,
|
||||
)
|
||||
DexFeeResult(
|
||||
transactionFee = TransactionFeeResult.Loaded(solanaFee),
|
||||
transaction = transaction,
|
||||
nativeCoinDecimals = nativeCoinDecimals,
|
||||
otherNativeFee = otherNativeFee,
|
||||
gas = null,
|
||||
)
|
||||
} else {
|
||||
val rawFeeResult = getFeeDataForDexSwap(
|
||||
isSolana(networkRawId) -> calculateSolanaFee(
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
transaction = transaction,
|
||||
otherNativeFee = otherNativeFee,
|
||||
)
|
||||
else -> calculateEvmFee(
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
transaction = transaction,
|
||||
selectedToken = selectedToken,
|
||||
permissionState = permissionState,
|
||||
).bind()
|
||||
// Apply the 12% bump on EVM, mirroring SwapInteractorImpl.loadFeeForDex.
|
||||
// The original cast `(fee as TransactionFeeResult.Loaded)` only holds when
|
||||
// selectedToken == null; we defensively support LoadedExtended too so the calculator
|
||||
// also handles the gasless-token DEX branch (currently unreachable from production
|
||||
// callers, kept for symmetry with the CEX calculator).
|
||||
val patched: TransactionFeeResult = when (rawFeeResult) {
|
||||
is TransactionFeeResult.Loaded ->
|
||||
TransactionFeeResult.Loaded(patchEthGasLimitForSwap(rawFeeResult.fee))
|
||||
is TransactionFeeResult.LoadedExtended ->
|
||||
TransactionFeeResult.LoadedExtended(
|
||||
rawFeeResult.fee.copy(
|
||||
transactionFee = patchEthGasLimitForSwap(rawFeeResult.fee.transactionFee),
|
||||
),
|
||||
)
|
||||
}
|
||||
DexFeeResult(
|
||||
transactionFee = patched,
|
||||
otherNativeFee = otherNativeFee,
|
||||
gas = transaction.gas,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bitcoin swaps arrive as a ready-made PSBT whose miner fee is implied by
|
||||
* sum(inputs) - sum(outputs); a single provider-fixed tier with no gas bump.
|
||||
*/
|
||||
private suspend fun Raise<GetFeeError>.calculateBitcoinFee(
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
transaction: ExpressTransactionModel.DEX,
|
||||
nativeCoinDecimals: Int,
|
||||
otherNativeFee: BigDecimal,
|
||||
): DexFeeResult {
|
||||
val network = fromSwapCurrencyStatus.currency.network
|
||||
val feeSatoshi = walletManagersFacade.getPsbtFee(
|
||||
userWalletId = fromSwapCurrencyStatus.userWalletId,
|
||||
network = network,
|
||||
psbtBase64 = transaction.txData,
|
||||
) ?: raise(GetFeeError.UnknownError)
|
||||
val feeAmount = Amount(
|
||||
currencySymbol = network.currencySymbol,
|
||||
value = feeSatoshi.movePointLeft(nativeCoinDecimals),
|
||||
decimals = nativeCoinDecimals,
|
||||
)
|
||||
return DexFeeResult(
|
||||
transactionFee = TransactionFeeResult.Loaded(TransactionFee.Single(normal = Fee.Common(feeAmount))),
|
||||
otherNativeFee = otherNativeFee,
|
||||
gas = null,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun Raise<GetFeeError>.calculateSolanaFee(
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
transaction: ExpressTransactionModel.DEX,
|
||||
otherNativeFee: BigDecimal,
|
||||
): DexFeeResult {
|
||||
val transactionBytes = Base64.decode(transaction.txData, Base64.NO_WRAP)
|
||||
val formattedHash = getFormattedHash(transactionBytes)
|
||||
|
||||
// TODO Update after new firmware [REDACTED_JIRA]
|
||||
if (formattedHash.size > SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES &&
|
||||
fromSwapCurrencyStatus.userWallet is UserWallet.Cold
|
||||
) {
|
||||
raise(GetFeeError.BlockchainErrors.TooLargeSolanaTransactionError)
|
||||
}
|
||||
|
||||
val solanaFee = getFeeDataForSolanaDexSwap(
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
transactionBytes = transactionBytes,
|
||||
)
|
||||
return DexFeeResult(
|
||||
transactionFee = TransactionFeeResult.Loaded(solanaFee),
|
||||
otherNativeFee = otherNativeFee,
|
||||
gas = null,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun Raise<GetFeeError>.calculateEvmFee(
|
||||
fromSwapCurrencyStatus: SwapCurrencyStatus,
|
||||
transaction: ExpressTransactionModel.DEX,
|
||||
selectedToken: CryptoCurrencyStatus?,
|
||||
permissionState: PermissionDataState,
|
||||
otherNativeFee: BigDecimal,
|
||||
): DexFeeResult {
|
||||
val rawFeeResult = getFeeDataForDexSwap(
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
transaction = transaction,
|
||||
selectedToken = selectedToken,
|
||||
permissionState = permissionState,
|
||||
).bind()
|
||||
// Apply the 12% bump on EVM, mirroring SwapInteractorImpl.loadFeeForDex.
|
||||
// The original cast `(fee as TransactionFeeResult.Loaded)` only holds when
|
||||
// selectedToken == null; we defensively support LoadedExtended too so the calculator
|
||||
// also handles the gasless-token DEX branch (currently unreachable from production
|
||||
// callers, kept for symmetry with the CEX calculator).
|
||||
val patched: TransactionFeeResult = when (rawFeeResult) {
|
||||
is TransactionFeeResult.Loaded ->
|
||||
TransactionFeeResult.Loaded(patchEthGasLimitForSwap(rawFeeResult.fee))
|
||||
is TransactionFeeResult.LoadedExtended ->
|
||||
TransactionFeeResult.LoadedExtended(
|
||||
rawFeeResult.fee.copy(
|
||||
transactionFee = patchEthGasLimitForSwap(rawFeeResult.fee.transactionFee),
|
||||
),
|
||||
)
|
||||
}
|
||||
return DexFeeResult(
|
||||
transactionFee = patched,
|
||||
otherNativeFee = otherNativeFee,
|
||||
gas = transaction.gas,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Yield-mode DEX fee path: routes the swap through the user's yield module proxy.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ sealed interface SwapState {
|
|||
val isAccountsMode: Boolean,
|
||||
val isFeeCoverage: Boolean,
|
||||
val sendingAmount: BigDecimal,
|
||||
val isSendingAmountLoading: Boolean = false,
|
||||
val currencyCheck: CryptoCurrencyCheck? = null,
|
||||
val validationResult: Throwable? = null,
|
||||
val minAdaValue: BigDecimal? = null,
|
||||
|
|
|
|||
|
|
@ -40,7 +40,6 @@ import com.tangem.feature.swap.domain.fee.TransactionFeeResult
|
|||
import com.tangem.feature.swap.domain.models.SwapAmount
|
||||
import com.tangem.feature.swap.domain.models.ui.SwapState
|
||||
import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo
|
||||
import com.tangem.features.send.api.subcomponents.feeSelector.utils.FeeCalculationUtils.checkAndCalculateSubtractedAmount
|
||||
import com.tangem.features.send.api.subcomponents.feeSelector.utils.FeeCalculationUtils.checkFeeCoverage
|
||||
import com.tangem.features.swap.SwapFeatureToggles
|
||||
import com.tangem.utils.extensions.orZero
|
||||
|
|
@ -107,7 +106,7 @@ class SwapTransferInteractorImpl @Inject constructor(
|
|||
fee = warningsFee,
|
||||
feeCurrencyBalanceAfterTransaction = null,
|
||||
)
|
||||
val (isFeeCoverage, sendingAmount) = getCoverageState(
|
||||
val coverageState = getCoverageState(
|
||||
fromTokenInfo = fromTokenInfo,
|
||||
userWallet = userWallet,
|
||||
fee = fee,
|
||||
|
|
@ -130,8 +129,9 @@ class SwapTransferInteractorImpl @Inject constructor(
|
|||
appCurrency = appCurrency,
|
||||
isBalanceHidden = isBalanceHidden,
|
||||
isAccountsMode = isAccountsMode,
|
||||
isFeeCoverage = isFeeCoverage,
|
||||
sendingAmount = sendingAmount,
|
||||
isFeeCoverage = coverageState.isFeeCoverage,
|
||||
sendingAmount = coverageState.sendingAmount,
|
||||
isSendingAmountLoading = coverageState.isSendingAmountLoading,
|
||||
currencyCheck = currencyCheck,
|
||||
)
|
||||
}
|
||||
|
|
@ -155,7 +155,7 @@ class SwapTransferInteractorImpl @Inject constructor(
|
|||
userWallet: UserWallet,
|
||||
fee: Fee?,
|
||||
currencyCheck: CryptoCurrencyCheck,
|
||||
): Pair<Boolean, BigDecimal> {
|
||||
): CoverageState {
|
||||
val swapCurrencyStatus = fromTokenInfo.swapCurrencyStatus
|
||||
val isAmountSubtractAvailable = isAmountSubtractAvailable(
|
||||
userWalletId = userWallet.walletId,
|
||||
|
|
@ -173,16 +173,29 @@ class SwapTransferInteractorImpl @Inject constructor(
|
|||
feeValue = feeValue,
|
||||
reduceAmountBy = reduceAmountBy,
|
||||
)
|
||||
val sendingAmount = checkAndCalculateSubtractedAmount(
|
||||
isAmountSubtractAvailable = isAmountSubtractAvailable,
|
||||
cryptoCurrencyStatus = fromTokenInfo.swapCurrencyStatus.status,
|
||||
amountValue = amount.value,
|
||||
feeValue = feeValue,
|
||||
reduceAmountBy = reduceAmountBy,
|
||||
// When fee coverage applies, the entered amount can't be sent together with the fee, so the
|
||||
// sent (and therefore received) amount is the entered amount reduced by the fee. This tracks the
|
||||
// input: as the user edits the amount, the received amount changes with it.
|
||||
val sendingAmount = if (isFeeCoverage) {
|
||||
(amount.value - feeValue).coerceAtLeast(BigDecimal.ZERO)
|
||||
} else {
|
||||
amount.value
|
||||
}
|
||||
// While subtraction is possible but the fee has not loaded yet, the final received amount
|
||||
// (entered - fee) is unknown, so it must be shown as loading instead of the un-subtracted value.
|
||||
return CoverageState(
|
||||
isFeeCoverage = isFeeCoverage,
|
||||
sendingAmount = sendingAmount,
|
||||
isSendingAmountLoading = fee == null && isAmountSubtractAvailable,
|
||||
)
|
||||
return isFeeCoverage to sendingAmount
|
||||
}
|
||||
|
||||
private data class CoverageState(
|
||||
val isFeeCoverage: Boolean,
|
||||
val sendingAmount: BigDecimal,
|
||||
val isSendingAmountLoading: Boolean,
|
||||
)
|
||||
|
||||
private suspend fun isAmountSubtractAvailable(
|
||||
userWalletId: UserWalletId,
|
||||
currency: CryptoCurrency,
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ internal open class SwapInteractorImplTestBase {
|
|||
protected val allowPermissionsHandler: AllowPermissionsHandler = mockk(relaxed = true)
|
||||
private val cryptoCurrencyBalanceFetcher: CryptoCurrencyBalanceFetcher = mockk(relaxed = true)
|
||||
protected val sendTransactionUseCase: SendTransactionUseCase = mockk(relaxed = true)
|
||||
protected val signAndBroadcastPsbtUseCase: SignAndBroadcastPsbtUseCase = mockk(relaxed = true)
|
||||
protected val createTransactionUseCase: CreateTransactionUseCase = mockk(relaxed = true)
|
||||
protected val createTransferTransactionUseCase: CreateTransferTransactionUseCase = mockk(relaxed = true)
|
||||
protected val createTransactionExtrasUseCase: CreateTransactionDataExtrasUseCase = mockk(relaxed = true)
|
||||
|
|
@ -99,6 +100,7 @@ internal open class SwapInteractorImplTestBase {
|
|||
allowPermissionsHandler = allowPermissionsHandler,
|
||||
cryptoCurrencyBalanceFetcher = cryptoCurrencyBalanceFetcher,
|
||||
sendTransactionUseCase = sendTransactionUseCase,
|
||||
signAndBroadcastPsbtUseCase = signAndBroadcastPsbtUseCase,
|
||||
createTransactionUseCase = createTransactionUseCase,
|
||||
createTransferTransactionUseCase = createTransferTransactionUseCase,
|
||||
createTransactionExtrasUseCase = createTransactionExtrasUseCase,
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ internal class DexSwapFeeCalculatorTest {
|
|||
|
||||
private val ethNetwork = Blockchain.Ethereum.toNetworkId()
|
||||
private val solanaNetwork = Blockchain.Solana.toNetworkId()
|
||||
private val bitcoinNetwork = Blockchain.Bitcoin.toNetworkId()
|
||||
|
||||
private val getFeeUseCase: GetFeeUseCase = mockk(relaxed = true)
|
||||
private val getEthSpecificFeeUseCase: GetEthSpecificFeeUseCase = mockk(relaxed = true)
|
||||
|
|
@ -584,6 +585,54 @@ internal class DexSwapFeeCalculatorTest {
|
|||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Bitcoin PSBT DEX path
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `Bitcoin DEX reads the PSBT fee from the wallet manager and skips the gas patch`() = runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = bitcoinNetwork, isCoin = true)
|
||||
val transaction = buildDex(txData = "cHNidP8B-base64-psbt", gas = null)
|
||||
|
||||
// 1_329 satoshi embedded in the PSBT → 0.00001329 BTC (8 decimals).
|
||||
coEvery {
|
||||
walletManagersFacade.getPsbtFee(any(), any(), psbtBase64 = "cHNidP8B-base64-psbt")
|
||||
} returns BigDecimal("1329")
|
||||
|
||||
val result = sut.calculate(fromStatus, transaction)
|
||||
|
||||
// getFee/getEthSpecificFee must NOT be used for Bitcoin — the fee comes from the PSBT.
|
||||
coVerify(exactly = 0) {
|
||||
getFeeUseCase.invoke(userWallet = any(), network = any(), transactionData = any())
|
||||
}
|
||||
coVerify(exactly = 0) {
|
||||
getEthSpecificFeeUseCase.invoke(userWallet = any(), cryptoCurrency = any(), gasLimit = any())
|
||||
}
|
||||
assertThat(result.isRight()).isTrue()
|
||||
result.onRight { dexFeeResult ->
|
||||
val fee = (dexFeeResult.transactionFee as TransactionFeeResult.Loaded).fee
|
||||
val btcFee = (fee as TransactionFee.Single).normal as Fee.Common
|
||||
assertThat(btcFee.amount.value).isEquivalentAccordingToCompareTo(BigDecimal("0.00001329"))
|
||||
assertThat(btcFee.amount.decimals).isEqualTo(8)
|
||||
assertThat(dexFeeResult.gas).isNull()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Bitcoin DEX returns Left UnknownError when the PSBT fee cannot be derived`() = runTest {
|
||||
val fromStatus = buildSwapCurrencyStatus(networkRawId = bitcoinNetwork, isCoin = true)
|
||||
val transaction = buildDex(txData = "cHNidP8B-base64-psbt", gas = null)
|
||||
|
||||
coEvery { walletManagersFacade.getPsbtFee(any(), any(), any()) } returns null
|
||||
|
||||
val result = sut.calculate(fromStatus, transaction)
|
||||
|
||||
assertThat(result.isLeft()).isTrue()
|
||||
result.onLeft { error ->
|
||||
assertThat(error).isEqualTo(GetFeeError.UnknownError)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Integrated-approve simulated estimation override ([REDACTED_TASK_KEY])
|
||||
//
|
||||
|
|
|
|||
|
|
@ -299,7 +299,7 @@ internal class SwapTransferInteractorImplTest {
|
|||
} returns true.right()
|
||||
|
||||
// entered amount = full balance → balance < amount + fee, balance > fee, balance >= amount
|
||||
// → isFeeCoverage = true, sendingAmount = balance - fee
|
||||
// → isFeeCoverage = true, sendingAmount = entered - fee (== balance - fee at max)
|
||||
val result = sut.updateTransfer(
|
||||
fromSwapCurrencyStatus = fromCurrencyStatus,
|
||||
toSwapCurrencyStatus = toCurrencyStatus,
|
||||
|
|
@ -310,6 +310,113 @@ internal class SwapTransferInteractorImplTest {
|
|||
|
||||
assertThat(result.isFeeCoverage).isTrue()
|
||||
assertThat(result.sendingAmount).isEqualTo(balance - feeValue)
|
||||
assertThat(result.isSendingAmountLoading).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN subtract available and sub-max amount in coverage zone WHEN updateTransfer THEN sendingAmount is entered minus fee`() =
|
||||
runTest {
|
||||
val appCurrency = AppCurrency(code = "USD", name = "US Dollar", symbol = "$")
|
||||
val userWallet: UserWallet = mockk(relaxed = true)
|
||||
val balance = BigDecimal("1.5")
|
||||
val feeValue = BigDecimal("0.2")
|
||||
// entered is below the balance but still within one fee of it → coverage applies, yet the
|
||||
// received amount must track the entered amount (entered - fee), not clamp to balance - fee.
|
||||
val enteredAmount = BigDecimal("1.45")
|
||||
val fromCurrencyStatus = buildCurrencyStatus(
|
||||
rawCurrencyId = FROM_RAW_CURRENCY_ID,
|
||||
decimals = FROM_DECIMALS,
|
||||
fiatRate = BigDecimal.TEN,
|
||||
amount = balance,
|
||||
userWallet = userWallet,
|
||||
)
|
||||
val toCurrencyStatus = buildCurrencyStatus(
|
||||
rawCurrencyId = TO_RAW_CURRENCY_ID,
|
||||
decimals = TO_DECIMALS,
|
||||
userWallet = userWallet,
|
||||
)
|
||||
val fee: Fee = mockk(relaxed = true) {
|
||||
every { amount.value } returns feeValue
|
||||
}
|
||||
every { getSelectedAppCurrencyUseCase() } returns flowOf(appCurrency.right())
|
||||
every { getBalanceHidingSettingsUseCase.isBalanceHidden() } returns flowOf(false)
|
||||
coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns false
|
||||
coEvery {
|
||||
getCurrencyCheckUseCase(
|
||||
userWalletId = any(),
|
||||
currencyStatus = any(),
|
||||
feeCurrencyStatus = any(),
|
||||
amount = any(),
|
||||
fee = any(),
|
||||
feeCurrencyBalanceAfterTransaction = any(),
|
||||
recipientAddress = any(),
|
||||
)
|
||||
} returns buildCurrencyCheck()
|
||||
coEvery {
|
||||
isAmountSubtractAvailableUseCase(any(), any(), any())
|
||||
} returns true.right()
|
||||
|
||||
val result = sut.updateTransfer(
|
||||
fromSwapCurrencyStatus = fromCurrencyStatus,
|
||||
toSwapCurrencyStatus = toCurrencyStatus,
|
||||
fromTokenAmount = enteredAmount.toPlainString(),
|
||||
feePaidCurrencyStatus = null,
|
||||
fee = fee,
|
||||
) as SwapState.Transfer
|
||||
|
||||
assertThat(result.isFeeCoverage).isTrue()
|
||||
assertThat(result.sendingAmount).isEqualTo(enteredAmount - feeValue)
|
||||
// it must NOT clamp to balance - fee
|
||||
assertThat(result.sendingAmount).isNotEqualTo(balance - feeValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN subtract available but fee not loaded yet WHEN updateTransfer THEN isSendingAmountLoading is true`() =
|
||||
runTest {
|
||||
val appCurrency = AppCurrency(code = "USD", name = "US Dollar", symbol = "$")
|
||||
val userWallet: UserWallet = mockk(relaxed = true)
|
||||
val balance = BigDecimal("1.5")
|
||||
val fromCurrencyStatus = buildCurrencyStatus(
|
||||
rawCurrencyId = FROM_RAW_CURRENCY_ID,
|
||||
decimals = FROM_DECIMALS,
|
||||
fiatRate = BigDecimal.TEN,
|
||||
amount = balance,
|
||||
userWallet = userWallet,
|
||||
)
|
||||
val toCurrencyStatus = buildCurrencyStatus(
|
||||
rawCurrencyId = TO_RAW_CURRENCY_ID,
|
||||
decimals = TO_DECIMALS,
|
||||
userWallet = userWallet,
|
||||
)
|
||||
every { getSelectedAppCurrencyUseCase() } returns flowOf(appCurrency.right())
|
||||
every { getBalanceHidingSettingsUseCase.isBalanceHidden() } returns flowOf(false)
|
||||
coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns false
|
||||
coEvery {
|
||||
getCurrencyCheckUseCase(
|
||||
userWalletId = any(),
|
||||
currencyStatus = any(),
|
||||
feeCurrencyStatus = any(),
|
||||
amount = any(),
|
||||
fee = any(),
|
||||
feeCurrencyBalanceAfterTransaction = any(),
|
||||
recipientAddress = any(),
|
||||
)
|
||||
} returns buildCurrencyCheck()
|
||||
coEvery {
|
||||
isAmountSubtractAvailableUseCase(any(), any(), any())
|
||||
} returns true.right()
|
||||
|
||||
// subtraction is possible but the fee has not loaded yet (fee = null) → the received amount
|
||||
// depends on the fee, so it can't be known yet and must be reported as loading.
|
||||
val result = sut.updateTransfer(
|
||||
fromSwapCurrencyStatus = fromCurrencyStatus,
|
||||
toSwapCurrencyStatus = toCurrencyStatus,
|
||||
fromTokenAmount = balance.toPlainString(),
|
||||
feePaidCurrencyStatus = null,
|
||||
fee = null,
|
||||
) as SwapState.Transfer
|
||||
|
||||
assertThat(result.isSendingAmountLoading).isTrue()
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
|
|
|||
|
|
@ -43,10 +43,10 @@ import com.tangem.datasource.local.appsflyer.AppsFlyerStore
|
|||
import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase
|
||||
import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase
|
||||
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
|
||||
import com.tangem.domain.card.IsWalletBackupProblematicUseCase
|
||||
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
|
||||
import com.tangem.domain.card.IsWalletBackupProblematicUseCase
|
||||
import com.tangem.domain.express.models.ExpressOperationType
|
||||
import com.tangem.domain.express.models.ProviderFilterType
|
||||
import com.tangem.domain.feedback.GetWalletMetaInfoUseCase
|
||||
|
|
@ -101,7 +101,6 @@ import com.tangem.feature.swap.models.states.SwapNotificationUM
|
|||
import com.tangem.feature.swap.router.SwapRoute
|
||||
import com.tangem.feature.swap.ui.StateBuilder
|
||||
import com.tangem.feature.swap.ui.transfer.SwapTransferStateBuilder
|
||||
import com.tangem.feature.swap.utils.formatToUIRepresentation
|
||||
import com.tangem.feature.swap.utils.getContractAddress
|
||||
import com.tangem.features.approval.api.GiveApprovalComponent
|
||||
import com.tangem.features.approval.api.GiveApprovalEntryComponent
|
||||
|
|
@ -541,6 +540,10 @@ internal class SwapModel @Inject constructor(
|
|||
)
|
||||
},
|
||||
),
|
||||
isTransferMode = swapTransferInteractor.shouldTransferInsteadOfSwap(
|
||||
fromSwapCurrency = fromSwapCurrencyStatus?.currency,
|
||||
toSwapCurrency = toSwapCurrencyStatus?.currency,
|
||||
),
|
||||
),
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
toSwapCurrencyStatus = toSwapCurrencyStatus,
|
||||
|
|
@ -623,6 +626,10 @@ internal class SwapModel @Inject constructor(
|
|||
)
|
||||
},
|
||||
),
|
||||
isTransferMode = swapTransferInteractor.shouldTransferInsteadOfSwap(
|
||||
fromSwapCurrency = newFromSwapCurrencyStatus?.currency,
|
||||
toSwapCurrency = newToSwapCurrencyStatus?.currency,
|
||||
),
|
||||
),
|
||||
fromSwapCurrencyStatus = newFromSwapCurrencyStatus,
|
||||
toSwapCurrencyStatus = newToSwapCurrencyStatus,
|
||||
|
|
@ -741,6 +748,10 @@ internal class SwapModel @Inject constructor(
|
|||
)
|
||||
},
|
||||
),
|
||||
isTransferMode = swapTransferInteractor.shouldTransferInsteadOfSwap(
|
||||
fromSwapCurrency = fromSwapCurrencyStatus.currency,
|
||||
toSwapCurrency = toSwapCurrencyStatus.currency,
|
||||
),
|
||||
),
|
||||
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
toSwapCurrencyStatus = toSwapCurrencyStatus,
|
||||
|
|
@ -1697,17 +1708,19 @@ internal class SwapModel @Inject constructor(
|
|||
* value is converted to the active input currency for display, while quotes still use crypto.
|
||||
*/
|
||||
private fun applyCryptoAmount(
|
||||
cryptoValue: String,
|
||||
cryptoAmount: SwapAmount,
|
||||
forceQuotesUpdate: Boolean = false,
|
||||
reduceBalanceBy: BigDecimal = BigDecimal.ZERO,
|
||||
) {
|
||||
val cryptoValue = cryptoAmount.value.parseBigDecimal(cryptoAmount.decimals)
|
||||
val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus
|
||||
val fiatRate = fromSwapCurrencyStatus?.status?.value?.fiatRate
|
||||
val fieldValue = if (fromSwapCurrencyStatus != null && isFiatInput.value && fiatRate != null) {
|
||||
cryptoValue.toFiatFromCrypto(fiatRate)
|
||||
cryptoAmount.value.toFiatFromCrypto(fiatRate)
|
||||
} else {
|
||||
cryptoValue
|
||||
}
|
||||
|
||||
updateAmount(
|
||||
cryptoValue = cryptoValue,
|
||||
fieldValue = fieldValue,
|
||||
|
|
@ -1806,7 +1819,7 @@ internal class SwapModel @Inject constructor(
|
|||
private fun onMaxAmountClicked() {
|
||||
dataState.fromSwapCurrencyStatus?.let { fromCurrency ->
|
||||
val balance = swapInteractor.getTokenBalance(fromCurrency.status)
|
||||
applyCryptoAmount(balance.formatToUIRepresentation())
|
||||
applyCryptoAmount(balance)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1822,17 +1835,13 @@ internal class SwapModel @Inject constructor(
|
|||
decimals = fromCurrency.status.currency.decimals,
|
||||
percent = percent,
|
||||
)
|
||||
applyCryptoAmount(
|
||||
SwapAmount(
|
||||
value = newValue,
|
||||
decimals = fromCurrency.status.currency.decimals,
|
||||
).formatToUIRepresentation(),
|
||||
)
|
||||
|
||||
applyCryptoAmount(SwapAmount(newValue, fromCurrency.status.currency.decimals))
|
||||
}
|
||||
|
||||
private fun onReduceAmountClicked(newAmount: SwapAmount, reduceBalanceBy: BigDecimal = BigDecimal.ZERO) {
|
||||
applyCryptoAmount(
|
||||
cryptoValue = newAmount.formatToUIRepresentation(),
|
||||
cryptoAmount = newAmount,
|
||||
forceQuotesUpdate = true,
|
||||
reduceBalanceBy = reduceBalanceBy,
|
||||
)
|
||||
|
|
@ -1850,8 +1859,13 @@ internal class SwapModel @Inject constructor(
|
|||
.parseBigDecimal(cryptoDecimals)
|
||||
}
|
||||
|
||||
private fun BigDecimal.toFiatFromCrypto(fiatRate: BigDecimal): String {
|
||||
return multiply(fiatRate)
|
||||
.parseBigDecimal(FIAT_DECIMALS)
|
||||
}
|
||||
|
||||
private fun String.toFiatFromCrypto(fiatRate: BigDecimal): String {
|
||||
return parseToBigDecimal(FIAT_DECIMALS)
|
||||
return (parseBigDecimalOrNull() ?: BigDecimal.ZERO)
|
||||
.multiply(fiatRate)
|
||||
.parseBigDecimal(FIAT_DECIMALS)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -153,10 +153,15 @@ internal class StateBuilder(
|
|||
isHoldToConfirm = fromSwapCurrencyStatus?.userWallet?.isHotWallet == true,
|
||||
onClick = { },
|
||||
),
|
||||
shouldShowMaxAmount = shouldShowMaxAmount(fromSwapCurrencyStatus?.currency, toSwapCurrencyStatus?.currency),
|
||||
predefinedButtons = createPredefinedButtons(
|
||||
shouldShowMaxAmount = shouldShowMaxAmount(
|
||||
fromSwapCurrencyStatus?.currency,
|
||||
toSwapCurrencyStatus?.currency,
|
||||
emptyAmountState.isTransferMode,
|
||||
),
|
||||
predefinedButtons = createPredefinedButtons(
|
||||
fromToken = fromSwapCurrencyStatus?.currency,
|
||||
toCurrency = toSwapCurrencyStatus?.currency,
|
||||
isTransferMode = emptyAmountState.isTransferMode,
|
||||
),
|
||||
changeCardsButtonState = ChangeCardsButtonState.ENABLED,
|
||||
providerState = ProviderState.Empty(),
|
||||
|
|
@ -244,6 +249,11 @@ internal class StateBuilder(
|
|||
toSwapCurrencyStatus: SwapCurrencyStatus?,
|
||||
shouldResetAmount: Boolean,
|
||||
): SwapStateHolder {
|
||||
val shouldShowMaxAmount = shouldShowMaxAmount(
|
||||
fromSwapCurrencyStatus?.currency,
|
||||
toSwapCurrencyStatus?.currency,
|
||||
emptyAmountState.isTransferMode,
|
||||
)
|
||||
return uiStateHolder.copy(
|
||||
sendCardData = uiStateHolder.sendCardData.updateCurrencyStatus(
|
||||
swapCurrencyStatus = fromSwapCurrencyStatus,
|
||||
|
|
@ -267,10 +277,11 @@ internal class StateBuilder(
|
|||
isHoldToConfirm = fromSwapCurrencyStatus?.userWallet?.isHotWallet == true,
|
||||
onClick = { },
|
||||
),
|
||||
shouldShowMaxAmount = shouldShowMaxAmount(fromSwapCurrencyStatus?.currency, toSwapCurrencyStatus?.currency),
|
||||
shouldShowMaxAmount = shouldShowMaxAmount,
|
||||
predefinedButtons = createPredefinedButtons(
|
||||
fromSwapCurrencyStatus?.currency,
|
||||
toSwapCurrencyStatus?.currency,
|
||||
fromToken = fromSwapCurrencyStatus?.currency,
|
||||
toCurrency = toSwapCurrencyStatus?.currency,
|
||||
isTransferMode = emptyAmountState.isTransferMode,
|
||||
),
|
||||
changeCardsButtonState = ChangeCardsButtonState.ENABLED,
|
||||
providerState = ProviderState.Empty(),
|
||||
|
|
@ -683,7 +694,12 @@ internal class StateBuilder(
|
|||
)
|
||||
}
|
||||
|
||||
private fun shouldShowMaxAmount(fromToken: CryptoCurrency?, toCurrency: CryptoCurrency?): Boolean {
|
||||
private fun shouldShowMaxAmount(
|
||||
fromToken: CryptoCurrency?,
|
||||
toCurrency: CryptoCurrency?,
|
||||
isTransferMode: Boolean = false,
|
||||
): Boolean {
|
||||
if (isTransferMode) return true
|
||||
return !(fromToken is CryptoCurrency.Coin && fromToken.network.id == toCurrency?.network?.id)
|
||||
}
|
||||
|
||||
|
|
@ -696,9 +712,10 @@ internal class StateBuilder(
|
|||
private fun createPredefinedButtons(
|
||||
fromToken: CryptoCurrency?,
|
||||
toCurrency: CryptoCurrency?,
|
||||
isTransferMode: Boolean = false,
|
||||
): ImmutableList<PredefinedPercentButtonUM> {
|
||||
if (!swapFeatureToggles.isSwapPredefinedButtonsEnabled) return persistentListOf()
|
||||
val shouldShowMaxAmount = shouldShowMaxAmount(fromToken, toCurrency)
|
||||
val shouldShowMaxAmount = shouldShowMaxAmount(fromToken, toCurrency, isTransferMode)
|
||||
return PredefinedPercentAmount.entries
|
||||
.filter { it != PredefinedPercentAmount.MAX || shouldShowMaxAmount }
|
||||
.map { percent ->
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import com.tangem.core.ui.extensions.wrappedList
|
|||
import com.tangem.core.ui.format.bigdecimal.crypto
|
||||
import com.tangem.core.ui.format.bigdecimal.fiat
|
||||
import com.tangem.core.ui.format.bigdecimal.format
|
||||
import com.tangem.core.ui.format.bigdecimal.simple
|
||||
import com.tangem.core.ui.utils.parseBigDecimalOrNull
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.models.account.Account
|
||||
|
|
@ -31,11 +32,11 @@ import com.tangem.feature.swap.domain.models.ui.SwapState
|
|||
import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo
|
||||
import com.tangem.feature.swap.model.SwapProcessDataState
|
||||
import com.tangem.feature.swap.models.*
|
||||
import com.tangem.feature.swap.ui.SwapAmountScreenClickIntents
|
||||
import com.tangem.feature.swap.ui.swapSuccessNavigation
|
||||
import com.tangem.feature.swap.models.SwapButton.Mode
|
||||
import com.tangem.feature.swap.models.states.SwapNotificationUM
|
||||
import com.tangem.feature.swap.presentation.R
|
||||
import com.tangem.feature.swap.ui.SwapAmountScreenClickIntents
|
||||
import com.tangem.feature.swap.ui.swapSuccessNavigation
|
||||
import com.tangem.features.send.api.utils.formatFooterFiatFee
|
||||
import com.tangem.features.send.api.utils.getTronTokenFeeSendingText
|
||||
import com.tangem.utils.extensions.orZero
|
||||
|
|
@ -59,11 +60,9 @@ internal class SwapTransferStateBuilder @Inject constructor(
|
|||
fee: Fee?,
|
||||
): SwapStateHolder {
|
||||
val fromTokenSwapInfo = transferState.fromTokenInfo
|
||||
val toTokenSwapInfo = transferState.toTokenInfo
|
||||
val isInsufficientBalance = transferState.isInsufficientBalance
|
||||
val prevSendCard = uiStateHolder.sendCardData as? SwapCardState.SwapCardData
|
||||
val prevAmountField = prevSendCard?.amountField
|
||||
val displayValue = prevAmountField?.value.orEmpty()
|
||||
val notifications = notificationsFactory.getNotifications(
|
||||
transferState = transferState,
|
||||
feeCryptoCurrencyStatus = feePaidCryptoCurrencyStatus,
|
||||
|
|
@ -75,25 +74,14 @@ internal class SwapTransferStateBuilder @Inject constructor(
|
|||
return uiStateHolder.copy(
|
||||
sendCardData = createSendSwapCardState(
|
||||
actions = actions,
|
||||
displayValue = displayValue,
|
||||
tokenSwapInfo = fromTokenSwapInfo,
|
||||
appCurrency = transferState.appCurrency,
|
||||
isAccountsMode = transferState.isAccountsMode,
|
||||
isFromCard = true,
|
||||
isBalanceHidden = transferState.isBalanceHidden,
|
||||
isInsufficientBalance = isInsufficientBalance,
|
||||
prevAmountField = prevAmountField,
|
||||
),
|
||||
receiveCardData = createSendSwapCardState(
|
||||
actions = actions,
|
||||
displayValue = displayValue,
|
||||
tokenSwapInfo = toTokenSwapInfo,
|
||||
appCurrency = transferState.appCurrency,
|
||||
isAccountsMode = transferState.isAccountsMode,
|
||||
isFromCard = false,
|
||||
isBalanceHidden = transferState.isBalanceHidden,
|
||||
isInsufficientBalance = isInsufficientBalance,
|
||||
),
|
||||
receiveCardData = createReceiveCard(actions = actions, transferState = transferState),
|
||||
isInsufficientFunds = isInsufficientBalance,
|
||||
swapButton = SwapButton(
|
||||
walletInteractionIcon = walletInterationIcon(transferState.userWallet),
|
||||
|
|
@ -109,14 +97,12 @@ internal class SwapTransferStateBuilder @Inject constructor(
|
|||
@Suppress("LongParameterList")
|
||||
private fun createSendSwapCardState(
|
||||
actions: UiActions,
|
||||
displayValue: String,
|
||||
tokenSwapInfo: TokenSwapInfo,
|
||||
appCurrency: AppCurrency,
|
||||
isAccountsMode: Boolean,
|
||||
isFromCard: Boolean,
|
||||
isBalanceHidden: Boolean,
|
||||
isInsufficientBalance: Boolean,
|
||||
prevAmountField: AmountFieldModel? = null,
|
||||
prevAmountField: AmountFieldModel?,
|
||||
): SwapCardState {
|
||||
val swapCurrencyStatus = tokenSwapInfo.swapCurrencyStatus
|
||||
val currency = swapCurrencyStatus.currency
|
||||
|
|
@ -126,7 +112,7 @@ internal class SwapTransferStateBuilder @Inject constructor(
|
|||
actions = actions,
|
||||
swapCurrencyStatus = tokenSwapInfo.swapCurrencyStatus,
|
||||
isAccountsMode = isAccountsMode,
|
||||
isFromCard = isFromCard,
|
||||
isFromCard = true,
|
||||
isInsufficientBalance = isInsufficientBalance,
|
||||
),
|
||||
currencyIconState = iconConverter.convert(
|
||||
|
|
@ -140,18 +126,61 @@ internal class SwapTransferStateBuilder @Inject constructor(
|
|||
balance = swapCurrencyStatus.status.getFormattedAmount(),
|
||||
isBalanceHidden = isBalanceHidden,
|
||||
appCurrency = appCurrency,
|
||||
amountField = if (isFromCard) {
|
||||
buildAmountField(
|
||||
actions = actions,
|
||||
prevAmountField = prevAmountField,
|
||||
swapCurrencyStatus = swapCurrencyStatus,
|
||||
appCurrency = appCurrency,
|
||||
)
|
||||
amountField = buildAmountField(
|
||||
actions = actions,
|
||||
prevAmountField = prevAmountField,
|
||||
swapCurrencyStatus = swapCurrencyStatus,
|
||||
appCurrency = appCurrency,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the read-only receive card from [SwapState.Transfer.sendingAmount] — the amount that will
|
||||
* actually be received, already reduced by the fee when fee coverage applies. While the reduced amount
|
||||
* is not yet known (fee still loading) the amount and fiat fields are null, which makes the read-only
|
||||
* card render a shimmer instead of the un-subtracted value.
|
||||
*/
|
||||
private fun createReceiveCard(actions: UiActions, transferState: SwapState.Transfer): SwapCardState {
|
||||
val toTokenSwapInfo = transferState.toTokenInfo
|
||||
val swapCurrencyStatus = toTokenSwapInfo.swapCurrencyStatus
|
||||
val currency = swapCurrencyStatus.currency
|
||||
val appCurrency = transferState.appCurrency
|
||||
val sendingAmount = transferState.sendingAmount
|
||||
// No reduction can happen when the balance is insufficient (fee coverage requires balance >= amount),
|
||||
// so there is nothing to wait for — show the amount instead of a shimmer.
|
||||
val isLoading = transferState.isSendingAmountLoading && !transferState.isInsufficientBalance
|
||||
val fiatRate = swapCurrencyStatus.status.value.fiatRate
|
||||
|
||||
return SwapCardState.SwapCardData(
|
||||
type = createSendTransactionCardType(
|
||||
actions = actions,
|
||||
swapCurrencyStatus = swapCurrencyStatus,
|
||||
isAccountsMode = transferState.isAccountsMode,
|
||||
isFromCard = false,
|
||||
isInsufficientBalance = transferState.isInsufficientBalance,
|
||||
),
|
||||
currencyIconState = iconConverter.convert(
|
||||
value = swapCurrencyStatus.status,
|
||||
),
|
||||
tokenSymbol = stringReference(currency.symbol),
|
||||
amountEquivalent = if (isLoading) {
|
||||
null
|
||||
} else {
|
||||
// Read-only receive card mirrors the same display value the "from" card shows in transfer mode.
|
||||
getFormattedFiatAmount(appCurrency = appCurrency, amount = fiatRate?.multiply(sendingAmount))
|
||||
},
|
||||
balance = swapCurrencyStatus.status.getFormattedAmount(),
|
||||
isBalanceHidden = transferState.isBalanceHidden,
|
||||
appCurrency = appCurrency,
|
||||
amountField = if (isLoading) {
|
||||
null
|
||||
} else {
|
||||
val value = sendingAmount.format {
|
||||
simple(decimals = currency.decimals)
|
||||
}
|
||||
displayAmountField(
|
||||
actions = actions,
|
||||
value = displayValue,
|
||||
value = value,
|
||||
swapCurrencyStatus = swapCurrencyStatus,
|
||||
appCurrency = appCurrency,
|
||||
)
|
||||
|
|
@ -324,6 +353,10 @@ internal class SwapTransferStateBuilder @Inject constructor(
|
|||
)
|
||||
return uiStateHolder.copy(
|
||||
notifications = notifications,
|
||||
// Rebuild the receive card from the refreshed transferState: this path runs after the fee
|
||||
// selector resolves, when sendingAmount may have just been reduced by the fee. Only the
|
||||
// receive card is rebuilt to avoid clobbering the user's in-progress input on the "from" card.
|
||||
receiveCardData = createReceiveCard(actions = actions, transferState = transferState),
|
||||
swapButton = uiStateHolder.swapButton.copy(
|
||||
isEnabled = getTransferButtonEnabled(notifications, fee, isTangemPayWithdrawal),
|
||||
),
|
||||
|
|
@ -431,14 +464,14 @@ internal class SwapTransferStateBuilder @Inject constructor(
|
|||
val fromCurrency = fromSwapCurrencyStatus.currency
|
||||
val toCurrency = toSwapCurrencyStatus.currency
|
||||
val fromAmountText = amount.format { crypto(fromCurrency.symbol, fromCurrency.decimals) }
|
||||
val toAmountText = amount.format { crypto(toCurrency.symbol, toCurrency.decimals) }
|
||||
val toAmountText = transferState.sendingAmount.format { crypto(toCurrency.symbol, toCurrency.decimals) }
|
||||
val fromFiatAmount = getFormattedFiatAmount(
|
||||
appCurrency = transferState.appCurrency,
|
||||
amount = fromSwapCurrencyStatus.status.value.fiatRate?.multiply(amount),
|
||||
)
|
||||
val toFiatAmount = getFormattedFiatAmount(
|
||||
appCurrency = transferState.appCurrency,
|
||||
amount = toSwapCurrencyStatus.status.value.fiatRate?.multiply(amount),
|
||||
amount = toSwapCurrencyStatus.status.value.fiatRate?.multiply(transferState.sendingAmount),
|
||||
)
|
||||
|
||||
return uiState.copy(
|
||||
|
|
@ -488,11 +521,15 @@ internal class SwapTransferStateBuilder @Inject constructor(
|
|||
val fromSwapCurrencyStatus = requireNotNull(dataState.fromSwapCurrencyStatus)
|
||||
val toSwapCurrencyStatus = requireNotNull(dataState.toSwapCurrencyStatus)
|
||||
val transferState = requireNotNull(dataState.currentTransferState)
|
||||
val amountValue = transferState.sendingAmount
|
||||
|
||||
val fiatAmount = getFormattedFiatAmount(
|
||||
val fromAmount = dataState.amount?.parseBigDecimalOrNull() ?: BigDecimal.ZERO
|
||||
val toAmount = transferState.sendingAmount
|
||||
val fromFiatAmount = getFormattedFiatAmount(
|
||||
appCurrency = transferState.appCurrency,
|
||||
amount = fromSwapCurrencyStatus.status.value.fiatRate?.multiply(amountValue),
|
||||
amount = fromSwapCurrencyStatus.status.value.fiatRate?.multiply(fromAmount),
|
||||
)
|
||||
val toFiatAmount = getFormattedFiatAmount(
|
||||
appCurrency = transferState.appCurrency,
|
||||
amount = fromSwapCurrencyStatus.status.value.fiatRate?.multiply(toAmount),
|
||||
)
|
||||
|
||||
return uiState.copy(
|
||||
|
|
@ -516,10 +553,10 @@ internal class SwapTransferStateBuilder @Inject constructor(
|
|||
isAccountsMode = transferState.isAccountsMode,
|
||||
isFromCard = false,
|
||||
),
|
||||
fromTokenAmount = stringReference(amountValue.toString()),
|
||||
toTokenAmount = stringReference(amountValue.toString()),
|
||||
fromTokenFiatAmount = fiatAmount,
|
||||
toTokenFiatAmount = fiatAmount,
|
||||
fromTokenAmount = stringReference(fromAmount.toString()),
|
||||
toTokenAmount = stringReference(toAmount.toString()),
|
||||
fromTokenFiatAmount = fromFiatAmount,
|
||||
toTokenFiatAmount = toFiatAmount,
|
||||
fromTokenIconState = iconConverter.convert(fromSwapCurrencyStatus.status),
|
||||
toTokenIconState = iconConverter.convert(toSwapCurrencyStatus.status),
|
||||
navigationUM = swapSuccessNavigation(
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import com.tangem.domain.express.models.ExpressError
|
|||
import com.tangem.domain.models.account.Account
|
||||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.swap.models.SwapCurrencyStatus
|
||||
|
|
@ -270,6 +271,50 @@ internal class StateBuilderInitialStateTest {
|
|||
|
||||
assertThat(result.notifications).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN same coin on different wallets in transfer mode WHEN called THEN shouldShowMaxAmount is true`() {
|
||||
// Arrange — same coin (shared network id) moved between two wallets => transfer mode
|
||||
val sharedNetwork = buildSharedNetwork()
|
||||
val walletA: UserWallet.Cold = mockk(relaxed = true) { every { walletId } returns UserWalletId("aabb") }
|
||||
val walletB: UserWallet.Cold = mockk(relaxed = true) { every { walletId } returns UserWalletId("ccdd") }
|
||||
val fromStatus = buildCoinSwapCurrencyStatus(walletA, sharedNetwork)
|
||||
val toStatus = buildCoinSwapCurrencyStatus(walletB, sharedNetwork)
|
||||
val transferState = SwapState.EmptyAmountState(
|
||||
zeroAmountEquivalent = com.tangem.core.ui.extensions.stringReference("$0.00"),
|
||||
isTransferMode = true,
|
||||
)
|
||||
|
||||
// Act
|
||||
val result = sut.createInitialReadyState(
|
||||
uiStateHolder = baseState,
|
||||
emptyAmountState = transferState,
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
toSwapCurrencyStatus = toStatus,
|
||||
)
|
||||
|
||||
// Assert
|
||||
assertThat(result.shouldShowMaxAmount).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN same-network coin swap not in transfer mode WHEN called THEN shouldShowMaxAmount is false`() {
|
||||
// Arrange — same network coin pair, regular swap => MAX hidden to keep balance for the fee
|
||||
val sharedNetwork = buildSharedNetwork()
|
||||
val fromStatus = buildCoinSwapCurrencyStatus(userWallet, sharedNetwork)
|
||||
val toStatus = buildCoinSwapCurrencyStatus(userWallet, sharedNetwork)
|
||||
|
||||
// Act
|
||||
val result = sut.createInitialReadyState(
|
||||
uiStateHolder = baseState,
|
||||
emptyAmountState = emptyAmountState,
|
||||
fromSwapCurrencyStatus = fromStatus,
|
||||
toSwapCurrencyStatus = toStatus,
|
||||
)
|
||||
|
||||
// Assert
|
||||
assertThat(result.shouldShowMaxAmount).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
|
@ -574,4 +619,43 @@ internal fun buildSwapCurrencyStatus(
|
|||
status = cryptoCurrencyStatus,
|
||||
account = account,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A single [Network] mock whose [Network.id] resolves to one shared instance, so two currencies built from
|
||||
* it compare equal on `network.id` — the condition that gates [StateBuilder.shouldShowMaxAmount].
|
||||
*/
|
||||
internal fun buildSharedNetwork(): Network = mockk(relaxed = true) {
|
||||
every { id } returns mockk(relaxed = true)
|
||||
every { name } returns "Ethereum"
|
||||
every { currencySymbol } returns "ETH"
|
||||
every { rawId } returns "ethereum"
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a [SwapCurrencyStatus] backed by a [CryptoCurrency.Coin] on the given [network]. Pass the same
|
||||
* [network] instance to two calls to model the "same coin on different wallets" (transfer) case.
|
||||
*/
|
||||
internal fun buildCoinSwapCurrencyStatus(
|
||||
userWallet: UserWallet,
|
||||
network: Network,
|
||||
): SwapCurrencyStatus {
|
||||
val account = Account.CryptoPortfolio.createMainAccount(userWallet.walletId)
|
||||
val currency = mockk<CryptoCurrency.Coin>(relaxed = true) {
|
||||
every { symbol } returns "ETH"
|
||||
every { decimals } returns 18
|
||||
every { name } returns "Ethereum"
|
||||
every { this@mockk.network } returns network
|
||||
}
|
||||
val statusValue: CryptoCurrencyStatus.Value = mockk(relaxed = true) {
|
||||
every { amount } returns java.math.BigDecimal("1.0")
|
||||
every { fiatRate } returns java.math.BigDecimal("2000.00")
|
||||
every { fiatAmount } returns java.math.BigDecimal("2000.00")
|
||||
}
|
||||
val cryptoCurrencyStatus = CryptoCurrencyStatus(currency = currency, value = statusValue)
|
||||
return SwapCurrencyStatus(
|
||||
userWallet = userWallet,
|
||||
status = cryptoCurrencyStatus,
|
||||
account = account,
|
||||
)
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ import com.tangem.core.ui.format.bigdecimal.crypto
|
|||
import com.tangem.core.ui.format.bigdecimal.fee
|
||||
import com.tangem.core.ui.format.bigdecimal.fiat
|
||||
import com.tangem.core.ui.format.bigdecimal.format
|
||||
import com.tangem.core.ui.utils.parseBigDecimal
|
||||
import com.tangem.domain.appcurrency.model.AppCurrency
|
||||
import com.tangem.domain.models.account.Account
|
||||
import com.tangem.domain.models.network.Network
|
||||
|
|
@ -371,6 +372,123 @@ internal class SwapTransferStateBuilderTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN fee coverage reduces amount WHEN createTransferState THEN receive card shows reduced sendingAmount`() =
|
||||
runTest {
|
||||
// entered 1.5, but fee coverage reduces the received (sending) amount to 1.3
|
||||
val sendingAmount = BigDecimal("1.3")
|
||||
val transferState = buildTransferState(
|
||||
fromAmount = BigDecimal("1.5"),
|
||||
toAmount = sendingAmount,
|
||||
isAccountsMode = false,
|
||||
isFeeCoverage = true,
|
||||
)
|
||||
|
||||
val result = sut.createTransferState(
|
||||
actions = actions,
|
||||
transferState = transferState,
|
||||
uiStateHolder = baseStateHolder(),
|
||||
feePaidCryptoCurrencyStatus = null,
|
||||
fee = mockk(relaxed = true),
|
||||
)
|
||||
|
||||
val sendCard = result.sendCardData as SwapCardState.SwapCardData
|
||||
val receiveCard = result.receiveCardData as SwapCardState.SwapCardData
|
||||
val expectedFiat = stringReference(
|
||||
toCurrencyStatus.status.value.fiatRate!!.multiply(sendingAmount).format {
|
||||
fiat(fiatCurrencyCode = AppCurrency.Default.code, fiatCurrencySymbol = AppCurrency.Default.symbol)
|
||||
},
|
||||
)
|
||||
// the "from" card keeps the user's typed value, the receive card shows the reduced amount + its fiat
|
||||
assertThat(sendCard.amountField?.value).isEqualTo(initialAmountValue)
|
||||
assertThat(receiveCard.amountField?.value).isEqualTo(
|
||||
sendingAmount.parseBigDecimal(transferState.toTokenInfo.tokenAmount.decimals),
|
||||
)
|
||||
assertThat(receiveCard.amountEquivalent).isEqualTo(expectedFiat)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN sendingAmount loading WHEN createTransferState THEN receive card amount and fiat shimmer`() =
|
||||
runTest {
|
||||
// fee not loaded yet → reduced amount unknown → receive card shimmers (null amount + null fiat)
|
||||
val transferState = buildTransferState(
|
||||
fromAmount = BigDecimal("1.5"),
|
||||
toAmount = BigDecimal("1.5"),
|
||||
isAccountsMode = false,
|
||||
isSendingAmountLoading = true,
|
||||
)
|
||||
|
||||
val result = sut.createTransferState(
|
||||
actions = actions,
|
||||
transferState = transferState,
|
||||
uiStateHolder = baseStateHolder(),
|
||||
feePaidCryptoCurrencyStatus = null,
|
||||
fee = null,
|
||||
)
|
||||
|
||||
val receiveCard = result.receiveCardData as SwapCardState.SwapCardData
|
||||
assertThat(receiveCard.amountField).isNull()
|
||||
assertThat(receiveCard.amountEquivalent).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN sendingAmount loading but insufficient balance WHEN createTransferState THEN receive card shows amount not shimmer`() =
|
||||
runTest {
|
||||
// Insufficient balance → fee coverage can't apply, so there is no reduction to wait for.
|
||||
// The receive card must show the amount instead of shimmering.
|
||||
val amount = BigDecimal("99")
|
||||
val transferState = buildTransferState(
|
||||
fromAmount = amount,
|
||||
toAmount = amount,
|
||||
isAccountsMode = false,
|
||||
isInsufficientBalance = true,
|
||||
isSendingAmountLoading = true,
|
||||
)
|
||||
|
||||
val result = sut.createTransferState(
|
||||
actions = actions,
|
||||
transferState = transferState,
|
||||
uiStateHolder = baseStateHolder(),
|
||||
feePaidCryptoCurrencyStatus = null,
|
||||
fee = null,
|
||||
)
|
||||
|
||||
val receiveCard = result.receiveCardData as SwapCardState.SwapCardData
|
||||
assertThat(receiveCard.amountField?.value).isEqualTo(
|
||||
amount.parseBigDecimal(transferState.toTokenInfo.tokenAmount.decimals),
|
||||
)
|
||||
assertThat(receiveCard.amountEquivalent).isNotNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN reduced sendingAmount WHEN updateTransferButtonEnableState THEN receive card is rebuilt to reduced amount`() =
|
||||
runTest {
|
||||
// After the fee resolves, sendingAmount is reduced; the refresh path must rebuild the receive card
|
||||
// so it no longer shows the stale full amount.
|
||||
val sendingAmount = BigDecimal("1.3")
|
||||
val transferState = buildTransferState(
|
||||
fromAmount = BigDecimal("1.5"),
|
||||
toAmount = sendingAmount,
|
||||
isAccountsMode = false,
|
||||
isFeeCoverage = true,
|
||||
)
|
||||
|
||||
val result = sut.updateTransferButtonEnableState(
|
||||
dataState = SwapProcessDataState(),
|
||||
transferState = transferState,
|
||||
actions = actions,
|
||||
uiStateHolder = baseStateHolder(),
|
||||
feePaidCryptoCurrencyStatus = null,
|
||||
fee = mockk(relaxed = true),
|
||||
isTangemPayWithdrawal = false,
|
||||
)
|
||||
|
||||
val receiveCard = result.receiveCardData as SwapCardState.SwapCardData
|
||||
assertThat(receiveCard.amountField?.value).isEqualTo(
|
||||
sendingAmount.parseBigDecimal(transferState.toTokenInfo.tokenAmount.decimals),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN Tron fee WHEN updateTransferButtonEnableState THEN transferFooter uses Tron token fee sending text`() =
|
||||
runTest {
|
||||
|
|
@ -505,16 +623,18 @@ 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 amount = BigDecimal("1.5")
|
||||
// from amount comes from dataState.amount, to amount from transferState.sendingAmount — keep them distinct
|
||||
val fromAmount = BigDecimal("2.0")
|
||||
val toAmount = BigDecimal("1.5")
|
||||
val transferState = buildTransferState(
|
||||
fromAmount = amount,
|
||||
toAmount = amount,
|
||||
fromAmount = fromAmount,
|
||||
toAmount = toAmount,
|
||||
isAccountsMode = true,
|
||||
)
|
||||
val dataState = SwapProcessDataState(
|
||||
fromSwapCurrencyStatus = fromCurrencyStatus,
|
||||
toSwapCurrencyStatus = toCurrencyStatus,
|
||||
amount = amount.toPlainString(),
|
||||
amount = fromAmount.toPlainString(),
|
||||
currentTransferState = transferState,
|
||||
)
|
||||
val feeValue = BigDecimal("0.001")
|
||||
|
|
@ -556,6 +676,26 @@ internal class SwapTransferStateBuilderTest {
|
|||
assertThat(success.fromTokenIconState).isEqualTo(fromIcon)
|
||||
assertThat(success.toTokenIconState).isEqualTo(toIcon)
|
||||
|
||||
// from side reflects dataState.amount, to side reflects transferState.sendingAmount
|
||||
assertThat(success.fromTokenAmount)
|
||||
.isEqualTo(stringReference(fromAmount.format { crypto(symbol = "ETH", decimals = 18) }))
|
||||
assertThat(success.toTokenAmount)
|
||||
.isEqualTo(stringReference(toAmount.format { crypto(symbol = "ETH", decimals = 18) }))
|
||||
assertThat(success.fromTokenFiatAmount).isEqualTo(
|
||||
stringReference(
|
||||
fromCurrencyStatus.status.value.fiatRate!!.multiply(fromAmount).format {
|
||||
fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol)
|
||||
},
|
||||
),
|
||||
)
|
||||
assertThat(success.toTokenFiatAmount).isEqualTo(
|
||||
stringReference(
|
||||
toCurrencyStatus.status.value.fiatRate!!.multiply(toAmount).format {
|
||||
fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
val portfolioAccount = fromCurrencyStatus.account as Account.CryptoPortfolio
|
||||
val expectedIcon = CryptoPortfolioIconConverter.convert(portfolioAccount.icon)
|
||||
val expectedName = portfolioAccount.accountName.toUM().value
|
||||
|
|
@ -643,21 +783,30 @@ internal class SwapTransferStateBuilderTest {
|
|||
|
||||
@Test
|
||||
fun `GIVEN transfer dataState WHEN createTangemPayWithdrawalSuccessState THEN feeless transfer success holder is built`() {
|
||||
val sendingAmount = BigDecimal("1.5")
|
||||
// from amount comes from dataState.amount, to amount from transferState.sendingAmount — keep them distinct
|
||||
val fromAmount = BigDecimal("2.0")
|
||||
val toAmount = BigDecimal("1.5")
|
||||
val transferState = buildTransferState(
|
||||
fromAmount = sendingAmount,
|
||||
toAmount = sendingAmount,
|
||||
fromAmount = fromAmount,
|
||||
toAmount = toAmount,
|
||||
isAccountsMode = true,
|
||||
)
|
||||
val dataState = SwapProcessDataState(
|
||||
fromSwapCurrencyStatus = fromCurrencyStatus,
|
||||
toSwapCurrencyStatus = toCurrencyStatus,
|
||||
amount = fromAmount.toPlainString(),
|
||||
currentTransferState = transferState,
|
||||
)
|
||||
val onExploreClick = {}
|
||||
val appCurrency = transferState.appCurrency
|
||||
val expectedFiat = stringReference(
|
||||
fromCurrencyStatus.status.value.fiatRate!!.multiply(sendingAmount).format {
|
||||
// both fiat amounts use the from-currency rate (see createTangemPayWithdrawalSuccessState)
|
||||
val expectedFromFiat = stringReference(
|
||||
fromCurrencyStatus.status.value.fiatRate!!.multiply(fromAmount).format {
|
||||
fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol)
|
||||
},
|
||||
)
|
||||
val expectedToFiat = stringReference(
|
||||
fromCurrencyStatus.status.value.fiatRate!!.multiply(toAmount).format {
|
||||
fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol)
|
||||
},
|
||||
)
|
||||
|
|
@ -683,10 +832,10 @@ internal class SwapTransferStateBuilderTest {
|
|||
assertThat(success.rate).isEqualTo(TextReference.EMPTY)
|
||||
assertThat(success.timestamp).isAtLeast(before)
|
||||
assertThat(success.timestamp).isAtMost(after)
|
||||
assertThat(success.fromTokenAmount).isEqualTo(stringReference(sendingAmount.toString()))
|
||||
assertThat(success.toTokenAmount).isEqualTo(stringReference(sendingAmount.toString()))
|
||||
assertThat(success.fromTokenFiatAmount).isEqualTo(expectedFiat)
|
||||
assertThat(success.toTokenFiatAmount).isEqualTo(expectedFiat)
|
||||
assertThat(success.fromTokenAmount).isEqualTo(stringReference(fromAmount.toString()))
|
||||
assertThat(success.toTokenAmount).isEqualTo(stringReference(toAmount.toString()))
|
||||
assertThat(success.fromTokenFiatAmount).isEqualTo(expectedFromFiat)
|
||||
assertThat(success.toTokenFiatAmount).isEqualTo(expectedToFiat)
|
||||
assertThat(success.fromTokenIconState).isEqualTo(fromIcon)
|
||||
assertThat(success.toTokenIconState).isEqualTo(toIcon)
|
||||
assertThat((success.navigationUM as NavigationUM.Content).primaryButton.onClick).isEqualTo(onExploreClick)
|
||||
|
|
@ -716,8 +865,12 @@ internal class SwapTransferStateBuilderTest {
|
|||
) {
|
||||
val sendCard = result.sendCardData as SwapCardState.SwapCardData
|
||||
val receiveCard = result.receiveCardData as SwapCardState.SwapCardData
|
||||
// The "from" card preserves the user's typed value; the receive card shows the (possibly
|
||||
// fee-reduced) sendingAmount formatted with the receive token's decimals.
|
||||
assertThat(sendCard.amountField?.value).isEqualTo(initialAmountValue)
|
||||
assertThat(receiveCard.amountField?.value).isEqualTo(initialAmountValue)
|
||||
assertThat(receiveCard.amountField?.value).isEqualTo(
|
||||
transferState.sendingAmount.parseBigDecimal(transferState.toTokenInfo.tokenAmount.decimals),
|
||||
)
|
||||
assertThat(sendCard.currencyIconState).isEqualTo(fromIcon)
|
||||
assertThat(receiveCard.currencyIconState).isEqualTo(toIcon)
|
||||
assertThat(sendCard.isBalanceHidden).isEqualTo(transferState.isBalanceHidden)
|
||||
|
|
@ -747,6 +900,8 @@ internal class SwapTransferStateBuilderTest {
|
|||
toAmount: BigDecimal,
|
||||
isAccountsMode: Boolean,
|
||||
isInsufficientBalance: Boolean = false,
|
||||
isFeeCoverage: Boolean = false,
|
||||
isSendingAmountLoading: Boolean = false,
|
||||
): SwapState.Transfer {
|
||||
val fromInfo = TokenSwapInfo(
|
||||
tokenAmount = SwapAmount(value = fromAmount, decimals = fromCurrencyStatus.currency.decimals),
|
||||
|
|
@ -767,8 +922,9 @@ internal class SwapTransferStateBuilderTest {
|
|||
appCurrency = AppCurrency.Default,
|
||||
isBalanceHidden = false,
|
||||
isAccountsMode = isAccountsMode,
|
||||
isFeeCoverage = false,
|
||||
sendingAmount = fromAmount,
|
||||
isFeeCoverage = isFeeCoverage,
|
||||
sendingAmount = toAmount,
|
||||
isSendingAmountLoading = isSendingAmountLoading,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -294,6 +294,7 @@ internal class TangemPayCardPageModel @Inject constructor(
|
|||
title = TextReference.Res(R.string.tangempay_card_details_title),
|
||||
onClick = ::onClickViewDetails,
|
||||
iconRes = CoreUiR.drawable.ic_visa_card_details_24,
|
||||
testTag = TangemPayTestTags.SHOW_DETAILS_ROW,
|
||||
),
|
||||
TangemPayCardPageSettingV2(
|
||||
id = TangemPayCardPageSettingV2.Id.Freeze,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ internal fun TangemPayChangePinCodeSuccessScreenV2(onClose: () -> Unit, modifier
|
|||
onButtonClick = onClose,
|
||||
titleTestTag = TangemPayTestTags.PIN_SUCCESS_TITLE,
|
||||
subtitleTestTag = TangemPayTestTags.PIN_SUCCESS_DESCRIPTION,
|
||||
buttonTestTag = TangemPayTestTags.PIN_DONE_BUTTON,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ import com.tangem.core.ui.extensions.resourceReference
|
|||
import com.tangem.core.ui.extensions.stringResourceSafe
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||
import com.tangem.core.ui.test.BaseActionButtonsBlockTestTags
|
||||
import com.tangem.core.ui.test.TangemPayTestTags
|
||||
import com.tangem.features.tangempay.components.express.PreviewEmptyExpressTransactionsComponent
|
||||
import com.tangem.features.tangempay.components.txHistory.PreviewTangemPayTxHistoryComponent
|
||||
|
|
@ -428,6 +429,7 @@ private fun LazyItemScope.ActionBlock(
|
|||
) {
|
||||
actionButtons.fastForEach { actionConfig ->
|
||||
TangemPayActionButton(
|
||||
modifier = Modifier.testTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON),
|
||||
iconRes = actionConfig.iconResId,
|
||||
onClick = actionConfig.onClick,
|
||||
isEnabled = actionConfig.isEnabled,
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ internal fun TangemPaySuccessScreenWrapper(
|
|||
fadeColor: Color = Color(DEFAULT_FADE_COLOR),
|
||||
titleTestTag: String? = null,
|
||||
subtitleTestTag: String? = null,
|
||||
buttonTestTag: String? = null,
|
||||
) {
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
Box(
|
||||
|
|
@ -90,7 +91,8 @@ internal fun TangemPaySuccessScreenWrapper(
|
|||
TangemButton(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = TangemTheme.dimens2.x3),
|
||||
.padding(vertical = TangemTheme.dimens2.x3)
|
||||
.then(buttonTestTag?.let { Modifier.testTag(it) } ?: Modifier),
|
||||
onClick = onButtonClick,
|
||||
size = TangemButton.Size.X12,
|
||||
text = buttonText,
|
||||
|
|
|
|||
|
|
@ -69,7 +69,6 @@ dependencies {
|
|||
implementation(projects.domain.dynamicAddresses)
|
||||
implementation(projects.domain.dynamicAddresses.models)
|
||||
implementation(projects.domain.feedback)
|
||||
implementation(projects.domain.feedback.models)
|
||||
implementation(projects.domain.markets.models)
|
||||
implementation(projects.domain.models)
|
||||
implementation(projects.domain.notifications.models)
|
||||
|
|
|
|||
|
|
@ -1,77 +0,0 @@
|
|||
package com.tangem.feature.tokendetails.domain
|
||||
|
||||
import com.tangem.domain.card.IsWalletBackupProblematicUseCase
|
||||
import com.tangem.domain.card.common.util.cardTypesResolver
|
||||
import com.tangem.domain.demo.IsDemoCardUseCase
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.wallets.usecase.HasSingleWalletSignedHashesUseCase
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Produces card/wallet-level warnings for the Token Details screen.
|
||||
*
|
||||
* These banners mirror the ones the main screen shows via `GetWalletNotificationsFactory`, but they only apply to
|
||||
* a single-currency cold wallet (a multi-currency wallet keeps them on the main screen only).
|
||||
*/
|
||||
internal class GetWalletCardWarningsUseCase @Inject constructor(
|
||||
private val isDemoCardUseCase: IsDemoCardUseCase,
|
||||
private val hasSingleWalletSignedHashesUseCase: HasSingleWalletSignedHashesUseCase,
|
||||
private val isWalletBackupProblematicUseCase: IsWalletBackupProblematicUseCase,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) {
|
||||
|
||||
operator fun invoke(userWallet: UserWallet, network: Network): Flow<Set<WalletCardWarning>> {
|
||||
if (userWallet !is UserWallet.Cold || userWallet.isMultiCurrency) {
|
||||
return flowOf(emptySet())
|
||||
}
|
||||
|
||||
return hasSingleWalletSignedHashesUseCase(userWallet, network)
|
||||
.map { hasIncorrectSignedHashes ->
|
||||
buildWarnings(
|
||||
userWallet = userWallet,
|
||||
hasIncorrectSignedHashes = hasIncorrectSignedHashes,
|
||||
)
|
||||
}
|
||||
.flowOn(dispatchers.io)
|
||||
}
|
||||
|
||||
private fun buildWarnings(userWallet: UserWallet.Cold, hasIncorrectSignedHashes: Boolean): Set<WalletCardWarning> {
|
||||
val cardTypesResolver = userWallet.cardTypesResolver
|
||||
|
||||
return buildSet {
|
||||
if (isWalletBackupProblematicUseCase(userWallet)) {
|
||||
add(WalletCardWarning.BackupError)
|
||||
}
|
||||
if (!cardTypesResolver.isReleaseFirmwareType()) {
|
||||
add(WalletCardWarning.DevCard)
|
||||
}
|
||||
if (cardTypesResolver.isReleaseFirmwareType() && cardTypesResolver.isAttestationFailed()) {
|
||||
add(WalletCardWarning.FailedCardValidation)
|
||||
}
|
||||
cardTypesResolver.getRemainingSignatures()?.let { remainingSignatures ->
|
||||
if (remainingSignatures <= MAX_REMAINING_SIGNATURES_COUNT) {
|
||||
add(WalletCardWarning.LowSignatures(count = remainingSignatures))
|
||||
}
|
||||
}
|
||||
if (isDemoCardUseCase(cardId = cardTypesResolver.getCardId())) {
|
||||
add(WalletCardWarning.DemoCard)
|
||||
}
|
||||
if (cardTypesResolver.isTestCard()) {
|
||||
add(WalletCardWarning.TestnetCard)
|
||||
}
|
||||
if (hasIncorrectSignedHashes) {
|
||||
add(WalletCardWarning.NumberOfSignedHashesIncorrect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MAX_REMAINING_SIGNATURES_COUNT = 10
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
package com.tangem.feature.tokendetails.domain
|
||||
|
||||
/**
|
||||
* Card/wallet-level warnings that are shown on the main screen for a single-currency wallet and must also be
|
||||
* displayed in Token Details (redesign), since single-currency wallets now have a Token Details screen.
|
||||
*
|
||||
* Currency-level warnings are produced separately by [GetCurrencyWarningsUseCase].
|
||||
*/
|
||||
internal sealed interface WalletCardWarning {
|
||||
|
||||
data object BackupError : WalletCardWarning
|
||||
|
||||
data object DevCard : WalletCardWarning
|
||||
|
||||
data object FailedCardValidation : WalletCardWarning
|
||||
|
||||
data object TestnetCard : WalletCardWarning
|
||||
|
||||
data class LowSignatures(val count: Int) : WalletCardWarning
|
||||
|
||||
data object NumberOfSignedHashesIncorrect : WalletCardWarning
|
||||
|
||||
data object DemoCard : WalletCardWarning
|
||||
}
|
||||
|
|
@ -78,14 +78,6 @@ interface TokenDetailsClickIntents {
|
|||
|
||||
fun onYieldInfoClick()
|
||||
|
||||
// region Wallet card warnings
|
||||
|
||||
fun onSupportClick()
|
||||
|
||||
fun onCloseSignedHashesWarning()
|
||||
|
||||
// endregion Wallet card warnings
|
||||
|
||||
// region Clore migration
|
||||
// TODO: Remove after 2025-04-01 when Clore migration ends ([REDACTED_TASK_KEY])
|
||||
|
||||
|
|
@ -169,10 +161,6 @@ internal class EmptyTokenDetailsClickIntents : TokenDetailsClickIntents {
|
|||
|
||||
override fun onCloseRentInfoNotification() { /* no op */ }
|
||||
|
||||
override fun onSupportClick() { /* no op */ }
|
||||
|
||||
override fun onCloseSignedHashesWarning() { /* no op */ }
|
||||
|
||||
override fun onRetryIncompleteTransactionClick() { /* no op */ }
|
||||
|
||||
override fun onOpenTrustlineClick() { /* no op */ }
|
||||
|
|
|
|||
|
|
@ -63,10 +63,6 @@ import com.tangem.domain.models.account.Account
|
|||
import com.tangem.domain.models.currency.CryptoCurrency
|
||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.network.NetworkAddress
|
||||
import com.tangem.domain.card.SetCardWasScannedUseCase
|
||||
import com.tangem.domain.feedback.GetWalletMetaInfoUseCase
|
||||
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
|
||||
import com.tangem.domain.feedback.models.FeedbackEmailType
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.UserWalletId
|
||||
import com.tangem.domain.offramp.GetOfframpUrlUseCase
|
||||
|
|
@ -102,7 +98,6 @@ import com.tangem.domain.yield.supply.models.YieldSupplyRewardBalance
|
|||
import com.tangem.domain.yield.supply.usecase.YieldSupplyGetRewardsBalanceUseCase
|
||||
import com.tangem.feature.tokendetails.deeplink.TokenDetailsDeepLinkActionListener
|
||||
import com.tangem.feature.tokendetails.domain.GetCurrencyWarningsUseCase
|
||||
import com.tangem.feature.tokendetails.domain.GetWalletCardWarningsUseCase
|
||||
import com.tangem.feature.tokendetails.presentation.router.InnerTokenDetailsRouter
|
||||
import com.tangem.feature.tokendetails.presentation.tokendetails.analytics.TokenDetailsCurrencyStatusAnalyticsSender
|
||||
import com.tangem.feature.tokendetails.presentation.tokendetails.analytics.TokenDetailsNotificationsAnalyticsSender
|
||||
|
|
@ -128,7 +123,6 @@ import com.tangem.feature.tokendetails.presentation.tokendetails.state.transform
|
|||
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.ToggleBalanceTypeTransformer
|
||||
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateStakingNotificationTransformer
|
||||
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateNotificationsTransformer
|
||||
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateWalletCardWarningsTransformer
|
||||
import com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer.UpdateTopBarMenuTransformer
|
||||
import com.tangem.features.tokendetails.ExpressTransactionsEvent
|
||||
import com.tangem.features.tokendetails.ExpressTransactionsEventListener
|
||||
|
|
@ -161,10 +155,6 @@ internal class TokenDetailsModel @Inject constructor(
|
|||
private val isCryptoCurrencyCouldHideUseCase: IsCryptoCurrencyCouldHideUseCase,
|
||||
private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
|
||||
private val getCurrencyWarningsUseCase: GetCurrencyWarningsUseCase,
|
||||
private val getWalletCardWarningsUseCase: GetWalletCardWarningsUseCase,
|
||||
private val setCardWasScannedUseCase: SetCardWasScannedUseCase,
|
||||
private val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase,
|
||||
private val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase,
|
||||
private val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase,
|
||||
private val getExtendedPublicKeyForCurrencyUseCase: GetExtendedPublicKeyForCurrencyUseCase,
|
||||
private val getStakingEntryInfoUseCase: GetStakingEntryInfoUseCase,
|
||||
|
|
@ -433,20 +423,13 @@ internal class TokenDetailsModel @Inject constructor(
|
|||
|
||||
private fun updateWarnings(cryptoCurrencyStatus: CryptoCurrencyStatus) {
|
||||
modelScope.launch(dispatchers.main) {
|
||||
combine(
|
||||
flow = getCurrencyWarningsUseCase(
|
||||
userWalletId = userWalletId,
|
||||
currencyStatus = cryptoCurrencyStatus,
|
||||
derivationPath = cryptoCurrency.network.derivationPath,
|
||||
),
|
||||
flow2 = getWalletCardWarningsUseCase(
|
||||
userWallet = userWallet,
|
||||
network = cryptoCurrency.network,
|
||||
),
|
||||
transform = ::Pair,
|
||||
getCurrencyWarningsUseCase(
|
||||
userWalletId = userWalletId,
|
||||
currencyStatus = cryptoCurrencyStatus,
|
||||
derivationPath = cryptoCurrency.network.derivationPath,
|
||||
)
|
||||
.distinctUntilChanged()
|
||||
.onEach { (warnings, cardWarnings) ->
|
||||
.onEach { warnings ->
|
||||
val updatedState = stateFactory.getStateWithNotifications(warnings)
|
||||
notificationsAnalyticsSender.send(uiState.value, updatedState.notifications)
|
||||
uiState.value = updatedState
|
||||
|
|
@ -457,12 +440,6 @@ internal class TokenDetailsModel @Inject constructor(
|
|||
clickIntents = this@TokenDetailsModel,
|
||||
),
|
||||
)
|
||||
redesignStateController.update(
|
||||
UpdateWalletCardWarningsTransformer(
|
||||
walletCardWarnings = cardWarnings,
|
||||
clickIntents = this@TokenDetailsModel,
|
||||
),
|
||||
)
|
||||
}
|
||||
.launchIn(modelScope)
|
||||
.saveIn(warningsJobHolder)
|
||||
|
|
@ -556,7 +533,7 @@ internal class TokenDetailsModel @Inject constructor(
|
|||
network = cryptoCurrency.network,
|
||||
).getOrElse { false }
|
||||
|
||||
val isSupported = isXPUBSupported()
|
||||
val isSupported = isXpubSupported()
|
||||
val isDynamicAddressesAvailable = isSupported &&
|
||||
isDynamicAddressesAvailableUseCase(userWallet, cryptoCurrency)
|
||||
|
||||
|
|
@ -569,7 +546,7 @@ internal class TokenDetailsModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun isXPUBSupported(): Boolean {
|
||||
private suspend fun isXpubSupported(): Boolean {
|
||||
return isXpubSupportedUseCase(userWalletId = userWalletId, network = cryptoCurrency.network)
|
||||
}
|
||||
|
||||
|
|
@ -876,7 +853,6 @@ internal class TokenDetailsModel @Inject constructor(
|
|||
if (handleUnavailabilityReason(unavailabilityReason = unavailabilityReason)) {
|
||||
return
|
||||
}
|
||||
if (isTopUpBlockedByBackupError()) return
|
||||
|
||||
modelScope.launch {
|
||||
if (checkYieldSupply && needShowYieldSupplyDepositedWarningUseCase(cryptoCurrencyStatus)) {
|
||||
|
|
@ -1032,21 +1008,6 @@ internal class TokenDetailsModel @Inject constructor(
|
|||
uiState.value = stateFactory.getStateWithRemovedRentNotification()
|
||||
}
|
||||
|
||||
override fun onSupportClick() {
|
||||
modelScope.launch {
|
||||
val metaInfo = getWalletMetaInfoUseCase(userWalletId).getOrNull() ?: return@launch
|
||||
sendFeedbackEmailUseCase(type = FeedbackEmailType.DirectUserRequest(walletMetaInfo = metaInfo))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCloseSignedHashesWarning() {
|
||||
modelScope.launch {
|
||||
(userWallet as? UserWallet.Cold)?.let { coldWallet ->
|
||||
setCardWasScannedUseCase(cardId = coldWallet.cardId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCopyAddress(): TextReference? {
|
||||
val networkAddress = cryptoCurrencyStatus?.value?.networkAddress ?: return null
|
||||
val addresses = networkAddress.availableAddresses.mapToAddressModels(cryptoCurrency).toImmutableList()
|
||||
|
|
@ -1476,15 +1437,17 @@ internal class TokenDetailsModel @Inject constructor(
|
|||
network = cryptoCurrency.network,
|
||||
).getOrElse { false }
|
||||
|
||||
val isSupported = isXPUBSupported()
|
||||
val isXpubSupported = isXpubSupported()
|
||||
val isDynamicAddressesAvailable = isXpubSupported &&
|
||||
isDynamicAddressesAvailableUseCase(userWallet, cryptoCurrency)
|
||||
|
||||
redesignStateController.update(
|
||||
UpdateTopBarMenuTransformer(
|
||||
userWallet = userWallet,
|
||||
hasDerivations = hasDerivations,
|
||||
isXPubSupported = isSupported,
|
||||
onGenerateExtendedKey = ::onGenerateExtendedKey,
|
||||
onHideClick = ::onHideClick,
|
||||
isXpubSupported = isXpubSupported,
|
||||
isDynamicAddressesAvailable = isDynamicAddressesAvailable,
|
||||
clickIntents = this@TokenDetailsModel,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import com.tangem.core.ui.extensions.themedColor
|
|||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.domain.card.common.util.cardTypesResolver
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents
|
||||
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
|
||||
import com.tangem.features.tokendetails.impl.R
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
|
|
@ -15,9 +16,9 @@ import kotlinx.collections.immutable.toImmutableList
|
|||
internal class UpdateTopBarMenuTransformer(
|
||||
private val userWallet: UserWallet,
|
||||
private val hasDerivations: Boolean,
|
||||
private val isXPubSupported: Boolean,
|
||||
private val onGenerateExtendedKey: () -> Unit,
|
||||
private val onHideClick: () -> Unit,
|
||||
private val isXpubSupported: Boolean,
|
||||
private val isDynamicAddressesAvailable: Boolean,
|
||||
private val clickIntents: TokenDetailsClickIntents,
|
||||
) : Transformer<TokenDetailsUM> {
|
||||
|
||||
override fun transform(prevState: TokenDetailsUM): TokenDetailsUM = prevState.copy(
|
||||
|
|
@ -30,12 +31,21 @@ internal class UpdateTopBarMenuTransformer(
|
|||
persistentListOf()
|
||||
} else {
|
||||
buildList {
|
||||
if (isXPubSupported && hasDerivations) {
|
||||
if (isDynamicAddressesAvailable) {
|
||||
add(
|
||||
TangemDropdownMenuItem(
|
||||
title = resourceReference(R.string.dynamic_addresses),
|
||||
textColor = themedColor { TangemTheme.colors.text.primary1 },
|
||||
onClick = clickIntents::onDynamicAddressesClick,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (isXpubSupported && hasDerivations) {
|
||||
add(
|
||||
TangemDropdownMenuItem(
|
||||
title = resourceReference(R.string.token_details_generate_xpub),
|
||||
textColor = themedColor { TangemTheme.colors.text.primary1 },
|
||||
onClick = onGenerateExtendedKey,
|
||||
onClick = clickIntents::onGenerateExtendedKey,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -43,7 +53,7 @@ internal class UpdateTopBarMenuTransformer(
|
|||
TangemDropdownMenuItem(
|
||||
title = resourceReference(R.string.token_details_hide_token),
|
||||
textColor = themedColor { TangemTheme.colors.text.warning },
|
||||
onClick = onHideClick,
|
||||
onClick = clickIntents::onHideClick,
|
||||
),
|
||||
)
|
||||
}.toImmutableList()
|
||||
|
|
|
|||
|
|
@ -1,131 +0,0 @@
|
|||
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
|
||||
|
||||
import com.tangem.core.ui.R
|
||||
import com.tangem.core.ui.ds.button.TangemButtonType
|
||||
import com.tangem.core.ui.ds.image.TangemIconUM
|
||||
import com.tangem.core.ui.ds.message.TangemMessageButtonUM
|
||||
import com.tangem.core.ui.ds.message.TangemMessageEffect
|
||||
import com.tangem.core.ui.ds.message.TangemMessageUM
|
||||
import com.tangem.core.ui.extensions.resourceReference
|
||||
import com.tangem.core.ui.extensions.wrappedList
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.feature.tokendetails.domain.WalletCardWarning
|
||||
import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents
|
||||
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
|
||||
import com.tangem.utils.transformer.Transformer
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import com.tangem.core.res.R as CoreResR
|
||||
|
||||
/**
|
||||
* Maps card/wallet-level warnings (produced by `GetWalletCardWarningsUseCase` for a single-currency wallet) into
|
||||
* Token Details notifications and prepends them to the existing currency-level notifications.
|
||||
*/
|
||||
internal class UpdateWalletCardWarningsTransformer(
|
||||
private val walletCardWarnings: Set<WalletCardWarning>,
|
||||
private val clickIntents: TokenDetailsClickIntents,
|
||||
) : Transformer<TokenDetailsUM> {
|
||||
|
||||
override fun transform(prevState: TokenDetailsUM): TokenDetailsUM {
|
||||
val others = prevState.notifications.filterNot { it.id in WALLET_CARD_WARNING_IDS }
|
||||
val messages = walletCardWarnings.map(::mapWarning)
|
||||
|
||||
return prevState.copy(
|
||||
notifications = (messages + others).toImmutableList(),
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
private fun mapWarning(warning: WalletCardWarning): TangemMessageUM {
|
||||
return when (warning) {
|
||||
WalletCardWarning.BackupError -> TangemMessageUM(
|
||||
id = ID_BACKUP_ERROR,
|
||||
title = resourceReference(CoreResR.string.warning_backup_errors_title),
|
||||
subtitle = resourceReference(CoreResR.string.warning_backup_errors_message),
|
||||
messageEffect = TangemMessageEffect.Warning,
|
||||
iconUM = attentionIcon(),
|
||||
buttonsUM = persistentListOf(
|
||||
TangemMessageButtonUM(
|
||||
text = resourceReference(CoreResR.string.common_contact_support),
|
||||
type = TangemButtonType.Secondary,
|
||||
onClick = clickIntents::onSupportClick,
|
||||
),
|
||||
),
|
||||
)
|
||||
WalletCardWarning.DevCard -> TangemMessageUM(
|
||||
id = ID_DEV_CARD,
|
||||
title = resourceReference(CoreResR.string.warning_developer_card_title),
|
||||
subtitle = resourceReference(CoreResR.string.warning_developer_card_message),
|
||||
messageEffect = TangemMessageEffect.None,
|
||||
iconUM = attentionIcon(),
|
||||
)
|
||||
WalletCardWarning.FailedCardValidation -> TangemMessageUM(
|
||||
id = ID_FAILED_CARD_VALIDATION,
|
||||
title = resourceReference(CoreResR.string.warning_failed_to_verify_card_title),
|
||||
subtitle = resourceReference(CoreResR.string.warning_failed_to_verify_card_message),
|
||||
messageEffect = TangemMessageEffect.Warning,
|
||||
iconUM = attentionIcon(),
|
||||
)
|
||||
WalletCardWarning.TestnetCard -> TangemMessageUM(
|
||||
id = ID_TESTNET_CARD,
|
||||
title = resourceReference(CoreResR.string.warning_testnet_card_title),
|
||||
subtitle = resourceReference(CoreResR.string.warning_testnet_card_message),
|
||||
messageEffect = TangemMessageEffect.None,
|
||||
iconUM = attentionIcon(),
|
||||
)
|
||||
is WalletCardWarning.LowSignatures -> TangemMessageUM(
|
||||
id = ID_LOW_SIGNATURES,
|
||||
title = resourceReference(CoreResR.string.warning_low_signatures_title),
|
||||
subtitle = resourceReference(
|
||||
id = CoreResR.string.warning_low_signatures_message,
|
||||
formatArgs = wrappedList(warning.count.toString()),
|
||||
),
|
||||
messageEffect = TangemMessageEffect.None,
|
||||
iconUM = attentionIcon(),
|
||||
)
|
||||
WalletCardWarning.NumberOfSignedHashesIncorrect -> TangemMessageUM(
|
||||
id = ID_NUMBER_OF_SIGNED_HASHES_INCORRECT,
|
||||
title = resourceReference(CoreResR.string.warning_number_of_signed_hashes_incorrect_title),
|
||||
subtitle = resourceReference(CoreResR.string.warning_number_of_signed_hashes_incorrect_message),
|
||||
messageEffect = TangemMessageEffect.Warning,
|
||||
iconUM = TangemIconUM.Icon(
|
||||
iconRes = R.drawable.img_knight_shield_32,
|
||||
tintReference = { TangemTheme.colors2.graphic.neutral.primary },
|
||||
),
|
||||
onCloseClick = clickIntents::onCloseSignedHashesWarning,
|
||||
)
|
||||
WalletCardWarning.DemoCard -> TangemMessageUM(
|
||||
id = ID_DEMO_CARD,
|
||||
title = resourceReference(CoreResR.string.warning_demo_mode_title),
|
||||
subtitle = resourceReference(CoreResR.string.warning_demo_mode_message),
|
||||
messageEffect = TangemMessageEffect.None,
|
||||
iconUM = attentionIcon(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun attentionIcon(): TangemIconUM.Icon = TangemIconUM.Icon(
|
||||
iconRes = R.drawable.ic_attention_default_24,
|
||||
tintReference = { TangemTheme.colors2.graphic.neutral.primary },
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val ID_BACKUP_ERROR = "BackupErrorNotification"
|
||||
const val ID_DEV_CARD = "DevCardNotification"
|
||||
const val ID_FAILED_CARD_VALIDATION = "FailedCardValidationNotification"
|
||||
const val ID_TESTNET_CARD = "TestnetCardNotification"
|
||||
const val ID_LOW_SIGNATURES = "LowSignaturesNotification"
|
||||
const val ID_NUMBER_OF_SIGNED_HASHES_INCORRECT = "NumberOfSignedHashesIncorrectNotification"
|
||||
const val ID_DEMO_CARD = "DemoCardNotification"
|
||||
|
||||
val WALLET_CARD_WARNING_IDS = setOf(
|
||||
ID_BACKUP_ERROR,
|
||||
ID_DEV_CARD,
|
||||
ID_FAILED_CARD_VALIDATION,
|
||||
ID_TESTNET_CARD,
|
||||
ID_LOW_SIGNATURES,
|
||||
ID_NUMBER_OF_SIGNED_HASHES_INCORRECT,
|
||||
ID_DEMO_CARD,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ import com.tangem.core.ui.components.marketprice.MarketPriceBlock
|
|||
import com.tangem.core.ui.components.marketprice.MarketPriceBlockState
|
||||
import com.tangem.core.ui.components.notifications.Notification
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemeRedesign
|
||||
import com.tangem.core.ui.res.TangemThemePreview
|
||||
import com.tangem.core.ui.test.TokenDetailsScreenTestTags
|
||||
import com.tangem.feature.tokendetails.presentation.tokendetails.TokenDetailsPreviewData
|
||||
|
|
@ -162,10 +163,12 @@ internal fun TokenDetailsScreenLegacy(
|
|||
|
||||
state.quickTopUpBlock?.let { quickTopUpBlock ->
|
||||
item(key = "quick_top_up_block") {
|
||||
QuickTopUpBlock(
|
||||
state = quickTopUpBlock,
|
||||
modifier = itemModifier,
|
||||
)
|
||||
TangemThemeRedesign {
|
||||
QuickTopUpBlock(
|
||||
state = quickTopUpBlock,
|
||||
modifier = itemModifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,169 +0,0 @@
|
|||
package com.tangem.feature.tokendetails.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.tangem.domain.card.CardTypesResolver
|
||||
import com.tangem.domain.card.IsWalletBackupProblematicUseCase
|
||||
import com.tangem.domain.card.common.util.cardTypesResolver
|
||||
import com.tangem.domain.demo.IsDemoCardUseCase
|
||||
import com.tangem.domain.models.network.Network
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.wallets.usecase.HasSingleWalletSignedHashesUseCase
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkStatic
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
internal class GetWalletCardWarningsUseCaseTest {
|
||||
|
||||
private val isDemoCardUseCase: IsDemoCardUseCase = mockk()
|
||||
private val hasSingleWalletSignedHashesUseCase: HasSingleWalletSignedHashesUseCase = mockk()
|
||||
private val isWalletBackupProblematicUseCase: IsWalletBackupProblematicUseCase = mockk()
|
||||
private val network: Network = mockk(relaxed = true)
|
||||
|
||||
private val useCase = GetWalletCardWarningsUseCase(
|
||||
isDemoCardUseCase = isDemoCardUseCase,
|
||||
hasSingleWalletSignedHashesUseCase = hasSingleWalletSignedHashesUseCase,
|
||||
isWalletBackupProblematicUseCase = isWalletBackupProblematicUseCase,
|
||||
dispatchers = TestingCoroutineDispatcherProvider(),
|
||||
)
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
clearMocks(
|
||||
isDemoCardUseCase,
|
||||
hasSingleWalletSignedHashesUseCase,
|
||||
isWalletBackupProblematicUseCase,
|
||||
)
|
||||
mockkStatic(UserWallet.Cold::cardTypesResolver)
|
||||
|
||||
// Default: nothing flagged
|
||||
every { isDemoCardUseCase(any()) } returns false
|
||||
every { hasSingleWalletSignedHashesUseCase(any(), any()) } returns flowOf(false)
|
||||
every { isWalletBackupProblematicUseCase(any()) } returns false
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
unmockkStatic(UserWallet.Cold::cardTypesResolver)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN multi-currency cold wallet WHEN invoke THEN empty set`() = runTest {
|
||||
// Arrange
|
||||
val wallet = coldWallet(isMultiCurrency = true)
|
||||
|
||||
// Act
|
||||
val result = useCase(userWallet = wallet, network = network).first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN hot wallet WHEN invoke THEN empty set`() = runTest {
|
||||
// Arrange
|
||||
val wallet = mockk<UserWallet.Hot>(relaxed = true)
|
||||
|
||||
// Act
|
||||
val result = useCase(userWallet = wallet, network = network).first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN single-currency cold wallet with clean card WHEN invoke THEN empty set`() = runTest {
|
||||
// Arrange
|
||||
val wallet = coldWallet(isMultiCurrency = false, resolver = cleanResolver())
|
||||
|
||||
// Act
|
||||
val result = useCase(userWallet = wallet, network = network).first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN single-currency dev card WHEN invoke THEN only dev card warning`() = runTest {
|
||||
// Arrange
|
||||
val resolver = cleanResolver().also { every { it.isReleaseFirmwareType() } returns false }
|
||||
val wallet = coldWallet(isMultiCurrency = false, resolver = resolver)
|
||||
|
||||
// Act
|
||||
val result = useCase(userWallet = wallet, network = network).first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).containsExactly(WalletCardWarning.DevCard)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN single-currency release card with every condition WHEN invoke THEN full warning set`() = runTest {
|
||||
// Arrange
|
||||
val resolver = cleanResolver().also {
|
||||
every { it.isReleaseFirmwareType() } returns true
|
||||
every { it.isAttestationFailed() } returns true
|
||||
every { it.getRemainingSignatures() } returns LOW_SIGNATURES
|
||||
every { it.isTestCard() } returns true
|
||||
}
|
||||
val wallet = coldWallet(isMultiCurrency = false, resolver = resolver)
|
||||
every { isWalletBackupProblematicUseCase(any()) } returns true
|
||||
every { isDemoCardUseCase(any()) } returns true
|
||||
every { hasSingleWalletSignedHashesUseCase(any(), any()) } returns flowOf(true)
|
||||
|
||||
// Act
|
||||
val result = useCase(userWallet = wallet, network = network).first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).containsExactly(
|
||||
WalletCardWarning.BackupError,
|
||||
WalletCardWarning.FailedCardValidation,
|
||||
WalletCardWarning.TestnetCard,
|
||||
WalletCardWarning.LowSignatures(count = LOW_SIGNATURES),
|
||||
WalletCardWarning.DemoCard,
|
||||
WalletCardWarning.NumberOfSignedHashesIncorrect,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN single-currency cold wallet with backup problem WHEN invoke THEN backup error warning`() = runTest {
|
||||
// Arrange
|
||||
val wallet = coldWallet(isMultiCurrency = false, resolver = cleanResolver())
|
||||
every { isWalletBackupProblematicUseCase(any()) } returns true
|
||||
|
||||
// Act
|
||||
val result = useCase(userWallet = wallet, network = network).first()
|
||||
|
||||
// Assert
|
||||
assertThat(result).containsExactly(WalletCardWarning.BackupError)
|
||||
}
|
||||
|
||||
private fun coldWallet(
|
||||
isMultiCurrency: Boolean,
|
||||
resolver: CardTypesResolver = cleanResolver(),
|
||||
): UserWallet.Cold {
|
||||
return mockk<UserWallet.Cold>(relaxed = true) {
|
||||
every { this@mockk.isMultiCurrency } returns isMultiCurrency
|
||||
every { cardTypesResolver } returns resolver
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanResolver(): CardTypesResolver = mockk {
|
||||
every { isReleaseFirmwareType() } returns true
|
||||
every { isAttestationFailed() } returns false
|
||||
every { getRemainingSignatures() } returns null
|
||||
every { isTestCard() } returns false
|
||||
every { getCardId() } returns "card"
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val LOW_SIGNATURES = 5
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import com.tangem.core.ui.extensions.stringReference
|
|||
import com.tangem.domain.card.CardTypesResolver
|
||||
import com.tangem.domain.card.common.util.cardTypesResolver
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents
|
||||
import com.tangem.feature.tokendetails.presentation.tokendetails.state.AddFundsUM
|
||||
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsBalanceBlockUM
|
||||
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsTopAppBarUM
|
||||
|
|
@ -29,8 +30,7 @@ class UpdateTopBarMenuTransformerTest {
|
|||
private val coldWallet: UserWallet.Cold = mockk(relaxed = true)
|
||||
private val hotWallet: UserWallet.Hot = mockk(relaxed = true)
|
||||
private val cardTypesResolver: CardTypesResolver = mockk(relaxed = true)
|
||||
private val onGenerateExtendedKey: () -> Unit = mockk(relaxed = true)
|
||||
private val onHideClick: () -> Unit = mockk(relaxed = true)
|
||||
private val clickIntents: TokenDetailsClickIntents = mockk(relaxed = true)
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
|
|
@ -77,8 +77,8 @@ class UpdateTopBarMenuTransformerTest {
|
|||
assertThat(result.topAppBarUM.menuItems).hasSize(1)
|
||||
|
||||
result.topAppBarUM.menuItems.single().onClick()
|
||||
verify(exactly = 1) { onHideClick.invoke() }
|
||||
verify(exactly = 0) { onGenerateExtendedKey.invoke() }
|
||||
verify(exactly = 1) { clickIntents.onHideClick() }
|
||||
verify(exactly = 0) { clickIntents.onGenerateExtendedKey() }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -169,6 +169,48 @@ class UpdateTopBarMenuTransformerTest {
|
|||
assertThat(result.marketPriceBlockState).isSameInstanceAs(state.marketPriceBlockState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN dynamic addresses available WHEN transform THEN dynamic addresses item is shown first`() {
|
||||
// GIVEN
|
||||
every { cardTypesResolver.isSingleWalletWithToken() } returns false
|
||||
val transformer = createTransformer(
|
||||
userWallet = coldWallet,
|
||||
hasDerivations = false,
|
||||
isXPubSupported = false,
|
||||
isDynamicAddressesAvailable = true,
|
||||
)
|
||||
|
||||
// WHEN
|
||||
val result = transformer.transform(initialState())
|
||||
|
||||
// THEN — Dynamic addresses item first, Hide token second
|
||||
assertThat(result.topAppBarUM.menuItems).hasSize(2)
|
||||
|
||||
result.topAppBarUM.menuItems.first().onClick()
|
||||
verify(exactly = 1) { clickIntents.onDynamicAddressesClick() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN dynamic addresses unavailable WHEN transform THEN dynamic addresses item is hidden`() {
|
||||
// GIVEN
|
||||
every { cardTypesResolver.isSingleWalletWithToken() } returns false
|
||||
val transformer = createTransformer(
|
||||
userWallet = coldWallet,
|
||||
hasDerivations = false,
|
||||
isXPubSupported = false,
|
||||
isDynamicAddressesAvailable = false,
|
||||
)
|
||||
|
||||
// WHEN
|
||||
val result = transformer.transform(initialState())
|
||||
|
||||
// THEN — only Hide token
|
||||
assertThat(result.topAppBarUM.menuItems).hasSize(1)
|
||||
|
||||
result.topAppBarUM.menuItems.single().onClick()
|
||||
verify(exactly = 0) { clickIntents.onDynamicAddressesClick() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN callbacks WHEN menu items invoked THEN callbacks are dispatched`() {
|
||||
// GIVEN
|
||||
|
|
@ -177,6 +219,7 @@ class UpdateTopBarMenuTransformerTest {
|
|||
userWallet = coldWallet,
|
||||
hasDerivations = true,
|
||||
isXPubSupported = true,
|
||||
isDynamicAddressesAvailable = true,
|
||||
)
|
||||
|
||||
// WHEN
|
||||
|
|
@ -184,20 +227,22 @@ class UpdateTopBarMenuTransformerTest {
|
|||
result.topAppBarUM.menuItems.forEach { it.onClick() }
|
||||
|
||||
// THEN
|
||||
verify(exactly = 1) { onGenerateExtendedKey.invoke() }
|
||||
verify(exactly = 1) { onHideClick.invoke() }
|
||||
verify(exactly = 1) { clickIntents.onDynamicAddressesClick() }
|
||||
verify(exactly = 1) { clickIntents.onGenerateExtendedKey() }
|
||||
verify(exactly = 1) { clickIntents.onHideClick() }
|
||||
}
|
||||
|
||||
private fun createTransformer(
|
||||
userWallet: UserWallet,
|
||||
hasDerivations: Boolean,
|
||||
isXPubSupported: Boolean,
|
||||
isDynamicAddressesAvailable: Boolean = false,
|
||||
) = UpdateTopBarMenuTransformer(
|
||||
userWallet = userWallet,
|
||||
hasDerivations = hasDerivations,
|
||||
isXPubSupported = isXPubSupported,
|
||||
onGenerateExtendedKey = onGenerateExtendedKey,
|
||||
onHideClick = onHideClick,
|
||||
isXpubSupported = isXPubSupported,
|
||||
isDynamicAddressesAvailable = isDynamicAddressesAvailable,
|
||||
clickIntents = clickIntents,
|
||||
)
|
||||
|
||||
private fun initialState(): TokenDetailsUM = TokenDetailsUM(
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ dependencies {
|
|||
|
||||
/* Tests */
|
||||
testImplementation(projects.common.test)
|
||||
testImplementation(projects.domain.onramp.models)
|
||||
testImplementation(deps.test.junit5)
|
||||
testImplementation(deps.test.mockk)
|
||||
testImplementation(deps.test.truth)
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import com.tangem.domain.notifications.GetIsHuaweiDeviceWithoutGoogleServicesUse
|
|||
import com.tangem.domain.notifications.repository.NotificationsRepository
|
||||
import com.tangem.domain.pay.flow.PaymentAccountStatusFetcher
|
||||
import com.tangem.domain.pay.repository.OnboardingRepository
|
||||
import com.tangem.domain.pushnotificationpreferences.PreloadWalletPushNotificationPreferencesUseCase
|
||||
import com.tangem.domain.qrscanning.models.ClassifiedQrContent
|
||||
import com.tangem.domain.qrscanning.models.QrResultSource
|
||||
import com.tangem.domain.qrscanning.models.QrSendTarget
|
||||
|
|
@ -47,7 +48,6 @@ import com.tangem.feature.wallet.presentation.wallet.analytics.utils.SelectedWal
|
|||
import com.tangem.feature.wallet.presentation.wallet.domain.OnrampStatusFactory
|
||||
import com.tangem.feature.wallet.presentation.wallet.domain.WalletContentFetcher
|
||||
import com.tangem.feature.wallet.presentation.wallet.domain.WalletImageResolver
|
||||
import com.tangem.domain.pushnotificationpreferences.PreloadWalletPushNotificationPreferencesUseCase
|
||||
import com.tangem.feature.wallet.presentation.wallet.domain.WalletNameMigrationUseCase
|
||||
import com.tangem.feature.wallet.presentation.wallet.loaders.WalletScreenContentLoader
|
||||
import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController
|
||||
|
|
@ -62,8 +62,8 @@ import com.tangem.feature.wallet.presentation.wallet.ui.components.visa.KycRejec
|
|||
import com.tangem.feature.wallet.presentation.wallet.utils.ScreenLifecycleProvider
|
||||
import com.tangem.features.biometry.AskBiometryComponent
|
||||
import com.tangem.features.hotwallet.HotWalletFeatureToggles
|
||||
import com.tangem.features.pushnotificationsettings.PushNotificationSettingsFeatureToggles
|
||||
import com.tangem.features.pushnotifications.api.PushNotificationsModelCallbacks
|
||||
import com.tangem.features.pushnotificationsettings.PushNotificationSettingsFeatureToggles
|
||||
import com.tangem.features.wallet.deeplink.WalletDeepLinkActionListener
|
||||
import com.tangem.features.wallet.featuretoggles.WalletFeatureToggles
|
||||
import com.tangem.utils.Provider
|
||||
|
|
|
|||
|
|
@ -373,7 +373,6 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
|
|||
)
|
||||
|
||||
if (handleUnavailabilityReason(unavailabilityReason)) return
|
||||
if (isTopUpBlockedByBackupError(accountId.userWalletId)) return
|
||||
|
||||
modelScope.launch(dispatchers.main) {
|
||||
if (needShowYieldSupplyWarning(cryptoCurrencyStatus)) {
|
||||
|
|
@ -462,7 +461,6 @@ internal class WalletCurrencyActionsClickIntentsImplementor @Inject constructor(
|
|||
}
|
||||
|
||||
override fun onMultiWalletSwapClick(userWalletId: UserWalletId) {
|
||||
if (isTopUpBlockedByBackupError(userWalletId)) return
|
||||
if (!isMultiWalletTokensLoaded()) return
|
||||
|
||||
modelScope.launch {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import com.tangem.domain.models.wallet.UserWallet
|
|||
import com.tangem.domain.settings.IsReadyToShowRateAppUseCase
|
||||
import com.tangem.domain.tokens.error.CurrencyStatusError
|
||||
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
|
||||
import com.tangem.domain.wallets.usecase.HasSingleWalletSignedHashesUseCase
|
||||
import com.tangem.domain.wallets.usecase.IsNeedToBackupUseCase
|
||||
import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
|
||||
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletNotification
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import com.tangem.domain.models.currency.CryptoCurrency
|
|||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.models.wallet.isMultiCurrency
|
||||
import com.tangem.domain.wallets.usecase.HasSingleWalletSignedHashesUseCase
|
||||
import com.tangem.domain.wallets.usecase.IsNeedToBackupUseCase
|
||||
import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
|
||||
import com.tangem.feature.wallet.impl.R
|
||||
|
|
@ -255,10 +254,6 @@ internal class GetWalletNotificationsFactory @Inject constructor(
|
|||
|
||||
addCloreMigrationNotification(userWallet, flattenCurrencies, clickIntents)
|
||||
|
||||
if (!userWallet.isMultiCurrency) {
|
||||
addNoAccountWarning(cryptoCurrencyStatus = flattenCurrencies.firstOrNull())
|
||||
}
|
||||
|
||||
addIf(
|
||||
element = WalletNotificationUM.NumberOfSignedHashesIncorrect(
|
||||
onCloseClick = clickIntents::onCloseAlreadySignedHashesWarningClick,
|
||||
|
|
@ -296,19 +291,6 @@ internal class GetWalletNotificationsFactory @Inject constructor(
|
|||
notification?.let(::add)
|
||||
}
|
||||
|
||||
private fun MutableList<WalletNotificationUM>.addNoAccountWarning(cryptoCurrencyStatus: CryptoCurrencyStatus?) {
|
||||
val noAccountStatus = cryptoCurrencyStatus?.value as? CryptoCurrencyStatus.NoAccount
|
||||
if (noAccountStatus != null) {
|
||||
add(
|
||||
element = WalletNotificationUM.NoAccount(
|
||||
network = cryptoCurrencyStatus.currency.name,
|
||||
amount = noAccountStatus.amountToCreateAccount.toString(),
|
||||
symbol = cryptoCurrencyStatus.currency.symbol,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableList<WalletNotificationUM>.addCloreMigrationNotification(
|
||||
userWallet: UserWallet,
|
||||
flattenCurrencies: List<CryptoCurrencyStatus>,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.tangem.domain.wallets.usecase
|
||||
package com.tangem.feature.wallet.presentation.wallet.domain
|
||||
|
||||
import com.tangem.core.decompose.di.ModelScoped
|
||||
import com.tangem.domain.card.common.util.cardTypesResolver
|
||||
import com.tangem.domain.card.repository.CardRepository
|
||||
import com.tangem.domain.demo.models.DemoConfig
|
||||
|
|
@ -9,8 +10,10 @@ import com.tangem.domain.walletmanager.WalletManagersFacade
|
|||
import com.tangem.utils.logging.TangemLogger
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
|
||||
class HasSingleWalletSignedHashesUseCase(
|
||||
@ModelScoped
|
||||
class HasSingleWalletSignedHashesUseCase @Inject constructor(
|
||||
private val cardRepository: CardRepository,
|
||||
private val walletManagersFacade: WalletManagersFacade,
|
||||
) {
|
||||
|
|
@ -25,16 +28,11 @@ class HasSingleWalletSignedHashesUseCase(
|
|||
return@map false
|
||||
}
|
||||
|
||||
val signedHashes = userWallet.scanResponse.card.wallets
|
||||
.firstOrNull()
|
||||
?.totalSignedHashes
|
||||
?: 0
|
||||
|
||||
return@map try {
|
||||
walletManagersFacade.validateSignatureCount(
|
||||
userWalletId = userWallet.walletId,
|
||||
network = network,
|
||||
signedHashes = signedHashes,
|
||||
signedHashes = userWallet.scanResponse.card.wallets.firstOrNull()?.totalSignedHashes ?: 0,
|
||||
)
|
||||
.fold(
|
||||
ifLeft = { true },
|
||||
|
|
@ -355,10 +355,10 @@ internal sealed class WalletNotificationUM(val messageUM: TangemMessageUM, val t
|
|||
title = resourceReference(id = CoreResR.string.main_add_funds_promo_title),
|
||||
subtitle = resourceReference(id = CoreResR.string.main_add_funds_promo_description),
|
||||
iconUM = TangemIconUM.Icon(
|
||||
iconRes = CoreUiR.drawable.ic_coins_swap_24,
|
||||
tintReference = { TangemTheme.colors2.graphic.status.accent },
|
||||
iconRes = CoreUiR.drawable.ic_swap_28,
|
||||
tintReference = { TangemTheme.colors2.graphic.neutral.primary },
|
||||
),
|
||||
messageEffect = TangemMessageEffect.None,
|
||||
messageEffect = TangemMessageEffect.Magic,
|
||||
buttonsUM = persistentListOf(
|
||||
TangemMessageButtonUM(
|
||||
text = resourceReference(id = CoreResR.string.common_add_funds),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.tangem.feature.wallet.presentation.wallet.subscribers
|
|||
import com.tangem.domain.models.wallet.UserWallet
|
||||
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
|
||||
import com.tangem.domain.pay.repository.TangemPayWithdrawRepository
|
||||
import com.tangem.domain.pay.usecase.RestoreActiveIssueOrdersUseCase
|
||||
import com.tangem.feature.wallet.presentation.wallet.analytics.utils.WalletTangemPayAnalyticsEventSender
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
|
|
@ -14,6 +15,7 @@ import kotlinx.coroutines.launch
|
|||
internal class TangemPayMainSubscriber @AssistedInject constructor(
|
||||
@Assisted private val userWallet: UserWallet,
|
||||
private val tangemPayWithdrawRepository: TangemPayWithdrawRepository,
|
||||
private val restoreActiveIssueOrdersUseCase: RestoreActiveIssueOrdersUseCase,
|
||||
private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier,
|
||||
private val analytics: WalletTangemPayAnalyticsEventSender,
|
||||
) : WalletSubscriber() {
|
||||
|
|
@ -23,6 +25,9 @@ internal class TangemPayMainSubscriber @AssistedInject constructor(
|
|||
// TODO: Doston move this logic to proper place(e.g. WalletBalanceFetcher)
|
||||
tangemPayWithdrawRepository.pollWithdrawOrdersIfNeeds(userWallet)
|
||||
}
|
||||
coroutineScope.launch {
|
||||
restoreActiveIssueOrdersUseCase(userWallet.walletId)
|
||||
}
|
||||
subscribeToStatus(coroutineScope)
|
||||
return emptyFlow<Any>()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@
|
|||
# https://github.com/tangem/tangem-sdk-android/
|
||||
# https://github.com/tangem/vico
|
||||
|
||||
tangemBlockchainSdk = "develop-1567"
|
||||
tangemBlockchainSdk = "develop-1586"
|
||||
#tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds
|
||||
tangemCardSdk = "develop-624"
|
||||
tangemCardSdk = "develop-630"
|
||||
#tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^
|
||||
tangemVico = "tangem-master-21"
|
||||
#tangemVico = "0.0.1" # Keep it! - used for local builds ^
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue