Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-24 22:26:58 +04:00
commit 4d2ec09d2d
100 changed files with 1972 additions and 1112 deletions

View file

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

View file

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

View file

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

View file

@ -20,14 +20,14 @@ import com.tangem.core.res.R as CoreResR
* After the onramp redesign this is a [BaseBottomSheetTestTags.CONTAINER] bottom sheet
* (centered title + close icon), not a full screen with a top app bar.
*/
class ChooseTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<ChooseTokenPageObject>(
class ChooseTokenBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<ChooseTokenBottomSheetPageObject>(
semanticsProvider = semanticsProvider,
viewBuilderAction = { hasTestTag(BaseBottomSheetTestTags.CONTAINER) },
) {
val topAppBarTitle: KNode = child {
hasText(getResourceString(CoreResR.string.common_add_funds))
val title: KNode = child {
hasText(getResourceString(CoreResR.string.common_choose_token))
useUnmergedTree = true
}
@ -44,5 +44,5 @@ class ChooseTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider
}
}
internal fun BaseTestCase.onChooseTokenScreen(function: ChooseTokenPageObject.() -> Unit) =
internal fun BaseTestCase.onChooseTokenBottomSheet(function: ChooseTokenBottomSheetPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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") {

View file

@ -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") {

View file

@ -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(

View file

@ -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(

View file

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

View file

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

View file

@ -102,6 +102,7 @@
<item quantity="one">Adresse</item>
<item quantity="other">Adressen</item>
</plurals>
<string name="address_book_choose_address">Adresse auswählen</string>
<string name="address_book_contact">Kontakt</string>
<string name="address_book_contact_name">Name der Kontaktperson</string>
<string name="address_book_copy_address">Adresse kopieren</string>
@ -112,6 +113,7 @@
<string name="address_book_discard">Verwerfen</string>
<string name="address_book_edit_address">Adresse bearbeiten</string>
<string name="address_book_enter_address">Adresse eingeben</string>
<string name="address_book_invalid_address_error">Ungültige Adresse</string>
<string name="address_book_keep_editing">Weiter bearbeiten</string>
<string name="address_book_new_contact">Neuer Kontakt</string>
<string name="address_book_no_contacts">Noch keine Kontakte</string>
@ -274,6 +276,7 @@
<string name="common_attention">Achtung</string>
<string name="common_available_networks">Verfügbare Netzwerke</string>
<string name="common_backup">Sicherungskopie</string>
<string name="common_backup_error">Sicherungsfehler</string>
<string name="common_balance">Bilanz: %s</string>
<string name="common_balance_title">Saldo</string>
<string name="common_biometric_authentication">biometrische Authentifizierung</string>
@ -429,6 +432,7 @@
<string name="common_send">Senden</string>
<string name="common_send_colon">Senden:</string>
<string name="common_send_tx_error">Absenden der Transaktion fehlgeschlagen</string>
<string name="common_send_with_swap">Senden &amp; Tauschen</string>
<string name="common_sending">Senden</string>
<string name="common_sent">Gesendet</string>
<string name="common_server_unavailable">Der Server ist nicht verfügbar. Bitte versuche es später erneut.</string>
@ -646,6 +650,7 @@
<string name="express_legal_two_placeholders">Durch die Nutzung der Swap-Funktionalität erklärst du dich mit des Anbieters %1$s und %2$s einverstanden.</string>
<string name="express_more_providers_soon">Weitere Anbieter folgen in Kürze</string>
<string name="express_provider">Anbieter</string>
<string name="express_provider_best_dex_rate">Bester DEX-Kurs</string>
<string name="express_provider_best_rate">Bester Preis</string>
<string name="express_provider_fca_warning_list">Warnliste der FCA</string>
<string name="express_provider_fixed_rate_is_unavailable">Der Festzins ist nicht verfügbar</string>
@ -658,6 +663,9 @@
<string name="express_provider_permission_needed">Erlaubnis erforderlich</string>
<string name="express_provider_permission_needed_v2">Genehmigung erforderlich</string>
<string name="express_provider_recommended">Empfohlen</string>
<string name="express_send_with_swap_not_supported_button">Tauschen</string>
<string name="express_send_with_swap_not_supported_text">Tauschen Sie es manuell aus und senden Sie es anschließend an den Empfänger.</string>
<string name="express_send_with_swap_not_supported_title">%s wird in Swap &amp; Send nicht unterstützt.</string>
<string name="express_status_bought">Gekauft %s</string>
<string name="express_status_buying">Kauf %s</string>
<string name="express_status_buying_active">Kauf %s…</string>
@ -692,9 +700,14 @@
<string name="feedback_subject_support_tangem">Feedback zu Tangem</string>
<string name="feedback_subject_tx_failed">Eine Transaktion kann nicht gesendet werden</string>
<string name="feedback_token_description_error">Fehler in der Coinbeschreibung</string>
<string name="force_update_action">Jetzt aktualisieren</string>
<string name="force_update_banner_message">Aktualisiere die Anwendung auf die neueste Version, um die ordnungsgemäße Funktionalität zu gewährleisten</string>
<string name="force_update_banner_title">Aktualisierung erforderlich</string>
<string name="force_update_brick_description">Diese Version der App wird nicht mehr unterstützt und kann auf diesem Gerät nicht aktualisiert werden.</string>
<string name="force_update_brick_title">Update nicht verfügbar</string>
<string name="force_update_button">Update</string>
<string name="force_update_os_description">Ihr Betriebssystem ist veraltet. Bitte aktualisieren Sie es, um die App weiterhin nutzen zu können.</string>
<string name="force_update_os_title">Aktualisieren Sie Ihr Betriebssystem</string>
<string name="force_update_warning_message">Bitte aktualisiere die Anwendung auf die neueste Version, um eine einwandfreie Funktion zu gewährleisten.</string>
<string name="force_update_warning_title">Aktualisierung erforderlich</string>
<string name="gasless_not_enough_funds_to_cover_token_fee">Nicht genügend Mittel</string>
@ -1281,6 +1294,7 @@
<string name="quick_action_buy_description">Kreditkarte oder Bankkonto</string>
<string name="quick_action_receive_description">Teilen deine Adresse oder dein QR-Code</string>
<string name="quick_action_sell_description">Sicherer Verkauf von Kryptowährungen</string>
<string name="quick_action_send_and_swap_description">Senden Sie mit Tausch an ein anderes Token</string>
<string name="quick_action_send_description">An eine andere Wallet senden</string>
<string name="quick_action_swap_description">Zwischen deinen Portfolios</string>
<string name="quick_top_up_chip_other">Andere</string>
@ -1511,6 +1525,8 @@
<string name="staking_details_apr">Effektiver Jahreszins</string>
<string name="staking_details_apy">APY</string>
<string name="staking_details_auto_claiming_rewards_daily_text">Belohnungen sammeln sich täglich automatisch in deinem Staking-Konto an.</string>
<string name="staking_details_autocompound_funds_earned">Erzielte Einnahmen: %s</string>
<string name="staking_details_autocompound_rewards_compounded">Die Prämien werden Ihrem Staking-Guthaben gutgeschrieben.</string>
<string name="staking_details_autocompound_rewards_earned">Die Belohnungen werden auf Dein Stakingguthaben aufgezinst. Verdiente Gelder: %s</string>
<string name="staking_details_available">Verfügbar</string>
<string name="staking_details_average_reward_rate">Durchschnittliche Belohnungsquote</string>
@ -1613,6 +1629,8 @@
<string name="staking_title_stake">Stake %s</string>
<string name="staking_title_unstake">Staking beenden %s</string>
<string name="staking_transaction_in_progress_text">Die Transaktion wird bearbeitet! Derzeit findet eine Validierung in der Blockchain statt. Dies kann einige Minuten dauern.</string>
<string name="staking_transaction_validation_unsafe">%s Staking aufgrund von Sicherheitsüberprüfungen untersagt</string>
<string name="staking_transaction_validation_warning">%s Das Staking erscheint verdächtig. Bitte staken Sie auf eigenes Risiko.</string>
<string name="staking_unbonding">Lösen der Bindungen</string>
<string name="staking_unlocked_locked">Gelocktes unlocken</string>
<string name="staking_unlocking">Entsperren</string>
@ -1641,6 +1659,8 @@
<string name="story_web3_title">Web 3.0-kompatibel</string>
<string name="sui_not_enough_coin_for_fee_description">Zum Fortfahren ist eine eingehende Transaktion von mindestens %1$s erforderlich</string>
<string name="sui_not_enough_coin_for_fee_title">Unzureichende Mittel</string>
<string name="support_chat_screen_title">Support-Chat</string>
<string name="support_chat_share_logs_button">App-Protokolle anhängen</string>
<string name="support_chat_swap_prefilled_message">SWAP-Operationsdaten:\nAus: %1$s %2$s\nZu: %3$s %4$s\nVon %5$s - %6$s</string>
<string name="support_selector_view_chat_button">Chat öffnen</string>
<string name="support_selector_view_email_button">Mail öffnen</string>
@ -1693,7 +1713,7 @@
<string name="swapping_insufficient_funds_description">Nicht genügend Geldmittel, um diese Transaktion abzuschließen. Verringern Sie den zu erhaltenden Betrag oder fügen Sie weitere Mittel hinzu.</string>
<string name="swapping_permission_header">Erlaubnis erteilen</string>
<string name="swapping_rate_experience_title">Bewerten deine Erfahrung mit dem Anbieter</string>
<string name="swapping_rate_feedback_placeholder">Geben dein Feedback ein</string>
<string name="swapping_rate_feedback_placeholder">Geben dein Feedback</string>
<string name="swapping_rate_feedback_submit">Feedback senden</string>
<string name="swapping_rate_feedback_title">Was waren deine Erfahrungen?</string>
<string name="swapping_swap_action">Tauschen</string>
@ -1711,6 +1731,7 @@
<string name="tangem_pay_card_details_unable_to_rename_card_title">Karte kann nicht umbenannt werden</string>
<string name="tangem_pay_card_frozen">Karte eingefroren</string>
<string name="tangem_pay_card_payment">Kartenzahlung</string>
<string name="tangem_pay_close_card_disabled_last_card">Die letzte Karte kann nicht geschlossen werden.</string>
<string name="tangem_pay_close_card_popup_description">Es wird aus der App verschwinden</string>
<string name="tangem_pay_close_card_popup_primary_button_title">Karte schließen</string>
<string name="tangem_pay_close_card_popup_secondary_button_title">Geh zurück</string>
@ -1823,6 +1844,7 @@
</plurals>
<string name="tangempay_change_pin_code">PIN-Code ändern</string>
<string name="tangempay_come_back_if_forget_pin">Kehren Sie zur App zurück, falls Sie ihn vergessen.</string>
<string name="tangempay_common_card">Karte</string>
<string name="tangempay_daily_limit_hint" formatted="false">Limit von %s bis %s festlegen</string>
<string name="tangempay_daily_limit_set_button">Limits festlegen</string>
<string name="tangempay_declined_reason_1">Unzureichendes Guthaben</string>
@ -1924,6 +1946,9 @@
<string name="tangempay_reissue_card_insufficient_funds_subtitle">Zahlen Sie USDC auf das Zahlungskonto ein, um die Ausstellungsgebühr zu decken</string>
<string name="tangempay_reissue_card_insufficient_funds_title">Gebühr kann nicht gedeckt werden</string>
<string name="tangempay_reissue_card_title">Ihre Karte neu ausstellen?</string>
<string name="tangempay_remove_account">Konto löschen</string>
<string name="tangempay_remove_account_alert_description">Tangem Pay wird vom Hauptbildschirm entfernt und erscheint auch nach einer Neuinstallation der App nicht wieder.</string>
<string name="tangempay_remove_account_alert_title">Konto löschen?</string>
<string name="tangempay_service_unavailable_description">Wir beheben ein technisches Problem. Bitte versuchen Sie es später erneut.</string>
<string name="tangempay_service_unavailable_title">Service vorübergehend nicht verfügbar</string>
<string name="tangempay_service_unreachable_try_later">Daten können derzeit nicht angezeigt werden, Kartenzahlungen funktionieren jedoch weiterhin.</string>
@ -2031,6 +2056,8 @@
<string name="twins_recreate_toolbar">Tangem Twin</string>
<string name="twins_recreate_warning">Diese Aktion ist unumkehrbar. Du hast keinen Zugriff mehr auf die alte Wallet.</string>
<string name="twins_scan_twin_with_number">Tippe auf die Doppelkarte oder Ring mit der Nummer %s und entferne sie erst am Ende des Vorgangs.</string>
<string name="tx_history_onramp_top_up">Aufladen</string>
<string name="tx_history_onramp_topped_up">Aufgeladen</string>
<string name="unexpected_error_description">Bitte versuche es später noch einmal. Sollte das Problem weiterhin bestehen, wende Dich bitte an den Support.</string>
<string name="unexpected_error_title">Etwas ist schiefgelaufen!</string>
<string name="universal_error">Es ist ein Fehler aufgetreten. Fehlercode: %s. Bitte kontaktiere unseren Support.</string>
@ -2062,6 +2089,8 @@
<string name="user_wallet_list_rename_popup_title">Wallet umbenennen</string>
<string name="user_wallet_list_unlock_all">Alle freischalten</string>
<string name="user_wallet_list_unlock_all_with">Alle mit %s freischalten</string>
<string name="virtual_account_title">Virtuelles Konto</string>
<string name="virtual_account_transactions_empty">Noch keine Transaktionen. Beginnen Sie mit dem Einkaufen und sehen Sie sich hier den Verlauf an</string>
<string name="visa_balance_limits_details_aml_verified">AML-geprüft</string>
<string name="visa_balance_limits_details_available">Verfügbar</string>
<string name="visa_balance_limits_details_blocked">Gesperrt</string>
@ -2368,6 +2397,7 @@
<string name="wc_all_dapps_disconnected">Alle dApps getrennt</string>
<string name="wc_allow_to_spend">Erlaubnis auszugeben</string>
<string name="wc_approve_description">Durch die Genehmigung erlaubst Du dApps oder Smart Contracts, Token in zukünftigen Transaktionen zu verwenden.</string>
<string name="wc_change_address">Adresse ändern</string>
<string name="wc_common_address">Vertragsadresse</string>
<string name="wc_common_connect">Verbinden</string>
<string name="wc_common_loading">Laden</string>

View file

@ -1542,6 +1542,7 @@
<string name="staking_enabled">Staking habilitado</string>
<string name="staking_error_no_validators_message">No hay validadores disponibles en este momento. Por favor, inténtelo de nuevo más tarde.</string>
<string name="staking_error_no_validators_title">Staking no disponible</string>
<string name="staking_error_unavailable_region">Staking no está disponible en tu región.</string>
<string name="staking_give_permission_fee_footer">La red cobrará una tarifa de aprobación de token para verificar que usted está autorizando el uso de su token para el staking.</string>
<string name="staking_legal">Al utilizar la función de staking, usted acepta %1$s y %2$s del proveedor</string>
<string name="staking_locked">Bloqueado</string>
@ -1621,6 +1622,8 @@
<string name="staking_title_stake">Stake %s</string>
<string name="staking_title_unstake">Unstaking de %s</string>
<string name="staking_transaction_in_progress_text">¡La transacción se está procesando! La validación está en curso en la cadena de bloques. Esto puede tardar unos minutos.</string>
<string name="staking_transaction_validation_unsafe">%s Prohibido el staking debido a controles de seguridad</string>
<string name="staking_transaction_validation_warning">%s staking sospechoso. Por favor, realice el staking bajo su propia responsabilidad.</string>
<string name="staking_unbonding">Desunión</string>
<string name="staking_unlocked_locked">Desbloquear</string>
<string name="staking_unlocking">Desbloqueando</string>
@ -1701,7 +1704,7 @@
<string name="swapping_insufficient_funds_description">No hay fondos suficientes para completar esta transacción. Reduzca el importe a recibir o añada más fondos.</string>
<string name="swapping_permission_header">Dar autorización</string>
<string name="swapping_rate_experience_title">Valore su experiencia con el proveedor</string>
<string name="swapping_rate_feedback_placeholder">Escriba sus comentarios</string>
<string name="swapping_rate_feedback_placeholder">Escriba sus comentarios (opcional)</string>
<string name="swapping_rate_feedback_submit">Enviar comentarios</string>
<string name="swapping_rate_feedback_title">¿Qué influyó en su \nexperiencia?</string>
<string name="swapping_swap_action">Intercambiar</string>

View file

@ -1500,6 +1500,8 @@
<string name="staking_title_stake">Stake %s</string>
<string name="staking_title_unstake">déstaker %s</string>
<string name="staking_transaction_in_progress_text">La transaction est en cours de traitement ! La validation est actuellement en cours dans la blockchain. Cela peut prendre quelques minutes.</string>
<string name="staking_transaction_validation_unsafe">%s staking interdit en raison de contrôles de sécurité</string>
<string name="staking_transaction_validation_warning">%s staking suspicieux. Continuez à vos propres risques.</string>
<string name="staking_unbonding">Dissociation</string>
<string name="staking_unlocked_locked">Débloquer</string>
<string name="staking_unlocking">Déverrouillage</string>
@ -1528,6 +1530,8 @@
<string name="story_web3_title">Compatible avec Web 3.0</string>
<string name="sui_not_enough_coin_for_fee_description">Une transaction entrante d\'au moins de %1$s est requise pour continuer</string>
<string name="sui_not_enough_coin_for_fee_title">Fonds insuffisants</string>
<string name="support_chat_screen_title">Discussion avec le support</string>
<string name="support_chat_share_logs_button">Joindre les logs de l\'application</string>
<string name="support_chat_swap_prefilled_message">Données du SWAP :\nDepuis :%1$s%2$s\nVers :%3$s%4$s\nPar :%5$s-%6$s</string>
<string name="support_selector_view_chat_button">Accéder au chat</string>
<string name="support_selector_view_email_button">Ouvrir un email</string>

View file

@ -97,6 +97,7 @@
<string name="addfunds_you_receive_title">受け取る</string>
<string name="address_book_add_address">アドレスを追加</string>
<string name="address_book_add_address_description">アドレスを追加し、ネットワークを選択してください。</string>
<string name="address_book_add_contact">連絡先を追加</string>
<plurals name="address_book_addresses">
<item quantity="other">%d件のアドレス</item>
</plurals>
@ -112,10 +113,13 @@
<string name="address_book_enter_address">アドレスを入力</string>
<string name="address_book_invalid_address_error">無効なアドレス</string>
<string name="address_book_keep_editing">編集を続ける</string>
<string name="address_book_new_contact">新しい連絡先</string>
<string name="address_book_no_contacts">連絡先はまだありません</string>
<string name="address_book_no_contacts_description">追加した連絡先はここに表示されます。</string>
<string name="address_book_remove_address">アドレスを削除</string>
<string name="address_book_save_wallet_to_description">この連絡先は、このウォレットのアドレス帳に紐付けられます。</string>
<string name="address_book_select_network">ネットワークを選択</string>
<string name="address_book_title">連絡先</string>
<string name="address_book_unsaved_changes">保存されていない変更</string>
<string name="address_book_unsaved_changes_description">編集内容を破棄してもよろしいですか?</string>
<string name="address_qr_code_message_format">このアドレスには%3$s ネットワークから%1$s (%2$s) のみを送信してください。他のトークンやネットワークを使用すると、資金を失う可能性があります。</string>
@ -685,7 +689,11 @@
<string name="feedback_token_description_error">コインの説明エラー</string>
<string name="force_update_banner_message">正常に動作するよう、アプリを最新バージョンに更新してください。</string>
<string name="force_update_banner_title">アップデートが必要です</string>
<string name="force_update_brick_description">このバージョンのアプリはサポート対象外となっており、このデバイスでは更新できません。</string>
<string name="force_update_brick_title">更新できません</string>
<string name="force_update_button">アップデート</string>
<string name="force_update_os_description">使用中のOSが古くなっています。アプリを引き続き使用するには、OSを更新してください。</string>
<string name="force_update_os_title">OSの更新が必要です</string>
<string name="force_update_warning_message">正常に動作するよう、アプリを最新バージョンに更新してください。</string>
<string name="force_update_warning_title">アップデートが必要です</string>
<string name="gasless_not_enough_funds_to_cover_token_fee">残高不足</string>
@ -1591,6 +1599,8 @@
<string name="staking_title_stake">%sをステーキングする</string>
<string name="staking_title_unstake">%sのステーキング解除</string>
<string name="staking_transaction_in_progress_text">取引を処理中です。現在、ブロックチェーンで検証が行われています。これには数分かかる場合があります。</string>
<string name="staking_transaction_validation_unsafe">セキュリティチェックにより、%sのステーキングは禁止されています</string>
<string name="staking_transaction_validation_warning">%sのステーキングにはリスクが伴う可能性があります。ご自身の判断と責任でご利用ください</string>
<string name="staking_unbonding">ステーキング解約中</string>
<string name="staking_unlocked_locked">ロック解除</string>
<string name="staking_unlocking">ロック解除中</string>
@ -1619,6 +1629,8 @@
<string name="story_web3_title">Web3.0対応</string>
<string name="sui_not_enough_coin_for_fee_description">続行するには少なくとも%1$sの受信取引が必要です</string>
<string name="sui_not_enough_coin_for_fee_title">残高不足</string>
<string name="support_chat_screen_title">サポートチャット</string>
<string name="support_chat_share_logs_button">アプリログを添付</string>
<string name="support_chat_swap_prefilled_message">SWAP操作データ\n交換元%1$s%2$s\n交換先%3$s%4$s\n提供元%5$s - %6$s</string>
<string name="support_selector_view_chat_button">チャットを開く</string>
<string name="support_selector_view_email_button">メールを開く</string>
@ -1671,7 +1683,7 @@
<string name="swapping_insufficient_funds_description">この取引を完了するには残高が不足しています。受け取り額を減らすか、資金を追加してください。</string>
<string name="swapping_permission_header">許可を与える</string>
<string name="swapping_rate_experience_title">プロバイダーの利用体験を評価してください</string>
<string name="swapping_rate_feedback_placeholder">フィードバックを入力してください</string>
<string name="swapping_rate_feedback_placeholder">フィードバックを入力(任意)</string>
<string name="swapping_rate_feedback_submit">フィードバックを送信</string>
<string name="swapping_rate_feedback_title">ご利用中に気になった点を\n教えてください</string>
<string name="swapping_swap_action">スワップ</string>
@ -1690,7 +1702,7 @@
<string name="tangem_pay_card_frozen">カードが凍結されています</string>
<string name="tangem_pay_card_payment">カード決済</string>
<string name="tangem_pay_close_card_disabled_last_card">最後のカードは停止できません</string>
<string name="tangem_pay_close_card_popup_description">支払いアカウントから削除されます。</string>
<string name="tangem_pay_close_card_popup_description">アプリに表示されなくなります</string>
<string name="tangem_pay_close_card_popup_primary_button_title">カードを解約する</string>
<string name="tangem_pay_close_card_popup_secondary_button_title">戻る</string>
<string name="tangem_pay_close_card_popup_title">カードを解約しますか?</string>
@ -1890,7 +1902,7 @@
<string name="tangempay_onboarding_title">Tangem Pay カードをすぐに手に入れよう</string>
<string name="tangempay_pay_support">Payサポート</string>
<string name="tangempay_payment_account">支払いアカウント</string>
<string name="tangempay_payment_account_sync_needed">Tangem Pay セッションの有効期限が切れました</string>
<string name="tangempay_payment_account_sync_needed">セッションの有効期限が切れました</string>
<string name="tangempay_pin_validation_error_message">無効な暗証番号:連続や繰り返しを避けてください</string>
<string name="tangempay_reissue_card_confirm">カードを交換</string>
<string name="tangempay_reissue_card_description">これにより、新しいカード情報が発行されます。現在のカード情報は使えなくなります。この操作は元に戻せません。</string>
@ -1918,12 +1930,12 @@
<string name="tangempay_sync_needed_button">セッションを更新</string>
<string name="tangempay_sync_needed_title">Tangem Pay セッションの有効期限が切れました</string>
<string name="tangempay_tangem_visa_card">日常の支払いにUSDCを利用</string>
<string name="tangempay_temporarily_unavailable">Tangem Payは現在一時的に利用できません</string>
<string name="tangempay_temporarily_unavailable">Tangem Payは一時的に利用できません</string>
<string name="tangempay_title">Tangem Pay</string>
<string name="tangempay_topup_receive_body">USDC Polygon をアカウントのアドレスに送信</string>
<string name="tangempay_topup_receive_title">別のウォレットまたは取引所から</string>
<string name="tangempay_topup_swap_body">ウォレットの暗号資産を使って、決済アカウントにチャージできます</string>
<string name="tangempay_topup_swap_title">Tangemウォレットからスワップ</string>
<string name="tangempay_topup_swap_title">Tangemウォレットから</string>
<string name="tangempay_usdc_on_polygon_network">Polygonネットワーク上のUSDC</string>
<string name="tangempay_withdrawal_note_description">返金分はオンチェーンのPolygon残高には戻らず、出金にも利用できません。ただし、カード残高として残り、支払いに利用できます。</string>
<string name="tangempay_withdrawal_note_title">ご注意ください</string>

View file

@ -1593,6 +1593,7 @@
<string name="staking_enabled">Стейкинг включен</string>
<string name="staking_error_no_validators_message">В данный момент нет доступных валидаторов. Попробуйте позже.</string>
<string name="staking_error_no_validators_title">Стейкинг недоступен</string>
<string name="staking_error_unavailable_region">Стейкинг недоступен в вашем регионе</string>
<string name="staking_give_permission_fee_footer">Комиссия сети за одобрение токена будет взиматься за подтверждение того, что именно вы разрешаете использовать ваш токен для стейкинга.</string>
<string name="staking_legal">Пользуясь стейкинг сервисом, вы соглашаетесь с %1$s и %2$s</string>
<string name="staking_locked">Заблокировано</string>
@ -1672,6 +1673,8 @@
<string name="staking_title_stake">Застейкать %s</string>
<string name="staking_title_unstake">Вывести %s</string>
<string name="staking_transaction_in_progress_text">Транзакция обрабатывается! В настоящее время идет проверка в блокчейне. Это может занять несколько минут.</string>
<string name="staking_transaction_validation_unsafe">%s стейкинг запрещен по результатам проверок безопасности</string>
<string name="staking_transaction_validation_warning">%s стейкинг вызывает сомнения. Пожалуйста, стейкайте на свой страх и риск</string>
<string name="staking_unbonding">Отзыв</string>
<string name="staking_unlocked_locked">Разблокировать</string>
<string name="staking_unlocking">Разблокировка</string>
@ -1700,6 +1703,8 @@
<string name="story_web3_title">Поддержка Web 3.0</string>
<string name="sui_not_enough_coin_for_fee_description">Для отправки требуется входящая транзакция на сумму не менее %1$s</string>
<string name="sui_not_enough_coin_for_fee_title">Недостаточно средств</string>
<string name="support_chat_screen_title">Чат поддержки</string>
<string name="support_chat_share_logs_button">Прикрепить логи</string>
<string name="support_chat_swap_prefilled_message">Данные об операции обмена:\nОткуда: %1$s %2$s\nКуда: %3$s %4$s\nЧерез: %5$s - %6$s</string>
<string name="support_selector_view_chat_button">Открыть чат</string>
<string name="support_selector_view_email_button">Открыть почту</string>
@ -1752,7 +1757,7 @@
<string name="swapping_insufficient_funds_description">Недостаточно средств для завершения транзакции. Уменьшите сумму получения или добавьте больше средств</string>
<string name="swapping_permission_header">Дать разрешение</string>
<string name="swapping_rate_experience_title">Оцените ваш опыт взаимодействия с провайдером</string>
<string name="swapping_rate_feedback_placeholder">Напишите ваш отзыв</string>
<string name="swapping_rate_feedback_placeholder">Оставьте отзыв (необязательно)</string>
<string name="swapping_rate_feedback_submit">Отправить отзыв</string>
<string name="swapping_rate_feedback_title">Что повлияло на вашу оценку?</string>
<string name="swapping_swap_action">Обменять</string>
@ -1951,11 +1956,11 @@
<string name="tangempay_newonboard_Q4_body">Баланс карты работает на USDC в сети Polygon, но пополнить его можно любым активом (USDT, SOL, ETH, BTC, XRP и др.) через удобные встроенные свопы Tangem.</string>
<string name="tangempay_newonboard_Q4_title">Какую крипту можно тратить?</string>
<string name="tangempay_newonboard_body">Тратьте крипту где угодно — без банков, бирж и посредников. Сила self-custody для повседневных платежей.</string>
<string name="tangempay_newonboard_bottomleft_body">Платите онлайн и c Apple Pay</string>
<string name="tangempay_newonboard_bottomleft_body">Онлайн и c Apple Pay</string>
<string name="tangempay_newonboard_bottomleft_title">Принимается везде</string>
<string name="tangempay_newonboard_bottomright_body">За покупки не в USD</string>
<string name="tangempay_newonboard_bottomright_title">FX-комиссия 1%</string>
<string name="tangempay_newonboard_title">Откройте карту Tangem Pay</string>
<string name="tangempay_newonboard_title">Откройте Tangem Pay</string>
<string name="tangempay_newonboard_topleft_body">1 USDC = 1 USD</string>
<string name="tangempay_newonboard_topleft_title">Без комиссии за покупки</string>
<string name="tangempay_newonboard_topright_body">Без сюрпризов</string>
@ -1999,7 +2004,7 @@
<string name="tangempay_sync_needed_button">Обновить сессию</string>
<string name="tangempay_sync_needed_title">Tangem Pay · Cессия истекла</string>
<string name="tangempay_tangem_visa_card">Оплачивайте ежедневные покупки в USDC</string>
<string name="tangempay_temporarily_unavailable">Tangem Pay временно недоступен</string>
<string name="tangempay_temporarily_unavailable">Проблема с соединением</string>
<string name="tangempay_title">Tangem Pay</string>
<string name="tangempay_topup_receive_body">Отправьте USDC Polygon на адрес вашего аккаунта</string>
<string name="tangempay_topup_receive_title">С другого кошелька или биржи</string>

View file

@ -1593,6 +1593,7 @@
<string name="staking_enabled">Стейкінг увімкнено</string>
<string name="staking_error_no_validators_message">Наразі немає вільних валідаторів. Повторіть спробу пізніше.</string>
<string name="staking_error_no_validators_title">Стейкінг недоступний</string>
<string name="staking_error_unavailable_region">Стейкінг недоступний у вашому регіоні</string>
<string name="staking_give_permission_fee_footer">Комісія за схвалення токену, щоб верифікувати, що саме ви дозволяєте використання токена для стейкінгу.</string>
<string name="staking_legal">Використовуючи функцію стейкінгу, ви погоджуєтесь з %1$s та %2$s</string>
<string name="staking_locked">Заблоковано</string>
@ -1672,6 +1673,8 @@
<string name="staking_title_stake">Застейкати %s</string>
<string name="staking_title_unstake">Зняти зі стейкінгу %s</string>
<string name="staking_transaction_in_progress_text">Транзакція обробляється! Наразі триває перевірка в блокчейні. Це може зайняти кілька хвилин.</string>
<string name="staking_transaction_validation_unsafe">%s стейкінг заборонено за результатами перевірок безпеки</string>
<string name="staking_transaction_validation_warning">%s стейкінг викликає сумніви. Будь ласка, стейкайте на свій страх і ризик</string>
<string name="staking_unbonding">Розблокування</string>
<string name="staking_unlocked_locked">Розблокувати</string>
<string name="staking_unlocking">Розблокування</string>
@ -1700,6 +1703,8 @@
<string name="story_web3_title">Web 3.0 сумісність</string>
<string name="sui_not_enough_coin_for_fee_description">Для відправки потрібна вхідна транзакція на суму не менше %1$s</string>
<string name="sui_not_enough_coin_for_fee_title">Недостатньо коштів</string>
<string name="support_chat_screen_title">Чат підтримки</string>
<string name="support_chat_share_logs_button">Прикріпити логи</string>
<string name="support_chat_swap_prefilled_message">Дані про операцію обміну:\nЗвідки: %1$s %2$s\nКуди: %3$s %4$s\nЧерез: %5$s - %6$s</string>
<string name="support_selector_view_chat_button">Відкрити чат</string>
<string name="support_selector_view_email_button">Відкрити пошту</string>
@ -1752,7 +1757,7 @@
<string name="swapping_insufficient_funds_description">Недостатньо коштів для завершення транзакції. Зменште суму отримання або додайте більше коштів</string>
<string name="swapping_permission_header">Надати дозвіл</string>
<string name="swapping_rate_experience_title">Оцініть ваш досвід взаємодії з провайдером</string>
<string name="swapping_rate_feedback_placeholder">Напишіть ваш відгук</string>
<string name="swapping_rate_feedback_placeholder">Залиште відгук (необов\'язково)</string>
<string name="swapping_rate_feedback_submit">Надіслати відгук</string>
<string name="swapping_rate_feedback_title">Що вплинуло на вашу оцінку?</string>
<string name="swapping_swap_action">Обміняти</string>

View file

@ -1591,6 +1591,8 @@
<string name="staking_title_stake">质押%s</string>
<string name="staking_title_unstake">取消抵押 %s</string>
<string name="staking_transaction_in_progress_text">交易正在处理中!区块链正在进行验证,这可能需要几分钟时间。</string>
<string name="staking_transaction_validation_unsafe">因安全检查问题,禁止质押%s </string>
<string name="staking_transaction_validation_warning">%s 质押行为可疑。请自行承担风险进行质押</string>
<string name="staking_unbonding">解除绑定</string>
<string name="staking_unlocked_locked">解锁</string>
<string name="staking_unlocking">解锁</string>
@ -1671,7 +1673,7 @@
<string name="swapping_insufficient_funds_description">账户余额不足,无法完成此交易。请减少收款金额或增加余额。</string>
<string name="swapping_permission_header">给予许可</string>
<string name="swapping_rate_experience_title">请评价您与服务提供商的互动体验</string>
<string name="swapping_rate_feedback_placeholder">请输入您的反馈</string>
<string name="swapping_rate_feedback_placeholder">请输入您的反馈(可选)</string>
<string name="swapping_rate_feedback_submit">发送反馈</string>
<string name="swapping_rate_feedback_title">是什么影响了您的\n体验</string>
<string name="swapping_swap_action">兑换</string>

View file

@ -333,7 +333,6 @@
<string name="common_error">Error</string>
<string name="common_estimated_fee">Top-up network fee</string>
<string name="common_exchange">Swap</string>
<string name="common_send_with_swap">Send&amp;Swap</string>
<string name="common_explore">Explore</string>
<string name="common_explore_transaction_history">Explore transaction history</string>
<string name="common_explorer">Explorer</string>
@ -433,6 +432,7 @@
<string name="common_send">Send</string>
<string name="common_send_colon">Send:</string>
<string name="common_send_tx_error">Failed to send transaction</string>
<string name="common_send_with_swap">Send&amp;Swap</string>
<string name="common_sending">Sending</string>
<string name="common_sent">Sent</string>
<string name="common_server_unavailable">The server is not available, please try again later</string>
@ -701,6 +701,7 @@
<string name="feedback_subject_support_tangem">Tangem feedback</string>
<string name="feedback_subject_tx_failed">Can\'t send a transaction</string>
<string name="feedback_token_description_error">Coin description error</string>
<string name="force_update_action">Update now</string>
<string name="force_update_banner_message">Update the app to its latest version to ensure proper functionality</string>
<string name="force_update_banner_title">Update needed</string>
<string name="force_update_brick_description">This version of the app is no longer supported and can\'t be updated on this device.</string>
@ -1294,9 +1295,9 @@
<string name="quick_action_buy_description">Credit card or bank account</string>
<string name="quick_action_receive_description">Share your address or QR-code</string>
<string name="quick_action_sell_description">Sell crypto securely</string>
<string name="quick_action_send_and_swap_description">Send with swap to another token</string>
<string name="quick_action_send_description">Send to another wallet</string>
<string name="quick_action_swap_description">Between your portfolios</string>
<string name="quick_action_send_and_swap_description">Send with swap to another token</string>
<string name="quick_top_up_chip_other">Other</string>
<string name="quick_top_up_title">Quick top up</string>
<string name="receive_bottom_sheet_no_memo_required_message">No memo required</string>
@ -1550,6 +1551,7 @@
<string name="staking_enabled">Staking enabled</string>
<string name="staking_error_no_validators_message">No available validators at the moment. Please try again later.</string>
<string name="staking_error_no_validators_title">Staking Unavailable</string>
<string name="staking_error_unavailable_region">Staking is unavailable in your region</string>
<string name="staking_give_permission_fee_footer">The network will charge a token approval fee to verify that you are authorizing the use of your token for the staking.</string>
<string name="staking_legal">By using staking functionality, you agree with providers %1$s and %2$s</string>
<string name="staking_locked">Locked</string>
@ -1629,6 +1631,8 @@
<string name="staking_title_stake">Stake %s</string>
<string name="staking_title_unstake">Unstake %s</string>
<string name="staking_transaction_in_progress_text">The transaction is being processed! Validation is currently underway in the blockchain. This may take a few minutes.</string>
<string name="staking_transaction_validation_unsafe">%s staking prohibited due to security checks</string>
<string name="staking_transaction_validation_warning">%s staking suspicious. Please stake at your own risk</string>
<string name="staking_unbonding">Unbonding</string>
<string name="staking_unlocked_locked">Unlock</string>
<string name="staking_unlocking">Unlocking</string>
@ -1657,6 +1661,8 @@
<string name="story_web3_title">Web 3.0 Compatible</string>
<string name="sui_not_enough_coin_for_fee_description">An incoming transaction of at least %1$s is required to proceed</string>
<string name="sui_not_enough_coin_for_fee_title">Insufficient funds</string>
<string name="support_chat_screen_title">Support chat</string>
<string name="support_chat_share_logs_button">Attach app logs</string>
<string name="support_chat_swap_prefilled_message">SWAP operation data:\nFrom: %1$s %2$s\nTo: %3$s %4$s\nBy %5$s - %6$s</string>
<string name="support_selector_view_chat_button">Open chat</string>
<string name="support_selector_view_email_button">Open mail</string>
@ -1709,7 +1715,7 @@
<string name="swapping_insufficient_funds_description">Not enough funds to complete this transaction. Reduce the amount to receive or add more funds.</string>
<string name="swapping_permission_header">Give Permission</string>
<string name="swapping_rate_experience_title">Rate your experience with provider</string>
<string name="swapping_rate_feedback_placeholder">Type your feedback</string>
<string name="swapping_rate_feedback_placeholder">Type your feedback(optional)</string>
<string name="swapping_rate_feedback_submit">Send feedback</string>
<string name="swapping_rate_feedback_title">What affected your \nexperience?</string>
<string name="swapping_swap_action">Swap</string>
@ -1840,6 +1846,7 @@
</plurals>
<string name="tangempay_change_pin_code">Change PIN-code</string>
<string name="tangempay_come_back_if_forget_pin">Come back to the app if you forget it.</string>
<string name="tangempay_common_card">Card</string>
<string name="tangempay_daily_limit_hint" formatted="false">Set a limit from %s to %s</string>
<string name="tangempay_daily_limit_set_button">Set limits</string>
<string name="tangempay_declined_reason_1">insufficient funds</string>
@ -1957,7 +1964,7 @@
<string name="tangempay_sync_needed_button">Renew session</string>
<string name="tangempay_sync_needed_title">Payment account session expired</string>
<string name="tangempay_tangem_visa_card">Use USDC for everyday payments</string>
<string name="tangempay_temporarily_unavailable">Tangem Pay is temporarily unavailable</string>
<string name="tangempay_temporarily_unavailable">Connection issues</string>
<string name="tangempay_title">Tangem Pay</string>
<string name="tangempay_topup_receive_body">Send USDC Polygon to your accounts address </string>
<string name="tangempay_topup_receive_title">From another wallet or exchange</string>
@ -2084,6 +2091,8 @@
<string name="user_wallet_list_rename_popup_title">Rename wallet</string>
<string name="user_wallet_list_unlock_all">Unlock all</string>
<string name="user_wallet_list_unlock_all_with">Unlock all with %s</string>
<string name="virtual_account_title">Virtual account</string>
<string name="virtual_account_transactions_empty">No transactions yet. Start spending and see history here</string>
<string name="visa_balance_limits_details_aml_verified">AML verifired</string>
<string name="visa_balance_limits_details_available">Available</string>
<string name="visa_balance_limits_details_blocked">Blocked</string>

View file

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

View file

@ -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",
),

View file

@ -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(),

View file

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

View file

@ -15,6 +15,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
@ -27,7 +28,6 @@ import androidx.compose.ui.unit.coerceAtLeast
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEachIndexed
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.conditional
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
@ -108,47 +108,121 @@ fun TangemSegmentedPicker(
segmentHeight = segmentHeight.value,
separatorWidth = SEPARATOR_WIDTH,
)
Row(verticalAlignment = Alignment.CenterVertically) {
items.fastForEachIndexed { index, item ->
Segment(
item = item,
index = index,
isFixed = isFixed,
minSegmentWidth = minSegmentWidth,
selectedIndex = selectedIndex,
onClick = { onClick(item) },
modifier = Modifier
.onGloballyPositioned {
with(density) {
itemsWidths[index] = it.size.width.toDp()
segmentHeight.value = it.size.height.toDp().coerceAtLeast(segmentHeight.value)
}
},
)
if (index != items.lastIndex) {
val selected by selectedIndex
val alpha by animateFloatAsState(
targetValue = if (selected == index || selected == index + 1) 0f else 1f,
animationSpec = tween(durationMillis = 300),
label = "separatorAlpha",
SegmentsRow(
isFixed = isFixed,
minSegmentWidth = minSegmentWidth,
segmentCount = items.size,
content = {
items.fastForEachIndexed { index, item ->
Segment(
item = item,
index = index,
minSegmentWidth = minSegmentWidth,
selectedIndex = selectedIndex,
onClick = { onClick(item) },
modifier = Modifier
.onGloballyPositioned {
with(density) {
itemsWidths[index] = it.size.width.toDp()
segmentHeight.value = it.size.height.toDp().coerceAtLeast(segmentHeight.value)
}
},
)
Box(
Modifier
.alpha(alpha)
.width(SEPARATOR_WIDTH)
.height(20.dp)
.background(
color = TangemTheme.colors2.border.neutral.tertiary.copy(alpha = 0.1f),
),
)
if (index != items.lastIndex) {
Separator(index = index, selectedIndex = selectedIndex)
}
}
},
)
}
}
/**
* Lays out segments (with the inter-segment separators interleaved) in a single row.
*
* - When [isFixed] is `true` and the parent width is bounded, every segment is sized to at least its
* content width, and the remaining free space is distributed equally between segments so the row
* fills the full width without clipping any segment's text.
* - Otherwise segments wrap their content.
*
* Children must be supplied as `segment, separator, segment, separator, , segment` (even indices are
* segments, odd indices are separators).
*/
@Composable
private fun SegmentsRow(
isFixed: Boolean,
minSegmentWidth: Dp,
segmentCount: Int,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Layout(modifier = modifier, content = content) { measurables, constraints ->
val segMeasurables = measurables.filterIndexed { i, _ -> i % 2 == 0 }
val sepMeasurables = measurables.filterIndexed { i, _ -> i % 2 == 1 }
val sepPlaceables = sepMeasurables.map { it.measure(constraints.copy(minWidth = 0)) }
val totalSeparators = sepPlaceables.sumOf { it.width }
val minPx = if (minSegmentWidth != Dp.Unspecified) minSegmentWidth.roundToPx() else 0
val baseWidths = segMeasurables.map { maxOf(it.maxIntrinsicWidth(constraints.maxHeight), minPx) }
val widths = if (isFixed && constraints.hasBoundedWidth) {
val leftover = constraints.maxWidth - baseWidths.sum() - totalSeparators
if (leftover > 0) {
val extra = leftover / segmentCount
val remainder = leftover % segmentCount
baseWidths.mapIndexed { i, w -> w + extra + if (i < remainder) 1 else 0 }
} else {
baseWidths
}
} else {
baseWidths
}
val segPlaceables = segMeasurables.mapIndexed { i, m ->
m.measure(constraints.copy(minWidth = widths[i], maxWidth = widths[i]))
}
val layoutWidth = (widths.sum() + totalSeparators)
.coerceIn(constraints.minWidth, constraints.maxWidth)
val layoutHeight = (segPlaceables + sepPlaceables).maxOf { it.height }
.coerceIn(constraints.minHeight, constraints.maxHeight)
layout(layoutWidth, layoutHeight) {
var x = 0
segPlaceables.forEachIndexed { i, seg ->
seg.placeRelative(x, (layoutHeight - seg.height) / 2)
x += seg.width
sepPlaceables.getOrNull(i)?.let { sep ->
sep.placeRelative(x, (layoutHeight - sep.height) / 2)
x += sep.width
}
}
}
}
}
@Composable
private fun Separator(index: Int, selectedIndex: MutableState<Int>) {
val selected by selectedIndex
val alpha by animateFloatAsState(
targetValue = if (selected == index || selected == index + 1) 0f else 1f,
animationSpec = tween(durationMillis = 300),
label = "separatorAlpha",
)
Box(
Modifier
.alpha(alpha)
.width(SEPARATOR_WIDTH)
.height(20.dp)
.background(
color = TangemTheme.colors2.border.neutral.tertiary.copy(alpha = 0.1f),
),
)
}
private val SEPARATOR_WIDTH = 0.5.dp
@Composable
@ -199,10 +273,9 @@ private fun SegmentSelection(
}
@Composable
private fun RowScope.Segment(
private fun Segment(
item: TangemSegmentUM,
index: Int,
isFixed: Boolean,
selectedIndex: MutableState<Int>,
onClick: () -> Unit,
modifier: Modifier = Modifier,
@ -211,9 +284,6 @@ private fun RowScope.Segment(
Box(
modifier = modifier
.defaultMinSize(minWidth = minSegmentWidth)
.conditional(isFixed) {
weight(1f)
}
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() },

View file

@ -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
*/

View file

@ -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"

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="28dp"
android:height="28dp"
android:viewportWidth="28"
android:viewportHeight="28">
<path
android:pathData="M18.221,10.776C22.333,10.776 25.667,14.11 25.667,18.222C25.667,22.334 22.333,25.667 18.221,25.667C14.109,25.667 10.775,22.333 10.775,18.222C10.775,14.11 14.109,10.777 18.221,10.776ZM17.152,17.301L15.115,17.81L15.418,19.024L16.653,18.715L15.859,20.967C15.792,21.158 15.821,21.37 15.939,21.535C16.056,21.701 16.246,21.8 16.448,21.8H21.177V20.55H17.332L18.107,18.351L20.146,17.842L19.843,16.629L18.606,16.938L19.401,14.686L18.223,14.269L17.152,17.301ZM9.779,2.333C13.861,2.333 17.173,5.617 17.223,9.687C15.365,9.902 13.689,10.708 12.386,11.914C12.441,11.727 12.47,11.525 12.47,11.307C12.47,10.301 11.878,9.682 10.618,9.389L10.15,9.285V7.188C10.814,7.268 11.267,7.687 11.287,8.22H12.337C12.306,7.139 11.452,6.367 10.15,6.258V5.585H9.461V6.258C8.108,6.377 7.28,7.124 7.28,8.244C7.28,9.195 7.892,9.847 9.014,10.121L9.461,10.236V12.451C8.715,12.366 8.231,11.963 8.18,11.386H7.115C7.12,12.516 8.026,13.277 9.461,13.372V14H10.15V13.367C10.608,13.328 11.005,13.23 11.336,13.076C10.451,14.259 9.865,15.679 9.687,17.222C5.617,17.172 2.333,13.86 2.333,9.779C2.333,5.667 5.667,2.333 9.779,2.333ZM10.15,10.36C11.004,10.525 11.4,10.863 11.4,11.421C11.4,12.038 10.942,12.417 10.15,12.462V10.36ZM9.461,9.146C8.741,9.006 8.355,8.657 8.355,8.159C8.356,7.627 8.818,7.219 9.461,7.184V9.146Z"
android:fillColor="#ffffff"/>
</vector>

View file

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

View file

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

View file

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

View file

@ -0,0 +1,80 @@
package com.tangem.domain.transaction.usecase
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import com.tangem.blockchain.common.TransactionSigner
import com.tangem.blockchain.extensions.Result
import com.tangem.domain.card.common.TapWorkarounds.isTangemTwins
import com.tangem.domain.card.models.TwinKey
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.domain.walletmanager.WalletManagersFacade
/**
* Signs and broadcasts a Bitcoin swap transaction supplied by a DEX provider as a Base64 PSBT.
*
* Unlike a normal send (where we build the transaction ourselves), the provider returns an
* almost-complete transaction encoded as a PSBT in `txData`. This use case:
* 1. derives which inputs belong to the wallet ([WalletManager.deriveSignInputs]),
* 2. signs them with the card/hot signer ([WalletManager.signPsbt]),
* 3. finalizes and broadcasts the transaction ([WalletManager.broadcastPsbt]),
*
* returning the resulting transaction hash. Errors from any step are mapped to [SendTransactionError]
* via [SendTransactionUseCase.handleError].
*/
class SignAndBroadcastPsbtUseCase(
private val cardSdkConfigRepository: CardSdkConfigRepository,
private val walletManagersFacade: WalletManagersFacade,
private val getHotTransactionSigner: (UserWallet.Hot) -> TransactionSigner,
) {
suspend operator fun invoke(
psbtBase64: String,
userWallet: UserWallet,
network: Network,
): Either<SendTransactionError, String> {
walletManagersFacade.update(
userWalletId = userWallet.walletId,
network = network,
extraTokens = emptySet(),
)
val walletManager = walletManagersFacade.getOrCreateWalletManager(userWallet.walletId, network)
?: return SendTransactionError.UnknownError().left()
val signInputs = when (val result = walletManager.deriveSignInputs(psbtBase64)) {
is Result.Success -> result.data
is Result.Failure -> return SendTransactionUseCase.handleError(result).left()
}
val signer = createSigner(userWallet)
val signedPsbt = when (val result = walletManager.signPsbt(psbtBase64, signInputs, signer)) {
is Result.Success -> result.data
is Result.Failure -> return SendTransactionUseCase.handleError(result).left()
}
return when (val result = walletManager.broadcastPsbt(signedPsbt)) {
is Result.Success -> result.data.right()
is Result.Failure -> SendTransactionUseCase.handleError(result).left()
}
}
private fun createSigner(userWallet: UserWallet): TransactionSigner {
return when (userWallet) {
is UserWallet.Hot -> getHotTransactionSigner(userWallet)
is UserWallet.Cold -> {
val card = userWallet.scanResponse.card
val isCardNotBackedUp = card.backupStatus?.isActive != true && !card.isTangemTwins
cardSdkConfigRepository.getCommonSigner(
cardId = card.cardId.takeIf { isCardNotBackedUp },
twinKey = TwinKey.getOrNull(scanResponse = userWallet.scanResponse),
userWalletId = userWallet.walletId,
)
}
}
}
}

View file

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

View file

@ -0,0 +1,116 @@
package com.tangem.domain.transaction.usecase
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.blockchain.blockchains.bitcoin.walletconnect.models.SignInput
import com.tangem.blockchain.common.BlockchainSdkError
import com.tangem.blockchain.common.TransactionSigner
import com.tangem.blockchain.common.WalletManager
import com.tangem.blockchain.extensions.Result
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.walletmanager.WalletManagersFacade
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
/**
* Tests for [SignAndBroadcastPsbtUseCase] the orchestration that derives the wallet's inputs from a
* provider PSBT, signs them, and broadcasts the finalized transaction (Bitcoin swap flow).
*/
internal class SignAndBroadcastPsbtUseCaseTest {
private val walletManagersFacade: WalletManagersFacade = mockk()
private val cardSdkConfigRepository: CardSdkConfigRepository = mockk(relaxed = true)
private val signer: TransactionSigner = mockk(relaxed = true)
private val walletManager: WalletManager = mockk()
private val useCase = SignAndBroadcastPsbtUseCase(
cardSdkConfigRepository = cardSdkConfigRepository,
walletManagersFacade = walletManagersFacade,
getHotTransactionSigner = { signer },
)
private val userWalletId = UserWalletId(stringValue = "deadbeef")
private val network: Network = mockk(relaxed = true)
private val userWallet: UserWallet = mockk<UserWallet.Hot>(relaxed = true) {
every { walletId } returns userWalletId
}
private val psbt = "psbt-base64"
private val signInputs = listOf(SignInput(address = "addr", index = 0, sighashTypes = listOf(1)))
@BeforeEach
fun setup() {
// The use case refreshes the wallet manager (fresh UTXO set) before signing the PSBT.
coEvery { walletManagersFacade.update(userWalletId, network, emptySet()) } returns mockk(relaxed = true)
}
@Test
fun `GIVEN derive sign broadcast succeed WHEN invoke THEN returns tx hash`() = runTest {
// Arrange
coEvery { walletManagersFacade.getOrCreateWalletManager(userWalletId, network) } returns walletManager
every { walletManager.deriveSignInputs(psbt) } returns Result.Success(signInputs)
coEvery { walletManager.signPsbt(psbt, signInputs, signer) } returns Result.Success("signed-psbt")
coEvery { walletManager.broadcastPsbt("signed-psbt") } returns Result.Success("tx-hash")
// Act
val actual = useCase(psbtBase64 = psbt, userWallet = userWallet, network = network)
// Assert
assertThat(actual).isEqualTo("tx-hash".right())
coVerify(exactly = 1) { walletManagersFacade.update(userWalletId, network, emptySet()) }
coVerify(exactly = 1) { walletManager.broadcastPsbt("signed-psbt") }
}
@Test
fun `GIVEN deriveSignInputs fails WHEN invoke THEN returns error and does not sign`() = runTest {
// Arrange
coEvery { walletManagersFacade.getOrCreateWalletManager(userWalletId, network) } returns walletManager
every {
walletManager.deriveSignInputs(psbt)
} returns Result.Failure(BlockchainSdkError.CustomError("no inputs"))
// Act
val actual = useCase(psbtBase64 = psbt, userWallet = userWallet, network = network)
// Assert
assertThat(actual.isLeft()).isTrue()
coVerify(exactly = 0) { walletManager.signPsbt(any(), any(), any()) }
}
@Test
fun `GIVEN signPsbt fails WHEN invoke THEN returns error and does not broadcast`() = runTest {
// Arrange
coEvery { walletManagersFacade.getOrCreateWalletManager(userWalletId, network) } returns walletManager
every { walletManager.deriveSignInputs(psbt) } returns Result.Success(signInputs)
coEvery {
walletManager.signPsbt(psbt, signInputs, signer)
} returns Result.Failure(BlockchainSdkError.CustomError("sign fail"))
// Act
val actual = useCase(psbtBase64 = psbt, userWallet = userWallet, network = network)
// Assert
assertThat(actual.isLeft()).isTrue()
coVerify(exactly = 0) { walletManager.broadcastPsbt(any()) }
}
@Test
fun `GIVEN wallet manager missing WHEN invoke THEN returns error`() = runTest {
// Arrange
coEvery { walletManagersFacade.getOrCreateWalletManager(userWalletId, network) } returns null
// Act
val actual = useCase(psbtBase64 = psbt, userWallet = userWallet, network = network)
// Assert
assertThat(actual.isLeft()).isTrue()
}
}

View file

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

View file

@ -8,8 +8,16 @@ enum class OrderStatus {
;
/** An order is active while it is still being processed (NEW or PROCESSING). */
val isActive: Boolean get() = this == NEW || this == PROCESSING
val isActive: Boolean get() = activeStatuses.contains(this)
/** Terminal statuses (COMPLETED or CANCELED) — used to invalidate the local order hint. */
val isTerminal: Boolean get() = this == COMPLETED || this == CANCELED
val isTerminal: Boolean get() = terminalStatuses.contains(this)
companion object {
/** Statuses of an in-flight order (still being processed). */
val activeStatuses: Set<OrderStatus> = setOf(NEW, PROCESSING)
/** Statuses of a finished order — no further state changes are expected. */
val terminalStatuses: Set<OrderStatus> = setOf(COMPLETED, CANCELED)
}
}

View file

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

View file

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

View file

@ -0,0 +1,68 @@
package com.tangem.domain.pay.usecase
import arrow.core.Either
import arrow.core.raise.Raise
import arrow.core.raise.catch
import arrow.core.raise.either
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.model.OrderType
import com.tangem.domain.pay.model.TangemPayOrderInfo
import com.tangem.domain.pay.repository.CustomerOrderRepository
import com.tangem.domain.pay.repository.TangemPayIssueCardRepository
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.launch
/**
* Restores in-flight card-issuance orders on app launch / when returning to the wallet screen.
*
* `findOrders` is the source of truth a locally stored order id is only a hint that does not
* survive a force close. This use case re-discovers the active issue orders, persists their ids so
* the payment-account state renders an "issuing" placeholder card, and (re)starts polling so the
* placeholder is driven to its terminal state.
*
* Non-fatal exceptions are logged and collapsed to [VisaApiError.Unspecified]; the caller treats the
* result as fire-and-forget.
*
* @property customerOrderRepository source of truth for active orders.
* @property issueCardRepository persists issue-order ids for placeholder rendering.
* @property startTangemPayOrderPollingUseCase drives a restored order to its terminal state.
*/
class RestoreActiveIssueOrdersUseCase(
private val customerOrderRepository: CustomerOrderRepository,
private val issueCardRepository: TangemPayIssueCardRepository,
private val startTangemPayOrderPollingUseCase: StartTangemPayOrderPollingUseCase,
private val appCoroutineScope: AppCoroutineScope,
) {
suspend operator fun invoke(userWalletId: UserWalletId): Either<VisaApiError, Unit> = either {
val orders = catch(
block = {
customerOrderRepository.findOrders(
userWalletId = userWalletId,
types = OrderType.issueCardTypes,
statuses = OrderStatus.activeStatuses,
).bind()
},
catch = { handleError(it) },
).filter { it.status.isActive }
orders.forEach { order ->
issueCardRepository.storeIssueOrderId(userWalletId = userWalletId, orderId = order.id)
appCoroutineScope.launch {
startTangemPayOrderPollingUseCase(
order = TangemPayOrderInfo(orderId = order.id, orderStatus = order.status),
userWalletId = userWalletId,
)
}
}
}
private fun Raise<VisaApiError>.handleError(throwable: Throwable): Nothing {
TangemLogger.e("Error in RestoreActiveIssueOrdersUseCase", throwable)
raise(VisaApiError.Unspecified)
}
}

View file

@ -1,30 +0,0 @@
package com.tangem.domain.pay.usecase
import arrow.core.Either
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.Order
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.repository.CustomerOrderRepository
import com.tangem.domain.visa.error.VisaApiError
/**
* same customer.
*
* Wraps `findOrders` (the source of truth) and filters to the active set (NEW / PROCESSING).
* The caller decides how to dispatch each order to the appropriate flow.
*/
class RestoreActiveOrdersUseCase(
private val customerOrderRepository: CustomerOrderRepository,
) {
suspend operator fun invoke(userWalletId: UserWalletId): Either<VisaApiError, List<Order>> {
return customerOrderRepository.findOrders(
userWalletId = userWalletId,
statuses = ACTIVE_STATUSES,
)
}
private companion object {
val ACTIVE_STATUSES: Set<OrderStatus> = setOf(OrderStatus.NEW, OrderStatus.PROCESSING)
}
}

View file

@ -6,25 +6,43 @@ import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.model.TangemPayOrderInfo
import com.tangem.domain.pay.repository.TangemPayCardDetailsRepository
import kotlinx.coroutines.delay
import java.util.concurrent.ConcurrentHashMap
class StartTangemPayOrderPollingUseCase(
private val cardDetailsRepository: TangemPayCardDetailsRepository,
private val paymentAccountStatusFetcher: PaymentAccountStatusFetcher,
) {
/**
* Order keys (`walletId:orderId`) currently being polled. Keeps polling idempotent so callers that
* may fire repeatedly for the same order (e.g. order restore on every wallet (re)load) never spawn a
* second poller for it.
*/
private val activeOrders = ConcurrentHashMap.newKeySet<String>()
suspend operator fun invoke(order: TangemPayOrderInfo, userWalletId: UserWalletId): Boolean {
while (true) {
val newOrder = if (order.orderStatus.isTerminal) {
order
} else {
cardDetailsRepository.getOrderInfo(userWalletId, order.orderId).getOrNull()
}
// A poller for this exact order is already running — `false` only reaches fire-and-forget issue
// callers (restore / issue-additional); the awaiting freeze caller always polls a fresh order id.
val key = "${userWalletId.stringValue}:${order.orderId}"
if (!activeOrders.add(key)) return false
if (newOrder != null && newOrder.orderStatus.isTerminal) {
paymentAccountStatusFetcher.invoke(userWalletId)
return newOrder.orderStatus == OrderStatus.COMPLETED
}
try {
while (true) {
val newOrder = if (order.orderStatus.isTerminal) {
order
} else {
cardDetailsRepository.getOrderInfo(userWalletId, order.orderId).getOrNull()
}
delay(POLLING_DELAY)
if (newOrder != null && newOrder.orderStatus.isTerminal) {
paymentAccountStatusFetcher.invoke(userWalletId)
return newOrder.orderStatus == OrderStatus.COMPLETED
}
delay(POLLING_DELAY)
}
} finally {
activeOrders.remove(key)
}
}

View file

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

View file

@ -67,10 +67,11 @@ internal class IssueAdditionalCardUseCaseTest {
userWalletId,
types = setOf(
OrderType.CARD_ISSUE_ADDITIONAL,
OrderType.CARD_ISSUE_VIRTUAL_RAIN,
OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC,
OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC_V2,
),
statuses = emptySet(),
statuses = setOf(OrderStatus.NEW, OrderStatus.PROCESSING),
)
} returns listOf(existing).right()
@ -89,10 +90,11 @@ internal class IssueAdditionalCardUseCaseTest {
userWalletId,
types = setOf(
OrderType.CARD_ISSUE_ADDITIONAL,
OrderType.CARD_ISSUE_VIRTUAL_RAIN,
OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC,
OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC_V2,
),
statuses = emptySet(),
statuses = setOf(OrderStatus.NEW, OrderStatus.PROCESSING),
)
} returns emptyList<Order>().right()
coEvery {
@ -117,10 +119,11 @@ internal class IssueAdditionalCardUseCaseTest {
userWalletId,
types = setOf(
OrderType.CARD_ISSUE_ADDITIONAL,
OrderType.CARD_ISSUE_VIRTUAL_RAIN,
OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC,
OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC_V2,
),
statuses = emptySet(),
statuses = setOf(OrderStatus.NEW, OrderStatus.PROCESSING),
)
} returns emptyList<Order>().right()
val newOrder = order(

View file

@ -0,0 +1,130 @@
package com.tangem.domain.pay.usecase
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.Order
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.model.OrderType
import com.tangem.domain.pay.model.TangemPayOrderInfo
import com.tangem.domain.pay.repository.CustomerOrderRepository
import com.tangem.domain.pay.repository.TangemPayIssueCardRepository
import com.tangem.domain.visa.error.VisaApiError
import com.tangem.test.core.TestAppCoroutineScope
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
internal class RestoreActiveIssueOrdersUseCaseTest {
private val orderRepository: CustomerOrderRepository = mockk()
private val issueCardRepository: TangemPayIssueCardRepository = mockk(relaxed = true)
private val startTangemPayOrderPollingUseCase: StartTangemPayOrderPollingUseCase = mockk(relaxed = true)
private val useCase = RestoreActiveIssueOrdersUseCase(
customerOrderRepository = orderRepository,
issueCardRepository = issueCardRepository,
startTangemPayOrderPollingUseCase = startTangemPayOrderPollingUseCase,
appCoroutineScope = TestAppCoroutineScope(),
)
private val userWalletId = UserWalletId("1234567890ABCDEF")
@Test
fun `GIVEN active issue orders WHEN invoke THEN each order is stored and polled`() = runTest {
// Arrange
val first = order(id = "first", type = OrderType.CARD_ISSUE_ADDITIONAL, status = OrderStatus.NEW)
val second = order(id = "second", type = OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC, status = OrderStatus.PROCESSING)
coEvery {
orderRepository.findOrders(
userWalletId = userWalletId,
types = ISSUE_ORDER_TYPES,
statuses = ACTIVE_STATUSES,
)
} returns listOf(first, second).right()
// Act
val result = useCase(userWalletId)
// Assert
assertThat(result.isRight()).isTrue()
coVerify(exactly = 1) { issueCardRepository.storeIssueOrderId(userWalletId, first.id) }
coVerify(exactly = 1) { issueCardRepository.storeIssueOrderId(userWalletId, second.id) }
coVerify(exactly = 1) {
startTangemPayOrderPollingUseCase(TangemPayOrderInfo(first.id, first.status), userWalletId)
}
coVerify(exactly = 1) {
startTangemPayOrderPollingUseCase(TangemPayOrderInfo(second.id, second.status), userWalletId)
}
}
@Test
fun `GIVEN no active orders WHEN invoke THEN nothing is stored or polled`() = runTest {
// Arrange
coEvery {
orderRepository.findOrders(userWalletId, types = ISSUE_ORDER_TYPES, statuses = ACTIVE_STATUSES)
} returns emptyList<Order>().right()
// Act
val result = useCase(userWalletId)
// Assert
assertThat(result.isRight()).isTrue()
coVerify(exactly = 0) { issueCardRepository.storeIssueOrderId(any(), any()) }
coVerify(exactly = 0) { startTangemPayOrderPollingUseCase(any(), any()) }
}
@Test
fun `GIVEN a terminal order leaks through WHEN invoke THEN it is filtered out`() = runTest {
// Arrange
val completed = order(id = "done", type = OrderType.CARD_ISSUE_ADDITIONAL, status = OrderStatus.COMPLETED)
coEvery {
orderRepository.findOrders(userWalletId, types = ISSUE_ORDER_TYPES, statuses = ACTIVE_STATUSES)
} returns listOf(completed).right()
// Act
val result = useCase(userWalletId)
// Assert
assertThat(result.isRight()).isTrue()
coVerify(exactly = 0) { issueCardRepository.storeIssueOrderId(any(), any()) }
coVerify(exactly = 0) { startTangemPayOrderPollingUseCase(any(), any()) }
}
@Test
fun `GIVEN findOrders fails WHEN invoke THEN returns Unspecified and stores nothing`() = runTest {
// Arrange
coEvery {
orderRepository.findOrders(userWalletId, types = ISSUE_ORDER_TYPES, statuses = ACTIVE_STATUSES)
} returns VisaApiError.Unspecified.left()
// Act
val result = useCase(userWalletId)
// Assert
assertThat(result.leftOrNull()).isEqualTo(VisaApiError.Unspecified)
coVerify(exactly = 0) { issueCardRepository.storeIssueOrderId(any(), any()) }
coVerify(exactly = 0) { startTangemPayOrderPollingUseCase(any(), any()) }
}
private fun order(id: String, type: OrderType, status: OrderStatus): Order = Order(
id = id,
customerId = "customer",
type = type,
status = status,
step = null,
stepChangeCode = null,
productInstanceId = null,
paymentAccountId = null,
cardId = null,
withdrawTxHash = null,
createdAt = null,
updatedAt = null,
)
private companion object {
val ISSUE_ORDER_TYPES = OrderType.issueCardTypes
val ACTIVE_STATUSES = OrderStatus.activeStatuses
}
}

View file

@ -1,72 +0,0 @@
package com.tangem.domain.pay.usecase
import arrow.core.left
import arrow.core.right
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.model.Order
import com.tangem.domain.pay.model.OrderStatus
import com.tangem.domain.pay.model.OrderType
import com.tangem.domain.pay.repository.CustomerOrderRepository
import com.tangem.domain.visa.error.VisaApiError
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
internal class RestoreActiveOrdersUseCaseTest {
private val repository: CustomerOrderRepository = mockk()
private val useCase = RestoreActiveOrdersUseCase(repository)
private val userWalletId = UserWalletId("1234567890ABCDEF")
@Test
fun `passes only NEW and PROCESSING statuses to findOrders`() = runTest {
val expected = setOf(OrderStatus.NEW, OrderStatus.PROCESSING)
coEvery { repository.findOrders(userWalletId, types = emptySet(), statuses = expected) } returns
emptyList<Order>().right()
useCase(userWalletId)
coVerify(exactly = 1) { repository.findOrders(userWalletId, types = emptySet(), statuses = expected) }
}
@Test
fun `returns the orders found by the repository`() = runTest {
val orders = listOf(
order(id = "issue", type = OrderType.CARD_ISSUE_VIRTUAL_RAIN_KYC, status = OrderStatus.PROCESSING),
order(id = "withdraw", type = OrderType.WITHDRAW, status = OrderStatus.NEW),
)
coEvery { repository.findOrders(userWalletId, types = emptySet(), statuses = any()) } returns orders.right()
val result = useCase(userWalletId)
assertThat(result.getOrNull()).containsExactlyElementsIn(orders)
}
@Test
fun `surfaces repository errors`() = runTest {
coEvery { repository.findOrders(userWalletId, types = emptySet(), statuses = any()) } returns
VisaApiError.Unspecified.left()
val result = useCase(userWalletId)
assertThat(result.leftOrNull()).isEqualTo(VisaApiError.Unspecified)
}
private fun order(id: String, type: OrderType, status: OrderStatus): Order = Order(
id = id,
customerId = "customer",
type = type,
status = status,
step = null,
stepChangeCode = null,
productInstanceId = null,
paymentAccountId = null,
cardId = null,
withdrawTxHash = null,
createdAt = null,
updatedAt = null,
)
}

View file

@ -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"

View file

@ -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].

View file

@ -7,11 +7,14 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.yield.supply.models.YieldBoostPromo
import com.tangem.domain.yield.supply.models.YieldBoostStatus
import com.tangem.domain.yield.supply.promo.YieldPromoRepository
import io.mockk.Deregisterable
import io.mockk.coEvery
import io.mockk.mockk
import io.mockk.registerInstanceFactory
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Instant
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@ -25,11 +28,23 @@ class IsYieldBoostPromoEnabledForTokenUseCaseTest {
private val contractAddress = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"
private val networkRawId = "ethereum"
// Stub concrete instances of the sealed return types so MockK doesn't subclass them while recording coEvery
// (Objenesis on a JVM-sealed type throws InstantiationError flakily under full-suite CI runs).
private val instanceFactories = mutableListOf<Deregisterable>()
@BeforeEach
fun setUp() {
instanceFactories += registerInstanceFactory { YieldBoostPromo.None }
instanceFactories += registerInstanceFactory { YieldBoostStatus.NotStarted }
useCase = IsYieldBoostPromoEnabledForTokenUseCase(repository = repository)
}
@AfterEach
fun tearDown() {
instanceFactories.forEach { it.deregister() }
instanceFactories.clear()
}
@Test
fun `GIVEN currency is coin WHEN invoke THEN returns Right(false)`() = runTest {
val coin = createCoin()

View file

@ -5,11 +5,14 @@ import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.yield.supply.models.YieldBoostPromo
import com.tangem.domain.yield.supply.models.YieldBoostStatus
import com.tangem.domain.yield.supply.promo.YieldPromoRepository
import io.mockk.Deregisterable
import io.mockk.coEvery
import io.mockk.mockk
import io.mockk.registerInstanceFactory
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Instant
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@ -23,11 +26,23 @@ class ShouldShowYieldBoostMainBannerUseCaseTest {
private val contractAddress = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"
private val networkRawId = "ethereum"
// Stub concrete instances of the sealed return types so MockK doesn't subclass them while recording coEvery
// (Objenesis on a JVM-sealed type throws InstantiationError flakily under full-suite CI runs).
private val instanceFactories = mutableListOf<Deregisterable>()
@BeforeEach
fun setUp() {
instanceFactories += registerInstanceFactory { YieldBoostPromo.None }
instanceFactories += registerInstanceFactory { YieldBoostStatus.NotStarted }
useCase = ShouldShowYieldBoostMainBannerUseCase(repository = repository)
}
@AfterEach
fun tearDown() {
instanceFactories.forEach { it.deregister() }
instanceFactories.clear()
}
@Test
fun `GIVEN promo repository throws WHEN invoke THEN returns Left`() = runTest {
coEvery { repository.getYieldBoostPromo(userWalletId, false) } throws RuntimeException("net")

View file

@ -40,6 +40,8 @@ internal class PortfolioListBlockDelegate @AssistedInject constructor(
private val onTokenItemClick: Channel<Pair<AccountStatus, CryptoCurrencyStatus>> = Channel()
private val isOnlyMultiCurrency: Boolean get() = !featureSettings.isShowSingleCurrencyWallets
val onTokenChosen: Channel<ChooseTokenResult> = Channel()
val tokenFilter: MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean> =
MutableStateFlow { _, _ -> true }
@ -53,7 +55,7 @@ internal class PortfolioListBlockDelegate @AssistedInject constructor(
multiAccountStatusListSupplier.invokeAsMap()
val allWalletsFlow: StateFlow<LinkedHashMap<UserWalletId, UserWallet>> =
getWalletsUseCase.invokeAsMap().stateIn(this)
getWalletsUseCase.invokeAsMap(isOnlyMultiCurrency = isOnlyMultiCurrency).stateIn(this)
onTokenItemClick.receiveAsFlow()
.onEach { (account, currencyStatus) ->

View file

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

View file

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

View file

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

View file

@ -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 = {},
),
)
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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,

View file

@ -27,7 +27,6 @@ import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.utils.PreviewShimmerContainer
import com.tangem.features.feed.impl.R
import com.tangem.features.feed.ui.components.ContainerWithDivider
import com.tangem.features.feed.ui.market.detailed.state.LinksUM
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@ -105,7 +104,6 @@ private fun LinksBlockV2(state: LinksUM, modifier: Modifier = Modifier) {
title = stringResourceSafe(id = R.string.markets_token_details_blockchain_site),
links = state.blockchainSite,
onLinkClick = state.onLinkClick,
lastBlock = true,
)
}
}
@ -159,44 +157,38 @@ private fun SubBlockV2(
links: ImmutableList<LinksUM.Link>,
onLinkClick: (LinksUM.Link) -> Unit,
modifier: Modifier = Modifier,
lastBlock: Boolean = false,
) {
if (links.isEmpty()) return
ContainerWithDivider(
modifier = modifier,
showDivider = !lastBlock,
Column(
modifier = modifier.padding(vertical = TangemTheme.dimens2.x2),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
) {
Column(
modifier = Modifier.padding(vertical = TangemTheme.dimens2.x2),
Text(
modifier = Modifier.padding(start = 10.dp, top = TangemTheme.dimens2.x4),
text = title,
style = TangemTheme.typography2.headingSemibold20,
color = TangemTheme.colors2.text.neutral.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
FlowRow(
modifier = Modifier.padding(vertical = TangemTheme.dimens2.x2, horizontal = 3.dp),
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
) {
Text(
modifier = Modifier.padding(start = 10.dp, top = TangemTheme.dimens2.x4),
text = title,
style = TangemTheme.typography2.headingSemibold20,
color = TangemTheme.colors2.text.neutral.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
FlowRow(
modifier = Modifier.padding(vertical = TangemTheme.dimens2.x2, horizontal = 3.dp),
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
) {
links.fastForEach { link ->
SecondaryTangemButton(
onClick = { onLinkClick(link) },
text = stringReference(link.title),
iconPosition = TangemButtonIconPosition.Start,
tangemIconUM = TangemIconUM.Icon(
iconRes = link.iconRes,
tintReference = { TangemTheme.colors2.graphic.neutral.primary },
),
size = TangemButtonSize.X9,
shape = TangemButtonShape.Rounded,
)
}
links.fastForEach { link ->
SecondaryTangemButton(
onClick = { onLinkClick(link) },
text = stringReference(link.title),
iconPosition = TangemButtonIconPosition.Start,
tangemIconUM = TangemIconUM.Icon(
iconRes = link.iconRes,
tintReference = { TangemTheme.colors2.graphic.neutral.primary },
),
size = TangemButtonSize.X9,
shape = TangemButtonShape.Rounded,
)
}
}
}
@ -237,7 +229,7 @@ private fun LinksBlockPlaceholderV2(modifier: Modifier = Modifier) {
Column(modifier = modifier) {
SubBlockPlaceholderV2()
SubBlockPlaceholderV2()
SubBlockPlaceholderV2(lastBlock = true)
SubBlockPlaceholderV2()
}
}
@ -269,30 +261,25 @@ private fun SubBlockPlaceholderV1(modifier: Modifier = Modifier, lastBlock: Bool
}
@Composable
private fun SubBlockPlaceholderV2(modifier: Modifier = Modifier, lastBlock: Boolean = false) {
ContainerWithDivider(
modifier = modifier,
showDivider = !lastBlock,
private fun SubBlockPlaceholderV2(modifier: Modifier = Modifier) {
Column(
modifier = modifier.padding(TangemTheme.dimens2.x2),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
) {
Column(
modifier = Modifier.padding(TangemTheme.dimens2.x2),
verticalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
TextShimmer(
modifier = Modifier.width(56.dp),
style = TangemTheme.typography2.bodySemibold16,
radius = TangemTheme.dimens2.x25,
)
Row(
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
) {
TextShimmer(
modifier = Modifier.width(56.dp),
style = TangemTheme.typography2.bodySemibold16,
radius = TangemTheme.dimens2.x25,
)
Row(
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x2),
) {
repeat(times = 3) {
ChipShimmer(
modifier = Modifier
.height(36.dp)
.weight(1f),
)
}
repeat(times = 3) {
ChipShimmer(
modifier = Modifier
.height(36.dp)
.weight(1f),
)
}
}
}

View file

@ -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,

View file

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

View file

@ -10,6 +10,7 @@ import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.Blockchain
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.smartcontract.SmartContractCallData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchain.yieldsupply.providers.YieldModuleUpgradeUnavailableException
import com.tangem.blockchain.yieldsupply.providers.YieldModuleVersionIndeterminateException
@ -32,6 +33,7 @@ import com.tangem.domain.yield.supply.usecase.WrapYieldSwapCallDataWithUpgradeUs
import com.tangem.feature.swap.domain.models.domain.ExpressTransactionModel
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
import com.tangem.lib.crypto.BlockchainUtils.SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES
import com.tangem.lib.crypto.BlockchainUtils.isBitcoin
import com.tangem.lib.crypto.BlockchainUtils.isSolana
import com.tangem.utils.logging.TangemLogger
import java.math.BigDecimal
@ -55,7 +57,7 @@ import java.math.BigInteger
*
* @see DexFeeResult for the returned shape.
*/
@Suppress("LongParameterList")
@Suppress("LongParameterList", "LargeClass", "TooManyFunctions")
class DexSwapFeeCalculator(
private val getFeeUseCase: GetFeeUseCase,
private val getEthSpecificFeeUseCase: GetEthSpecificFeeUseCase,
@ -79,56 +81,117 @@ class DexSwapFeeCalculator(
?.movePointLeft(nativeCoinDecimals)
?: BigDecimal.ZERO
if (isSolana(networkRawId)) {
val transactionBytes = Base64.decode(transaction.txData, Base64.NO_WRAP)
val formattedHash = getFormattedHash(transactionBytes)
// TODO Update after new firmware [REDACTED_JIRA]
if (formattedHash.size > SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES &&
fromSwapCurrencyStatus.userWallet is UserWallet.Cold
) {
raise(GetFeeError.BlockchainErrors.TooLargeSolanaTransactionError)
}
val solanaFee = getFeeDataForSolanaDexSwap(
when {
isBitcoin(networkRawId) -> calculateBitcoinFee(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
transactionBytes = transactionBytes,
)
DexFeeResult(
transactionFee = TransactionFeeResult.Loaded(solanaFee),
transaction = transaction,
nativeCoinDecimals = nativeCoinDecimals,
otherNativeFee = otherNativeFee,
gas = null,
)
} else {
val rawFeeResult = getFeeDataForDexSwap(
isSolana(networkRawId) -> calculateSolanaFee(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
transaction = transaction,
otherNativeFee = otherNativeFee,
)
else -> calculateEvmFee(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
transaction = transaction,
selectedToken = selectedToken,
permissionState = permissionState,
).bind()
// Apply the 12% bump on EVM, mirroring SwapInteractorImpl.loadFeeForDex.
// The original cast `(fee as TransactionFeeResult.Loaded)` only holds when
// selectedToken == null; we defensively support LoadedExtended too so the calculator
// also handles the gasless-token DEX branch (currently unreachable from production
// callers, kept for symmetry with the CEX calculator).
val patched: TransactionFeeResult = when (rawFeeResult) {
is TransactionFeeResult.Loaded ->
TransactionFeeResult.Loaded(patchEthGasLimitForSwap(rawFeeResult.fee))
is TransactionFeeResult.LoadedExtended ->
TransactionFeeResult.LoadedExtended(
rawFeeResult.fee.copy(
transactionFee = patchEthGasLimitForSwap(rawFeeResult.fee.transactionFee),
),
)
}
DexFeeResult(
transactionFee = patched,
otherNativeFee = otherNativeFee,
gas = transaction.gas,
)
}
}
/**
* Bitcoin swaps arrive as a ready-made PSBT whose miner fee is implied by
* sum(inputs) - sum(outputs); a single provider-fixed tier with no gas bump.
*/
private suspend fun Raise<GetFeeError>.calculateBitcoinFee(
fromSwapCurrencyStatus: SwapCurrencyStatus,
transaction: ExpressTransactionModel.DEX,
nativeCoinDecimals: Int,
otherNativeFee: BigDecimal,
): DexFeeResult {
val network = fromSwapCurrencyStatus.currency.network
val feeSatoshi = walletManagersFacade.getPsbtFee(
userWalletId = fromSwapCurrencyStatus.userWalletId,
network = network,
psbtBase64 = transaction.txData,
) ?: raise(GetFeeError.UnknownError)
val feeAmount = Amount(
currencySymbol = network.currencySymbol,
value = feeSatoshi.movePointLeft(nativeCoinDecimals),
decimals = nativeCoinDecimals,
)
return DexFeeResult(
transactionFee = TransactionFeeResult.Loaded(TransactionFee.Single(normal = Fee.Common(feeAmount))),
otherNativeFee = otherNativeFee,
gas = null,
)
}
private suspend fun Raise<GetFeeError>.calculateSolanaFee(
fromSwapCurrencyStatus: SwapCurrencyStatus,
transaction: ExpressTransactionModel.DEX,
otherNativeFee: BigDecimal,
): DexFeeResult {
val transactionBytes = Base64.decode(transaction.txData, Base64.NO_WRAP)
val formattedHash = getFormattedHash(transactionBytes)
// TODO Update after new firmware [REDACTED_JIRA]
if (formattedHash.size > SOLANA_TRANSACTION_SIZE_THRESHOLD_BYTES &&
fromSwapCurrencyStatus.userWallet is UserWallet.Cold
) {
raise(GetFeeError.BlockchainErrors.TooLargeSolanaTransactionError)
}
val solanaFee = getFeeDataForSolanaDexSwap(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
transactionBytes = transactionBytes,
)
return DexFeeResult(
transactionFee = TransactionFeeResult.Loaded(solanaFee),
otherNativeFee = otherNativeFee,
gas = null,
)
}
private suspend fun Raise<GetFeeError>.calculateEvmFee(
fromSwapCurrencyStatus: SwapCurrencyStatus,
transaction: ExpressTransactionModel.DEX,
selectedToken: CryptoCurrencyStatus?,
permissionState: PermissionDataState,
otherNativeFee: BigDecimal,
): DexFeeResult {
val rawFeeResult = getFeeDataForDexSwap(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
transaction = transaction,
selectedToken = selectedToken,
permissionState = permissionState,
).bind()
// Apply the 12% bump on EVM, mirroring SwapInteractorImpl.loadFeeForDex.
// The original cast `(fee as TransactionFeeResult.Loaded)` only holds when
// selectedToken == null; we defensively support LoadedExtended too so the calculator
// also handles the gasless-token DEX branch (currently unreachable from production
// callers, kept for symmetry with the CEX calculator).
val patched: TransactionFeeResult = when (rawFeeResult) {
is TransactionFeeResult.Loaded ->
TransactionFeeResult.Loaded(patchEthGasLimitForSwap(rawFeeResult.fee))
is TransactionFeeResult.LoadedExtended ->
TransactionFeeResult.LoadedExtended(
rawFeeResult.fee.copy(
transactionFee = patchEthGasLimitForSwap(rawFeeResult.fee.transactionFee),
),
)
}
return DexFeeResult(
transactionFee = patched,
otherNativeFee = otherNativeFee,
gas = transaction.gas,
)
}
/**
* Yield-mode DEX fee path: routes the swap through the user's yield module proxy.
*

View file

@ -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,

View file

@ -40,7 +40,6 @@ import com.tangem.feature.swap.domain.fee.TransactionFeeResult
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.ui.SwapState
import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo
import com.tangem.features.send.api.subcomponents.feeSelector.utils.FeeCalculationUtils.checkAndCalculateSubtractedAmount
import com.tangem.features.send.api.subcomponents.feeSelector.utils.FeeCalculationUtils.checkFeeCoverage
import com.tangem.features.swap.SwapFeatureToggles
import com.tangem.utils.extensions.orZero
@ -107,7 +106,7 @@ class SwapTransferInteractorImpl @Inject constructor(
fee = warningsFee,
feeCurrencyBalanceAfterTransaction = null,
)
val (isFeeCoverage, sendingAmount) = getCoverageState(
val coverageState = getCoverageState(
fromTokenInfo = fromTokenInfo,
userWallet = userWallet,
fee = fee,
@ -130,8 +129,9 @@ class SwapTransferInteractorImpl @Inject constructor(
appCurrency = appCurrency,
isBalanceHidden = isBalanceHidden,
isAccountsMode = isAccountsMode,
isFeeCoverage = isFeeCoverage,
sendingAmount = sendingAmount,
isFeeCoverage = coverageState.isFeeCoverage,
sendingAmount = coverageState.sendingAmount,
isSendingAmountLoading = coverageState.isSendingAmountLoading,
currencyCheck = currencyCheck,
)
}
@ -155,7 +155,7 @@ class SwapTransferInteractorImpl @Inject constructor(
userWallet: UserWallet,
fee: Fee?,
currencyCheck: CryptoCurrencyCheck,
): Pair<Boolean, BigDecimal> {
): CoverageState {
val swapCurrencyStatus = fromTokenInfo.swapCurrencyStatus
val isAmountSubtractAvailable = isAmountSubtractAvailable(
userWalletId = userWallet.walletId,
@ -173,16 +173,29 @@ class SwapTransferInteractorImpl @Inject constructor(
feeValue = feeValue,
reduceAmountBy = reduceAmountBy,
)
val sendingAmount = checkAndCalculateSubtractedAmount(
isAmountSubtractAvailable = isAmountSubtractAvailable,
cryptoCurrencyStatus = fromTokenInfo.swapCurrencyStatus.status,
amountValue = amount.value,
feeValue = feeValue,
reduceAmountBy = reduceAmountBy,
// When fee coverage applies, the entered amount can't be sent together with the fee, so the
// sent (and therefore received) amount is the entered amount reduced by the fee. This tracks the
// input: as the user edits the amount, the received amount changes with it.
val sendingAmount = if (isFeeCoverage) {
(amount.value - feeValue).coerceAtLeast(BigDecimal.ZERO)
} else {
amount.value
}
// While subtraction is possible but the fee has not loaded yet, the final received amount
// (entered - fee) is unknown, so it must be shown as loading instead of the un-subtracted value.
return CoverageState(
isFeeCoverage = isFeeCoverage,
sendingAmount = sendingAmount,
isSendingAmountLoading = fee == null && isAmountSubtractAvailable,
)
return isFeeCoverage to sendingAmount
}
private data class CoverageState(
val isFeeCoverage: Boolean,
val sendingAmount: BigDecimal,
val isSendingAmountLoading: Boolean,
)
private suspend fun isAmountSubtractAvailable(
userWalletId: UserWalletId,
currency: CryptoCurrency,

View file

@ -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,

View file

@ -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])
//

View file

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

View file

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

View file

@ -153,10 +153,15 @@ internal class StateBuilder(
isHoldToConfirm = fromSwapCurrencyStatus?.userWallet?.isHotWallet == true,
onClick = { },
),
shouldShowMaxAmount = shouldShowMaxAmount(fromSwapCurrencyStatus?.currency, toSwapCurrencyStatus?.currency),
predefinedButtons = createPredefinedButtons(
shouldShowMaxAmount = shouldShowMaxAmount(
fromSwapCurrencyStatus?.currency,
toSwapCurrencyStatus?.currency,
emptyAmountState.isTransferMode,
),
predefinedButtons = createPredefinedButtons(
fromToken = fromSwapCurrencyStatus?.currency,
toCurrency = toSwapCurrencyStatus?.currency,
isTransferMode = emptyAmountState.isTransferMode,
),
changeCardsButtonState = ChangeCardsButtonState.ENABLED,
providerState = ProviderState.Empty(),
@ -244,6 +249,11 @@ internal class StateBuilder(
toSwapCurrencyStatus: SwapCurrencyStatus?,
shouldResetAmount: Boolean,
): SwapStateHolder {
val shouldShowMaxAmount = shouldShowMaxAmount(
fromSwapCurrencyStatus?.currency,
toSwapCurrencyStatus?.currency,
emptyAmountState.isTransferMode,
)
return uiStateHolder.copy(
sendCardData = uiStateHolder.sendCardData.updateCurrencyStatus(
swapCurrencyStatus = fromSwapCurrencyStatus,
@ -267,10 +277,11 @@ internal class StateBuilder(
isHoldToConfirm = fromSwapCurrencyStatus?.userWallet?.isHotWallet == true,
onClick = { },
),
shouldShowMaxAmount = shouldShowMaxAmount(fromSwapCurrencyStatus?.currency, toSwapCurrencyStatus?.currency),
shouldShowMaxAmount = shouldShowMaxAmount,
predefinedButtons = createPredefinedButtons(
fromSwapCurrencyStatus?.currency,
toSwapCurrencyStatus?.currency,
fromToken = fromSwapCurrencyStatus?.currency,
toCurrency = toSwapCurrencyStatus?.currency,
isTransferMode = emptyAmountState.isTransferMode,
),
changeCardsButtonState = ChangeCardsButtonState.ENABLED,
providerState = ProviderState.Empty(),
@ -683,7 +694,12 @@ internal class StateBuilder(
)
}
private fun shouldShowMaxAmount(fromToken: CryptoCurrency?, toCurrency: CryptoCurrency?): Boolean {
private fun shouldShowMaxAmount(
fromToken: CryptoCurrency?,
toCurrency: CryptoCurrency?,
isTransferMode: Boolean = false,
): Boolean {
if (isTransferMode) return true
return !(fromToken is CryptoCurrency.Coin && fromToken.network.id == toCurrency?.network?.id)
}
@ -696,9 +712,10 @@ internal class StateBuilder(
private fun createPredefinedButtons(
fromToken: CryptoCurrency?,
toCurrency: CryptoCurrency?,
isTransferMode: Boolean = false,
): ImmutableList<PredefinedPercentButtonUM> {
if (!swapFeatureToggles.isSwapPredefinedButtonsEnabled) return persistentListOf()
val shouldShowMaxAmount = shouldShowMaxAmount(fromToken, toCurrency)
val shouldShowMaxAmount = shouldShowMaxAmount(fromToken, toCurrency, isTransferMode)
return PredefinedPercentAmount.entries
.filter { it != PredefinedPercentAmount.MAX || shouldShowMaxAmount }
.map { percent ->

View file

@ -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(

View file

@ -7,6 +7,7 @@ import com.tangem.domain.express.models.ExpressError
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.swap.models.SwapCurrencyStatus
@ -270,6 +271,50 @@ internal class StateBuilderInitialStateTest {
assertThat(result.notifications).isEmpty()
}
@Test
fun `GIVEN same coin on different wallets in transfer mode WHEN called THEN shouldShowMaxAmount is true`() {
// Arrange — same coin (shared network id) moved between two wallets => transfer mode
val sharedNetwork = buildSharedNetwork()
val walletA: UserWallet.Cold = mockk(relaxed = true) { every { walletId } returns UserWalletId("aabb") }
val walletB: UserWallet.Cold = mockk(relaxed = true) { every { walletId } returns UserWalletId("ccdd") }
val fromStatus = buildCoinSwapCurrencyStatus(walletA, sharedNetwork)
val toStatus = buildCoinSwapCurrencyStatus(walletB, sharedNetwork)
val transferState = SwapState.EmptyAmountState(
zeroAmountEquivalent = com.tangem.core.ui.extensions.stringReference("$0.00"),
isTransferMode = true,
)
// Act
val result = sut.createInitialReadyState(
uiStateHolder = baseState,
emptyAmountState = transferState,
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
)
// Assert
assertThat(result.shouldShowMaxAmount).isTrue()
}
@Test
fun `GIVEN same-network coin swap not in transfer mode WHEN called THEN shouldShowMaxAmount is false`() {
// Arrange — same network coin pair, regular swap => MAX hidden to keep balance for the fee
val sharedNetwork = buildSharedNetwork()
val fromStatus = buildCoinSwapCurrencyStatus(userWallet, sharedNetwork)
val toStatus = buildCoinSwapCurrencyStatus(userWallet, sharedNetwork)
// Act
val result = sut.createInitialReadyState(
uiStateHolder = baseState,
emptyAmountState = emptyAmountState,
fromSwapCurrencyStatus = fromStatus,
toSwapCurrencyStatus = toStatus,
)
// Assert
assertThat(result.shouldShowMaxAmount).isFalse()
}
}
// endregion
@ -574,4 +619,43 @@ internal fun buildSwapCurrencyStatus(
status = cryptoCurrencyStatus,
account = account,
)
}
/**
* A single [Network] mock whose [Network.id] resolves to one shared instance, so two currencies built from
* it compare equal on `network.id` the condition that gates [StateBuilder.shouldShowMaxAmount].
*/
internal fun buildSharedNetwork(): Network = mockk(relaxed = true) {
every { id } returns mockk(relaxed = true)
every { name } returns "Ethereum"
every { currencySymbol } returns "ETH"
every { rawId } returns "ethereum"
}
/**
* Builds a [SwapCurrencyStatus] backed by a [CryptoCurrency.Coin] on the given [network]. Pass the same
* [network] instance to two calls to model the "same coin on different wallets" (transfer) case.
*/
internal fun buildCoinSwapCurrencyStatus(
userWallet: UserWallet,
network: Network,
): SwapCurrencyStatus {
val account = Account.CryptoPortfolio.createMainAccount(userWallet.walletId)
val currency = mockk<CryptoCurrency.Coin>(relaxed = true) {
every { symbol } returns "ETH"
every { decimals } returns 18
every { name } returns "Ethereum"
every { this@mockk.network } returns network
}
val statusValue: CryptoCurrencyStatus.Value = mockk(relaxed = true) {
every { amount } returns java.math.BigDecimal("1.0")
every { fiatRate } returns java.math.BigDecimal("2000.00")
every { fiatAmount } returns java.math.BigDecimal("2000.00")
}
val cryptoCurrencyStatus = CryptoCurrencyStatus(currency = currency, value = statusValue)
return SwapCurrencyStatus(
userWallet = userWallet,
status = cryptoCurrencyStatus,
account = account,
)
}

View file

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

View file

@ -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,

View file

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

View file

@ -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,

View file

@ -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,

View file

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

View file

@ -1,77 +0,0 @@
package com.tangem.feature.tokendetails.domain
import com.tangem.domain.card.IsWalletBackupProblematicUseCase
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.usecase.HasSingleWalletSignedHashesUseCase
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import javax.inject.Inject
/**
* Produces card/wallet-level warnings for the Token Details screen.
*
* These banners mirror the ones the main screen shows via `GetWalletNotificationsFactory`, but they only apply to
* a single-currency cold wallet (a multi-currency wallet keeps them on the main screen only).
*/
internal class GetWalletCardWarningsUseCase @Inject constructor(
private val isDemoCardUseCase: IsDemoCardUseCase,
private val hasSingleWalletSignedHashesUseCase: HasSingleWalletSignedHashesUseCase,
private val isWalletBackupProblematicUseCase: IsWalletBackupProblematicUseCase,
private val dispatchers: CoroutineDispatcherProvider,
) {
operator fun invoke(userWallet: UserWallet, network: Network): Flow<Set<WalletCardWarning>> {
if (userWallet !is UserWallet.Cold || userWallet.isMultiCurrency) {
return flowOf(emptySet())
}
return hasSingleWalletSignedHashesUseCase(userWallet, network)
.map { hasIncorrectSignedHashes ->
buildWarnings(
userWallet = userWallet,
hasIncorrectSignedHashes = hasIncorrectSignedHashes,
)
}
.flowOn(dispatchers.io)
}
private fun buildWarnings(userWallet: UserWallet.Cold, hasIncorrectSignedHashes: Boolean): Set<WalletCardWarning> {
val cardTypesResolver = userWallet.cardTypesResolver
return buildSet {
if (isWalletBackupProblematicUseCase(userWallet)) {
add(WalletCardWarning.BackupError)
}
if (!cardTypesResolver.isReleaseFirmwareType()) {
add(WalletCardWarning.DevCard)
}
if (cardTypesResolver.isReleaseFirmwareType() && cardTypesResolver.isAttestationFailed()) {
add(WalletCardWarning.FailedCardValidation)
}
cardTypesResolver.getRemainingSignatures()?.let { remainingSignatures ->
if (remainingSignatures <= MAX_REMAINING_SIGNATURES_COUNT) {
add(WalletCardWarning.LowSignatures(count = remainingSignatures))
}
}
if (isDemoCardUseCase(cardId = cardTypesResolver.getCardId())) {
add(WalletCardWarning.DemoCard)
}
if (cardTypesResolver.isTestCard()) {
add(WalletCardWarning.TestnetCard)
}
if (hasIncorrectSignedHashes) {
add(WalletCardWarning.NumberOfSignedHashesIncorrect)
}
}
}
private companion object {
const val MAX_REMAINING_SIGNATURES_COUNT = 10
}
}

View file

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

View file

@ -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 */ }

View file

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

View file

@ -6,6 +6,7 @@ import com.tangem.core.ui.extensions.themedColor
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.features.tokendetails.impl.R
import com.tangem.utils.transformer.Transformer
@ -15,9 +16,9 @@ import kotlinx.collections.immutable.toImmutableList
internal class UpdateTopBarMenuTransformer(
private val userWallet: UserWallet,
private val hasDerivations: Boolean,
private val isXPubSupported: Boolean,
private val onGenerateExtendedKey: () -> Unit,
private val onHideClick: () -> Unit,
private val isXpubSupported: Boolean,
private val isDynamicAddressesAvailable: Boolean,
private val clickIntents: TokenDetailsClickIntents,
) : Transformer<TokenDetailsUM> {
override fun transform(prevState: TokenDetailsUM): TokenDetailsUM = prevState.copy(
@ -30,12 +31,21 @@ internal class UpdateTopBarMenuTransformer(
persistentListOf()
} else {
buildList {
if (isXPubSupported && hasDerivations) {
if (isDynamicAddressesAvailable) {
add(
TangemDropdownMenuItem(
title = resourceReference(R.string.dynamic_addresses),
textColor = themedColor { TangemTheme.colors.text.primary1 },
onClick = clickIntents::onDynamicAddressesClick,
),
)
}
if (isXpubSupported && hasDerivations) {
add(
TangemDropdownMenuItem(
title = resourceReference(R.string.token_details_generate_xpub),
textColor = themedColor { TangemTheme.colors.text.primary1 },
onClick = onGenerateExtendedKey,
onClick = clickIntents::onGenerateExtendedKey,
),
)
}
@ -43,7 +53,7 @@ internal class UpdateTopBarMenuTransformer(
TangemDropdownMenuItem(
title = resourceReference(R.string.token_details_hide_token),
textColor = themedColor { TangemTheme.colors.text.warning },
onClick = onHideClick,
onClick = clickIntents::onHideClick,
),
)
}.toImmutableList()

View file

@ -1,131 +0,0 @@
package com.tangem.feature.tokendetails.presentation.tokendetails.state.transformer
import com.tangem.core.ui.R
import com.tangem.core.ui.ds.button.TangemButtonType
import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.message.TangemMessageButtonUM
import com.tangem.core.ui.ds.message.TangemMessageEffect
import com.tangem.core.ui.ds.message.TangemMessageUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.tokendetails.domain.WalletCardWarning
import com.tangem.feature.tokendetails.presentation.tokendetails.model.TokenDetailsClickIntents
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsUM
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import com.tangem.core.res.R as CoreResR
/**
* Maps card/wallet-level warnings (produced by `GetWalletCardWarningsUseCase` for a single-currency wallet) into
* Token Details notifications and prepends them to the existing currency-level notifications.
*/
internal class UpdateWalletCardWarningsTransformer(
private val walletCardWarnings: Set<WalletCardWarning>,
private val clickIntents: TokenDetailsClickIntents,
) : Transformer<TokenDetailsUM> {
override fun transform(prevState: TokenDetailsUM): TokenDetailsUM {
val others = prevState.notifications.filterNot { it.id in WALLET_CARD_WARNING_IDS }
val messages = walletCardWarnings.map(::mapWarning)
return prevState.copy(
notifications = (messages + others).toImmutableList(),
)
}
@Suppress("LongMethod")
private fun mapWarning(warning: WalletCardWarning): TangemMessageUM {
return when (warning) {
WalletCardWarning.BackupError -> TangemMessageUM(
id = ID_BACKUP_ERROR,
title = resourceReference(CoreResR.string.warning_backup_errors_title),
subtitle = resourceReference(CoreResR.string.warning_backup_errors_message),
messageEffect = TangemMessageEffect.Warning,
iconUM = attentionIcon(),
buttonsUM = persistentListOf(
TangemMessageButtonUM(
text = resourceReference(CoreResR.string.common_contact_support),
type = TangemButtonType.Secondary,
onClick = clickIntents::onSupportClick,
),
),
)
WalletCardWarning.DevCard -> TangemMessageUM(
id = ID_DEV_CARD,
title = resourceReference(CoreResR.string.warning_developer_card_title),
subtitle = resourceReference(CoreResR.string.warning_developer_card_message),
messageEffect = TangemMessageEffect.None,
iconUM = attentionIcon(),
)
WalletCardWarning.FailedCardValidation -> TangemMessageUM(
id = ID_FAILED_CARD_VALIDATION,
title = resourceReference(CoreResR.string.warning_failed_to_verify_card_title),
subtitle = resourceReference(CoreResR.string.warning_failed_to_verify_card_message),
messageEffect = TangemMessageEffect.Warning,
iconUM = attentionIcon(),
)
WalletCardWarning.TestnetCard -> TangemMessageUM(
id = ID_TESTNET_CARD,
title = resourceReference(CoreResR.string.warning_testnet_card_title),
subtitle = resourceReference(CoreResR.string.warning_testnet_card_message),
messageEffect = TangemMessageEffect.None,
iconUM = attentionIcon(),
)
is WalletCardWarning.LowSignatures -> TangemMessageUM(
id = ID_LOW_SIGNATURES,
title = resourceReference(CoreResR.string.warning_low_signatures_title),
subtitle = resourceReference(
id = CoreResR.string.warning_low_signatures_message,
formatArgs = wrappedList(warning.count.toString()),
),
messageEffect = TangemMessageEffect.None,
iconUM = attentionIcon(),
)
WalletCardWarning.NumberOfSignedHashesIncorrect -> TangemMessageUM(
id = ID_NUMBER_OF_SIGNED_HASHES_INCORRECT,
title = resourceReference(CoreResR.string.warning_number_of_signed_hashes_incorrect_title),
subtitle = resourceReference(CoreResR.string.warning_number_of_signed_hashes_incorrect_message),
messageEffect = TangemMessageEffect.Warning,
iconUM = TangemIconUM.Icon(
iconRes = R.drawable.img_knight_shield_32,
tintReference = { TangemTheme.colors2.graphic.neutral.primary },
),
onCloseClick = clickIntents::onCloseSignedHashesWarning,
)
WalletCardWarning.DemoCard -> TangemMessageUM(
id = ID_DEMO_CARD,
title = resourceReference(CoreResR.string.warning_demo_mode_title),
subtitle = resourceReference(CoreResR.string.warning_demo_mode_message),
messageEffect = TangemMessageEffect.None,
iconUM = attentionIcon(),
)
}
}
private fun attentionIcon(): TangemIconUM.Icon = TangemIconUM.Icon(
iconRes = R.drawable.ic_attention_default_24,
tintReference = { TangemTheme.colors2.graphic.neutral.primary },
)
private companion object {
const val ID_BACKUP_ERROR = "BackupErrorNotification"
const val ID_DEV_CARD = "DevCardNotification"
const val ID_FAILED_CARD_VALIDATION = "FailedCardValidationNotification"
const val ID_TESTNET_CARD = "TestnetCardNotification"
const val ID_LOW_SIGNATURES = "LowSignaturesNotification"
const val ID_NUMBER_OF_SIGNED_HASHES_INCORRECT = "NumberOfSignedHashesIncorrectNotification"
const val ID_DEMO_CARD = "DemoCardNotification"
val WALLET_CARD_WARNING_IDS = setOf(
ID_BACKUP_ERROR,
ID_DEV_CARD,
ID_FAILED_CARD_VALIDATION,
ID_TESTNET_CARD,
ID_LOW_SIGNATURES,
ID_NUMBER_OF_SIGNED_HASHES_INCORRECT,
ID_DEMO_CARD,
)
}
}

View file

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

View file

@ -1,169 +0,0 @@
package com.tangem.feature.tokendetails.domain
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.card.CardTypesResolver
import com.tangem.domain.card.IsWalletBackupProblematicUseCase
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.usecase.HasSingleWalletSignedHashesUseCase
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
internal class GetWalletCardWarningsUseCaseTest {
private val isDemoCardUseCase: IsDemoCardUseCase = mockk()
private val hasSingleWalletSignedHashesUseCase: HasSingleWalletSignedHashesUseCase = mockk()
private val isWalletBackupProblematicUseCase: IsWalletBackupProblematicUseCase = mockk()
private val network: Network = mockk(relaxed = true)
private val useCase = GetWalletCardWarningsUseCase(
isDemoCardUseCase = isDemoCardUseCase,
hasSingleWalletSignedHashesUseCase = hasSingleWalletSignedHashesUseCase,
isWalletBackupProblematicUseCase = isWalletBackupProblematicUseCase,
dispatchers = TestingCoroutineDispatcherProvider(),
)
@BeforeEach
fun setup() {
clearMocks(
isDemoCardUseCase,
hasSingleWalletSignedHashesUseCase,
isWalletBackupProblematicUseCase,
)
mockkStatic(UserWallet.Cold::cardTypesResolver)
// Default: nothing flagged
every { isDemoCardUseCase(any()) } returns false
every { hasSingleWalletSignedHashesUseCase(any(), any()) } returns flowOf(false)
every { isWalletBackupProblematicUseCase(any()) } returns false
}
@AfterEach
fun tearDown() {
unmockkStatic(UserWallet.Cold::cardTypesResolver)
}
@Test
fun `GIVEN multi-currency cold wallet WHEN invoke THEN empty set`() = runTest {
// Arrange
val wallet = coldWallet(isMultiCurrency = true)
// Act
val result = useCase(userWallet = wallet, network = network).first()
// Assert
assertThat(result).isEmpty()
}
@Test
fun `GIVEN hot wallet WHEN invoke THEN empty set`() = runTest {
// Arrange
val wallet = mockk<UserWallet.Hot>(relaxed = true)
// Act
val result = useCase(userWallet = wallet, network = network).first()
// Assert
assertThat(result).isEmpty()
}
@Test
fun `GIVEN single-currency cold wallet with clean card WHEN invoke THEN empty set`() = runTest {
// Arrange
val wallet = coldWallet(isMultiCurrency = false, resolver = cleanResolver())
// Act
val result = useCase(userWallet = wallet, network = network).first()
// Assert
assertThat(result).isEmpty()
}
@Test
fun `GIVEN single-currency dev card WHEN invoke THEN only dev card warning`() = runTest {
// Arrange
val resolver = cleanResolver().also { every { it.isReleaseFirmwareType() } returns false }
val wallet = coldWallet(isMultiCurrency = false, resolver = resolver)
// Act
val result = useCase(userWallet = wallet, network = network).first()
// Assert
assertThat(result).containsExactly(WalletCardWarning.DevCard)
}
@Test
fun `GIVEN single-currency release card with every condition WHEN invoke THEN full warning set`() = runTest {
// Arrange
val resolver = cleanResolver().also {
every { it.isReleaseFirmwareType() } returns true
every { it.isAttestationFailed() } returns true
every { it.getRemainingSignatures() } returns LOW_SIGNATURES
every { it.isTestCard() } returns true
}
val wallet = coldWallet(isMultiCurrency = false, resolver = resolver)
every { isWalletBackupProblematicUseCase(any()) } returns true
every { isDemoCardUseCase(any()) } returns true
every { hasSingleWalletSignedHashesUseCase(any(), any()) } returns flowOf(true)
// Act
val result = useCase(userWallet = wallet, network = network).first()
// Assert
assertThat(result).containsExactly(
WalletCardWarning.BackupError,
WalletCardWarning.FailedCardValidation,
WalletCardWarning.TestnetCard,
WalletCardWarning.LowSignatures(count = LOW_SIGNATURES),
WalletCardWarning.DemoCard,
WalletCardWarning.NumberOfSignedHashesIncorrect,
)
}
@Test
fun `GIVEN single-currency cold wallet with backup problem WHEN invoke THEN backup error warning`() = runTest {
// Arrange
val wallet = coldWallet(isMultiCurrency = false, resolver = cleanResolver())
every { isWalletBackupProblematicUseCase(any()) } returns true
// Act
val result = useCase(userWallet = wallet, network = network).first()
// Assert
assertThat(result).containsExactly(WalletCardWarning.BackupError)
}
private fun coldWallet(
isMultiCurrency: Boolean,
resolver: CardTypesResolver = cleanResolver(),
): UserWallet.Cold {
return mockk<UserWallet.Cold>(relaxed = true) {
every { this@mockk.isMultiCurrency } returns isMultiCurrency
every { cardTypesResolver } returns resolver
}
}
private fun cleanResolver(): CardTypesResolver = mockk {
every { isReleaseFirmwareType() } returns true
every { isAttestationFailed() } returns false
every { getRemainingSignatures() } returns null
every { isTestCard() } returns false
every { getCardId() } returns "card"
}
private companion object {
const val LOW_SIGNATURES = 5
}
}

View file

@ -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(

View file

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

View file

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

View file

@ -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 {

View file

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

View file

@ -20,7 +20,6 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.wallets.usecase.HasSingleWalletSignedHashesUseCase
import com.tangem.domain.wallets.usecase.IsNeedToBackupUseCase
import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
import com.tangem.feature.wallet.impl.R
@ -255,10 +254,6 @@ internal class GetWalletNotificationsFactory @Inject constructor(
addCloreMigrationNotification(userWallet, flattenCurrencies, clickIntents)
if (!userWallet.isMultiCurrency) {
addNoAccountWarning(cryptoCurrencyStatus = flattenCurrencies.firstOrNull())
}
addIf(
element = WalletNotificationUM.NumberOfSignedHashesIncorrect(
onCloseClick = clickIntents::onCloseAlreadySignedHashesWarningClick,
@ -296,19 +291,6 @@ internal class GetWalletNotificationsFactory @Inject constructor(
notification?.let(::add)
}
private fun MutableList<WalletNotificationUM>.addNoAccountWarning(cryptoCurrencyStatus: CryptoCurrencyStatus?) {
val noAccountStatus = cryptoCurrencyStatus?.value as? CryptoCurrencyStatus.NoAccount
if (noAccountStatus != null) {
add(
element = WalletNotificationUM.NoAccount(
network = cryptoCurrencyStatus.currency.name,
amount = noAccountStatus.amountToCreateAccount.toString(),
symbol = cryptoCurrencyStatus.currency.symbol,
),
)
}
}
private fun MutableList<WalletNotificationUM>.addCloreMigrationNotification(
userWallet: UserWallet,
flattenCurrencies: List<CryptoCurrencyStatus>,

View file

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

View file

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

View file

@ -3,6 +3,7 @@ package com.tangem.feature.wallet.presentation.wallet.subscribers
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
import com.tangem.domain.pay.repository.TangemPayWithdrawRepository
import com.tangem.domain.pay.usecase.RestoreActiveIssueOrdersUseCase
import com.tangem.feature.wallet.presentation.wallet.analytics.utils.WalletTangemPayAnalyticsEventSender
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
@ -14,6 +15,7 @@ import kotlinx.coroutines.launch
internal class TangemPayMainSubscriber @AssistedInject constructor(
@Assisted private val userWallet: UserWallet,
private val tangemPayWithdrawRepository: TangemPayWithdrawRepository,
private val restoreActiveIssueOrdersUseCase: RestoreActiveIssueOrdersUseCase,
private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier,
private val analytics: WalletTangemPayAnalyticsEventSender,
) : WalletSubscriber() {
@ -23,6 +25,9 @@ internal class TangemPayMainSubscriber @AssistedInject constructor(
// TODO: Doston move this logic to proper place(e.g. WalletBalanceFetcher)
tangemPayWithdrawRepository.pollWithdrawOrdersIfNeeds(userWallet)
}
coroutineScope.launch {
restoreActiveIssueOrdersUseCase(userWallet.walletId)
}
subscribeToStatus(coroutineScope)
return emptyFlow<Any>()
}

View file

@ -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 ^