diff --git a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt index cf9fb967fc..95ccc3e96a 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/BaseTestCase.kt @@ -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, ) ) } diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/CheckMainScreenScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/CheckMainScreenScenarios.kt index 665515e2cb..9ad0c60297 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/CheckMainScreenScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/CheckMainScreenScenarios.kt @@ -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() } } } } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt index fa9a604135..cd9cfb4dd6 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt @@ -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() } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/ChooseTokenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/ChooseTokenBottomSheetPageObject.kt similarity index 82% rename from app/src/androidTest/kotlin/com/tangem/screens/ChooseTokenPageObject.kt rename to app/src/androidTest/kotlin/com/tangem/screens/ChooseTokenBottomSheetPageObject.kt index c2dd74ddd5..f0618bcd82 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/ChooseTokenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/ChooseTokenBottomSheetPageObject.kt @@ -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( +class ChooseTokenBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen( 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) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt index 5127d23985..7ce81ac85b 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt @@ -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 } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/tangempay/TangemPayCardPagePageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/tangempay/TangemPayCardPagePageObject.kt index b9e4b888dd..5d43dec5e8 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/tangempay/TangemPayCardPagePageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/tangempay/TangemPayCardPagePageObject.kt @@ -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 } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/BuyTokenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/BuyTokenTest.kt index becda76ceb..8a918669d6 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/BuyTokenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/BuyTokenTest.kt @@ -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() } } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/DetailsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/DetailsTest.kt index 23194d3d60..51339285da 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/DetailsTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/DetailsTest.kt @@ -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() diff --git a/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt index 029a430d70..96a6fb73dc 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt @@ -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() } } } } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenTest.kt index c9c75494e4..3e6ae3f082 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenTest.kt @@ -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 diff --git a/app/src/androidTest/kotlin/com/tangem/tests/tangempay/TangemPayTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/tangempay/TangemPayTest.kt index 5d87dfedf1..fa4e3f9910 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/tangempay/TangemPayTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/tangempay/TangemPayTest.kt @@ -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") { diff --git a/app/src/androidTest/kotlin/com/tangem/tests/tangempay/TangemPayTopUpTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/tangempay/TangemPayTopUpTest.kt index 5c8ca21fe3..700738e23c 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/tangempay/TangemPayTopUpTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/tangempay/TangemPayTopUpTest.kt @@ -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") { diff --git a/app/src/main/java/com/tangem/tap/di/domain/CardDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/CardDomainModule.kt index 9477d6c380..cef5171d26 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/CardDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/CardDomainModule.kt @@ -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( diff --git a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt index 667b78653d..57ab3eb8e5 100644 --- a/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt +++ b/app/src/main/java/com/tangem/tap/di/domain/TransactionDomainModule.kt @@ -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( diff --git a/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt b/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt index 34881a8063..e8c71b07bf 100644 --- a/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt +++ b/app/src/main/java/com/tangem/tap/features/main/MainViewModel.kt @@ -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) } } diff --git a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsHandler.kt b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsHandler.kt index 3b59bc5471..02e289b3f1 100644 --- a/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsHandler.kt +++ b/common/ui-markets/src/main/kotlin/com/tangem/common/ui/markets/action/TokenActionsHandler.kt @@ -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 diff --git a/core/res/src/main/res/values-de/strings.xml b/core/res/src/main/res/values-de/strings.xml index f792607c78..f4e0b41e2d 100644 --- a/core/res/src/main/res/values-de/strings.xml +++ b/core/res/src/main/res/values-de/strings.xml @@ -102,6 +102,7 @@ Adresse Adressen + Adresse auswählen Kontakt Name der Kontaktperson Adresse kopieren @@ -112,6 +113,7 @@ Verwerfen Adresse bearbeiten Adresse eingeben + Ungültige Adresse Weiter bearbeiten Neuer Kontakt Noch keine Kontakte @@ -274,6 +276,7 @@ Achtung Verfügbare Netzwerke Sicherungskopie + Sicherungsfehler Bilanz: %s Saldo biometrische Authentifizierung @@ -429,6 +432,7 @@ Senden Senden: Absenden der Transaktion fehlgeschlagen + Senden & Tauschen Senden Gesendet Der Server ist nicht verfügbar. Bitte versuche es später erneut. @@ -646,6 +650,7 @@ Durch die Nutzung der Swap-Funktionalität erklärst du dich mit des Anbieters %1$s und %2$s einverstanden. Weitere Anbieter folgen in Kürze Anbieter + Bester DEX-Kurs Bester Preis Warnliste der FCA Der Festzins ist nicht verfügbar @@ -658,6 +663,9 @@ Erlaubnis erforderlich Genehmigung erforderlich Empfohlen + Tauschen + Tauschen Sie es manuell aus und senden Sie es anschließend an den Empfänger. + %s wird in Swap & Send nicht unterstützt. Gekauft %s Kauf %s Kauf %s… @@ -692,9 +700,14 @@ Feedback zu Tangem Eine Transaktion kann nicht gesendet werden Fehler in der Coinbeschreibung + Jetzt aktualisieren Aktualisiere die Anwendung auf die neueste Version, um die ordnungsgemäße Funktionalität zu gewährleisten Aktualisierung erforderlich + Diese Version der App wird nicht mehr unterstützt und kann auf diesem Gerät nicht aktualisiert werden. + Update nicht verfügbar Update + Ihr Betriebssystem ist veraltet. Bitte aktualisieren Sie es, um die App weiterhin nutzen zu können. + Aktualisieren Sie Ihr Betriebssystem Bitte aktualisiere die Anwendung auf die neueste Version, um eine einwandfreie Funktion zu gewährleisten. Aktualisierung erforderlich Nicht genügend Mittel @@ -1281,6 +1294,7 @@ Kreditkarte oder Bankkonto Teilen deine Adresse oder dein QR-Code Sicherer Verkauf von Kryptowährungen + Senden Sie mit Tausch an ein anderes Token An eine andere Wallet senden Zwischen deinen Portfolios Andere @@ -1511,6 +1525,8 @@ Effektiver Jahreszins APY Belohnungen sammeln sich täglich automatisch in deinem Staking-Konto an. + Erzielte Einnahmen: %s + Die Prämien werden Ihrem Staking-Guthaben gutgeschrieben. Die Belohnungen werden auf Dein Stakingguthaben aufgezinst. Verdiente Gelder: %s Verfügbar Durchschnittliche Belohnungsquote @@ -1613,6 +1629,8 @@ Stake %s Staking beenden %s Die Transaktion wird bearbeitet! Derzeit findet eine Validierung in der Blockchain statt. Dies kann einige Minuten dauern. + %s Staking aufgrund von Sicherheitsüberprüfungen untersagt + %s Das Staking erscheint verdächtig. Bitte staken Sie auf eigenes Risiko. Lösen der Bindungen Gelocktes unlocken Entsperren @@ -1641,6 +1659,8 @@ Web 3.0-kompatibel Zum Fortfahren ist eine eingehende Transaktion von mindestens %1$s erforderlich Unzureichende Mittel + Support-Chat + App-Protokolle anhängen SWAP-Operationsdaten:\nAus: %1$s %2$s\nZu: %3$s %4$s\nVon %5$s - %6$s Chat öffnen Mail öffnen @@ -1693,7 +1713,7 @@ Nicht genügend Geldmittel, um diese Transaktion abzuschließen. Verringern Sie den zu erhaltenden Betrag oder fügen Sie weitere Mittel hinzu. Erlaubnis erteilen Bewerten deine Erfahrung mit dem Anbieter - Geben dein Feedback ein + Geben dein Feedback Feedback senden Was waren deine Erfahrungen? Tauschen @@ -1711,6 +1731,7 @@ Karte kann nicht umbenannt werden Karte eingefroren Kartenzahlung + Die letzte Karte kann nicht geschlossen werden. Es wird aus der App verschwinden Karte schließen Geh zurück @@ -1823,6 +1844,7 @@ PIN-Code ändern Kehren Sie zur App zurück, falls Sie ihn vergessen. + Karte Limit von %s bis %s festlegen Limits festlegen Unzureichendes Guthaben @@ -1924,6 +1946,9 @@ Zahlen Sie USDC auf das Zahlungskonto ein, um die Ausstellungsgebühr zu decken Gebühr kann nicht gedeckt werden Ihre Karte neu ausstellen? + Konto löschen + Tangem Pay wird vom Hauptbildschirm entfernt und erscheint auch nach einer Neuinstallation der App nicht wieder. + Konto löschen? Wir beheben ein technisches Problem. Bitte versuchen Sie es später erneut. Service vorübergehend nicht verfügbar Daten können derzeit nicht angezeigt werden, Kartenzahlungen funktionieren jedoch weiterhin. @@ -2031,6 +2056,8 @@ Tangem Twin Diese Aktion ist unumkehrbar. Du hast keinen Zugriff mehr auf die alte Wallet. Tippe auf die Doppelkarte oder Ring mit der Nummer %s und entferne sie erst am Ende des Vorgangs. + Aufladen + Aufgeladen Bitte versuche es später noch einmal. Sollte das Problem weiterhin bestehen, wende Dich bitte an den Support. Etwas ist schiefgelaufen! Es ist ein Fehler aufgetreten. Fehlercode: %s. Bitte kontaktiere unseren Support. @@ -2062,6 +2089,8 @@ Wallet umbenennen Alle freischalten Alle mit %s freischalten + Virtuelles Konto + Noch keine Transaktionen. Beginnen Sie mit dem Einkaufen und sehen Sie sich hier den Verlauf an AML-geprüft Verfügbar Gesperrt @@ -2368,6 +2397,7 @@ Alle dApps getrennt Erlaubnis auszugeben Durch die Genehmigung erlaubst Du dApps oder Smart Contracts, Token in zukünftigen Transaktionen zu verwenden. + Adresse ändern Vertragsadresse Verbinden Laden diff --git a/core/res/src/main/res/values-es/strings.xml b/core/res/src/main/res/values-es/strings.xml index 7878ffe8a4..3a5403aaec 100644 --- a/core/res/src/main/res/values-es/strings.xml +++ b/core/res/src/main/res/values-es/strings.xml @@ -1542,6 +1542,7 @@ Staking habilitado No hay validadores disponibles en este momento. Por favor, inténtelo de nuevo más tarde. Staking no disponible + Staking no está disponible en tu región. La red cobrará una tarifa de aprobación de token para verificar que usted está autorizando el uso de su token para el staking. Al utilizar la función de staking, usted acepta %1$s y %2$s del proveedor Bloqueado @@ -1621,6 +1622,8 @@ Stake %s Unstaking de %s ¡La transacción se está procesando! La validación está en curso en la cadena de bloques. Esto puede tardar unos minutos. + %s Prohibido el staking debido a controles de seguridad + %s staking sospechoso. Por favor, realice el staking bajo su propia responsabilidad. Desunión Desbloquear Desbloqueando @@ -1701,7 +1704,7 @@ No hay fondos suficientes para completar esta transacción. Reduzca el importe a recibir o añada más fondos. Dar autorización Valore su experiencia con el proveedor - Escriba sus comentarios + Escriba sus comentarios (opcional) Enviar comentarios ¿Qué influyó en su \nexperiencia? Intercambiar diff --git a/core/res/src/main/res/values-fr/strings.xml b/core/res/src/main/res/values-fr/strings.xml index d61d050cc8..ec381bcad1 100644 --- a/core/res/src/main/res/values-fr/strings.xml +++ b/core/res/src/main/res/values-fr/strings.xml @@ -1500,6 +1500,8 @@ Stake %s déstaker %s La transaction est en cours de traitement ! La validation est actuellement en cours dans la blockchain. Cela peut prendre quelques minutes. + %s staking interdit en raison de contrôles de sécurité + %s staking suspicieux. Continuez à vos propres risques. Dissociation Débloquer Déverrouillage @@ -1528,6 +1530,8 @@ Compatible avec Web 3.0 Une transaction entrante d\'au moins de %1$s est requise pour continuer Fonds insuffisants + Discussion avec le support + Joindre les logs de l\'application Données du SWAP :\nDepuis :%1$s%2$s\nVers :%3$s%4$s\nPar :%5$s-%6$s Accéder au chat Ouvrir un email diff --git a/core/res/src/main/res/values-ja/strings.xml b/core/res/src/main/res/values-ja/strings.xml index 5ab8311991..b851b50576 100644 --- a/core/res/src/main/res/values-ja/strings.xml +++ b/core/res/src/main/res/values-ja/strings.xml @@ -97,6 +97,7 @@ 受け取る アドレスを追加 アドレスを追加し、ネットワークを選択してください。 + 連絡先を追加 %d件のアドレス @@ -112,10 +113,13 @@ アドレスを入力 無効なアドレス 編集を続ける + 新しい連絡先 + 連絡先はまだありません 追加した連絡先はここに表示されます。 アドレスを削除 この連絡先は、このウォレットのアドレス帳に紐付けられます。 ネットワークを選択 + 連絡先 保存されていない変更 編集内容を破棄してもよろしいですか? このアドレスには%3$s ネットワークから%1$s (%2$s) のみを送信してください。他のトークンやネットワークを使用すると、資金を失う可能性があります。 @@ -685,7 +689,11 @@ コインの説明エラー 正常に動作するよう、アプリを最新バージョンに更新してください。 アップデートが必要です + このバージョンのアプリはサポート対象外となっており、このデバイスでは更新できません。 + 更新できません アップデート + 使用中のOSが古くなっています。アプリを引き続き使用するには、OSを更新してください。 + OSの更新が必要です 正常に動作するよう、アプリを最新バージョンに更新してください。 アップデートが必要です 残高不足 @@ -1591,6 +1599,8 @@ %sをステーキングする %sのステーキング解除 取引を処理中です。現在、ブロックチェーンで検証が行われています。これには数分かかる場合があります。 + セキュリティチェックにより、%sのステーキングは禁止されています + %sのステーキングにはリスクが伴う可能性があります。ご自身の判断と責任でご利用ください ステーキング解約中 ロック解除 ロック解除中 @@ -1619,6 +1629,8 @@ Web3.0対応 続行するには少なくとも%1$sの受信取引が必要です 残高不足 + サポートチャット + アプリログを添付 SWAP操作データ:\n交換元:%1$s%2$s\n交換先:%3$s%4$s\n提供元:%5$s - %6$s チャットを開く メールを開く @@ -1671,7 +1683,7 @@ この取引を完了するには残高が不足しています。受け取り額を減らすか、資金を追加してください。 許可を与える プロバイダーの利用体験を評価してください - フィードバックを入力してください + フィードバックを入力(任意) フィードバックを送信 ご利用中に気になった点を\n教えてください スワップ @@ -1690,7 +1702,7 @@ カードが凍結されています カード決済 最後のカードは停止できません - 支払いアカウントから削除されます。 + アプリに表示されなくなります カードを解約する 戻る カードを解約しますか? @@ -1890,7 +1902,7 @@ Tangem Pay カードをすぐに手に入れよう Payサポート 支払いアカウント - Tangem Pay セッションの有効期限が切れました + セッションの有効期限が切れました 無効な暗証番号:連続や繰り返しを避けてください カードを交換 これにより、新しいカード情報が発行されます。現在のカード情報は使えなくなります。この操作は元に戻せません。 @@ -1918,12 +1930,12 @@ セッションを更新 Tangem Pay セッションの有効期限が切れました 日常の支払いにUSDCを利用 - Tangem Payは現在一時的に利用できません。 + Tangem Payは一時的に利用できません Tangem Pay USDC Polygon をアカウントのアドレスに送信 別のウォレットまたは取引所から ウォレットの暗号資産を使って、決済アカウントにチャージできます - Tangemウォレットからスワップ + Tangemウォレットから Polygonネットワーク上のUSDC 返金分はオンチェーンのPolygon残高には戻らず、出金にも利用できません。ただし、カード残高として残り、支払いに利用できます。 ご注意ください diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index dadbbeace6..74baf3a874 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -1593,6 +1593,7 @@ Стейкинг включен В данный момент нет доступных валидаторов. Попробуйте позже. Стейкинг недоступен + Стейкинг недоступен в вашем регионе Комиссия сети за одобрение токена будет взиматься за подтверждение того, что именно вы разрешаете использовать ваш токен для стейкинга. Пользуясь стейкинг сервисом, вы соглашаетесь с %1$s и %2$s Заблокировано @@ -1672,6 +1673,8 @@ Застейкать %s Вывести %s Транзакция обрабатывается! В настоящее время идет проверка в блокчейне. Это может занять несколько минут. + %s стейкинг запрещен по результатам проверок безопасности + %s стейкинг вызывает сомнения. Пожалуйста, стейкайте на свой страх и риск Отзыв Разблокировать Разблокировка @@ -1700,6 +1703,8 @@ Поддержка Web 3.0 Для отправки требуется входящая транзакция на сумму не менее %1$s Недостаточно средств + Чат поддержки + Прикрепить логи Данные об операции обмена:\nОткуда: %1$s %2$s\nКуда: %3$s %4$s\nЧерез: %5$s - %6$s Открыть чат Открыть почту @@ -1752,7 +1757,7 @@ Недостаточно средств для завершения транзакции. Уменьшите сумму получения или добавьте больше средств Дать разрешение Оцените ваш опыт взаимодействия с провайдером - Напишите ваш отзыв + Оставьте отзыв (необязательно) Отправить отзыв Что повлияло на вашу оценку? Обменять @@ -1951,11 +1956,11 @@ Баланс карты работает на USDC в сети Polygon, но пополнить его можно любым активом (USDT, SOL, ETH, BTC, XRP и др.) через удобные встроенные свопы Tangem. Какую крипту можно тратить? Тратьте крипту где угодно — без банков, бирж и посредников. Сила self-custody для повседневных платежей. - Платите онлайн и c Apple Pay + Онлайн и c Apple Pay Принимается везде За покупки не в USD FX-комиссия 1% - Откройте карту Tangem Pay + Откройте Tangem Pay 1 USDC = 1 USD Без комиссии за покупки Без сюрпризов @@ -1999,7 +2004,7 @@ Обновить сессию Tangem Pay · Cессия истекла Оплачивайте ежедневные покупки в USDC - Tangem Pay временно недоступен + Проблема с соединением Tangem Pay Отправьте USDC Polygon на адрес вашего аккаунта С другого кошелька или биржи diff --git a/core/res/src/main/res/values-uk-rUA/strings.xml b/core/res/src/main/res/values-uk-rUA/strings.xml index c264918ba7..7924e3da11 100644 --- a/core/res/src/main/res/values-uk-rUA/strings.xml +++ b/core/res/src/main/res/values-uk-rUA/strings.xml @@ -1593,6 +1593,7 @@ Стейкінг увімкнено Наразі немає вільних валідаторів. Повторіть спробу пізніше. Стейкінг недоступний + Стейкінг недоступний у вашому регіоні Комісія за схвалення токену, щоб верифікувати, що саме ви дозволяєте використання токена для стейкінгу. Використовуючи функцію стейкінгу, ви погоджуєтесь з %1$s та %2$s Заблоковано @@ -1672,6 +1673,8 @@ Застейкати %s Зняти зі стейкінгу %s Транзакція обробляється! Наразі триває перевірка в блокчейні. Це може зайняти кілька хвилин. + %s стейкінг заборонено за результатами перевірок безпеки + %s стейкінг викликає сумніви. Будь ласка, стейкайте на свій страх і ризик Розблокування Розблокувати Розблокування @@ -1700,6 +1703,8 @@ Web 3.0 сумісність Для відправки потрібна вхідна транзакція на суму не менше %1$s Недостатньо коштів + Чат підтримки + Прикріпити логи Дані про операцію обміну:\nЗвідки: %1$s %2$s\nКуди: %3$s %4$s\nЧерез: %5$s - %6$s Відкрити чат Відкрити пошту @@ -1752,7 +1757,7 @@ Недостатньо коштів для завершення транзакції. Зменште суму отримання або додайте більше коштів Надати дозвіл Оцініть ваш досвід взаємодії з провайдером - Напишіть ваш відгук + Залиште відгук (необов\'язково) Надіслати відгук Що вплинуло на вашу оцінку? Обміняти diff --git a/core/res/src/main/res/values-zh-rCN/strings.xml b/core/res/src/main/res/values-zh-rCN/strings.xml index 7e8b479e7d..d74f43edfc 100644 --- a/core/res/src/main/res/values-zh-rCN/strings.xml +++ b/core/res/src/main/res/values-zh-rCN/strings.xml @@ -1591,6 +1591,8 @@ 质押%s 取消抵押 %s 交易正在处理中!区块链正在进行验证,这可能需要几分钟时间。 + 因安全检查问题,禁止质押%s + %s 质押行为可疑。请自行承担风险进行质押 解除绑定 解锁 解锁 @@ -1671,7 +1673,7 @@ 账户余额不足,无法完成此交易。请减少收款金额或增加余额。 给予许可 请评价您与服务提供商的互动体验 - 请输入您的反馈 + 请输入您的反馈(可选) 发送反馈 是什么影响了您的\n体验? 兑换 diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 91a906d6b8..e644cefa16 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -333,7 +333,6 @@ Error Top-up network fee Swap - Send&Swap Explore Explore transaction history Explorer @@ -433,6 +432,7 @@ Send Send: Failed to send transaction + Send&Swap Sending Sent The server is not available, please try again later @@ -701,6 +701,7 @@ Tangem feedback Can\'t send a transaction Coin description error + Update now Update the app to its latest version to ensure proper functionality Update needed This version of the app is no longer supported and can\'t be updated on this device. @@ -1294,9 +1295,9 @@ Credit card or bank account Share your address or QR-code Sell crypto securely + Send with swap to another token Send to another wallet Between your portfolios - Send with swap to another token Other Quick top up No memo required @@ -1550,6 +1551,7 @@ Staking enabled No available validators at the moment. Please try again later. Staking Unavailable + Staking is unavailable in your region The network will charge a token approval fee to verify that you are authorizing the use of your token for the staking. By using staking functionality, you agree with provider’s %1$s and %2$s Locked @@ -1629,6 +1631,8 @@ Stake %s Unstake %s The transaction is being processed! Validation is currently underway in the blockchain. This may take a few minutes. + %s staking prohibited due to security checks + %s staking suspicious. Please stake at your own risk Unbonding Unlock Unlocking @@ -1657,6 +1661,8 @@ Web 3.0 Compatible An incoming transaction of at least %1$s is required to proceed Insufficient funds + Support chat + Attach app logs SWAP operation data:\nFrom: %1$s %2$s\nTo: %3$s %4$s\nBy %5$s - %6$s Open chat Open mail @@ -1709,7 +1715,7 @@ Not enough funds to complete this transaction. Reduce the amount to receive or add more funds. Give Permission Rate your experience with provider - Type your feedback + Type your feedback(optional) Send feedback What affected your \nexperience? Swap @@ -1840,6 +1846,7 @@ Change PIN-code Come back to the app if you forget it. + Card Set a limit from %s to %s Set limits insufficient funds @@ -1957,7 +1964,7 @@ Renew session Payment account session expired Use USDC for everyday payments - Tangem Pay is temporarily unavailable + Connection issues Tangem Pay Send USDC Polygon to your account’s address From another wallet or exchange @@ -2084,6 +2091,8 @@ Rename wallet Unlock all Unlock all with %s + Virtual account + No transactions yet. Start spending and see history here AML verifired Available Blocked diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheetV2.kt b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheetV2.kt index ec474e7288..baad8cca0a 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheetV2.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/bottomsheets/message/MessageBottomSheetV2.kt @@ -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 diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/snackbar/TangemTopSnackbar.kt b/core/ui/src/main/java/com/tangem/core/ui/components/snackbar/TangemTopSnackbar.kt index a35b7a0e09..e31a9341d0 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/snackbar/TangemTopSnackbar.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/snackbar/TangemTopSnackbar.kt @@ -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", ), diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/message/TangemMessage.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/message/TangemMessage.kt index 2ca58571e2..6ef457f21c 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/message/TangemMessage.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/message/TangemMessage.kt @@ -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(), diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/message/TangemMessageEffect.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/message/TangemMessageEffect.kt index 0cefe4c1bd..c2e766e98e 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/message/TangemMessageEffect.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/message/TangemMessageEffect.kt @@ -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 diff --git a/core/ui/src/main/java/com/tangem/core/ui/ds/tabs/TangemSegmentedPicker.kt b/core/ui/src/main/java/com/tangem/core/ui/ds/tabs/TangemSegmentedPicker.kt index c889e863df..e8cf99da89 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/ds/tabs/TangemSegmentedPicker.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/ds/tabs/TangemSegmentedPicker.kt @@ -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) { + 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, 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() }, diff --git a/core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalFiatFormat.kt b/core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalFiatFormat.kt index af950e8bfe..3c78f403f3 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalFiatFormat.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalFiatFormat.kt @@ -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 */ diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/TangemPayTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/TangemPayTestTags.kt index 3a6f095c0b..234e5f8236 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/test/TangemPayTestTags.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/test/TangemPayTestTags.kt @@ -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" diff --git a/core/ui/src/main/res/drawable/ic_swap_28.xml b/core/ui/src/main/res/drawable/ic_swap_28.xml new file mode 100644 index 0000000000..6cbcd93634 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_swap_28.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/test/kotlin/com/tangem/core/ui/format/bigdecimal/BigDecimalFiatFormatTest.kt b/core/ui/src/test/kotlin/com/tangem/core/ui/format/bigdecimal/BigDecimalFiatFormatTest.kt index 837be0beaa..5420e70b74 100644 --- a/core/ui/src/test/kotlin/com/tangem/core/ui/format/bigdecimal/BigDecimalFiatFormatTest.kt +++ b/core/ui/src/test/kotlin/com/tangem/core/ui/format/bigdecimal/BigDecimalFiatFormatTest.kt @@ -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 diff --git a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt index 071bda168c..30297c0d46 100644 --- a/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt +++ b/data/visa/src/main/kotlin/com/tangem/data/pay/di/TangemPayDataModule.kt @@ -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 diff --git a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt index 3a0617f596..cba4635b01 100644 --- a/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt +++ b/data/wallet-manager/src/main/java/com/tangem/data/walletmanager/DefaultWalletManagersFacade.kt @@ -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 diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SignAndBroadcastPsbtUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SignAndBroadcastPsbtUseCase.kt new file mode 100644 index 0000000000..7f8d93b979 --- /dev/null +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/SignAndBroadcastPsbtUseCase.kt @@ -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 { + 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, + ) + } + } + } +} \ No newline at end of file diff --git a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt index 5fe647247b..d307e498ab 100644 --- a/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt +++ b/domain/transaction/src/main/java/com/tangem/domain/transaction/usecase/gasless/CreateAndSendGaslessTransactionUseCase.kt @@ -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 } diff --git a/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/SignAndBroadcastPsbtUseCaseTest.kt b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/SignAndBroadcastPsbtUseCaseTest.kt new file mode 100644 index 0000000000..aaf09a36da --- /dev/null +++ b/domain/transaction/src/test/java/com/tangem/domain/transaction/usecase/SignAndBroadcastPsbtUseCaseTest.kt @@ -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(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() + } +} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderConflictRules.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderConflictRules.kt index 4c576e6bd3..43b47e9cf8 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderConflictRules.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderConflictRules.kt @@ -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 } } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderStatus.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderStatus.kt index 9741d825f6..27ad8bc888 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderStatus.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderStatus.kt @@ -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 = setOf(NEW, PROCESSING) + + /** Statuses of a finished order — no further state changes are expected. */ + val terminalStatuses: Set = setOf(COMPLETED, CANCELED) + } } \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderType.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderType.kt index 006a67088f..c5cf104ae8 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderType.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/model/OrderType.kt @@ -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 diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/IssueAdditionalCardUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/IssueAdditionalCardUseCase.kt index e33223eab4..23b67e2b72 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/IssueAdditionalCardUseCase.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/IssueAdditionalCardUseCase.kt @@ -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() }, diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/RestoreActiveIssueOrdersUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/RestoreActiveIssueOrdersUseCase.kt new file mode 100644 index 0000000000..f4b83bd2a5 --- /dev/null +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/RestoreActiveIssueOrdersUseCase.kt @@ -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 = 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.handleError(throwable: Throwable): Nothing { + TangemLogger.e("Error in RestoreActiveIssueOrdersUseCase", throwable) + raise(VisaApiError.Unspecified) + } +} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/RestoreActiveOrdersUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/RestoreActiveOrdersUseCase.kt deleted file mode 100644 index e812258ed6..0000000000 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/RestoreActiveOrdersUseCase.kt +++ /dev/null @@ -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> { - return customerOrderRepository.findOrders( - userWalletId = userWalletId, - statuses = ACTIVE_STATUSES, - ) - } - - private companion object { - val ACTIVE_STATUSES: Set = setOf(OrderStatus.NEW, OrderStatus.PROCESSING) - } -} \ No newline at end of file diff --git a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/StartTangemPayOrderPollingUseCase.kt b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/StartTangemPayOrderPollingUseCase.kt index d5c9ef3234..dc2b5ea60b 100644 --- a/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/StartTangemPayOrderPollingUseCase.kt +++ b/domain/visa/src/main/kotlin/com/tangem/domain/pay/usecase/StartTangemPayOrderPollingUseCase.kt @@ -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() + 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) } } diff --git a/domain/visa/src/test/kotlin/com/tangem/domain/pay/model/OrderConflictRulesTest.kt b/domain/visa/src/test/kotlin/com/tangem/domain/pay/model/OrderConflictRulesTest.kt index a288112d52..d0a4c4895a 100644 --- a/domain/visa/src/test/kotlin/com/tangem/domain/pay/model/OrderConflictRulesTest.kt +++ b/domain/visa/src/test/kotlin/com/tangem/domain/pay/model/OrderConflictRulesTest.kt @@ -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)) diff --git a/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/IssueAdditionalCardUseCaseTest.kt b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/IssueAdditionalCardUseCaseTest.kt index 8e6b1926a4..5200c2b329 100644 --- a/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/IssueAdditionalCardUseCaseTest.kt +++ b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/IssueAdditionalCardUseCaseTest.kt @@ -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().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().right() val newOrder = order( diff --git a/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/RestoreActiveIssueOrdersUseCaseTest.kt b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/RestoreActiveIssueOrdersUseCaseTest.kt new file mode 100644 index 0000000000..bfbfecfd1a --- /dev/null +++ b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/RestoreActiveIssueOrdersUseCaseTest.kt @@ -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().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 + } +} \ No newline at end of file diff --git a/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/RestoreActiveOrdersUseCaseTest.kt b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/RestoreActiveOrdersUseCaseTest.kt deleted file mode 100644 index 03fa34daf2..0000000000 --- a/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/RestoreActiveOrdersUseCaseTest.kt +++ /dev/null @@ -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().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, - ) -} \ No newline at end of file diff --git a/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/StartTangemPayOrderPollingUseCaseTest.kt b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/StartTangemPayOrderPollingUseCaseTest.kt index 86ab6a5bf2..c14025f994 100644 --- a/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/StartTangemPayOrderPollingUseCaseTest.kt +++ b/domain/visa/src/test/kotlin/com/tangem/domain/pay/usecase/StartTangemPayOrderPollingUseCaseTest.kt @@ -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" diff --git a/domain/wallet-manager/src/main/kotlin/com/tangem/domain/walletmanager/WalletManagersFacade.kt b/domain/wallet-manager/src/main/kotlin/com/tangem/domain/walletmanager/WalletManagersFacade.kt index b1f17fb70a..aa21b96b61 100644 --- a/domain/wallet-manager/src/main/kotlin/com/tangem/domain/walletmanager/WalletManagersFacade.kt +++ b/domain/wallet-manager/src/main/kotlin/com/tangem/domain/walletmanager/WalletManagersFacade.kt @@ -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]. diff --git a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/promo/usecase/IsYieldBoostPromoEnabledForTokenUseCaseTest.kt b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/promo/usecase/IsYieldBoostPromoEnabledForTokenUseCaseTest.kt index 9afbbb6492..f8f9929dd0 100644 --- a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/promo/usecase/IsYieldBoostPromoEnabledForTokenUseCaseTest.kt +++ b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/promo/usecase/IsYieldBoostPromoEnabledForTokenUseCaseTest.kt @@ -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() + @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() diff --git a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/promo/usecase/ShouldShowYieldBoostMainBannerUseCaseTest.kt b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/promo/usecase/ShouldShowYieldBoostMainBannerUseCaseTest.kt index ae20808d28..72e4c6e0fa 100644 --- a/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/promo/usecase/ShouldShowYieldBoostMainBannerUseCaseTest.kt +++ b/domain/yield-supply/src/test/java/com/tangem/domain/yield/supply/promo/usecase/ShouldShowYieldBoostMainBannerUseCaseTest.kt @@ -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() + @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") diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/PortfolioListBlockDelegate.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/PortfolioListBlockDelegate.kt index 8a8420567e..515cabd4d1 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/PortfolioListBlockDelegate.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/model/PortfolioListBlockDelegate.kt @@ -40,6 +40,8 @@ internal class PortfolioListBlockDelegate @AssistedInject constructor( private val onTokenItemClick: Channel> = Channel() + private val isOnlyMultiCurrency: Boolean get() = !featureSettings.isShowSingleCurrencyWallets + val onTokenChosen: Channel = Channel() val tokenFilter: MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean> = MutableStateFlow { _, _ -> true } @@ -53,7 +55,7 @@ internal class PortfolioListBlockDelegate @AssistedInject constructor( multiAccountStatusListSupplier.invokeAsMap() val allWalletsFlow: StateFlow> = - getWalletsUseCase.invokeAsMap().stateIn(this) + getWalletsUseCase.invokeAsMap(isOnlyMultiCurrency = isOnlyMultiCurrency).stateIn(this) onTokenItemClick.receiveAsFlow() .onEach { (account, currencyStatus) -> diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt index d1d47436a2..9df57ca897 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/DefaultFeedEntryComponent.kt @@ -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, ) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/earn/DefaultEarnComponent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/earn/DefaultEarnComponent.kt index 393512c4d7..24d2d2fead 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/earn/DefaultEarnComponent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/components/earn/DefaultEarnComponent.kt @@ -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, ) diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/EarnModel.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/EarnModel.kt index 77b81313fe..df8a5efe2a 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/EarnModel.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/EarnModel.kt @@ -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) }, ), ) } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/state/EarnStateController.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/state/EarnStateController.kt index 34a71d62a2..f074cbdc66 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/state/EarnStateController.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/state/EarnStateController.kt @@ -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 = {}, - ), ) } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/state/transformers/UpdateEarnUMInitialStateTransformer.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/state/transformers/UpdateEarnUMInitialStateTransformer.kt index 8991f72503..d46ac02956 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/state/transformers/UpdateEarnUMInitialStateTransformer.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/model/earn/state/transformers/UpdateEarnUMInitialStateTransformer.kt @@ -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), - ), ) } } \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/components/ContainerWithDivider.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/components/ContainerWithDivider.kt deleted file mode 100644 index 31b88692e3..0000000000 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/components/ContainerWithDivider.kt +++ /dev/null @@ -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, - ) - } - } -} \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/EarnContent.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/EarnContent.kt index fe24ef79ea..a8bbac4a18 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/EarnContent.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/EarnContent.kt @@ -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 diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/state/EarnUM.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/state/EarnUM.kt index fd8611ff2b..974b5a71b2 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/state/EarnUM.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/earn/state/EarnUM.kt @@ -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, ) \ No newline at end of file diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/InfoPoint.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/InfoPoint.kt index e8fff6971a..d9ef6ae298 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/InfoPoint.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/InfoPoint.kt @@ -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, diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/LinksBlock.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/LinksBlock.kt index ff70bd80a1..78b45dd66f 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/LinksBlock.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/LinksBlock.kt @@ -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, 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), + ) } } } diff --git a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/MetricsCards.kt b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/MetricsCards.kt index 7bfdefee9c..9f0a64aa21 100644 --- a/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/MetricsCards.kt +++ b/features/feed/impl/src/main/kotlin/com/tangem/features/feed/ui/market/detailed/components/MetricsCards.kt @@ -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, diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt index 0b10ee5654..9464e5f5b1 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/SwapInteractorImpl.kt @@ -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) { diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt index 171921234c..0dd14bd531 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculator.kt @@ -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.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.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.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. * diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt index 5b41f9ef63..a1759b9e86 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt @@ -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, diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt index ff071510a7..03f6d275be 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt @@ -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 { + ): 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, diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplTestBase.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplTestBase.kt index 94d013973d..0d9b2d6065 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplTestBase.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/SwapInteractorImplTestBase.kt @@ -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, diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt index 9b07cca2b8..39bc1e1c00 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/fee/DexSwapFeeCalculatorTest.kt @@ -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]) // diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt index fc31a2c1ef..cdecb5a391 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt @@ -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 diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index cec56c2b2f..4dd33ac7bf 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -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) } diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index 9f0f4886a7..362c8b1bea 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -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 { 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 -> diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt index 8af1285be9..10a023634f 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt @@ -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( diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderInitialStateTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderInitialStateTest.kt index d0ef725adf..daee7b3ac7 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderInitialStateTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderInitialStateTest.kt @@ -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(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, + ) } \ No newline at end of file diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt index 2fecf27628..a5d4203e98 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt @@ -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, ) } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt index 7b604eb42c..f8ee0c6d02 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/model/TangemPayCardPageModel.kt @@ -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, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayChangePinCodeSuccessScreenV2.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayChangePinCodeSuccessScreenV2.kt index 6a17a5b56c..703379bc57 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayChangePinCodeSuccessScreenV2.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayChangePinCodeSuccessScreenV2.kt @@ -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, ) } diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreenV2.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreenV2.kt index 1103004af6..faa9c8cce9 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreenV2.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/TangemPayDetailsScreenV2.kt @@ -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, diff --git a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/components/TangemPaySuccessScreenWrapper.kt b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/components/TangemPaySuccessScreenWrapper.kt index bcddc5af74..41c6409547 100644 --- a/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/components/TangemPaySuccessScreenWrapper.kt +++ b/features/tangempay/details/impl/src/main/kotlin/com/tangem/features/tangempay/ui/components/TangemPaySuccessScreenWrapper.kt @@ -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, diff --git a/features/tokendetails/impl/build.gradle.kts b/features/tokendetails/impl/build.gradle.kts index 6a8211f32c..a2bcd8d884 100644 --- a/features/tokendetails/impl/build.gradle.kts +++ b/features/tokendetails/impl/build.gradle.kts @@ -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) diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/domain/GetWalletCardWarningsUseCase.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/domain/GetWalletCardWarningsUseCase.kt deleted file mode 100644 index 97f5e2c457..0000000000 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/domain/GetWalletCardWarningsUseCase.kt +++ /dev/null @@ -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> { - 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 { - 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 - } -} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/domain/WalletCardWarning.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/domain/WalletCardWarning.kt deleted file mode 100644 index afc9f50393..0000000000 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/domain/WalletCardWarning.kt +++ /dev/null @@ -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 -} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt index 319472b5ea..fff85000be 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsClickIntents.kt @@ -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 */ } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt index 8c9f7b835c..df605c6c7e 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/model/TokenDetailsModel.kt @@ -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, ), ) } diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateTopBarMenuTransformer.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateTopBarMenuTransformer.kt index 320e28bdce..682927afcf 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateTopBarMenuTransformer.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateTopBarMenuTransformer.kt @@ -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 { 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() diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateWalletCardWarningsTransformer.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateWalletCardWarningsTransformer.kt deleted file mode 100644 index 2f3bc9bc7e..0000000000 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateWalletCardWarningsTransformer.kt +++ /dev/null @@ -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, - private val clickIntents: TokenDetailsClickIntents, -) : Transformer { - - 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, - ) - } -} \ No newline at end of file diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreenLegacy.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreenLegacy.kt index ea6976fc1b..4dbe6900a9 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreenLegacy.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/TokenDetailsScreenLegacy.kt @@ -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, + ) + } } } diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/domain/GetWalletCardWarningsUseCaseTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/domain/GetWalletCardWarningsUseCaseTest.kt deleted file mode 100644 index 032327d98b..0000000000 --- a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/domain/GetWalletCardWarningsUseCaseTest.kt +++ /dev/null @@ -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(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(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 - } -} \ No newline at end of file diff --git a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateTopBarMenuTransformerTest.kt b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateTopBarMenuTransformerTest.kt index d66631ccae..3c53e0f255 100644 --- a/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateTopBarMenuTransformerTest.kt +++ b/features/tokendetails/impl/src/test/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/transformer/UpdateTopBarMenuTransformerTest.kt @@ -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( diff --git a/features/txhistory/impl/build.gradle.kts b/features/txhistory/impl/build.gradle.kts index 2e1f349d03..34f003cbab 100644 --- a/features/txhistory/impl/build.gradle.kts +++ b/features/txhistory/impl/build.gradle.kts @@ -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) diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt index 644d1602c5..8a0506515f 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/WalletModel.kt @@ -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 diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt index 1b19f874c0..b2cc823dbd 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/child/wallet/model/intents/WalletCurrencyActionsClickIntents.kt @@ -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 { diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetSingleWalletWarningsFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetSingleWalletWarningsFactory.kt index b31d3c96a2..82596dbd03 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetSingleWalletWarningsFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetSingleWalletWarningsFactory.kt @@ -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 diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsFactory.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsFactory.kt index 604cd72fc1..6909483f63 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsFactory.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/GetWalletNotificationsFactory.kt @@ -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.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.addCloreMigrationNotification( userWallet: UserWallet, flattenCurrencies: List, diff --git a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/HasSingleWalletSignedHashesUseCase.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/HasSingleWalletSignedHashesUseCase.kt similarity index 86% rename from domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/HasSingleWalletSignedHashesUseCase.kt rename to features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/HasSingleWalletSignedHashesUseCase.kt index be6a4b0ddd..4f32140696 100644 --- a/domain/wallets/src/main/java/com/tangem/domain/wallets/usecase/HasSingleWalletSignedHashesUseCase.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/domain/HasSingleWalletSignedHashesUseCase.kt @@ -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 }, diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotificationUM.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotificationUM.kt index 4432079a8c..b3be6cc73a 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotificationUM.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/state/model/WalletNotificationUM.kt @@ -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), diff --git a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/TangemPayMainSubscriber.kt b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/TangemPayMainSubscriber.kt index e58aa664f5..23bb81d8db 100644 --- a/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/TangemPayMainSubscriber.kt +++ b/features/wallet/impl/src/main/java/com/tangem/feature/wallet/presentation/wallet/subscribers/TangemPayMainSubscriber.kt @@ -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() } diff --git a/gradle/tangem_dependencies.toml b/gradle/tangem_dependencies.toml index 074e02bb80..f5f4f1626b 100644 --- a/gradle/tangem_dependencies.toml +++ b/gradle/tangem_dependencies.toml @@ -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 ^