Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-06 17:16:32 +04:00
commit 19df6fefae
194 changed files with 5193 additions and 1945 deletions

View file

@ -113,6 +113,7 @@ dependencies {
implementation(projects.domain.account) implementation(projects.domain.account)
implementation(projects.domain.account.status) implementation(projects.domain.account.status)
implementation(projects.domain.addressBook) implementation(projects.domain.addressBook)
implementation(projects.domain.appsflyer)
implementation(projects.domain.models) implementation(projects.domain.models)
implementation(projects.domain.core) implementation(projects.domain.core)
api(projects.domain.common) api(projects.domain.common)

View file

@ -0,0 +1,98 @@
package com.tangem.scenarios
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_VERY_LONG
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.isDisplayedSafely
import com.tangem.screens.*
import com.tangem.tap.domain.sdk.mocks.MockContent
import com.tangem.tap.domain.sdk.mocks.MockProvider
import io.qameta.allure.kotlin.Allure.step
/** 'Add Wallet' scans a card immediately (no type chooser), so [mockContent] must be set before the click. */
fun BaseTestCase.addNewCardWallet(mockContent: MockContent) {
step("Click 'More' button on TopBar") {
onMainScreenTopBar { moreButton.clickWithAssertion() }
}
MockProvider.setMocks(mockContent)
step("Click on 'Add Wallet' button (scans a new hardware wallet)") {
onDetailsScreen { addWalletButton.clickWithAssertion() }
}
// Gate on the top-bar More button, not the container — the bottom Markets sheet can leave the container un-"displayed".
step("Assert 'Main' screen is displayed with the new wallet") {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_VERY_LONG) {
runCatching { onMainScreenTopBar { moreButton.assertIsDisplayed() } }.isSuccess
}
}
// The added card is the newest pager page; its "Synchronize addresses" prompt is off-screen until swiped to.
step("Synchronize the new card wallet's addresses") {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
var shown = false
onMainScreen { shown = synchronizeAddressesButton.isDisplayedSafely() }
if (!shown) onMainScreen { swipeToAdjacentWallet(toPrevious = false) }
shown
}
// Let the pager fling settle — a click mid-animation is eaten by the button's clickableSingle debounce.
waitForIdle()
onMainScreen { synchronizeAddressesButton.clickWithAssertion() }
// The prompt clears once the card's addresses are derived (re-scan + reload over many, some failing, RPCs).
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_VERY_LONG) {
var generated = false
onMainScreen { generated = !synchronizeAddressesButton.isDisplayedSafely() }
generated
}
}
}
fun BaseTestCase.clickDisplayedTokenOnMain(tokenName: String) {
step("Click on token '$tokenName' on the visible wallet") {
onMainScreen { clickDisplayedToken(tokenName) }
}
}
fun BaseTestCase.switchToPreviousWallet() {
step("Swipe wallet card to the previous wallet") {
onMainScreen { swipeToAdjacentWallet(toPrevious = true) }
}
}
/** Picks a [token] the recipient [walletName] already holds, via the wallet tab. */
fun BaseTestCase.selectReceiveTokenOnWallet(token: String, walletName: String) {
step("Click on 'Choose token' button") {
onSwapTokenScreen { chooseTokenButton.performClick() }
}
step("Select wallet tab '$walletName'") {
onBuyTokenScreen { walletTab(walletName).performClick() }
}
step("Click on token with name '$token'") {
onBuyTokenScreen { tokenWithTitle(token).performClick() }
}
}
/** Adds [token] to [recipientWalletName] which lacks it, via market search. */
fun BaseTestCase.addMissingReceiveTokenToWallet(token: String, recipientWalletName: String) {
step("Click on 'Choose token' button") {
onSwapTokenScreen { chooseTokenButton.performClick() }
}
step("Type '$token' in search field") {
onSwapSelectTokenScreen {
searchBarBlock.performClick()
searchBarBlock.performTextInput(token)
}
}
step("Click on market token '$token'") {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching { onSwapSelectTokenScreen { marketsTokenWithName(token).performClick() } }.isSuccess
}
}
// The 'Add token' sheet pre-selects the recipient (the only wallet missing the token, since the source already holds it).
step("Assert recipient wallet '$recipientWalletName' is selected") {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching { onAddToPortfolioScreen { walletName(recipientWalletName).assertIsDisplayed() } }.isSuccess
}
}
step("Click on 'Add' button") {
onAddToPortfolioScreen { addButton.performClick() }
}
}

View file

@ -490,11 +490,12 @@ fun BaseTestCase.confirmSwapByHolding(accessCode: String? = null) {
} }
} }
/** Holds the last BASE_BUTTON to confirm a transfer; the caller asserts the outcome (transfer mode has no in-progress marker to wait on). */ // Caller asserts the outcome — transfer mode has no in-progress marker to wait on.
fun BaseTestCase.holdToConfirmTransfer() { fun BaseTestCase.holdToConfirmTransfer() {
val buttons = composeTestRule.onAllNodes(hasTestTag(BaseButtonTestTags.BUTTON)) composeTestRule.onNode(
val confirmButton = buttons[buttons.fetchSemanticsNodes().lastIndex] hasTestTag(BaseButtonTestTags.BUTTON) and
confirmButton.performTouchInput { longClick(durationMillis = HOLD_DURATION_MS) } hasText(getResourceString(CoreUiR.string.swapping_transfer_action)),
).performTouchInput { longClick(durationMillis = HOLD_DURATION_MS) }
waitForIdle() waitForIdle()
} }

View file

@ -0,0 +1,27 @@
package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import androidx.compose.ui.test.hasClickAction
import com.tangem.common.BaseTestCase
import com.tangem.wallet.R
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
class AddToPortfolioPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<AddToPortfolioPageObject>(semanticsProvider = semanticsProvider) {
fun walletName(walletName: String): KNode = child {
hasText(walletName)
useUnmergedTree = true
}
val addButton: KNode = child {
hasText(getResourceString(R.string.common_add))
hasClickAction()
}
}
internal fun BaseTestCase.onAddToPortfolioScreen(function: AddToPortfolioPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -15,6 +15,7 @@ import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onCompose
import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode
import io.github.kakaocup.kakao.common.utilities.getResourceString import io.github.kakaocup.kakao.common.utilities.getResourceString
import androidx.compose.ui.test.hasText as withText
class BuyTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : class BuyTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<BuyTokenPageObject>(semanticsProvider = semanticsProvider) { ComposeScreen<BuyTokenPageObject>(semanticsProvider = semanticsProvider) {
@ -48,6 +49,19 @@ class BuyTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
useUnmergedTree = true useUnmergedTree = true
} }
} }
fun walletTab(walletName: String): KNode = child {
hasTestTag(BuyTokenScreenTestTags.WALLET_TAB)
hasAnyDescendant(withText(walletName))
useUnmergedTree = true
}
@OptIn(ExperimentalTestApi::class)
fun tokenWithTitle(tokenTitle: String): LazyListItemNode = lazyList.childWith<LazyListItemNode> {
hasTestTag(BuyTokenScreenTestTags.LAZY_LIST_ITEM)
hasText(tokenTitle)
useUnmergedTree = true
}
} }
internal fun BaseTestCase.onBuyTokenScreen(function: BuyTokenPageObject.() -> Unit) = internal fun BaseTestCase.onBuyTokenScreen(function: BuyTokenPageObject.() -> Unit) =

View file

@ -32,6 +32,10 @@ class DetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
useUnmergedTree = true useUnmergedTree = true
} }
val addWalletButton: KNode = child {
hasTestTag(DetailsScreenTestTags.ADD_WALLET_BUTTON)
}
val buyTangemButton: KNode = child { val buyTangemButton: KNode = child {
hasTestTag(DetailsScreenTestTags.SCREEN_ITEM) hasTestTag(DetailsScreenTestTags.SCREEN_ITEM)
hasText(getResourceString(R.string.details_buy_wallet)) hasText(getResourceString(R.string.details_buy_wallet))

View file

@ -111,6 +111,28 @@ class MainScreenPageObject(private val semanticsProvider: SemanticsNodeInteracti
) )
} }
// Wallet pager keeps the adjacent page composed (beyondViewportPageCount=1), so the token is mounted on two pages — click the displayed copy.
fun clickDisplayedToken(tokenName: String) {
val matcher = withTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) and hasAnyDescendant(withText(tokenName))
val nodes = semanticsProvider.onAllNodes(matcher, useUnmergedTree = true)
for (i in 0 until nodes.fetchSemanticsNodes().size) {
if (runCatching { nodes[i].assertIsDisplayed(); nodes[i].performClick() }.isSuccess) return
}
error("Token '$tokenName' is not displayed on the current wallet page")
}
// Adjacent pager pages stay mounted; swipe the wallet card that's actually on-screen.
fun swipeToAdjacentWallet(toPrevious: Boolean) {
val nodes = semanticsProvider.onAllNodes(withTestTag(MainScreenTestTags.WALLET_LIST_ITEM))
for (i in 0 until nodes.fetchSemanticsNodes().size) {
val swiped = runCatching {
nodes[i].assertIsDisplayed()
nodes[i].performTouchInput { if (toPrevious) swipeRight() else swipeLeft() }
}.isSuccess
if (swiped) return
}
}
val restoringProgressText: KNode = child { val restoringProgressText: KNode = child {
hasTestTag(MainScreenTestTags.SYNC_PROGRESS_TEXT) hasTestTag(MainScreenTestTags.SYNC_PROGRESS_TEXT)
useUnmergedTree = true useUnmergedTree = true

View file

@ -5,14 +5,18 @@ import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.extractText import com.tangem.common.extensions.extractText
import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO
import com.tangem.common.constants.TestConstants.SVS_SEED_PHRASE_12 import com.tangem.common.constants.TestConstants.SVS_SEED_PHRASE_12
import com.tangem.common.constants.TestConstants.TANGEM_PAY_ELIGIBILITY_SCENARIO
import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_VERY_LONG
import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.core.ui.R as CoreUiR import com.tangem.core.ui.R as CoreUiR
import com.tangem.scenarios.* import com.tangem.scenarios.*
import com.tangem.screens.* import com.tangem.screens.*
import com.tangem.screens.tangempay.*
import com.tangem.tap.domain.sdk.mocks.content.Wallet2WithDerivationsMockContent import com.tangem.tap.domain.sdk.mocks.content.Wallet2WithDerivationsMockContent
import com.tangem.tap.domain.sdk.mocks.content.WalletMockContent
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
import io.github.kakaocup.kakao.common.utilities.getResourceString import io.github.kakaocup.kakao.common.utilities.getResourceString
import io.qameta.allure.kotlin.Allure.step import io.qameta.allure.kotlin.Allure.step
@ -808,6 +812,333 @@ class AppTransfersTest : BaseTestCase() {
} }
} }
@AllureId("10005")
@DisplayName("App transfers: Tron network fee")
@Test
fun tronNetworkFeeTest() {
val token = "Tron"
val amount = "0.001"
val userTokensState = "TwoAccountsSameTron"
val networksProvidersScenario = "networks_providers"
val appTransfersNetworksState = "AppTransfersNetworks"
setupHooks(
additionalBeforeAppLaunchSection = {
setWireMockScenarioState(storiesScenario, storiesErrorState)
// networks_providers configures SDK RPC hosts at launch — must be set before the activity starts.
setWireMockScenarioState(networksProvidersScenario, appTransfersNetworksState)
},
additionalAfterSection = {
resetWireMockScenarioState(storiesScenario)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(networksProvidersScenario)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
// Non-zero prices keep total fiat > 0 so the empty-wallet banner doesn't push the account list under the Markets sheet.
step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$appTransfersNetworksState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = appTransfersNetworksState)
}
step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) }
step("Enter amount '$amount'") { inputAmount(amount) }
step("Assert Transfer mode is ready") { assertTransferReady() }
step("Assert network fee is displayed") { waitForFeeDisplayed() }
}
}
// blockchain SDK TonProvidersBuilder drops public providers, so TON has no provider in the mocked build.
@Ignore("[REDACTED_JIRA]")
@AllureId("10012")
@DisplayName("App transfers: TON network fee")
@Test
fun tonNetworkFeeTest() {
// The SDK names TON's coin "Gram" (Blockchain.TON.getCoinName), so the portfolio row shows "Gram", not "Toncoin".
val token = "Gram"
val amount = "0.001"
val userTokensState = "TwoAccountsSameTON"
val networksProvidersScenario = "networks_providers"
val appTransfersNetworksState = "AppTransfersNetworks"
setupHooks(
additionalBeforeAppLaunchSection = {
setWireMockScenarioState(storiesScenario, storiesErrorState)
// networks_providers configures SDK RPC hosts at launch — must be set before the activity starts.
setWireMockScenarioState(networksProvidersScenario, appTransfersNetworksState)
},
additionalAfterSection = {
resetWireMockScenarioState(storiesScenario)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(networksProvidersScenario)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
// Non-zero prices keep total fiat > 0 so the empty-wallet banner doesn't push the account list under the Markets sheet.
step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$appTransfersNetworksState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = appTransfersNetworksState)
}
step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) }
step("Enter amount '$amount'") { inputAmount(amount) }
step("Assert Transfer mode is ready") { assertTransferReady() }
step("Assert network fee is displayed") { waitForFeeDisplayed() }
}
}
@AllureId("10013")
@DisplayName("App transfers: Cosmos network fee")
@Test
fun cosmosNetworkFeeTest() {
val token = "Cosmos"
val amount = "0.001"
val userTokensState = "TwoAccountsSameCosmos"
val networksProvidersScenario = "networks_providers"
val appTransfersNetworksState = "AppTransfersNetworks"
setupHooks(
additionalBeforeAppLaunchSection = {
setWireMockScenarioState(storiesScenario, storiesErrorState)
// networks_providers configures SDK RPC hosts at launch — must be set before the activity starts.
setWireMockScenarioState(networksProvidersScenario, appTransfersNetworksState)
},
additionalAfterSection = {
resetWireMockScenarioState(storiesScenario)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(networksProvidersScenario)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
// Non-zero prices keep total fiat > 0 so the empty-wallet banner doesn't push the account list under the Markets sheet.
step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$appTransfersNetworksState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = appTransfersNetworksState)
}
step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) }
step("Enter amount '$amount'") { inputAmount(amount) }
step("Assert Transfer mode is ready") { assertTransferReady() }
step("Assert network fee is displayed") { waitForFeeDisplayed() }
}
}
@AllureId("10015")
@DisplayName("App transfers: Aptos network fee")
@Test
fun aptosNetworkFeeTest() {
val token = "Aptos"
val amount = "0.001"
val userTokensState = "TwoAccountsSameAptos"
val networksProvidersScenario = "networks_providers"
val appTransfersNetworksState = "AppTransfersNetworks"
setupHooks(
additionalBeforeAppLaunchSection = {
setWireMockScenarioState(storiesScenario, storiesErrorState)
// networks_providers configures SDK RPC hosts at launch — must be set before the activity starts.
setWireMockScenarioState(networksProvidersScenario, appTransfersNetworksState)
},
additionalAfterSection = {
resetWireMockScenarioState(storiesScenario)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(networksProvidersScenario)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
// Non-zero prices keep total fiat > 0 so the empty-wallet banner doesn't push the account list under the Markets sheet.
step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$appTransfersNetworksState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = appTransfersNetworksState)
}
step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) }
step("Enter amount '$amount'") { inputAmount(amount) }
step("Assert Transfer mode is ready") { assertTransferReady() }
step("Assert network fee is displayed") { waitForFeeDisplayed() }
}
}
@AllureId("9856")
@DisplayName("App transfers: Transfer mode is available from a Tangem Pay account")
@Test
fun transferModeAvailableFromTangemPayAccountTest() {
val token = "USDC"
val receiveAccountName = "Main account"
val eligibilityState = "PaeraCustomer"
val balanceScenario = "tangem_pay_balance_update"
val balanceInitialState = "InitialBalance"
val historyScenario = "tangem_pay_transaction_history"
val historyInitialState = "InitialEmpty"
val userTokensState = "TangemPayTransferUsdc"
setupHooks(
additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) },
additionalAfterSection = {
resetWireMockScenarioState(storiesScenario)
resetWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO)
resetWireMockScenarioState(balanceScenario)
resetWireMockScenarioState(historyScenario)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
}
).run {
step("Set WireMock scenario: '$TANGEM_PAY_ELIGIBILITY_SCENARIO' to state: '$eligibilityState'") {
setWireMockScenarioState(scenarioName = TANGEM_PAY_ELIGIBILITY_SCENARIO, state = eligibilityState)
}
step("Set WireMock scenario: '$balanceScenario' to state: '$balanceInitialState'") {
setWireMockScenarioState(scenarioName = balanceScenario, state = balanceInitialState)
}
step("Set WireMock scenario: '$historyScenario' to state: '$historyInitialState'") {
setWireMockScenarioState(scenarioName = historyScenario, state = historyInitialState)
}
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Open Tangem Pay") { openTangemPay() }
step("Click on 'Withdraw' button") {
onTangemPayMainScreen { withdrawButton.clickWithAssertion() }
}
step("Acknowledge withdrawal note sheet") {
onTangemPayWithdrawNoteSheet {
title.assertIsDisplayed()
gotItButton.clickWithAssertion()
}
}
step("Choose identical receive token '$token' from '$receiveAccountName'") {
chooseIdenticalReceiveToken(tokenName = token, receiveAccountName = receiveAccountName)
}
// Withdraw-entry swap keeps recalculating — use flakySafely rather than assertTransferReady's waitUntil.
step("Assert Transfer mode is ready") {
flakySafely(WAIT_UNTIL_TIMEOUT_VERY_LONG) {
onSwapTokenScreen { transferTitle.assertIsDisplayed() }
}
onSwapTokenScreen { providersBlock.assertIsNotDisplayed() }
}
}
}
@AllureId("9995")
@DisplayName("App transfers: transfer between different wallets reaches 'Transfer in progress' screen")
@Test
fun transferBetweenDifferentWalletsReachesFinishTest() {
val token = "Ethereum"
val amount = "0.001"
val secondWalletName = "Wallet 2"
val userTokensState = "EthereumWithSecondToken"
setupHooks(
additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) },
additionalAfterSection = {
resetWireMockScenarioState(storiesScenario)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(ethCallScenario)
resetWireMockScenarioState(ethBalanceScenario)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = ethCallScenario, state = started)
}
step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started)
}
step("Open 'Main' screen with existing hot wallet") {
openMainScreenWithExistingHotWallet(SVS_SEED_PHRASE_12)
}
step("Generate missing addresses") { generateMissingHotWalletAddresses() }
step("Wait for addresses to be generated") { waitForAddressesGenerated() }
step("Add a second card wallet '$secondWalletName'") {
addNewCardWallet(WalletMockContent)
}
step("Switch back to the hot wallet") { switchToPreviousWallet() }
step("Click on token with name: '$token'") { clickDisplayedTokenOnMain(token) }
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false)
}
step("Select identical receive token '$token' on '$secondWalletName'") {
selectReceiveTokenOnWallet(token = token, walletName = secondWalletName)
}
step("Enter amount '$amount'") { inputAmount(amount) }
step("Assert Transfer mode is ready") { assertTransferReady() }
step("Assert network fee is displayed") { waitForFeeDisplayed() }
step("Hold to confirm the transfer") { holdToConfirmTransfer() }
step("Assert 'Transfer in progress' screen is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSwapSuccessScreen { transferInProgressTitle.assertIsDisplayed() }
}
}
}
}
@AllureId("9996")
@DisplayName("App transfers: adding a missing token to the recipient wallet enables Transfer")
@Test
fun addMissingTokenToRecipientWalletEnablesTransferTest() {
val token = "Ethereum"
val bitcoinToken = "Bitcoin"
val recipientWalletName = "Wallet"
val recipientWithoutEthereumState = "RecipientWithoutEthereum"
val ethereumWithSecondTokenState = "EthereumWithSecondToken"
setupHooks(
additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) },
additionalAfterSection = {
resetWireMockScenarioState(storiesScenario)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(ethCallScenario)
resetWireMockScenarioState(ethBalanceScenario)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$recipientWithoutEthereumState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = recipientWithoutEthereumState)
}
step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = ethCallScenario, state = started)
}
step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started)
}
step("Open 'Main' screen with existing hot wallet") {
openMainScreenWithExistingHotWallet(SVS_SEED_PHRASE_12)
}
step("Generate missing addresses") { generateMissingHotWalletAddresses() }
step("Wait for addresses to be generated") { waitForAddressesGenerated() }
step("Assert token '$bitcoinToken' is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onMainScreen { tokenWithTitleAndAddress(bitcoinToken).assertIsDisplayed() }
}
}
// Switch the user-tokens mock so the second wallet loads with Ethereum while the recipient stays Ethereum-less.
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$ethereumWithSecondTokenState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = ethereumWithSecondTokenState)
}
step("Add a second card wallet") {
addNewCardWallet(WalletMockContent)
}
step("Click on token with name: '$token'") { clickDisplayedTokenOnMain(token) }
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false)
}
step("Add missing token '$token' to recipient wallet '$recipientWalletName'") {
addMissingReceiveTokenToWallet(token = token, recipientWalletName = recipientWalletName)
}
step("Assert Transfer mode is ready") { assertTransferReady() }
}
}
// [REDACTED_TASK_KEY]: transfer mode never runs tx validation, so the destination rent-exemption notification never shows. // [REDACTED_TASK_KEY]: transfer mode never runs tx validation, so the destination rent-exemption notification never shows.
@Ignore("[REDACTED_JIRA]") @Ignore("[REDACTED_JIRA]")
@AllureId("9852") @AllureId("9852")

@ -1 +1 @@
Subproject commit a7b32c766817076c6346156390c135a3dae1b6ce Subproject commit f983c6defd0b2240eb6f134aefe30f7e93696795

View file

@ -4,13 +4,10 @@ import com.appsflyer.deeplink.DeepLink
import com.tangem.datasource.local.appsflyer.AppsFlyerDeeplinkSource import com.tangem.datasource.local.appsflyer.AppsFlyerDeeplinkSource
import com.tangem.datasource.local.appsflyer.AppsFlyerStore import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.domain.wallets.models.AppsFlyerConversionData import com.tangem.domain.wallets.models.AppsFlyerConversionData
import com.tangem.feature.referral.domain.SetShouldShowMobileWalletPromoUseCase
import com.tangem.utils.coroutines.AppCoroutineScope import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.logging.TangemLogger import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.contracts.ExperimentalContracts import kotlin.contracts.ExperimentalContracts
@ -19,11 +16,9 @@ import kotlin.contracts.contract
@Singleton @Singleton
class AppsFlyerReferralParamsHandler @Inject constructor( class AppsFlyerReferralParamsHandler @Inject constructor(
private val appsFlyerStore: AppsFlyerStore, private val appsFlyerStore: AppsFlyerStore,
private val setShouldShowMobileWalletPromoUseCase: SetShouldShowMobileWalletPromoUseCase,
private val coroutineScope: AppCoroutineScope, private val coroutineScope: AppCoroutineScope,
) { ) {
private val mutex = Mutex()
private val deepLinkDeferred = CompletableDeferred<String?>() private val deepLinkDeferred = CompletableDeferred<String?>()
fun handle(params: Map<String?, Any?>) { fun handle(params: Map<String?, Any?>) {
@ -48,15 +43,17 @@ class AppsFlyerReferralParamsHandler @Inject constructor(
} }
suspend fun waitForDeeplink(deeplinkSource: AppsFlyerDeeplinkSource): String? { suspend fun waitForDeeplink(deeplinkSource: AppsFlyerDeeplinkSource): String? {
val deeplinkFromCache = appsFlyerStore.getDeeplink(deeplinkSource) appsFlyerStore.getDeeplink(deeplinkSource)?.let { return it }
return if (deeplinkFromCache == null) {
val value = when (deeplinkSource) { val expectedValue = when (deeplinkSource) {
AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding -> TANGEM_PAY_HOT_WALLET_ONBOARDING_DEEP_LINK_VALUE AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding -> TANGEM_PAY_HOT_WALLET_ONBOARDING_DEEP_LINK_VALUE
} AppsFlyerDeeplinkSource.Referral -> REFERRAL_DEEP_LINK_VALUE
deepLinkDeferred.await().takeIf { it == value }
} else {
deeplinkFromCache
} }
val resolvedValue = deepLinkDeferred.await().takeIf { it == expectedValue }
// The deep link may have been persisted to the store while we were awaiting (e.g. from
// conversion-data handling, which stores the deep link but doesn't complete the deferred).
return resolvedValue ?: appsFlyerStore.getDeeplink(deeplinkSource)
} }
private fun handle(deepLinkValue: String?, deepLinkSub1: String?, deepLinkSub2: String?) { private fun handle(deepLinkValue: String?, deepLinkSub1: String?, deepLinkSub2: String?) {
@ -79,6 +76,10 @@ class AppsFlyerReferralParamsHandler @Inject constructor(
@Suppress("NullableToStringCall") @Suppress("NullableToStringCall")
TangemLogger.i("refcode=$deepLinkSub1\ncampaign=$deepLinkSub2") TangemLogger.i("refcode=$deepLinkSub1\ncampaign=$deepLinkSub2")
coroutineScope.launch {
appsFlyerStore.storeDeeplink(AppsFlyerDeeplinkSource.Referral, REFERRAL_DEEP_LINK_VALUE)
}
if (!isValidParam(deepLinkSub1)) { if (!isValidParam(deepLinkSub1)) {
TangemLogger.e("Deeplink conversion data is invalid") TangemLogger.e("Deeplink conversion data is invalid")
return return
@ -98,13 +99,9 @@ class AppsFlyerReferralParamsHandler @Inject constructor(
private fun storeConversionData(refcode: String, campaign: String?) { private fun storeConversionData(refcode: String, campaign: String?) {
coroutineScope.launch { coroutineScope.launch {
mutex.withLock { appsFlyerStore.storeIfAbsent(
setShouldShowMobileWalletPromoUseCase(true) value = AppsFlyerConversionData(refcode = refcode, campaign = campaign),
.onLeft { TangemLogger.e("Error", it) } )
appsFlyerStore.storeIfAbsent(
value = AppsFlyerConversionData(refcode = refcode, campaign = campaign),
)
}
} }
} }

View file

@ -35,12 +35,15 @@ internal interface CardSdkModule {
sdkRepository: CardSdkConfigRepository, sdkRepository: CardSdkConfigRepository,
@ApplicationContext context: Context, @ApplicationContext context: Context,
): CardArtworksProvider { ): CardArtworksProvider {
// Use internal storage (always mounted) instead of external files dir. External
// storage can be transiently unavailable/unmounted or cleared after this singleton
// is constructed, leaving the directory missing when the SDK later writes to it —
// ArtworksStorage.store() opens a FileOutputStream without re-creating the parent,
// which crashes with ENOENT. Artwork is only a cache, so internal storage is fine.
val artworksDirectory = File(context.filesDir, "card_artworks").apply { mkdirs() }
return CardArtworksProvider( return CardArtworksProvider(
tangemApiBaseUrlProvider = { sdkRepository.sdk.config.tangemApiBaseUrl }, tangemApiBaseUrlProvider = { sdkRepository.sdk.config.tangemApiBaseUrl },
artworksDirectory = File( artworksDirectory = artworksDirectory,
context.getExternalFilesDir(null) ?: context.filesDir,
"card_artworks",
).apply { mkdirs() },
) )
} }
} }

View file

@ -132,6 +132,10 @@ object WalletMockContent : MockContent {
publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62),
chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41),
), ),
DerivationPath("m/44'/118'/0'/0/0") to ExtendedPublicKey( // Cosmos
publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62),
chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41),
),
DerivationPath("m/1852'/1815'/0'/0/0") to ExtendedPublicKey( DerivationPath("m/1852'/1815'/0'/0/0") to ExtendedPublicKey(
publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62),
chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41),
@ -181,6 +185,22 @@ object WalletMockContent : MockContent {
publicKey = byteArrayOf(-65, -53, -62, -12, -57, -32, -38, -9, -128, -52, -83, -61, 73, 39, 41, 15, -74, -97, 38, 52, -101, 63, 74, -56, -20, 15, 57, -127, 114, -93, -17, -109), publicKey = byteArrayOf(-65, -53, -62, -12, -57, -32, -38, -9, -128, -52, -83, -61, 73, 39, 41, 15, -74, -97, 38, 52, -101, 63, 74, -56, -20, 15, 57, -127, 114, -93, -17, -109),
chainCode = byteArrayOf(-81, 15, 125, 28, -115, -22, 87, 81, 87, -123, -25, -74, 86, 2, 1, 110, -115, 65, -110, 63, -64, 83, -93, -97, -104, 123, 12, -26, 94, 27, 84, -6), chainCode = byteArrayOf(-81, 15, 125, 28, -115, -22, 87, 81, 87, -123, -25, -74, 86, 2, 1, 110, -115, 65, -110, 63, -64, 83, -93, -97, -104, 123, 12, -26, 94, 27, 84, -6),
), ),
DerivationPath("m/44'/607'/0'/0/0") to ExtendedPublicKey( // TON (account 1)
publicKey = byteArrayOf(30, -109, -39, 33, -94, -73, 121, 50, -75, 86, 102, -2, -74, -23, 63, -3, 79, -82, -103, 106, 82, -86, -107, -63, -46, 104, 7, 18, -41, 15, -87, -43),
chainCode = byteArrayOf(15, 61, -29, 22, 30, 45, -51, -60, 5, 62, -87, -35, 54, -97, -5, -44, -54, -107, -14, -119, -3, 92, 91, 75, -66, 26, 112, 83, 122, -25, -64, 40),
),
DerivationPath("m/44'/607'/1'/0/0") to ExtendedPublicKey( // TON (account 2)
publicKey = byteArrayOf(-51, 62, 97, 25, 83, 75, -79, 23, 6, -42, -94, 45, 91, -66, 57, -80, -75, -39, 19, -88, 95, -124, 50, 39, 114, -118, 27, -122, 48, -69, 7, -111),
chainCode = byteArrayOf(77, 63, 69, -114, -25, 105, -123, -42, -87, 107, 86, -43, 46, 92, -78, -107, -72, -81, -102, 45, 75, 97, -120, -10, 118, 27, -34, -50, -92, 3, 47, -126),
),
DerivationPath("m/44'/637'/0'/0'/0'") to ExtendedPublicKey( // Aptos (account 1)
publicKey = byteArrayOf(-65, -53, -62, -12, -57, -32, -38, -9, -128, -52, -83, -61, 73, 39, 41, 15, -74, -97, 38, 52, -101, 63, 74, -56, -20, 15, 57, -127, 114, -93, -17, -109),
chainCode = byteArrayOf(-81, 15, 125, 28, -115, -22, 87, 81, 87, -123, -25, -74, 86, 2, 1, 110, -115, 65, -110, 63, -64, 83, -93, -97, -104, 123, 12, -26, 94, 27, 84, -6),
),
DerivationPath("m/44'/637'/1'/0'/0'") to ExtendedPublicKey( // Aptos (account 2)
publicKey = byteArrayOf(-65, -53, -62, -12, -57, -32, -38, -9, -128, -52, -83, -61, 73, 39, 41, 15, -74, -97, 38, 52, -101, 63, 74, -56, -20, 15, 57, -127, 114, -93, -17, -109),
chainCode = byteArrayOf(-81, 15, 125, 28, -115, -22, 87, 81, 87, -123, -25, -74, 86, 2, 1, 110, -115, 65, -110, 63, -64, 83, -93, -97, -104, 123, 12, -26, 94, 27, 84, -6),
),
), ),
extendedPublicKey = ExtendedPublicKey( extendedPublicKey = ExtendedPublicKey(
publicKey = byteArrayOf(-65, -53, -62, -12, -57, -32, -38, -9, -128, -52, -83, -61, 73, 39, 41, 15, -74, -97, 38, 52, -101, 63, 74, -56, -20, 15, 57, -127, 114, -93, -17, -109), publicKey = byteArrayOf(-65, -53, -62, -12, -57, -32, -38, -9, -128, -52, -83, -61, 73, 39, 41, 15, -74, -97, 38, 52, -101, 63, 74, -56, -20, 15, 57, -127, 114, -93, -17, -109),
@ -290,6 +310,20 @@ object WalletMockContent : MockContent {
parentFingerprint = byteArrayOf(0, 0, 0, 0), parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 0, childNumber = 0,
), ),
DerivationPath("m/44'/118'/0'/0/0") to ExtendedPublicKey( // Cosmos (account 1)
publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62),
chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41),
depth = 0,
parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 0,
),
DerivationPath("m/44'/118'/1'/0/0") to ExtendedPublicKey( // Cosmos (account 2)
publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62),
chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41),
depth = 0,
parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 0,
),
DerivationPath("m/44'/144'/0'/0/0") to ExtendedPublicKey( // XRP DerivationPath("m/44'/144'/0'/0/0") to ExtendedPublicKey( // XRP
publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62),
chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41),
@ -424,6 +458,34 @@ object WalletMockContent : MockContent {
parentFingerprint = byteArrayOf(0, 0, 0, 0), parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 0, childNumber = 0,
), ),
DerivationPath("m/44'/607'/0'/0/0") to ExtendedPublicKey( // TON (account 1)
publicKey = byteArrayOf(30, -109, -39, 33, -94, -73, 121, 50, -75, 86, 102, -2, -74, -23, 63, -3, 79, -82, -103, 106, 82, -86, -107, -63, -46, 104, 7, 18, -41, 15, -87, -43),
chainCode = byteArrayOf(15, 61, -29, 22, 30, 45, -51, -60, 5, 62, -87, -35, 54, -97, -5, -44, -54, -107, -14, -119, -3, 92, 91, 75, -66, 26, 112, 83, 122, -25, -64, 40),
depth = 0,
parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 0,
),
DerivationPath("m/44'/607'/1'/0/0") to ExtendedPublicKey( // TON (account 2)
publicKey = byteArrayOf(-51, 62, 97, 25, 83, 75, -79, 23, 6, -42, -94, 45, 91, -66, 57, -80, -75, -39, 19, -88, 95, -124, 50, 39, 114, -118, 27, -122, 48, -69, 7, -111),
chainCode = byteArrayOf(77, 63, 69, -114, -25, 105, -123, -42, -87, 107, 86, -43, 46, 92, -78, -107, -72, -81, -102, 45, 75, 97, -120, -10, 118, 27, -34, -50, -92, 3, 47, -126),
depth = 0,
parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 0,
),
DerivationPath("m/44'/637'/0'/0'/0'") to ExtendedPublicKey( // Aptos (account 1)
publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82),
chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109),
depth = 0,
parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 0,
),
DerivationPath("m/44'/637'/1'/0'/0'") to ExtendedPublicKey( // Aptos (account 2)
publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82),
chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109),
depth = 0,
parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 0,
),
), ),
), ),

View file

@ -9,6 +9,7 @@ import com.tangem.common.services.secure.SecureStorage
import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.utils.TrackingContextProxy import com.tangem.core.analytics.utils.TrackingContextProxy
import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.appsflyer.usecase.ClearAppsFlyerDeeplinkUseCase
import com.tangem.domain.common.wallets.UserWalletSelectedHandler import com.tangem.domain.common.wallets.UserWalletSelectedHandler
import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.hotwallet.repository.HotWalletRepository import com.tangem.domain.hotwallet.repository.HotWalletRepository
@ -17,7 +18,6 @@ import com.tangem.domain.visa.model.VisaActivationRemoteState
import com.tangem.domain.visa.model.VisaCardActivationStatus import com.tangem.domain.visa.model.VisaCardActivationStatus
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository
import com.tangem.domain.wallets.hot.HotWalletPasswordRequester import com.tangem.domain.wallets.hot.HotWalletPasswordRequester
import com.tangem.feature.referral.domain.MobileWalletPromoRepository
import com.tangem.hot.sdk.TangemHotSdk import com.tangem.hot.sdk.TangemHotSdk
import com.tangem.sdk.storage.AndroidSecureStorage import com.tangem.sdk.storage.AndroidSecureStorage
import com.tangem.sdk.storage.AndroidSecureStorageV2 import com.tangem.sdk.storage.AndroidSecureStorageV2
@ -57,7 +57,7 @@ internal object UserWalletsListRepositoryModule {
trackingContextProxy: TrackingContextProxy, trackingContextProxy: TrackingContextProxy,
analyticsEventHandler: AnalyticsEventHandler, analyticsEventHandler: AnalyticsEventHandler,
hotWalletRepository: HotWalletRepository, hotWalletRepository: HotWalletRepository,
mobileWalletPromoRepository: MobileWalletPromoRepository, clearAppsFlyerDeeplinkUseCase: ClearAppsFlyerDeeplinkUseCase,
userWalletSelectedHandler: Lazy<UserWalletSelectedHandler>, userWalletSelectedHandler: Lazy<UserWalletSelectedHandler>,
): UserWalletsListRepository { ): UserWalletsListRepository {
val moshi = buildMoshi() val moshi = buildMoshi()
@ -109,7 +109,7 @@ internal object UserWalletsListRepositoryModule {
trackingContextProxy = trackingContextProxy, trackingContextProxy = trackingContextProxy,
analyticsEventHandler = analyticsEventHandler, analyticsEventHandler = analyticsEventHandler,
hotWalletRepository = hotWalletRepository, hotWalletRepository = hotWalletRepository,
mobileWalletPromoRepository = mobileWalletPromoRepository, clearAppsFlyerDeeplinkUseCase = clearAppsFlyerDeeplinkUseCase,
userWalletSelectedHandler = userWalletSelectedHandler, userWalletSelectedHandler = userWalletSelectedHandler,
) )
} }

View file

@ -14,6 +14,8 @@ import com.tangem.core.analytics.utils.TrackingContextProxy
import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.preferences.utils.getSyncOrDefault import com.tangem.datasource.local.preferences.utils.getSyncOrDefault
import com.tangem.domain.appsflyer.AppsFlyerDeeplinkSource
import com.tangem.domain.appsflyer.usecase.ClearAppsFlyerDeeplinkUseCase
import com.tangem.domain.common.wallets.UserWalletSelectedHandler import com.tangem.domain.common.wallets.UserWalletSelectedHandler
import com.tangem.domain.common.wallets.UserWalletTransformAction import com.tangem.domain.common.wallets.UserWalletTransformAction
import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.common.wallets.UserWalletsListRepository
@ -26,7 +28,6 @@ import com.tangem.domain.wallets.analytics.WalletSettingsAnalyticEvents
import com.tangem.domain.wallets.builder.UserWalletIdBuilder import com.tangem.domain.wallets.builder.UserWalletIdBuilder
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository
import com.tangem.domain.wallets.hot.HotWalletPasswordRequester import com.tangem.domain.wallets.hot.HotWalletPasswordRequester
import com.tangem.feature.referral.domain.MobileWalletPromoRepository
import com.tangem.hot.sdk.TangemHotSdk import com.tangem.hot.sdk.TangemHotSdk
import com.tangem.hot.sdk.model.HotWalletId import com.tangem.hot.sdk.model.HotWalletId
import com.tangem.sdk.api.TangemSdkManager import com.tangem.sdk.api.TangemSdkManager
@ -60,7 +61,7 @@ internal class DefaultUserWalletsListRepository(
private val trackingContextProxy: TrackingContextProxy, private val trackingContextProxy: TrackingContextProxy,
private val analyticsEventHandler: AnalyticsEventHandler, private val analyticsEventHandler: AnalyticsEventHandler,
private val hotWalletRepository: HotWalletRepository, private val hotWalletRepository: HotWalletRepository,
private val mobileWalletPromoRepository: MobileWalletPromoRepository, private val clearAppsFlyerDeeplinkUseCase: ClearAppsFlyerDeeplinkUseCase,
private val userWalletSelectedHandler: Lazy<UserWalletSelectedHandler>, private val userWalletSelectedHandler: Lazy<UserWalletSelectedHandler>,
) : UserWalletsListRepository { ) : UserWalletsListRepository {
@ -620,14 +621,13 @@ internal class DefaultUserWalletsListRepository(
} }
private suspend fun onFirstWalletCreated() { private suspend fun onFirstWalletCreated() {
// reset flag (that is set from AF deeplink) after creating a new wallet // reset the referral attribution (set from AF deeplink) after creating a new wallet
mobileWalletPromoRepository.setShouldShowMobileWalletPromo(false) clearAppsFlyerDeeplinkUseCase(AppsFlyerDeeplinkSource.Referral)
} }
private suspend fun onAllWalletsDeleted() { private suspend fun onAllWalletsDeleted() {
// reset flag (that is set from AF deeplink) after removing the last wallet // reset the referral attribution (set from AF deeplink) after removing the last wallet
mobileWalletPromoRepository.setShouldShowMobileWalletPromo(false) clearAppsFlyerDeeplinkUseCase(AppsFlyerDeeplinkSource.Referral)
// wipe the Usedesk support-chat clientId so a fresh UUID is generated for the next wallet
appPreferencesStore.editData { it.remove(PreferencesKeys.USEDESK_CLIENT_ID_KEY) } appPreferencesStore.editData { it.remove(PreferencesKeys.USEDESK_CLIENT_ID_KEY) }
} }
} }

View file

@ -166,4 +166,5 @@ internal val Blockchain.moonPaySupportedCurrency: MoonPaySupportedCurrency?
Adi, AdiTestnet -> null Adi, AdiTestnet -> null
SeiEvm, SeiEvmTestnet -> null SeiEvm, SeiEvmTestnet -> null
Monad, MonadTestnet -> null Monad, MonadTestnet -> null
Gonka -> null
} }

View file

@ -43,7 +43,6 @@ import com.tangem.domain.onboarding.repository.OnboardingRepository
import com.tangem.domain.settings.NeverRequestPermissionUseCase import com.tangem.domain.settings.NeverRequestPermissionUseCase
import com.tangem.domain.settings.NeverToInitiallyAskPermissionUseCase import com.tangem.domain.settings.NeverToInitiallyAskPermissionUseCase
import com.tangem.domain.settings.ShouldInitiallyAskPermissionUseCase import com.tangem.domain.settings.ShouldInitiallyAskPermissionUseCase
import com.tangem.feature.referral.domain.ShouldShowMobileWalletPromoUseCase
import com.tangem.features.hotwallet.HotAccessCodeRequestComponent import com.tangem.features.hotwallet.HotAccessCodeRequestComponent
import com.tangem.features.hotwallet.accesscoderequest.proxy.HotWalletPasswordRequesterProxy import com.tangem.features.hotwallet.accesscoderequest.proxy.HotWalletPasswordRequesterProxy
import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent import com.tangem.features.onboarding.v2.common.analytics.OnboardingEvent
@ -69,6 +68,8 @@ import com.tangem.wallet.R
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@ -102,7 +103,6 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
private val shouldInitiallyAskPermissionUseCase: ShouldInitiallyAskPermissionUseCase, private val shouldInitiallyAskPermissionUseCase: ShouldInitiallyAskPermissionUseCase,
private val neverRequestPermissionUseCase: NeverRequestPermissionUseCase, private val neverRequestPermissionUseCase: NeverRequestPermissionUseCase,
private val featureTogglesManager: FeatureTogglesManager, private val featureTogglesManager: FeatureTogglesManager,
private val shouldShowMobileWalletPromoUseCase: ShouldShowMobileWalletPromoUseCase,
) : RoutingComponent, ) : RoutingComponent,
AppComponentContext by context, AppComponentContext by context,
SnackbarHandler { SnackbarHandler {
@ -206,23 +206,8 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
} }
private suspend fun navigateForEmptyWallets(): AppRoute { private suspend fun navigateForEmptyWallets(): AppRoute {
val isHotWalletOnboardingEnabled = featureTogglesManager.isFeatureEnabled( val afterEmptyRoute = resolveAppsFlyerOnboardingRoute()
FeatureToggles.AND_15101_TANGEM_PAY_HOT_WALLET_ONBOARDING, ?: AppRoute.Home(launchMode = launchMode)
)
TangemLogger.i("[TangemPay][HWO] Feature toggle enabled=$isHotWalletOnboardingEnabled")
val afterEmptyRoute: AppRoute = if (isHotWalletOnboardingEnabled) {
val tangemPayHotWalletOnboardingDeepLink = withTimeoutOrNull(2.seconds) {
appsFlyerReferralParamsHandler.waitForDeeplink(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding)
}
TangemLogger.i("[TangemPay][HWO] Deep link present=${tangemPayHotWalletOnboardingDeepLink != null}")
if (tangemPayHotWalletOnboardingDeepLink != null) {
AppRoute.TangemPayHotWalletOnboarding
} else {
getDefaultRoute()
}
} else {
getDefaultRoute()
}
val shouldAskPushPermission = shouldInitiallyAskPermissionUseCase(PUSH_PERMISSION).getOrNull() val shouldAskPushPermission = shouldInitiallyAskPermissionUseCase(PUSH_PERMISSION).getOrNull()
?: return afterEmptyRoute ?: return afterEmptyRoute
@ -242,16 +227,41 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
} }
} }
private suspend fun getDefaultRoute(): AppRoute { private suspend fun resolveAppsFlyerOnboardingRoute(): AppRoute? = coroutineScope {
val isHideStoriesForReferralEnabled = featureTogglesManager.isFeatureEnabled( val tangemPayRoute = async { resolveTangemPayHotWalletOnboardingRoute() }
val referralRoute = async { resolveReferralRoute() }
tangemPayRoute.await() ?: referralRoute.await()
}
private suspend fun resolveTangemPayHotWalletOnboardingRoute(): AppRoute? {
val isEnabled = featureTogglesManager.isFeatureEnabled(
FeatureToggles.AND_15101_TANGEM_PAY_HOT_WALLET_ONBOARDING,
)
TangemLogger.i("[TangemPay][HWO] Feature toggle enabled=$isEnabled")
if (!isEnabled) return null
val deepLink = awaitAppsFlyerDeeplink(AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding)
TangemLogger.i("[TangemPay][HWO] Deep link present=${deepLink != null}")
return if (deepLink != null) AppRoute.TangemPayHotWalletOnboarding else null
}
private suspend fun resolveReferralRoute(): AppRoute? {
val isEnabled = featureTogglesManager.isFeatureEnabled(
FeatureToggles.TWI_1512_HIDE_STORIES_FOR_REFERRAL_ENABLED, FeatureToggles.TWI_1512_HIDE_STORIES_FOR_REFERRAL_ENABLED,
) )
// Referral users skip the Home stories screen and land directly on the if (!isEnabled) return null
// mobile wallet creation flow.
return if (isHideStoriesForReferralEnabled && shouldShowMobileWalletPromoUseCase()) { val referralDeepLink = awaitAppsFlyerDeeplink(AppsFlyerDeeplinkSource.Referral)
return if (referralDeepLink != null) {
AppRoute.CreateWalletStart(mode = AppRoute.CreateWalletStart.Mode.HotWallet) AppRoute.CreateWalletStart(mode = AppRoute.CreateWalletStart.Mode.HotWallet)
} else { } else {
AppRoute.Home(launchMode = launchMode) null
}
}
private suspend fun awaitAppsFlyerDeeplink(source: AppsFlyerDeeplinkSource): String? {
return withTimeoutOrNull(2.seconds) {
appsFlyerReferralParamsHandler.waitForDeeplink(source)
} }
} }

View file

@ -5,9 +5,7 @@ import com.google.common.truth.Truth.assertThat
import com.tangem.datasource.local.appsflyer.AppsFlyerDeeplinkSource import com.tangem.datasource.local.appsflyer.AppsFlyerDeeplinkSource
import com.tangem.datasource.local.appsflyer.AppsFlyerStore import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.domain.wallets.models.AppsFlyerConversionData import com.tangem.domain.wallets.models.AppsFlyerConversionData
import com.tangem.feature.referral.domain.SetShouldShowMobileWalletPromoUseCase
import com.tangem.test.core.ProvideTestModels import com.tangem.test.core.ProvideTestModels
import arrow.core.right
import com.tangem.test.core.TestAppCoroutineScope import com.tangem.test.core.TestAppCoroutineScope
import io.mockk.clearMocks import io.mockk.clearMocks
import io.mockk.coEvery import io.mockk.coEvery
@ -28,13 +26,9 @@ import org.junit.jupiter.params.ParameterizedTest
class AppsFlyerReferralParamsHandlerTest { class AppsFlyerReferralParamsHandlerTest {
private val appsFlyerStore: AppsFlyerStore = mockk(relaxUnitFun = true) private val appsFlyerStore: AppsFlyerStore = mockk(relaxUnitFun = true)
private val setShouldShowMobileWalletPromoUseCase: SetShouldShowMobileWalletPromoUseCase = mockk {
coEvery { this@mockk.invoke(true) } returns Unit.right()
}
private val handler = AppsFlyerReferralParamsHandler( private val handler = AppsFlyerReferralParamsHandler(
appsFlyerStore = appsFlyerStore, appsFlyerStore = appsFlyerStore,
coroutineScope = TestAppCoroutineScope(), coroutineScope = TestAppCoroutineScope(),
setShouldShowMobileWalletPromoUseCase = setShouldShowMobileWalletPromoUseCase,
) )
@AfterEach @AfterEach
@ -175,7 +169,6 @@ class AppsFlyerReferralParamsHandlerTest {
private val localHandler = AppsFlyerReferralParamsHandler( private val localHandler = AppsFlyerReferralParamsHandler(
appsFlyerStore = localStore, appsFlyerStore = localStore,
coroutineScope = TestAppCoroutineScope(), coroutineScope = TestAppCoroutineScope(),
setShouldShowMobileWalletPromoUseCase = mockk { coEvery { this@mockk.invoke(true) } returns Unit.right() },
) )
@Test @Test
@ -240,6 +233,93 @@ class AppsFlyerReferralParamsHandlerTest {
} }
} }
@Nested
inner class WaitForReferralDeeplink {
private val localStore: AppsFlyerStore = mockk(relaxUnitFun = true)
private val localHandler = AppsFlyerReferralParamsHandler(
appsFlyerStore = localStore,
coroutineScope = TestAppCoroutineScope(),
)
@Test
fun `GIVEN cached referral deeplink WHEN waitForDeeplink THEN returns cached value`() = runTest {
// GIVEN
coEvery { localStore.getDeeplink(AppsFlyerDeeplinkSource.Referral) } returns "referral"
// WHEN
val result = localHandler.waitForDeeplink(AppsFlyerDeeplinkSource.Referral)
// THEN
assertThat(result).isEqualTo("referral")
}
@Test
fun `GIVEN no cache and referral deeplink WHEN handleDeeplink then waitForDeeplink THEN returns referral value`() =
runTest {
// GIVEN
coEvery { localStore.getDeeplink(AppsFlyerDeeplinkSource.Referral) } returns null
val deepLink = mockk<DeepLink> {
every { deepLinkValue } returns "referral"
every { getStringValue(any()) } returns SUCCESS_REFCODE
}
// WHEN
localHandler.handleDeeplink(deepLink)
val result = localHandler.waitForDeeplink(AppsFlyerDeeplinkSource.Referral)
// THEN
assertThat(result).isEqualTo("referral")
coVerify { localStore.storeDeeplink(AppsFlyerDeeplinkSource.Referral, "referral") }
}
@Test
fun `GIVEN no cache and non-referral deeplink WHEN handleDeeplink then waitForDeeplink THEN returns null`() =
runTest {
// GIVEN
coEvery { localStore.getDeeplink(AppsFlyerDeeplinkSource.Referral) } returns null
val deepLink = mockk<DeepLink> {
every { deepLinkValue } returns "tpay_mobileonboard"
every { getStringValue(any()) } returns null
}
// WHEN
localHandler.handleDeeplink(deepLink)
val result = localHandler.waitForDeeplink(AppsFlyerDeeplinkSource.Referral)
// THEN
assertThat(result).isNull()
coVerify(inverse = true) { localStore.storeDeeplink(AppsFlyerDeeplinkSource.Referral, any()) }
}
@Test
fun `GIVEN no cache WHEN handleNoDeeplink then waitForDeeplink THEN returns null`() = runTest {
// GIVEN
coEvery { localStore.getDeeplink(AppsFlyerDeeplinkSource.Referral) } returns null
// WHEN
localHandler.handleNoDeeplink()
val result = localHandler.waitForDeeplink(AppsFlyerDeeplinkSource.Referral)
// THEN
assertThat(result).isNull()
}
@Test
fun `GIVEN deeplink stored during wait WHEN waitForDeeplink THEN returns stored value`() = runTest {
// GIVEN cache is empty on the initial read but populated (e.g. from conversion-data
// handling) by the time we re-check after awaiting the deferred
coEvery { localStore.getDeeplink(AppsFlyerDeeplinkSource.Referral) } returns null andThen "referral"
// WHEN the deferred resolves without a matching value (UDL reported no deep link)
localHandler.handleNoDeeplink()
val result = localHandler.waitForDeeplink(AppsFlyerDeeplinkSource.Referral)
// THEN the value persisted during the wait is preferred
assertThat(result).isEqualTo("referral")
}
}
private companion object Companion { private companion object Companion {
const val SUCCESS_REFCODE = "valid_refcode" const val SUCCESS_REFCODE = "valid_refcode"
const val SUCCESS_CAMPAIGN = "valid_campaign" const val SUCCESS_CAMPAIGN = "valid_campaign"

View file

@ -7,13 +7,13 @@ import com.tangem.common.test.domain.wallet.MockUserWalletFactory
import com.tangem.core.analytics.api.AnalyticsEventHandler import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.utils.TrackingContextProxy import com.tangem.core.analytics.utils.TrackingContextProxy
import com.tangem.datasource.local.preferences.AppPreferencesStore import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.domain.appsflyer.usecase.ClearAppsFlyerDeeplinkUseCase
import com.tangem.domain.common.wallets.UserWalletSelectedHandler import com.tangem.domain.common.wallets.UserWalletSelectedHandler
import com.tangem.domain.hotwallet.repository.HotWalletRepository import com.tangem.domain.hotwallet.repository.HotWalletRepository
import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository
import com.tangem.domain.wallets.hot.HotWalletPasswordRequester import com.tangem.domain.wallets.hot.HotWalletPasswordRequester
import com.tangem.feature.referral.domain.MobileWalletPromoRepository
import com.tangem.hot.sdk.TangemHotSdk import com.tangem.hot.sdk.TangemHotSdk
import com.tangem.sdk.api.TangemSdkManager import com.tangem.sdk.api.TangemSdkManager
import com.tangem.utils.Provider import com.tangem.utils.Provider
@ -45,7 +45,7 @@ internal class DefaultUserWalletsListRepositoryTest {
private val trackingContextProxy: TrackingContextProxy = mockk(relaxed = true) private val trackingContextProxy: TrackingContextProxy = mockk(relaxed = true)
private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true) private val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true)
private val hotWalletRepository: HotWalletRepository = mockk(relaxed = true) private val hotWalletRepository: HotWalletRepository = mockk(relaxed = true)
private val mobileWalletPromoRepository: MobileWalletPromoRepository = mockk(relaxed = true) private val clearAppsFlyerDeeplinkUseCase: ClearAppsFlyerDeeplinkUseCase = mockk(relaxed = true)
private val userWalletSelectedHandler: UserWalletSelectedHandler = mockk(relaxed = true) private val userWalletSelectedHandler: UserWalletSelectedHandler = mockk(relaxed = true)
private val walletA = MockUserWalletFactory.create().copy(walletId = UserWalletId("0011"), name = "Wallet A") private val walletA = MockUserWalletFactory.create().copy(walletId = UserWalletId("0011"), name = "Wallet A")
@ -61,7 +61,7 @@ internal class DefaultUserWalletsListRepositoryTest {
selectedUserWalletRepository, selectedUserWalletRepository,
userWalletEncryptionKeysRepository, userWalletEncryptionKeysRepository,
trackingContextProxy, trackingContextProxy,
mobileWalletPromoRepository, clearAppsFlyerDeeplinkUseCase,
userWalletSelectedHandler, userWalletSelectedHandler,
) )
@ -82,7 +82,7 @@ internal class DefaultUserWalletsListRepositoryTest {
trackingContextProxy = trackingContextProxy, trackingContextProxy = trackingContextProxy,
analyticsEventHandler = analyticsEventHandler, analyticsEventHandler = analyticsEventHandler,
hotWalletRepository = hotWalletRepository, hotWalletRepository = hotWalletRepository,
mobileWalletPromoRepository = mobileWalletPromoRepository, clearAppsFlyerDeeplinkUseCase = clearAppsFlyerDeeplinkUseCase,
userWalletSelectedHandler = Lazy { userWalletSelectedHandler }, userWalletSelectedHandler = Lazy { userWalletSelectedHandler },
) )
} }

View file

@ -137,6 +137,8 @@ private fun iconSetOf(blockchain: Blockchain): IconSet? = when (blockchain) {
-> IconSet(active = R.drawable.img_flare_22, greyedOut = R.drawable.ic_flare_22) -> IconSet(active = R.drawable.img_flare_22, greyedOut = R.drawable.ic_flare_22)
Blockchain.Gnosis, Blockchain.Gnosis,
-> IconSet(active = R.drawable.img_gnosis_22, greyedOut = R.drawable.ic_gnosis_22) -> IconSet(active = R.drawable.img_gnosis_22, greyedOut = R.drawable.ic_gnosis_22)
Blockchain.Gonka,
-> IconSet(active = R.drawable.img_gonka_22, greyedOut = R.drawable.ic_gonka_22)
Blockchain.Hedera, Blockchain.Hedera,
Blockchain.HederaTestnet, Blockchain.HederaTestnet,
-> IconSet(active = R.drawable.img_hedera_22, greyedOut = R.drawable.ic_hedera_22) -> IconSet(active = R.drawable.img_hedera_22, greyedOut = R.drawable.ic_hedera_22)

View file

@ -179,11 +179,15 @@ sealed class NotificationUM(val config: NotificationConfig) {
subtitle = resourceReference(id = R.string.send_notification_invalid_reserve_amount_text), subtitle = resourceReference(id = R.string.send_notification_invalid_reserve_amount_text),
) )
data class NetworkAccountNotFunded(val coinName: String) : Error( data class NetworkAccountNotFunded(
val coinName: String,
val reserveAmount: String,
val reserveSymbol: String,
) : Error(
title = resourceReference(R.string.alert_failed_to_send_transaction_title), title = resourceReference(R.string.alert_failed_to_send_transaction_title),
subtitle = resourceReference( subtitle = resourceReference(
id = R.string.no_account_generic, id = R.string.no_account_generic,
formatArgs = wrappedList(coinName), formatArgs = wrappedList(coinName, reserveAmount, reserveSymbol),
), ),
) )
@ -191,6 +195,11 @@ sealed class NotificationUM(val config: NotificationConfig) {
title = resourceReference(id = R.string.send_validation_destination_tag_required_title), title = resourceReference(id = R.string.send_validation_destination_tag_required_title),
subtitle = resourceReference(id = R.string.send_validation_destination_tag_required_description), subtitle = resourceReference(id = R.string.send_validation_destination_tag_required_description),
) )
data object RequiredTrustline : Error(
title = resourceReference(id = R.string.common_error),
subtitle = resourceReference(id = R.string.no_trustline_xlm_asset),
)
} }
open class Warning( open class Warning(

View file

@ -120,40 +120,66 @@ object NotificationsFactory {
} }
} }
@Suppress("LongParameterList")
fun MutableList<NotificationUM>.addReserveAmountErrorNotification( fun MutableList<NotificationUM>.addReserveAmountErrorNotification(
reserveAmount: BigDecimal?, reserveAmount: BigDecimal?,
sendingAmount: BigDecimal, sendingAmount: BigDecimal,
cryptoCurrency: CryptoCurrency, cryptoCurrency: CryptoCurrency,
feeCryptoCurrency: CryptoCurrency?, feeCryptoCurrency: CryptoCurrency?,
isAccountFunded: Boolean, isAccountFunded: Boolean,
hasRequiredTrustline: Boolean,
) { ) {
val sendingCoinAmount = when (cryptoCurrency) { val sendingCoinAmount = when (cryptoCurrency) {
is CryptoCurrency.Coin -> sendingAmount is CryptoCurrency.Coin -> sendingAmount
is CryptoCurrency.Token -> BigDecimal.ZERO is CryptoCurrency.Token -> BigDecimal.ZERO
} }
if (feeCryptoCurrency == null && cryptoCurrency is CryptoCurrency.Token) { when {
// No need to show reserve amount warning if fee currency is unknown for token transfer // No need to show reserve amount warning if fee currency is unknown for token transfer
return feeCryptoCurrency == null && cryptoCurrency is CryptoCurrency.Token -> Unit
} else if (!isAccountFunded && reserveAmount != null && reserveAmount > sendingCoinAmount) {
// account not funded, sending coin amount < reserve (send coin with less amount OR send any token) // account not funded, sending coin amount < reserve (send coin with less amount OR send any token)
!isAccountFunded && reserveAmount != null && reserveAmount > sendingCoinAmount ->
addAccountNotFundedNotification(
reserveAmount = reserveAmount,
cryptoCurrency = cryptoCurrency,
feeCryptoCurrency = feeCryptoCurrency,
)
hasRequiredTrustline -> addTrustlineRequiredNotification()
}
}
if (cryptoCurrency is CryptoCurrency.Coin) { private fun MutableList<NotificationUM>.addAccountNotFundedNotification(
// Try to send coin amount less than reserve amount (e.g. less than 1 XLM in Stellar) reserveAmount: BigDecimal,
cryptoCurrency: CryptoCurrency,
feeCryptoCurrency: CryptoCurrency?,
) {
when (cryptoCurrency) {
// Try to send coin amount less than reserve amount (e.g. less than 1 XLM in Stellar)
is CryptoCurrency.Coin -> add(
NotificationUM.Error.ReserveAmount(
reserveAmount.format {
crypto(feeCryptoCurrency ?: cryptoCurrency)
},
),
)
// Try to send any token (e.g. USDC in Stellar, but account not funded -> user must send XLM at first)
is CryptoCurrency.Token -> {
checkNotNull(feeCryptoCurrency)
add( add(
NotificationUM.Error.ReserveAmount( NotificationUM.Error.NetworkAccountNotFunded(
reserveAmount.format { coinName = feeCryptoCurrency.name,
crypto(feeCryptoCurrency ?: cryptoCurrency) reserveAmount = reserveAmount.format {
crypto(symbol = "", decimals = feeCryptoCurrency.decimals)
}, },
reserveSymbol = feeCryptoCurrency.symbol,
), ),
) )
} else {
checkNotNull(feeCryptoCurrency)
// Try to send any token (e.g. USDC in Stellar, but account not funded -> user must send XLM at first)
add(NotificationUM.Error.NetworkAccountNotFunded(coinName = feeCryptoCurrency.name))
} }
} }
// TODO: check the RECEIVER account trustline before sending }
private fun MutableList<NotificationUM>.addTrustlineRequiredNotification() {
add(NotificationUM.Error.RequiredTrustline)
} }
fun MutableList<NotificationUM>.addMinimumAmountErrorNotification( fun MutableList<NotificationUM>.addMinimumAmountErrorNotification(

View file

@ -75,6 +75,7 @@ internal class BlockchainIconsTest {
Blockchain.Filecoin -> R.drawable.img_filecoin_22 Blockchain.Filecoin -> R.drawable.img_filecoin_22
Blockchain.Flare, Blockchain.FlareTestnet -> R.drawable.img_flare_22 Blockchain.Flare, Blockchain.FlareTestnet -> R.drawable.img_flare_22
Blockchain.Gnosis -> R.drawable.img_gnosis_22 Blockchain.Gnosis -> R.drawable.img_gnosis_22
Blockchain.Gonka -> R.drawable.img_gonka_22
Blockchain.Hedera, Blockchain.HederaTestnet -> R.drawable.img_hedera_22 Blockchain.Hedera, Blockchain.HederaTestnet -> R.drawable.img_hedera_22
Blockchain.Hyperliquid, Blockchain.HyperliquidTestnet -> R.drawable.img_hyperliquid_22 Blockchain.Hyperliquid, Blockchain.HyperliquidTestnet -> R.drawable.img_hyperliquid_22
Blockchain.InternetComputer -> R.drawable.img_icp_22 Blockchain.InternetComputer -> R.drawable.img_icp_22
@ -198,6 +199,7 @@ internal class BlockchainIconsTest {
Blockchain.Filecoin -> R.drawable.ic_filecoin_22 Blockchain.Filecoin -> R.drawable.ic_filecoin_22
Blockchain.Flare, Blockchain.FlareTestnet -> R.drawable.ic_flare_22 Blockchain.Flare, Blockchain.FlareTestnet -> R.drawable.ic_flare_22
Blockchain.Gnosis -> R.drawable.ic_gnosis_22 Blockchain.Gnosis -> R.drawable.ic_gnosis_22
Blockchain.Gonka -> R.drawable.ic_gonka_22
Blockchain.Hedera, Blockchain.HederaTestnet -> R.drawable.ic_hedera_22 Blockchain.Hedera, Blockchain.HederaTestnet -> R.drawable.ic_hedera_22
Blockchain.Hyperliquid, Blockchain.HyperliquidTestnet -> R.drawable.ic_hyperliquid_22 Blockchain.Hyperliquid, Blockchain.HyperliquidTestnet -> R.drawable.ic_hyperliquid_22
Blockchain.InternetComputer -> R.drawable.ic_icp_22 Blockchain.InternetComputer -> R.drawable.ic_icp_22

View file

@ -1,6 +1,7 @@
package com.tangem.datasource.api.ethpool package com.tangem.datasource.api.ethpool
import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolAccountsListRequest
import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolBroadcastRequest import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolBroadcastRequest
import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolTransactionRequest import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolTransactionRequest
import com.tangem.datasource.api.ethpool.models.response.* import com.tangem.datasource.api.ethpool.models.response.*
@ -94,4 +95,20 @@ interface P2PEthPoolApi {
@Path("delegatorAddress") delegatorAddress: String, @Path("delegatorAddress") delegatorAddress: String,
@Path("vaultAddress") vaultAddress: String, @Path("vaultAddress") vaultAddress: String,
): ApiResponse<P2PEthPoolResponse<P2PEthPoolAccountResponse>> ): ApiResponse<P2PEthPoolResponse<P2PEthPoolAccountResponse>>
/**
* Get account summaries for multiple delegators in a vault (batch).
*
* Designed to be called once per client to avoid rate-limit bursts.
*
* @param network Ethereum pool network: "mainnet" or "hoodi"
* @param vaultAddress Ethereum address of the vault
* @param body Delegator addresses to fetch (up to 255)
*/
@POST("api/v1/staking/pool/{network}/vaults/{vaultAddress}/accounts/list")
suspend fun getAccountsList(
@Path("network") network: String,
@Path("vaultAddress") vaultAddress: String,
@Body body: P2PEthPoolAccountsListRequest,
): ApiResponse<P2PEthPoolResponse<P2PEthPoolAccountsListResponse>>
} }

View file

@ -0,0 +1,16 @@
package com.tangem.datasource.api.ethpool.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Request body for POST /api/v1/staking/pool/{network}/vaults/{vaultAddress}/accounts/list
*
* Batch fetch of staking balances for multiple delegator addresses within a single vault.
* Limit: up to 255 addresses per request. Addresses are deduplicated server-side.
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolAccountsListRequest(
@Json(name = "delegatorAddresses")
val delegatorAddresses: List<String>,
)

View file

@ -0,0 +1,26 @@
package com.tangem.datasource.api.ethpool.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Response for POST /api/v1/staking/pool/{network}/vaults/{vaultAddress}/accounts/list
*
* Each item is keyed by delegatorAddress and carries either a non-null [account]
* or a per-address [error] (e.g. code 127108 invalid delegator address).
*/
@JsonClass(generateAdapter = true)
data class P2PEthPoolAccountsListResponse(
@Json(name = "list")
val list: List<P2PEthPoolAccountListItem>,
)
@JsonClass(generateAdapter = true)
data class P2PEthPoolAccountListItem(
@Json(name = "delegatorAddress")
val delegatorAddress: String,
@Json(name = "account")
val account: P2PEthPoolAccountResponse?,
@Json(name = "error")
val error: P2PEthPoolErrorDetailsDTO?,
)

View file

@ -23,7 +23,7 @@ data class P2PEthPoolErrorDetailsDTO(
@Json(name = "message") @Json(name = "message")
val message: String, // Human-readable error message val message: String, // Human-readable error message
@Json(name = "name") @Json(name = "name")
val name: String, // Error name/type val name: String?, // Error name/type
@Json(name = "errors") @Json(name = "errors")
val errors: List<String>? = null, // Optional validation errors array val errors: List<String>? = null, // Optional validation errors array
) )

View file

@ -23,9 +23,11 @@ interface AppsFlyerStore {
enum class AppsFlyerDeeplinkSource { enum class AppsFlyerDeeplinkSource {
TangemPayHotWalletOnboarding, TangemPayHotWalletOnboarding,
Referral,
; ;
fun toStoreKey() = when (this) { fun toStoreKey() = when (this) {
TangemPayHotWalletOnboarding -> "tangem_pay_hot_wallet_onboarding" TangemPayHotWalletOnboarding -> "tangem_pay_hot_wallet_onboarding"
Referral -> "referral"
} }
} }

View file

@ -0,0 +1,68 @@
package com.tangem.datasource.api.ethpool
import com.google.common.truth.Truth.assertThat
import com.squareup.moshi.Types
import com.tangem.datasource.api.common.MoshiConverter
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountsListResponse
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolResponse
import org.junit.jupiter.api.Test
import java.math.BigDecimal
internal class P2PEthPoolAccountsListResponseTest {
private val adapter = MoshiConverter.networkMoshi.adapter<P2PEthPoolResponse<P2PEthPoolAccountsListResponse>>(
Types.newParameterizedType(
P2PEthPoolResponse::class.java,
P2PEthPoolAccountsListResponse::class.java,
),
)
@Test
fun `decode batch payload with valid account and per-address error`() {
val response = requireNotNull(adapter.fromJson(SAMPLE_JSON))
val list = requireNotNull(response.result).list
assertThat(list).hasSize(2)
val good = list.first { it.account != null }
val account = requireNotNull(good.account)
assertThat(account.stake.assets.compareTo(BigDecimal("1.2345"))).isEqualTo(0)
assertThat(account.availableToWithdraw).isGreaterThan(BigDecimal(15049))
assertThat(account.exitQueue.requests).isEmpty()
val bad = list.first { it.account == null }
assertThat(requireNotNull(bad.error).code).isEqualTo(127108)
}
private companion object {
private val SAMPLE_JSON = """
{
"error": null,
"result": {
"list": [
{
"delegatorAddress": "0x008d3cd3e349Cd3D5F7c287b3BaF9e4f3E4ba99b",
"account": {
"delegatorAddress": "0x008d3cd3e349Cd3D5F7c287b3BaF9e4f3E4ba99b",
"vaultAddress": "0x4c09BC47db288F998b33CD63BCc1b6ddCCe13F33",
"stake": { "assets": "1.234500000000000000", "totalEarnedAssets": 0.0191 },
"availableToUnstake": "0.000000000000000005",
"availableToWithdraw": 15049.547647281135,
"exitQueue": { "total": 0, "requests": [] }
},
"error": null
},
{
"delegatorAddress": "0xBADADDRESS",
"account": null,
"error": {
"code": 127108,
"message": "The provided delegator address is invalid or not properly formatted."
}
}
]
}
}
""".trimIndent()
}
}

View file

@ -83,12 +83,16 @@ fun TangemMessage(
trailingContent = if (isIconLeading) null else icon, trailingContent = if (isIconLeading) null else icon,
contentColor = contentColor, contentColor = contentColor,
onCloseClick = messageUM.onCloseClick, onCloseClick = messageUM.onCloseClick,
buttons = { buttons = if (messageUM.buttonsUM.isEmpty()) {
messageUM.buttonsUM.fastForEach { buttonUM -> null
TangemButton( } else {
buttonUM = buttonUM.tangemButtonUM, {
modifier = Modifier.weight(1f), messageUM.buttonsUM.fastForEach { buttonUM ->
) TangemButton(
buttonUM = buttonUM.tangemButtonUM,
modifier = Modifier.weight(1f),
)
}
} }
}, },
) )

View file

@ -27,19 +27,20 @@ import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.test.TokenElementsTestTags import com.tangem.core.ui.test.TokenElementsTestTags
import org.burnoutcrew.reorderable.ReorderableLazyListState
/** /**
* UI model for header row component * UI model for header row component
* *
* @param headerRowUM UI model for the header row * @param headerRowUM UI model for the header row
* @param modifier Modifier for the composable * @param modifier Modifier for the composable
* @param dragHandleModifier Modifier applied to the drag handle in the row's tail (e.g. a reorderable
* drag-handle modifier). Defaults to [Modifier] for non-reorderable rows.
*/ */
@Composable @Composable
fun TangemHeaderRow( fun TangemHeaderRow(
headerRowUM: TangemHeaderRowUM, headerRowUM: TangemHeaderRowUM,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
reorderableState: ReorderableLazyListState? = null, dragHandleModifier: Modifier = Modifier,
isBalanceHidden: Boolean = false, isBalanceHidden: Boolean = false,
) { ) {
TangemHeaderRow( TangemHeaderRow(
@ -48,7 +49,7 @@ fun TangemHeaderRow(
title = headerRowUM.title, title = headerRowUM.title,
subtitle = headerRowUM.subtitle, subtitle = headerRowUM.subtitle,
isBalanceHidden = isBalanceHidden, isBalanceHidden = isBalanceHidden,
reorderableState = reorderableState, dragHandleModifier = dragHandleModifier,
modifier = modifier, modifier = modifier,
) )
} }
@ -129,7 +130,7 @@ fun TangemHeaderRow(
subtitle: TextReference? = null, subtitle: TextReference? = null,
headTangemIconUM: TangemIconUM? = null, headTangemIconUM: TangemIconUM? = null,
tailUM: TangemRowTailUM = TangemRowTailUM.Empty, tailUM: TangemRowTailUM = TangemRowTailUM.Empty,
reorderableState: ReorderableLazyListState? = null, dragHandleModifier: Modifier = Modifier,
isEnabled: Boolean = false, isEnabled: Boolean = false,
onItemClick: (() -> Unit)? = null, onItemClick: (() -> Unit)? = null,
) { ) {
@ -181,7 +182,7 @@ fun TangemHeaderRow(
SpacerWMax() SpacerWMax()
TangemRowTail( TangemRowTail(
tangemRowTailUM = tailUM, tangemRowTailUM = tailUM,
reorderableState = reorderableState, dragHandleModifier = dragHandleModifier,
) )
} }
} }

View file

@ -19,14 +19,12 @@ import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resolveAnnotatedReference import com.tangem.core.ui.extensions.resolveAnnotatedReference
import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.test.OrganizeTokensScreenTestTags import com.tangem.core.ui.test.OrganizeTokensScreenTestTags
import org.burnoutcrew.reorderable.ReorderableLazyListState
import org.burnoutcrew.reorderable.detectReorder
@Composable @Composable
fun TangemRowTail( fun TangemRowTail(
tangemRowTailUM: TangemRowTailUM, tangemRowTailUM: TangemRowTailUM,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
reorderableState: ReorderableLazyListState? = null, dragHandleModifier: Modifier = Modifier,
) { ) {
AnimatedContent( AnimatedContent(
targetState = tangemRowTailUM, targetState = tangemRowTailUM,
@ -39,7 +37,7 @@ fun TangemRowTail(
TangemRowTailUM.Empty -> Unit TangemRowTailUM.Empty -> Unit
is TangemRowTailUM.Draggable -> DraggableImage( is TangemRowTailUM.Draggable -> DraggableImage(
iconRes = animatedState.iconRes, iconRes = animatedState.iconRes,
reorderableState = reorderableState, dragHandleModifier = dragHandleModifier,
modifier = innerModifier, modifier = innerModifier,
) )
is TangemRowTailUM.Text -> ContentText(text = animatedState.text, modifier = innerModifier) is TangemRowTailUM.Text -> ContentText(text = animatedState.text, modifier = innerModifier)
@ -54,21 +52,11 @@ fun TangemRowTail(
} }
@Composable @Composable
private fun DraggableImage( private fun DraggableImage(@DrawableRes iconRes: Int, dragHandleModifier: Modifier, modifier: Modifier = Modifier) {
@DrawableRes iconRes: Int,
reorderableState: ReorderableLazyListState?,
modifier: Modifier = Modifier,
) {
Box( Box(
modifier = modifier modifier = modifier
.size(size = TangemTheme.dimens2.x6) .size(size = TangemTheme.dimens2.x6)
.then( .then(dragHandleModifier)
other = if (reorderableState != null) {
Modifier.detectReorder(reorderableState)
} else {
Modifier
},
)
.testTag(OrganizeTokensScreenTestTags.DRAGGABLE_IMAGE), .testTag(OrganizeTokensScreenTestTags.DRAGGABLE_IMAGE),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {

View file

@ -21,24 +21,24 @@ import com.tangem.core.ui.extensions.clickableSingle
import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.test.TokenElementsTestTags import com.tangem.core.ui.test.TokenElementsTestTags
import org.burnoutcrew.reorderable.ReorderableLazyListState
/** /**
* Composable function that represents a Tangem token row in a list. * Composable function that represents a Tangem token row in a list.
* *
* [Token Row](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=8207-17583&t=k8dyaykorsNocGVq-4) * [Token Row](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=8207-17583&t=k8dyaykorsNocGVq-4)
* *
* @param tokenRowUM The user model containing the data for the token row. * @param tokenRowUM The user model containing the data for the token row.
* @param isBalanceHidden A boolean indicating whether the balance should be hidden. * @param isBalanceHidden A boolean indicating whether the balance should be hidden.
* @param reorderableState The state of the reorderable lazy list, if applicable. * @param dragHandleModifier Modifier applied to the drag handle in the row's tail (e.g. a reorderable
* @param modifier The modifier to be applied to the row. * drag-handle modifier). Defaults to [Modifier] for non-reorderable rows.
* @param modifier The modifier to be applied to the row.
*/ */
@Composable @Composable
fun TangemTokenRow( fun TangemTokenRow(
tokenRowUM: TangemTokenRowUM, tokenRowUM: TangemTokenRowUM,
isBalanceHidden: Boolean, isBalanceHidden: Boolean,
reorderableState: ReorderableLazyListState?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
dragHandleModifier: Modifier = Modifier,
) { ) {
TangemRowContainer( TangemRowContainer(
content = { content = {
@ -91,7 +91,7 @@ fun TangemTokenRow(
TangemRowTail( TangemRowTail(
tangemRowTailUM = tokenRowUM.tailUM, tangemRowTailUM = tokenRowUM.tailUM,
reorderableState = reorderableState, dragHandleModifier = dragHandleModifier,
modifier = Modifier modifier = Modifier
.layoutId(layoutId = TangemRowLayoutId.TAIL) .layoutId(layoutId = TangemRowLayoutId.TAIL)
.testTag(tag = TokenElementsTestTags.TOKEN_NON_FIAT_BLOCK), .testTag(tag = TokenElementsTestTags.TOKEN_NON_FIAT_BLOCK),
@ -116,18 +116,19 @@ fun TangemTokenRow(
* [Token Row](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=8207-17583&t=k8dyaykorsNocGVq-4) * [Token Row](https://www.figma.com/design/RU7AIgwHtGdMfy83T5UOoR/Core-Library?node-id=8207-17583&t=k8dyaykorsNocGVq-4)
* *
* @param tokenRowUM The user model containing the data for the token row. * @param tokenRowUM The user model containing the data for the token row.
* @param headComponent The composable function representing the head component. * @param headComponent The composable function representing the head component.
* @param titleComponent The composable function representing the title component. * @param titleComponent The composable function representing the title component.
* @param isBalanceHidden A boolean indicating whether the balance should be hidden. * @param isBalanceHidden A boolean indicating whether the balance should be hidden.
* @param reorderableState The state of the reorderable lazy list, if applicable. * @param dragHandleModifier Modifier applied to the drag handle in the row's tail (e.g. a reorderable
* @param modifier The modifier to be applied to the row. * drag-handle modifier). Defaults to [Modifier] for non-reorderable rows.
* @param modifier The modifier to be applied to the row.
*/ */
@Composable @Composable
fun TangemTokenRow( fun TangemTokenRow(
tokenRowUM: TangemTokenRowUM, tokenRowUM: TangemTokenRowUM,
isBalanceHidden: Boolean, isBalanceHidden: Boolean,
reorderableState: ReorderableLazyListState?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
dragHandleModifier: Modifier = Modifier,
headComponent: @Composable (Modifier) -> Unit, headComponent: @Composable (Modifier) -> Unit,
titleComponent: @Composable (Modifier) -> Unit, titleComponent: @Composable (Modifier) -> Unit,
) { ) {
@ -190,7 +191,7 @@ fun TangemTokenRow(
TangemRowTail( TangemRowTail(
tangemRowTailUM = tokenRowUM.tailUM, tangemRowTailUM = tokenRowUM.tailUM,
reorderableState = reorderableState, dragHandleModifier = dragHandleModifier,
modifier = Modifier modifier = Modifier
.layoutId(layoutId = TangemRowLayoutId.TAIL) .layoutId(layoutId = TangemRowLayoutId.TAIL)
.testTag(tag = TokenElementsTestTags.TOKEN_NON_FIAT_BLOCK), .testTag(tag = TokenElementsTestTags.TOKEN_NON_FIAT_BLOCK),
@ -210,7 +211,6 @@ private fun TangemTokenRow_Preview(
TangemTokenRow( TangemTokenRow(
tokenRowUM = tokenRowUM, tokenRowUM = tokenRowUM,
isBalanceHidden = false, isBalanceHidden = false,
reorderableState = null,
modifier = Modifier.background(TangemTheme.colors2.surface.level1), modifier = Modifier.background(TangemTheme.colors2.surface.level1),
) )
} }

View file

@ -28,6 +28,8 @@ import kotlinx.coroutines.flow.drop
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
private const val LIST_FLING_DAMPING = 0.1f
/** /**
* A scroll behavior for a collapsing top app bar that collapses when scrolling up and expands when scrolling down. * A scroll behavior for a collapsing top app bar that collapses when scrolling up and expands when scrolling down.
* When the user stops scrolling, the app bar will settle to either fully collapsed or fully expanded state * When the user stops scrolling, the app bar will settle to either fully collapsed or fully expanded state
@ -176,7 +178,8 @@ private fun exitUntilCollapsedScrollBehavior(
} }
} }
return Velocity(0f, available.y - remainingVelocity) val passedVelocity = remainingVelocity * LIST_FLING_DAMPING
return Velocity(0f, available.y - passedVelocity)
} }
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {

View file

@ -4,9 +4,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.NonRestartableComposable import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.graphics.vector.ImageVector
import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.res.generated.icons.Icons import com.tangem.core.ui.res.generated.icons.Icons
import com.tangem.core.ui.res.generated.icons.ic_arrow_left_20 import com.tangem.core.ui.res.generated.icons.ic_chevron_left_20
import com.tangem.core.ui.res.generated.icons.ic_cross_20 import com.tangem.core.ui.res.generated.icons.ic_cross_20
import com.tangem.core.ui.test.TopNavigationTestTags import com.tangem.core.ui.test.TopNavigationTestTags
@ -16,7 +17,8 @@ fun TangemButton.Back(modifier: Modifier = Modifier, onClick: () -> Unit) {
TangemButton( TangemButton(
modifier = modifier.testTag(TopNavigationTestTags.BACK_BUTTON), modifier = modifier.testTag(TopNavigationTestTags.BACK_BUTTON),
variant = TangemButton.Variant.Material, variant = TangemButton.Variant.Material,
iconStart = TangemIconUM.Icon(Icons.ic_arrow_left_20), size = TangemButton.Size.X11,
iconStart = TangemIconUM.Icon(Icons.ic_chevron_left_20),
onClick = onClick, onClick = onClick,
) )
} }
@ -27,7 +29,26 @@ fun TangemButton.Close(modifier: Modifier = Modifier, onClick: () -> Unit) {
TangemButton( TangemButton(
modifier = modifier, modifier = modifier,
variant = TangemButton.Variant.Material, variant = TangemButton.Variant.Material,
size = TangemButton.Size.X11,
iconStart = TangemIconUM.Icon(Icons.ic_cross_20), iconStart = TangemIconUM.Icon(Icons.ic_cross_20),
onClick = onClick, onClick = onClick,
) )
}
@Composable
@NonRestartableComposable
fun TangemButton.GroupEntry(iconUM: TangemIconUM, modifier: Modifier = Modifier, onClick: () -> Unit) {
TangemButton(
modifier = modifier,
variant = TangemButton.Variant.Ghost,
size = TangemButton.Size.X9,
iconStart = iconUM,
onClick = onClick,
)
}
@Composable
@NonRestartableComposable
fun TangemButton.GroupEntry(imageVector: ImageVector, modifier: Modifier = Modifier, onClick: () -> Unit) {
GroupEntry(iconUM = TangemIconUM.Icon(imageVector), modifier = modifier, onClick = onClick)
} }

View file

@ -1,27 +1,13 @@
package com.tangem.core.ui.ds2.button package com.tangem.core.ui.ds2.button
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.*
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.Spring import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring import androidx.compose.animation.core.spring
import androidx.compose.animation.expandHorizontally import androidx.compose.foundation.layout.*
import androidx.compose.animation.fadeIn import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
@ -38,11 +24,7 @@ import androidx.compose.ui.unit.dp
import com.tangem.core.ui.ds.image.TangemIcon import com.tangem.core.ui.ds.image.TangemIcon
import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds2.loader.TangemLoader import com.tangem.core.ui.ds2.loader.TangemLoader
import com.tangem.core.ui.extensions.ColorReference2 import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.conditionalCompose
import com.tangem.core.ui.extensions.rememberLastNonNull
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemTheme
/** /**
@ -168,6 +150,10 @@ private fun ContentRow(
maxLines = 1, maxLines = 1,
softWrap = false, softWrap = false,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
autoSize = TextAutoSize.StepBased(
minFontSize = TangemTheme.typography3.caption.medium.fontSize,
maxFontSize = TangemTheme.typography3.body.medium.fontSize,
),
) )
} }
} }

View file

@ -140,7 +140,11 @@ private fun SearchField(state: TangemSearch.State, focusRequester: FocusRequeste
Icon( Icon(
modifier = Modifier.padding(end = 8.dp), modifier = Modifier.padding(end = 8.dp),
imageVector = Icons.ic_search_20, imageVector = Icons.ic_search_20,
tint = TangemTheme.colors3.icon.primary, tint = if (state.isActive) {
TangemTheme.colors3.icon.secondary
} else {
TangemTheme.colors3.icon.primary
},
contentDescription = null, contentDescription = null,
) )
QueryTextField(state = state, focusRequester = focusRequester) QueryTextField(state = state, focusRequester = focusRequester)

View file

@ -6,6 +6,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.draw.drawWithCache
@ -14,7 +15,7 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -24,25 +25,17 @@ import kotlin.math.cos
import kotlin.math.sin import kotlin.math.sin
/** /**
* Design-system rectangle shimmer placeholder. * Design-system v2 shimmer placeholder a rounded rectangle with a sweeping highlight.
* *
* A rounded rectangle painted with `bg.opaque.secondary`. A tilted band sweeps across it where
* the base color's alpha is gradually dimmed toward the center of the band and restored at the
* edges, producing a soft "blade" highlight passing through the placeholder. The alpha profile
* matches [com.tangem.core.ui.components.text.BladeAnimation].
*
* Cycle: 1.5s hold 0.8s linear sweep restart.
*
* Version 1.0
* [Figma](https://www.figma.com/design/AsnJ5CPHib4Qxw12gszjMS/%F0%9F%92%A0-DS-Components?node-id=3398-625&p=f&m=dev) * [Figma](https://www.figma.com/design/AsnJ5CPHib4Qxw12gszjMS/%F0%9F%92%A0-DS-Components?node-id=3398-625&p=f&m=dev)
* *
* Sizing is the caller's responsibility set width and height via [modifier]. * For a placeholder sized after a typography line, use the [TangemShimmer] text overload instead.
* *
* @param modifier Modifier applied to the shimmer's root. * @param modifier Modifier applied to the shimmer's root. Set the width and height here.
* @param radius Corner radius of the rectangle. * @param radius Corner radius of the rectangle.
*/ */
@Composable @Composable
fun RectangleShimmer(modifier: Modifier = Modifier, radius: Dp = 6.dp) { fun TangemShimmer(modifier: Modifier = Modifier, radius: Dp = TangemShimmer.DefaultRadius) {
val baseColor = TangemTheme.colors3.bg.opaque.secondary val baseColor = TangemTheme.colors3.bg.opaque.secondary
val progress = LocalTangemShimmerProgress.current ?: rememberShimmerProgressInstance() val progress = LocalTangemShimmerProgress.current ?: rememberShimmerProgressInstance()
val colorStops = remember(baseColor) { buildColorStops(baseColor) } val colorStops = remember(baseColor) { buildColorStops(baseColor) }
@ -77,63 +70,76 @@ fun RectangleShimmer(modifier: Modifier = Modifier, radius: Dp = 6.dp) {
} }
/** /**
* Text-sized shimmer placeholder. Sizes itself to the bounding box of the [text] measured in the * Text-line shimmer placeholder, sized and styled after the typography line described by [style].
* typography preset selected by [style], plus the preset's vertical padding (top + bottom).
* *
* @param text Text used to determine the shimmer's size. Not drawn. * [Figma](https://www.figma.com/design/AsnJ5CPHib4Qxw12gszjMS/%F0%9F%92%A0-DS-Components?node-id=3398-625&p=f&m=dev)
* @param style Typography preset drives both the measurement style and the vertical padding. *
* @param radius Corner radius of the rectangle. * @param style A [TangemTheme.typography3] style (e.g. `TangemTheme.typography3.body.medium`) the
* placeholder is sized after. Unrecognized styles fall back to `body.medium`.
* @param modifier Modifier applied to the shimmer's root. * @param modifier Modifier applied to the shimmer's root.
* @param textAlign Horizontal position of the block within the parent width.
*/ */
@Composable @Composable
fun TextShimmer(text: String, style: TextShimmerStyle, radius: Dp, modifier: Modifier = Modifier) { fun TangemShimmer(style: TextStyle, modifier: Modifier = Modifier, textAlign: TextAlign = TextAlign.Start) {
val textStyle = style.toTextStyle() val preset = TangemShimmer.TextPreset.forStyle(style)
val measurer = rememberTextMeasurer() val lineHeightDp = with(LocalDensity.current) { style.lineHeight.toDp() }
val density = LocalDensity.current val alignment = when (textAlign) {
val (widthDp, heightDp) = remember(text, textStyle, measurer, density) { TextAlign.Center -> Alignment.Center
val measured = measurer.measure(text = text, style = textStyle) TextAlign.End -> Alignment.CenterEnd
with(density) { measured.size.width.toDp() to measured.size.height.toDp() } else -> Alignment.CenterStart
} }
RectangleShimmer( Box(
modifier = modifier.size( modifier = modifier.fillMaxWidth(),
width = widthDp, contentAlignment = alignment,
height = heightDp + style.verticalPadding * 2, ) {
), TangemShimmer(
radius = radius, modifier = Modifier
) .fillMaxWidth(preset.widthFraction)
.height(lineHeightDp)
.padding(vertical = preset.verticalPadding),
radius = preset.radius,
)
}
}
/** Public API namespace for [TangemShimmer]. */
object TangemShimmer {
/** Default corner radius of the rectangle shimmer. */
val DefaultRadius: Dp = 6.dp
/** Per-typography sizing for the [TangemShimmer] text overload. */
internal enum class TextPreset(val widthFraction: Float, val verticalPadding: Dp, val radius: Dp) {
Display(widthFraction = 0.5f, verticalPadding = 4.dp, radius = 12.dp),
HeadingMedium(widthFraction = 0.7f, verticalPadding = 2.dp, radius = 8.dp),
HeadingSmall(widthFraction = 0.6f, verticalPadding = 2.dp, radius = 16.dp),
Body(widthFraction = 0.5f, verticalPadding = 2.dp, radius = 16.dp),
Subheading(widthFraction = 0.4f, verticalPadding = 2.dp, radius = 16.dp),
Caption(widthFraction = 0.3f, verticalPadding = 2.dp, radius = 16.dp),
;
companion object {
@Composable
@ReadOnlyComposable
fun forStyle(style: TextStyle): TextPreset {
val typography = TangemTheme.typography3
return when (style) {
typography.display.medium -> Display
typography.heading.medium -> HeadingMedium
typography.heading.small -> HeadingSmall
typography.subheading.medium -> Subheading
typography.caption.medium -> Caption
else -> Body
}
}
}
}
} }
/** /**
* Typography preset for [TextShimmer]. Each preset maps to a [TangemTheme.typography3] style * Wraps [content] so every [TangemShimmer] inside shares a single, in-phase animation driver
* and contributes additional [verticalPadding] applied to both top and bottom the shimmer * use it around lists of shimmers. Safe to nest; safe to omit.
* block ends up `2 * verticalPadding` taller than the raw measured text.
*/
enum class TextShimmerStyle(val verticalPadding: Dp) {
DISPLAY(verticalPadding = 4.dp),
HEADING_MEDIUM(verticalPadding = 2.dp),
HEADING_SMALL(verticalPadding = 2.dp),
BODY(verticalPadding = 2.dp),
SUBHEADING(verticalPadding = 2.dp),
CAPTION(verticalPadding = 2.dp),
}
@Composable
@ReadOnlyComposable
private fun TextShimmerStyle.toTextStyle(): TextStyle = when (this) {
TextShimmerStyle.DISPLAY -> TangemTheme.typography3.display.medium
TextShimmerStyle.HEADING_MEDIUM -> TangemTheme.typography3.heading.medium
TextShimmerStyle.HEADING_SMALL -> TangemTheme.typography3.heading.small
TextShimmerStyle.BODY -> TangemTheme.typography3.body.medium
TextShimmerStyle.SUBHEADING -> TangemTheme.typography3.subheading.medium
TextShimmerStyle.CAPTION -> TangemTheme.typography3.caption.medium
}
/**
* Wraps [content] so every [RectangleShimmer] / [TextShimmer] inside reuses a single shimmer
* animation driver. Without this provider each shimmer creates its own
* [rememberInfiniteTransition] that scales poorly in lists and lets sweeps drift out of phase.
* Safe to nest; safe to omit (each shimmer falls back to its own driver).
*/ */
@Composable @Composable
fun ProvideTangemShimmer(content: @Composable () -> Unit) { fun ProvideTangemShimmer(content: @Composable () -> Unit) {
@ -198,24 +204,16 @@ private fun TangemShimmerPreview() {
.padding(16.dp), .padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
RectangleShimmer( TangemShimmer(
modifier = Modifier.size(width = 200.dp, height = 24.dp), modifier = Modifier.size(width = 200.dp, height = 24.dp),
radius = 6.dp, radius = 6.dp,
) )
RectangleShimmer( TangemShimmer(
modifier = Modifier.size(width = 120.dp, height = 16.dp), modifier = Modifier.size(width = 120.dp, height = 16.dp),
radius = 4.dp, radius = 4.dp,
) )
TextShimmer( TangemShimmer(style = TangemTheme.typography3.body.medium)
text = "Account balance", TangemShimmer(style = TangemTheme.typography3.heading.medium)
style = TextShimmerStyle.BODY,
radius = 4.dp,
)
TextShimmer(
text = "$12,345.67",
style = TextShimmerStyle.HEADING_MEDIUM,
radius = 6.dp,
)
} }
} }
} }

View file

@ -100,7 +100,7 @@ fun TangemSurface(
} }
if (onClick != null) { if (onClick != null) {
CompositionLocalProvider(LocalRippleConfiguration provides tangemSurfaceRipple()) { CompositionLocalProvider(LocalRippleConfiguration provides tangemSurfaceRipple(color)) {
surface() surface()
} }
} else { } else {
@ -117,14 +117,17 @@ fun TangemSurface(
* `isAlphaContentClip`) to avoid the dark blur bleeding through the surface. * `isAlphaContentClip`) to avoid the dark blur bleeding through the surface.
*/ */
@Composable @Composable
private fun Modifier.materialShadow(shape: Shape, radius: Dp): Modifier = softLayerShadow( private fun Modifier.materialShadow(shape: Shape, radius: Dp): Modifier {
radius = radius, val isBlurEnabled = LocalHazeState.current.blurEnabled
color = Color.Black.copy(alpha = 0.12f), return softLayerShadow(
shape = shape, radius = radius,
spread = 0.dp, color = Color.Black.copy(alpha = 0.12f),
offset = DpOffset(x = 0.dp, y = 8.dp), shape = shape,
isAlphaContentClip = true, spread = 0.dp,
) offset = DpOffset(x = 0.dp, y = 8.dp),
isAlphaContentClip = isBlurEnabled,
)
}
/** Diagonal gradient stroke that wraps the material variant. */ /** Diagonal gradient stroke that wraps the material variant. */
@Composable @Composable
@ -193,8 +196,12 @@ private fun materialBorderBrush(): Brush {
@Composable @Composable
@ReadOnlyComposable @ReadOnlyComposable
private fun tangemSurfaceRipple(): RippleConfiguration = RippleConfiguration( private fun tangemSurfaceRipple(backgroundColor: Color): RippleConfiguration = RippleConfiguration(
color = TangemTheme.colors3.interaction.press.default, color = if (backgroundColor == TangemTheme.colors3.bg.inverse) {
TangemTheme.colors3.interaction.press.inverse
} else {
TangemTheme.colors3.interaction.press.default
},
rippleAlpha = RippleAlpha( rippleAlpha = RippleAlpha(
draggedAlpha = 0f, draggedAlpha = 0f,
focusedAlpha = 0f, focusedAlpha = 0f,

View file

@ -48,7 +48,7 @@ fun TangemNavigationText(
modifier = modifier, modifier = modifier,
color = navigationTextColor(role), color = navigationTextColor(role),
style = navigationTextStyle(role), style = navigationTextStyle(role),
textAlign = TextAlign.Center, textAlign = TextAlign.Start,
maxLines = maxLines, maxLines = maxLines,
overflow = overflow, overflow = overflow,
) )
@ -73,7 +73,7 @@ fun TangemNavigationText(
modifier = modifier, modifier = modifier,
color = navigationTextColor(role), color = navigationTextColor(role),
style = navigationTextStyle(role), style = navigationTextStyle(role),
textAlign = TextAlign.Center, textAlign = TextAlign.Start,
maxLines = maxLines, maxLines = maxLines,
overflow = overflow, overflow = overflow,
) )

View file

@ -53,7 +53,7 @@ private enum class SlotId { Start, Content, Group, End }
* @param blurBackground Whether the fade behind the row should blur the content below. * @param blurBackground Whether the fade behind the row should blur the content below.
* @param startButton Leading slot. Typically a back button (see [TangemButton.Back]). * @param startButton Leading slot. Typically a back button (see [TangemButton.Back]).
* @param endButtonsGroup Optional pill-grouped secondary actions placed just before [endButton]. * @param endButtonsGroup Optional pill-grouped secondary actions placed just before [endButton].
* @param endButton Trailing slot. Typically a close button (see [TangemButton.Close]). * @param endButton Trailing slot. Typically, a close button (see [TangemButton.Close]).
* @param contentColumn Center slot. Place title/subtitle children here. * @param contentColumn Center slot. Place title/subtitle children here.
*/ */
@Suppress("LongMethod") @Suppress("LongMethod")
@ -88,7 +88,6 @@ fun TangemTopNavigation(
blur = blurBackground, blur = blurBackground,
) )
val groupSpacing = 8.dp
Layout( Layout(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -108,7 +107,7 @@ fun TangemTopNavigation(
Column( Column(
modifier = Modifier modifier = Modifier
.padding(horizontal = 12.dp) .padding(start = if (startButton != null) 12.dp else 0.dp, end = 12.dp)
.layoutId(SlotId.Content), .layoutId(SlotId.Content),
horizontalAlignment = when (contentAlign) { horizontalAlignment = when (contentAlign) {
TangemTopNavigation.ContentAlign.Start -> Alignment.Start TangemTopNavigation.ContentAlign.Start -> Alignment.Start
@ -148,7 +147,7 @@ fun TangemTopNavigation(
} }
}, },
) { measurables, constraints -> ) { measurables, constraints ->
val groupSpacingPx = groupSpacing.roundToPx() val groupSpacingPx = 8.dp.roundToPx()
val totalWidth = constraints.maxWidth val totalWidth = constraints.maxWidth
val startM = measurables.first { it.layoutId == SlotId.Start } val startM = measurables.first { it.layoutId == SlotId.Start }
@ -161,13 +160,15 @@ fun TangemTopNavigation(
val endP = endM.measure(slotConstraints) val endP = endM.measure(slotConstraints)
val groupP = groupM.measure(slotConstraints) val groupP = groupM.measure(slotConstraints)
val endGap = if (endP.width > 0) groupSpacingPx else 0
val groupOccupiedWidth = if (groupP.width > 0) endGap + groupP.width else 0
val trailingWidth = endP.width + groupOccupiedWidth
val contentMaxWidth = when (contentAlign) { val contentMaxWidth = when (contentAlign) {
// Symmetric band so the content can be visually centered within `totalWidth`
// without colliding with the start/end slots.
TangemTopNavigation.ContentAlign.Center -> TangemTopNavigation.ContentAlign.Center ->
(totalWidth - 2 * maxOf(startP.width, endP.width)).coerceAtLeast(0) (totalWidth - 2 * maxOf(startP.width, trailingWidth)).coerceAtLeast(0)
TangemTopNavigation.ContentAlign.Start -> TangemTopNavigation.ContentAlign.Start ->
(totalWidth - startP.width - endP.width).coerceAtLeast(0) (totalWidth - startP.width - trailingWidth).coerceAtLeast(0)
} }
val contentP = contentM.measure(slotConstraints.copy(maxWidth = contentMaxWidth)) val contentP = contentM.measure(slotConstraints.copy(maxWidth = contentMaxWidth))
@ -183,15 +184,14 @@ fun TangemTopNavigation(
((totalWidth - contentP.width) / 2) ((totalWidth - contentP.width) / 2)
.coerceIn( .coerceIn(
startP.width, startP.width,
(totalWidth - endP.width - contentP.width).coerceAtLeast(startP.width), (totalWidth - trailingWidth - contentP.width).coerceAtLeast(startP.width),
) )
} }
contentP.placeRelative(x = contentX, y = centerY(contentP.height)) contentP.placeRelative(x = contentX, y = centerY(contentP.height))
endP.placeRelative(x = totalWidth - endP.width, y = centerY(endP.height)) endP.placeRelative(x = totalWidth - endP.width, y = centerY(endP.height))
// Group floats to the left of endButton with `groupSpacing` gap, overlaying the
// tail of the content band if necessary. val groupX = (totalWidth - endP.width - endGap - groupP.width)
val groupX = (totalWidth - endP.width - groupSpacingPx - groupP.width)
.coerceAtLeast(0) .coerceAtLeast(0)
groupP.placeRelative(x = groupX, y = centerY(groupP.height)) groupP.placeRelative(x = groupX, y = centerY(groupP.height))
} }

View file

@ -7,6 +7,7 @@ import androidx.compose.foundation.text.selection.TextSelectionColors
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.* import androidx.compose.runtime.*
import com.tangem.core.ui.components.haze.ProvideHaze import com.tangem.core.ui.components.haze.ProvideHaze
import com.tangem.core.ui.ds2.shimmers.ProvideTangemShimmer
import com.tangem.core.ui.res.generated.TangemTypography3 import com.tangem.core.ui.res.generated.TangemTypography3
import com.tangem.core.ui.res.generated.darkColors3 import com.tangem.core.ui.res.generated.darkColors3
import com.tangem.core.ui.res.generated.lightColors3 import com.tangem.core.ui.res.generated.lightColors3
@ -48,8 +49,10 @@ fun TangemThemeRedesign(content: @Composable () -> Unit) {
CompositionLocalProvider( CompositionLocalProvider(
LocalTextSelectionColors provides TangemTextSelectionColors2, LocalTextSelectionColors provides TangemTextSelectionColors2,
) { ) {
ProvideHaze { ProvideTangemShimmer {
content() ProvideHaze {
content()
}
} }
} }
} }

View file

@ -3,4 +3,5 @@ package com.tangem.core.ui.test
object BuyTokenScreenTestTags { object BuyTokenScreenTestTags {
const val LAZY_LIST = "BUY_TOKEN_SCREEN_LAZY_LIST" const val LAZY_LIST = "BUY_TOKEN_SCREEN_LAZY_LIST"
const val LAZY_LIST_ITEM = "BUY_TOKEN_SCREEN_LAZY_LIST_ITEM" const val LAZY_LIST_ITEM = "BUY_TOKEN_SCREEN_LAZY_LIST_ITEM"
const val WALLET_TAB = "BUY_TOKEN_SCREEN_WALLET_TAB"
} }

View file

@ -5,4 +5,5 @@ object DetailsScreenTestTags {
const val SCREEN_ITEM = "DETAILS_SCREEN_ITEM" const val SCREEN_ITEM = "DETAILS_SCREEN_ITEM"
const val VERSION_NAME = "DETAILS_SCREEN_VERSION_NAME" const val VERSION_NAME = "DETAILS_SCREEN_VERSION_NAME"
const val USER_WALLET_ITEM = "DETAILS_SCREEN_USER_WALLET_ITEM" const val USER_WALLET_ITEM = "DETAILS_SCREEN_USER_WALLET_ITEM"
const val ADD_WALLET_BUTTON = "DETAILS_SCREEN_ADD_WALLET_BUTTON"
} }

View file

@ -1,18 +1,3 @@
<!--
~ Copyright (C) 2026 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"

View file

@ -1,18 +1,3 @@
<!--
~ Copyright (C) 2026 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp" android:width="20dp"
android:height="20dp" android:height="20dp"

View file

@ -1,18 +1,3 @@
<!--
~ Copyright (C) 2026 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp" android:width="20dp"
android:height="20dp" android:height="20dp"

View file

@ -1,18 +1,3 @@
<!--
~ Copyright (C) 2026 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"

View file

@ -0,0 +1,21 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="22dp"
android:height="22dp"
android:viewportWidth="44"
android:viewportHeight="44">
<path
android:pathData="M22.0779,0.0009C22.2017,6.0786 22.4682,12.1564 22.8218,18.234C23.2874,26.2385 26.7009,34.2436 29.0827,42.2481C29.1354,42.4251 29.185,42.6029 29.2367,42.7799C26.9701,43.5691 24.5355,44 22,44C19.4649,44 17.0305,43.5698 14.7642,42.7808C14.816,42.6034 14.8663,42.4255 14.9191,42.2481C17.3009,34.2436 20.7144,26.2385 21.18,18.234C21.5336,12.1564 21.7992,6.0786 21.923,0.0009C21.9487,0.0008 21.9743,0 22,0C22.026,0 22.0519,0.0008 22.0779,0.0009Z"
android:fillColor="#000000" />
<path
android:pathData="M20.7262,21.2364C19.4639,28.1647 15.6505,35.0935 12.8718,42.0216C12.0526,41.6475 11.2603,41.2248 10.4987,40.7568C14.3095,34.2502 19.1923,27.7431 20.7262,21.2364Z"
android:fillColor="#000000" />
<path
android:pathData="M23.2747,21.2364C24.8085,27.7426 29.6898,34.2498 33.5004,40.7559C32.739,41.2237 31.9472,41.6467 31.1282,42.0208C28.3495,35.093 24.5369,28.1642 23.2747,21.2364Z"
android:fillColor="#000000" />
<path
android:pathData="M20.6062,0.0448C20.5973,6.1451 20.5901,12.2455 20.5641,18.3459C20.5354,25.059 13.5075,31.7743 7.4363,38.4875C2.8764,34.4566 0,28.5647 0,22C0,10.3179 9.1054,0.7638 20.6062,0.0448Z"
android:fillColor="#000000" />
<path
android:pathData="M23.3938,0.0448C34.8946,0.7638 44,10.3179 44,22C44,28.5649 41.123,34.4565 36.5628,38.4875C30.4919,31.7747 23.4655,25.0585 23.4368,18.3459C23.4108,12.2455 23.4027,6.1451 23.3938,0.0448Z"
android:fillColor="#000000" />
</vector>

View file

@ -1,18 +1,3 @@
<!--
~ Copyright (C) 2026 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp" android:width="20dp"
android:height="20dp" android:height="20dp"

View file

@ -1,18 +1,3 @@
<!--
~ Copyright (C) 2026 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp" android:width="20dp"
android:height="20dp" android:height="20dp"

View file

@ -1,18 +1,3 @@
<!--
~ Copyright (C) 2026 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:viewportHeight="32" android:viewportWidth="32" android:width="24dp"> <vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:viewportHeight="32" android:viewportWidth="32" android:width="24dp">
<path android:fillColor="#EBEBEB" android:pathData="M16,23.697L22.663,27.802C23.715,28.45 25.011,27.488 24.733,26.268L22.964,18.528L28.852,13.321C29.78,12.5 29.284,10.944 28.061,10.838L20.31,10.167L17.278,2.864C16.799,1.712 15.201,1.712 14.722,2.864L11.69,10.167L3.939,10.838C2.716,10.944 2.22,12.5 3.148,13.321L9.036,18.528L7.267,26.268C6.988,27.488 8.285,28.45 9.336,27.802L16,23.697Z"/> <path android:fillColor="#EBEBEB" android:pathData="M16,23.697L22.663,27.802C23.715,28.45 25.011,27.488 24.733,26.268L22.964,18.528L28.852,13.321C29.78,12.5 29.284,10.944 28.061,10.838L20.31,10.167L17.278,2.864C16.799,1.712 15.201,1.712 14.722,2.864L11.69,10.167L3.939,10.838C2.716,10.944 2.22,12.5 3.148,13.321L9.036,18.528L7.267,26.268C6.988,27.488 8.285,28.45 9.336,27.802L16,23.697Z"/>

View file

@ -1,18 +1,3 @@
<!--
~ Copyright (C) 2026 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"

View file

@ -1,18 +1,3 @@
<!--
~ Copyright (C) 2026 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt" xmlns:aapt="http://schemas.android.com/aapt"
android:width="128dp" android:width="128dp"

View file

@ -1,18 +1,3 @@
<!--
~ Copyright (C) 2026 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="22dp" android:width="22dp"
android:height="8dp" android:height="8dp"

View file

@ -1,18 +1,3 @@
<!--
~ Copyright (C) 2026 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp" android:width="20dp"
android:height="20dp" android:height="20dp"

View file

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="22dp"
android:height="22dp"
android:viewportWidth="44"
android:viewportHeight="44">
<path
android:pathData="M22,0C34.15,0 44,9.85 44,22C44,34.15 34.15,44 22,44C9.85,44 0,34.15 0,22C0,9.85 9.85,0 22,0Z"
android:fillColor="#242424" />
<path
android:pathData="M22,0C34.15,0 44,9.85 44,22C44,34.15 34.15,44 22,44C9.85,44 0,34.15 0,22C0,9.85 9.85,0 22,0ZM22.0779,0.0009C22.2017,6.0786 22.4682,12.1564 22.8218,18.234C23.2874,26.2385 26.7009,34.2436 29.0827,42.2481C29.1354,42.4251 29.185,42.6029 29.2367,42.7799C26.9701,43.5691 24.5355,44 22,44C19.4649,44 17.0305,43.5698 14.7642,42.7808C14.816,42.6034 14.8663,42.4255 14.9191,42.2481C17.3009,34.2436 20.7144,26.2385 21.18,18.234C21.5336,12.1564 21.7992,6.0786 21.923,0.0009C21.9487,0.0008 21.9743,0 22,0C22.026,0 22.0519,0.0008 22.0779,0.0009ZM20.7262,21.2364C19.4639,28.1647 15.6505,35.0935 12.8718,42.0216C12.0526,41.6475 11.2603,41.2248 10.4987,40.7568C14.3095,34.2502 19.1923,27.7431 20.7262,21.2364ZM23.2747,21.2364C24.8085,27.7426 29.6898,34.2498 33.5004,40.7559C32.739,41.2237 31.9472,41.6467 31.1282,42.0208C28.3495,35.093 24.5369,28.1642 23.2747,21.2364ZM20.6062,0.0448C20.5973,6.1451 20.5901,12.2455 20.5641,18.3459C20.5354,25.059 13.5075,31.7743 7.4363,38.4875C2.8764,34.4566 0,28.5647 0,22C0,10.3179 9.1054,0.7638 20.6062,0.0448ZM23.3938,0.0448C34.8946,0.7638 44,10.3179 44,22C44,28.5649 41.123,34.4565 36.5628,38.4875C30.4919,31.7747 23.4655,25.0585 23.4368,18.3459C23.4108,12.2455 23.4027,6.1451 23.3938,0.0448Z"
android:fillColor="#FFFFFF"
android:fillType="evenOdd" />
</vector>

View file

@ -25,6 +25,7 @@ import com.tangem.datasource.api.tangemTech.models.orDefault
import com.tangem.datasource.utils.getSyncOrNull import com.tangem.datasource.utils.getSyncOrNull
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -143,6 +144,11 @@ internal class DefaultWalletAccountsFetcher @Inject constructor(
apiResponse.bind().enrichByAccountId() apiResponse.bind().enrichByAccountId()
}, },
onError = { error -> onError = { error ->
TangemLogger.e(
"pushInternal wallet=$userWalletId: PUT /accounts failed, " +
"isPreconditionFailed=${error.isNetworkError(code = Code.PRECONDITION_FAILED)}, error=$error",
)
if (error.isNetworkError(code = Code.PRECONDITION_FAILED)) { if (error.isNetworkError(code = Code.PRECONDITION_FAILED)) {
throw error throw error
} }

View file

@ -59,6 +59,8 @@ internal class FetchWalletAccountsErrorHandler @Inject constructor(
storeWalletAccounts: suspend (UserWalletId, GetWalletAccountsResponse) -> Unit, storeWalletAccounts: suspend (UserWalletId, GetWalletAccountsResponse) -> Unit,
): FetchResult { ): FetchResult {
val isResponseUpToDate = error.isNetworkError(code = Code.NOT_MODIFIED) val isResponseUpToDate = error.isNetworkError(code = Code.NOT_MODIFIED)
val isNotFoundError = error.isNetworkError(code = Code.NOT_FOUND)
if (isResponseUpToDate) { if (isResponseUpToDate) {
TangemLogger.e("ETag is up to date, no need to update accounts for wallet: $userWalletId") TangemLogger.e("ETag is up to date, no need to update accounts for wallet: $userWalletId")
val response = requireNotNull(savedAccountsResponse) { val response = requireNotNull(savedAccountsResponse) {
@ -71,13 +73,16 @@ internal class FetchWalletAccountsErrorHandler @Inject constructor(
val response = savedAccountsResponse ?: createDefaultResponse(userWalletId) val response = savedAccountsResponse ?: createDefaultResponse(userWalletId)
val (accountDTOs, userTokensResponse) = response.accounts to response.toUserTokensResponse() val (accountDTOs, userTokensResponse) = response.accounts to response.toUserTokensResponse()
val isNotFoundError = error.isNetworkError(code = Code.NOT_FOUND)
if (isNotFoundError) { if (isNotFoundError) {
val eTag = createWallet(userWalletId) val eTag = createWallet(userWalletId)
if (eTag != null) { if (eTag != null) {
pushWalletAccounts(accountDTOs, eTag) pushWalletAccounts(accountDTOs, eTag)
userTokensSaver.pushWithRetryer(userWalletId, userTokensResponse) userTokensSaver.pushWithRetryer(userWalletId, userTokensResponse)
} else {
TangemLogger.e(
"handle wallet=$userWalletId: account creation skipped, createWallet returned null eTag",
)
} }
} }
@ -112,10 +117,17 @@ internal class FetchWalletAccountsErrorHandler @Inject constructor(
private suspend fun createWallet(userWalletId: UserWalletId): String? { private suspend fun createWallet(userWalletId: UserWalletId): String? {
val creationResponse = walletServerBinder.bind(userWalletId) val creationResponse = walletServerBinder.bind(userWalletId)
return if (creationResponse is ApiResponse.Success && creationResponse.code == Code.CREATED) { val isCreated = creationResponse is ApiResponse.Success && creationResponse.code == Code.CREATED
val eTag = if (isCreated) {
creationResponse.headers[ETAG_HEADER]?.firstOrNull() creationResponse.headers[ETAG_HEADER]?.firstOrNull()
} else { } else {
null null
} }
if (eTag == null) {
TangemLogger.e("ETag is null for wallet: $userWalletId, isCreated: $isCreated")
}
return eTag
} }
} }

View file

@ -10,11 +10,16 @@ internal class DefaultAppsFlyerRepository @Inject constructor(
private val appsFlyerStore: AppsFlyerStore, private val appsFlyerStore: AppsFlyerStore,
) : AppsFlyerRepository { ) : AppsFlyerRepository {
override suspend fun getDeeplink(source: AppsFlyerDeeplinkSource): String? {
return appsFlyerStore.getDeeplink(source.toStoreSource())
}
override suspend fun clearDeeplink(source: AppsFlyerDeeplinkSource) { override suspend fun clearDeeplink(source: AppsFlyerDeeplinkSource) {
appsFlyerStore.clearDeeplink(source.toStoreSource()) appsFlyerStore.clearDeeplink(source.toStoreSource())
} }
private fun AppsFlyerDeeplinkSource.toStoreSource() = when (this) { private fun AppsFlyerDeeplinkSource.toStoreSource() = when (this) {
AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding -> StoreDeeplinkSource.TangemPayHotWalletOnboarding AppsFlyerDeeplinkSource.TangemPayHotWalletOnboarding -> StoreDeeplinkSource.TangemPayHotWalletOnboarding
AppsFlyerDeeplinkSource.Referral -> StoreDeeplinkSource.Referral
} }
} }

View file

@ -3,6 +3,7 @@ package com.tangem.data.appsflyer.di
import com.tangem.data.appsflyer.DefaultAppsFlyerRepository import com.tangem.data.appsflyer.DefaultAppsFlyerRepository
import com.tangem.domain.appsflyer.repository.AppsFlyerRepository import com.tangem.domain.appsflyer.repository.AppsFlyerRepository
import com.tangem.domain.appsflyer.usecase.ClearAppsFlyerDeeplinkUseCase import com.tangem.domain.appsflyer.usecase.ClearAppsFlyerDeeplinkUseCase
import com.tangem.domain.appsflyer.usecase.IsReferralInstallUseCase
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
@ -26,5 +27,10 @@ internal interface AppsFlyerDataModule {
): ClearAppsFlyerDeeplinkUseCase { ): ClearAppsFlyerDeeplinkUseCase {
return ClearAppsFlyerDeeplinkUseCase(appsFlyerRepository) return ClearAppsFlyerDeeplinkUseCase(appsFlyerRepository)
} }
@Provides
fun provideIsReferralInstallUseCase(appsFlyerRepository: AppsFlyerRepository): IsReferralInstallUseCase {
return IsReferralInstallUseCase(appsFlyerRepository)
}
} }
} }

View file

@ -1,6 +1,7 @@
package com.tangem.data.common.wallet package com.tangem.data.common.wallet
import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ETAG_HEADER
import com.tangem.datasource.api.tangemTech.TangemTechApi import com.tangem.datasource.api.tangemTech.TangemTechApi
import com.tangem.datasource.api.tangemTech.converters.WalletIdBodyConverter import com.tangem.datasource.api.tangemTech.converters.WalletIdBodyConverter
import com.tangem.datasource.local.appsflyer.AppsFlyerStore import com.tangem.datasource.local.appsflyer.AppsFlyerStore
@ -9,6 +10,7 @@ import com.tangem.domain.common.wallets.getSyncOrNull
import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
internal class DefaultWalletServerBinder( internal class DefaultWalletServerBinder(
@ -19,7 +21,12 @@ internal class DefaultWalletServerBinder(
) : WalletServerBinder { ) : WalletServerBinder {
override suspend fun bind(userWalletId: UserWalletId): ApiResponse<Unit>? { override suspend fun bind(userWalletId: UserWalletId): ApiResponse<Unit>? {
val userWallet = userWalletsListRepository.getSyncOrNull(id = userWalletId) ?: return null val userWallet = userWalletsListRepository.getSyncOrNull(id = userWalletId)
if (userWallet == null) {
TangemLogger.e("bind wallet=$userWalletId: user wallet not found locally, skipping createWallet call")
return null
}
return bind(userWallet) return bind(userWallet)
} }
@ -31,6 +38,12 @@ internal class DefaultWalletServerBinder(
tangemTechApi.createWallet( tangemTechApi.createWallet(
body = WalletIdBodyConverter.convert(userWallet, conversionData), body = WalletIdBodyConverter.convert(userWallet, conversionData),
) )
}.also { response ->
val eTag = response.headers[ETAG_HEADER]?.firstOrNull()
TangemLogger.i(
"bind wallet=${userWallet.walletId}: createWallet code=${(response as? ApiResponse.Success)?.code}, " +
"hasETag=${eTag != null}, eTagNotEmpty=${!eTag.isNullOrEmpty()}",
)
} }
} }
} }

View file

@ -387,6 +387,7 @@ class NetworkFactoryTest {
Blockchain.CosmosTestnet, Blockchain.CosmosTestnet,
Blockchain.Dogecoin, Blockchain.Dogecoin,
Blockchain.Ducatus, Blockchain.Ducatus,
Blockchain.Gonka,
Blockchain.Ethereum, Blockchain.EthereumTestnet, Blockchain.Ethereum, Blockchain.EthereumTestnet,
Blockchain.EthereumClassic, Blockchain.EthereumClassicTestnet, Blockchain.EthereumClassic, Blockchain.EthereumClassicTestnet,
Blockchain.Fantom, Blockchain.FantomTestnet, Blockchain.Fantom, Blockchain.FantomTestnet,

View file

@ -166,5 +166,6 @@ public val Blockchain.mercuryoNetwork: String?
Blockchain.Adi, Blockchain.AdiTestnet -> null Blockchain.Adi, Blockchain.AdiTestnet -> null
Blockchain.SeiEvm, Blockchain.SeiEvmTestnet -> null Blockchain.SeiEvm, Blockchain.SeiEvmTestnet -> null
Blockchain.Monad, Blockchain.MonadTestnet -> null Blockchain.Monad, Blockchain.MonadTestnet -> null
Blockchain.Gonka -> null
} }
} }

View file

@ -8,7 +8,6 @@ import com.tangem.data.common.api.safeApiCall
import com.tangem.data.staking.store.P2PEthPoolBalancesStore import com.tangem.data.staking.store.P2PEthPoolBalancesStore
import com.tangem.data.staking.store.StakeKitBalancesStore import com.tangem.data.staking.store.StakeKitBalancesStore
import com.tangem.data.staking.utils.YieldBalanceRequestBodyFactory import com.tangem.data.staking.utils.YieldBalanceRequestBodyFactory
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.ethpool.P2PEthPoolApi import com.tangem.datasource.api.ethpool.P2PEthPoolApi
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse
import com.tangem.datasource.api.stakekit.StakeKitApi import com.tangem.datasource.api.stakekit.StakeKitApi
@ -25,7 +24,6 @@ import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isMultiCurrency import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.staking.model.StakingIntegrationID import com.tangem.domain.staking.model.StakingIntegrationID
import com.tangem.domain.staking.model.ethpool.P2PEthPoolStakingConfig
import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault
import com.tangem.domain.staking.multi.MultiStakingBalanceFetcher import com.tangem.domain.staking.multi.MultiStakingBalanceFetcher
import com.tangem.utils.coroutines.CoroutineDispatcherProvider import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -65,6 +63,11 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor(
private val dispatchers: CoroutineDispatcherProvider, private val dispatchers: CoroutineDispatcherProvider,
) : MultiStakingBalanceFetcher { ) : MultiStakingBalanceFetcher {
private val p2pAccountsFetcher = P2PEthPoolAccountsFetcher(
p2pEthPoolApi = p2pEthPoolApi,
dispatchers = dispatchers,
)
override suspend fun invoke(params: MultiStakingBalanceFetcher.Params): Either<Throwable, Unit> { override suspend fun invoke(params: MultiStakingBalanceFetcher.Params): Either<Throwable, Unit> {
TangemLogger.i("Start fetching staking balances for params:\n$params") TangemLogger.i("Start fetching staking balances for params:\n$params")
@ -195,50 +198,7 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor(
vaults: List<P2PEthPoolVault>, vaults: List<P2PEthPoolVault>,
addresses: Set<String>, addresses: Set<String>,
): Set<P2PEthPoolAccountResponse> { ): Set<P2PEthPoolAccountResponse> {
val responses = mutableSetOf<P2PEthPoolAccountResponse>() return p2pAccountsFetcher.fetchBatch(vaults = vaults, addresses = addresses)
for (vault in vaults) {
for (address in addresses) {
runSuspendCatching {
val response = p2pEthPoolApi.getAccountInfo(
network = P2PEthPoolStakingConfig.activeNetwork.value,
delegatorAddress = address,
vaultAddress = vault.vaultAddress,
)
when (response) {
is ApiResponse.Success -> {
val data = response.data
if (data.error != null) {
TangemLogger.w(
"P2PEthPool API returned error for vault ${vault.vaultAddress}, " +
"address $address: ${data.error ?: "error"}",
)
} else {
val result = requireNotNull(data.result) {
"Result is null in successful response"
}
responses.add(result)
}
}
is ApiResponse.Error -> {
TangemLogger.w(
"Failed to fetch P2PEthPool balance for vault ${vault.vaultAddress}, " +
"address $address",
response.cause,
)
}
}
}.onFailure { error ->
TangemLogger.w(
"Failed to fetch P2PEthPool balance for vault ${vault.vaultAddress}, address $address",
error,
)
}
}
}
return responses
} }
private inline fun checkIsSupportedByWalletOrElse(userWalletId: UserWalletId, ifNotSupported: (Throwable) -> Unit) { private inline fun checkIsSupportedByWalletOrElse(userWalletId: UserWalletId, ifNotSupported: (Throwable) -> Unit) {

View file

@ -0,0 +1,102 @@
package com.tangem.data.staking.multi
import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.ethpool.P2PEthPoolApi
import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolAccountsListRequest
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountResponse
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolAccountsListResponse
import com.tangem.datasource.api.ethpool.models.response.P2PEthPoolResponse
import com.tangem.domain.staking.model.ethpool.P2PEthPoolStakingConfig
import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
/**
* Fetches P2P ETH Pool account responses via the batch strategy:
* one POST per vault sending all delegator addresses at once.
*
* @property p2pEthPoolApi P2PEthPool API
* @property dispatchers coroutine dispatcher provider
*
[REDACTED_AUTHOR]
*/
internal class P2PEthPoolAccountsFetcher(
private val p2pEthPoolApi: P2PEthPoolApi,
private val dispatchers: CoroutineDispatcherProvider,
) {
suspend fun fetchBatch(vaults: List<P2PEthPoolVault>, addresses: Set<String>): Set<P2PEthPoolAccountResponse> =
coroutineScope {
val request = P2PEthPoolAccountsListRequest(delegatorAddresses = addresses.toList())
vaults
.map { vault ->
async(dispatchers.io) {
runSuspendCatching {
val response = p2pEthPoolApi.getAccountsList(
network = P2PEthPoolStakingConfig.activeNetwork.value,
vaultAddress = vault.vaultAddress,
body = request,
)
mapBatchVaultResponse(vault = vault, response = response)
}.getOrElse { error ->
TangemLogger.w(
"Failed to fetch P2PEthPool batch balances for vault ${vault.vaultAddress}",
error,
)
emptyList()
}
}
}
.awaitAll()
.flatten()
.toSet()
}
private fun mapBatchVaultResponse(
vault: P2PEthPoolVault,
response: ApiResponse<P2PEthPoolResponse<P2PEthPoolAccountsListResponse>>,
): List<P2PEthPoolAccountResponse> {
return when (response) {
is ApiResponse.Success -> {
val data = response.data
if (data.error != null) {
TangemLogger.w(
"P2PEthPool batch API returned error for vault " +
"${vault.vaultAddress}: ${data.error}",
)
emptyList()
} else {
val result = requireNotNull(data.result) {
"Result is null in successful response"
}
result.list.mapNotNull { item ->
if (item.error != null) {
TangemLogger.w(
"P2PEthPool batch item error for vault " +
"${vault.vaultAddress}, address " +
"${item.delegatorAddress}: ${item.error}",
)
null
} else {
item.account
}
}
}
}
is ApiResponse.Error -> {
TangemLogger.w(
"Failed to fetch P2PEthPool batch balances for vault " +
"${vault.vaultAddress}",
response.cause,
)
emptyList()
}
}
}
}

View file

@ -10,12 +10,15 @@ import com.tangem.data.staking.utils.YieldBalanceRequestBodyFactory
import com.tangem.datasource.api.common.response.ApiResponse import com.tangem.datasource.api.common.response.ApiResponse
import com.tangem.datasource.api.common.response.ApiResponseError import com.tangem.datasource.api.common.response.ApiResponseError
import com.tangem.datasource.api.ethpool.P2PEthPoolApi import com.tangem.datasource.api.ethpool.P2PEthPoolApi
import com.tangem.datasource.api.ethpool.models.request.P2PEthPoolAccountsListRequest
import com.tangem.datasource.api.ethpool.models.response.*
import com.tangem.datasource.api.stakekit.StakeKitApi import com.tangem.datasource.api.stakekit.StakeKitApi
import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO import com.tangem.datasource.api.stakekit.models.response.model.YieldBalanceWrapperDTO
import com.tangem.datasource.local.token.P2PEthPoolVaultsStore import com.tangem.datasource.local.token.P2PEthPoolVaultsStore
import com.tangem.datasource.local.token.StakingYieldsStore import com.tangem.datasource.local.token.StakingYieldsStore
import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.staking.StakingID import com.tangem.domain.models.staking.StakingID
import com.tangem.domain.staking.model.ethpool.P2PEthPoolVault
import com.tangem.domain.staking.multi.MultiStakingBalanceFetcher import com.tangem.domain.staking.multi.MultiStakingBalanceFetcher
import com.tangem.test.core.assertEitherLeft import com.tangem.test.core.assertEitherLeft
import com.tangem.test.core.assertEitherRight import com.tangem.test.core.assertEitherRight
@ -26,6 +29,7 @@ import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
/** /**
[REDACTED_AUTHOR] [REDACTED_AUTHOR]
@ -54,7 +58,14 @@ internal class DefaultMultiStakingBalanceFetcherTest {
@BeforeEach @BeforeEach
fun resetMocks() { fun resetMocks() {
clearMocks(userWalletsListRepository, stakingYieldsStore, stakeKitBalancesStore, stakeKitApi) clearMocks(
userWalletsListRepository,
stakingYieldsStore,
stakeKitBalancesStore,
stakeKitApi,
p2pEthPoolApi,
p2pEthPoolVaultsStore,
)
} }
@Test @Test
@ -359,6 +370,88 @@ internal class DefaultMultiStakingBalanceFetcherTest {
assertEitherLeft(actual, expected) assertEitherLeft(actual, expected)
} }
@Test
fun `fetch P2P balances via batch endpoint`() = runTest {
// Arrange
val params = MultiStakingBalanceFetcher.Params(userWalletId, setOf(p2pId1, p2pId2))
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet))
coEvery { p2pEthPoolVaultsStore.getSync() } returns listOf(vault(VAULT_A), vault(VAULT_B))
coEvery { p2pEthPoolApi.getAccountsList(any(), VAULT_A, any()) } returns
accountsListSuccess(accountResponse(ADDR_1, VAULT_A))
coEvery { p2pEthPoolApi.getAccountsList(any(), VAULT_B, any()) } returns
accountsListSuccess(accountResponse(ADDR_2, VAULT_B))
// Actual
val actual = fetcher.invoke(params)
// Assert
coVerify(exactly = 1) {
p2pEthPoolApi.getAccountsList(
network = any(),
vaultAddress = VAULT_A,
body = match { it.delegatorAddresses.containsAll(listOf(ADDR_1, ADDR_2)) },
)
}
coVerify(exactly = 1) {
p2pEthPoolApi.getAccountsList(network = any(), vaultAddress = VAULT_B, body = any())
}
coVerify(inverse = true) { p2pEthPoolApi.getAccountInfo(any(), any(), any()) }
coVerify { p2PEthPoolBalancesStore.storeActual(userWalletId = userWalletId, values = any()) }
assertEitherRight(actual)
}
@Test
fun `fetch P2P batch maps per-item error to missing stakingId`() = runTest {
// Arrange
val params = MultiStakingBalanceFetcher.Params(userWalletId, setOf(p2pId1, p2pId2))
every { userWalletsListRepository.userWallets } returns MutableStateFlow(listOf(userWallet))
coEvery { p2pEthPoolVaultsStore.getSync() } returns listOf(vault(VAULT_A))
coEvery { p2pEthPoolApi.getAccountsList(any(), VAULT_A, any()) } returns
ApiResponse.Success(
P2PEthPoolResponse(
error = null,
result = P2PEthPoolAccountsListResponse(
list = listOf(
P2PEthPoolAccountListItem(
delegatorAddress = ADDR_1,
account = accountResponse(ADDR_1, VAULT_A),
error = null,
),
P2PEthPoolAccountListItem(
delegatorAddress = ADDR_2,
account = null,
error = P2PEthPoolErrorDetailsDTO(
code = 127108,
message = "invalid",
name = null,
errors = null,
),
),
),
),
),
)
// Actual
val actual = fetcher.invoke(params)
// Assert
coVerify { p2PEthPoolBalancesStore.storeActual(userWalletId = userWalletId, values = any()) }
coVerify {
p2PEthPoolBalancesStore.storeError(
userWalletId = userWalletId,
stakingIds = match { it == setOf(p2pId2) },
)
}
assertEitherRight(actual)
}
private companion object { private companion object {
val userWallet = MockUserWalletFactory.create() val userWallet = MockUserWalletFactory.create()
val userWalletId = userWallet.walletId val userWalletId = userWallet.walletId
@ -370,5 +463,55 @@ internal class DefaultMultiStakingBalanceFetcherTest {
) )
val tonAndSolanaIds = setOf(tonId, solanaId) val tonAndSolanaIds = setOf(tonId, solanaId)
const val ADDR_1 = "0x1111111111111111111111111111111111111111"
const val ADDR_2 = "0x2222222222222222222222222222222222222222"
const val VAULT_A = "0xVaultAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
const val VAULT_B = "0xVaultBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
val p2pId1 = StakingID(integrationId = "p2p-ethereum-pooled", address = ADDR_1)
val p2pId2 = StakingID(integrationId = "p2p-ethereum-pooled", address = ADDR_2)
fun vault(address: String) = P2PEthPoolVault(
vaultAddress = address,
displayName = "Vault",
apy = BigDecimal("4.5"),
baseApy = BigDecimal("4.0"),
capacity = BigDecimal("1000"),
totalAssets = BigDecimal("100"),
feePercent = BigDecimal("10"),
isPrivate = false,
isGenesis = false,
isSmoothingPool = true,
isErc20 = false,
tokenName = null,
tokenSymbol = null,
createdAt = 0L,
)
fun accountResponse(address: String, vaultAddress: String) = P2PEthPoolAccountResponse(
delegatorAddress = address,
vaultAddress = vaultAddress,
stake = P2PEthPoolStakeDTO(assets = BigDecimal("1.5"), totalEarnedAssets = BigDecimal("0.1")),
availableToUnstake = BigDecimal.ZERO,
availableToWithdraw = BigDecimal.ZERO,
exitQueue = P2PEthPoolExitQueueDTO(total = BigDecimal.ZERO, requests = emptyList()),
)
fun accountsListSuccess(vararg accounts: P2PEthPoolAccountResponse) =
ApiResponse.Success(
P2PEthPoolResponse(
error = null,
result = P2PEthPoolAccountsListResponse(
list = accounts.map {
P2PEthPoolAccountListItem(
delegatorAddress = it.delegatorAddress,
account = it,
error = null,
)
},
),
),
)
} }
} }

View file

@ -346,6 +346,7 @@ internal class DefaultTransactionRepository(
Blockchain.Binance -> BinanceTransactionExtras(memo) Blockchain.Binance -> BinanceTransactionExtras(memo)
Blockchain.XRP -> memo.toLongOrNull()?.let { XrpTransactionBuilder.XrpTransactionExtras(it) } Blockchain.XRP -> memo.toLongOrNull()?.let { XrpTransactionBuilder.XrpTransactionExtras(it) }
Blockchain.Cosmos, Blockchain.Cosmos,
Blockchain.Gonka,
Blockchain.Sei, Blockchain.Sei,
Blockchain.TerraV1, Blockchain.TerraV1,
Blockchain.TerraV2, Blockchain.TerraV2,

View file

@ -57,7 +57,7 @@ internal class WcSolanaSignAndSendTransactionUseCase @AssistedInject constructor
val formattedHash = getFormattedHash(hash) // uses for flow sendLargeSolanaTransaction val formattedHash = getFormattedHash(hash) // uses for flow sendLargeSolanaTransaction
if (context.session.wallet is UserWallet.Cold && isLargeHash(formattedHash)) { if (context.session.wallet is UserWallet.Cold && isLargeHash(formattedHash)) {
// workaround for large transactions that cannot be signed directly by card // workaround for large transactions that cannot be signed directly by card
TangemLogger.w("The transaction hash is too large to be signed directly: ${formattedHash.size} bytes") TangemLogger.i("The transaction hash is too large to be signed directly: ${formattedHash.size} bytes")
sendLargeSolanaTransactionUseCase(context.session.wallet as UserWallet.Cold, context.network, formattedHash) sendLargeSolanaTransactionUseCase(context.session.wallet as UserWallet.Cold, context.network, formattedHash)
.fold( .fold(
ifLeft = { error -> ifLeft = { error ->

View file

@ -10,12 +10,12 @@ import com.tangem.domain.account.supplier.MultiAccountListSupplier
import com.tangem.domain.account.supplier.SingleAccountListSupplier import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.card.IsWalletBackupProblematicUseCase import com.tangem.domain.card.IsWalletBackupProblematicUseCase
import com.tangem.domain.common.wallets.UserWalletsListRepository import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
import com.tangem.domain.express.ExpressServiceFetcher import com.tangem.domain.express.ExpressServiceFetcher
import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher
import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier
import com.tangem.domain.networks.utils.NetworksCleaner import com.tangem.domain.networks.utils.NetworksCleaner
import com.tangem.domain.nft.utils.NFTCleaner import com.tangem.domain.nft.utils.NFTCleaner
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher import com.tangem.domain.quotes.multi.MultiQuoteStatusFetcher
import com.tangem.domain.staking.StakingIdFactory import com.tangem.domain.staking.StakingIdFactory
import com.tangem.domain.staking.multi.MultiStakingBalanceFetcher import com.tangem.domain.staking.multi.MultiStakingBalanceFetcher
@ -71,10 +71,12 @@ internal object AccountStatusUseCaseModule {
fun provideIsAccountsModeEnabledUseCase( fun provideIsAccountsModeEnabledUseCase(
multiAccountListSupplier: MultiAccountListSupplier, multiAccountListSupplier: MultiAccountListSupplier,
paymentAccountStatusSupplier: PaymentAccountStatusSupplier, paymentAccountStatusSupplier: PaymentAccountStatusSupplier,
appCoroutineScope: AppCoroutineScope,
): IsAccountsModeEnabledUseCase { ): IsAccountsModeEnabledUseCase {
return IsAccountsModeEnabledUseCase( return IsAccountsModeEnabledUseCase(
multiAccountListSupplier = multiAccountListSupplier, multiAccountListSupplier = multiAccountListSupplier,
paymentAccountStatusSupplier = paymentAccountStatusSupplier, paymentAccountStatusSupplier = paymentAccountStatusSupplier,
appCoroutineScope = appCoroutineScope,
) )
} }

View file

@ -84,7 +84,7 @@ class GetAccountCurrencyByAddressUseCase(
params = MultiNetworkStatusProducer.Params(userWalletId = id), params = MultiNetworkStatusProducer.Params(userWalletId = id),
timeMillis = 1000L, timeMillis = 1000L,
) )
?.firstOrNull { it.getAddress() == address } ?.firstOrNull { it.getAddress().equals(address, ignoreCase = true) }
if (networkStatus != null) { if (networkStatus != null) {
pair = id to networkStatus.network.id pair = id to networkStatus.network.id

View file

@ -4,11 +4,13 @@ import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.supplier.MultiAccountListSupplier import com.tangem.domain.account.supplier.MultiAccountListSupplier
import com.tangem.domain.models.account.Account import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.pay.flow.PaymentAccountStatusProducer
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.logging.TangemLogger import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import java.util.concurrent.TimeUnit
/** /**
* Use case to determine if the accounts mode is enabled. * Use case to determine if the accounts mode is enabled.
@ -24,10 +26,23 @@ import kotlinx.coroutines.flow.*
class IsAccountsModeEnabledUseCase( class IsAccountsModeEnabledUseCase(
private val multiAccountListSupplier: MultiAccountListSupplier, private val multiAccountListSupplier: MultiAccountListSupplier,
private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier, private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier,
appCoroutineScope: AppCoroutineScope,
) { ) {
private val flow = createFlow()
.retry {
delay(timeMillis = TimeUnit.SECONDS.toMillis(1))
true
}
.distinctUntilChanged()
.shareIn(
scope = appCoroutineScope,
started = SharingStarted.WhileSubscribed(0, 0),
replay = 1,
)
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
operator fun invoke(): Flow<Boolean> { private fun createFlow(): Flow<Boolean> {
TangemLogger.i("$TAG: invoke() started") TangemLogger.i("$TAG: invoke() started")
val cryptoMode = multiAccountListSupplier.invoke() val cryptoMode = multiAccountListSupplier.invoke()
@ -73,25 +88,14 @@ class IsAccountsModeEnabledUseCase(
TangemLogger.i("$TAG: final combine crypto=$crypto, payment=$payment, result=$isEnabled") TangemLogger.i("$TAG: final combine crypto=$crypto, payment=$payment, result=$isEnabled")
isEnabled isEnabled
} }
.distinctUntilChanged() }
operator fun invoke(): Flow<Boolean> {
return flow
} }
suspend fun invokeSync(): Boolean { suspend fun invokeSync(): Boolean {
val accountLists = multiAccountListSupplier.getSyncOrNull(Unit).orEmpty() return flow.first()
if (accountLists.any { it.hasMultipleCryptoPortfolios() }) return true
val walletIdsWithPayment = accountLists.mapNotNull { list ->
if (list.accounts.any { it is Account.Payment }) list.userWalletId else null
}
return walletIdsWithPayment.any { walletId ->
paymentAccountStatusSupplier
.getSyncOrNull(
params = PaymentAccountStatusProducer.Params(walletId),
timeMillis = PAYMENT_STATUS_SYNC_TIMEOUT_MS,
)
?.value
?.isActivePayment() == true
}
} }
private fun AccountList.hasMultipleCryptoPortfolios(): Boolean = private fun AccountList.hasMultipleCryptoPortfolios(): Boolean =
@ -113,7 +117,6 @@ class IsAccountsModeEnabledUseCase(
} }
private companion object { private companion object {
const val PAYMENT_STATUS_SYNC_TIMEOUT_MS = 1_000L
const val TAG = "IsAccountsModeEnabledUseCase" const val TAG = "IsAccountsModeEnabledUseCase"
} }
} }

View file

@ -280,6 +280,35 @@ class GetAccountCurrencyByAddressUseCaseTest {
} }
} }
@Test
fun `returns Some when query address case differs from stored network address`() = runTest {
// Arrange
val currency = MockCryptoCurrencyFactory().ethereum
val networkStatus = NetworkStatus(
network = currency.network,
// Stored address is lower-case
value = NetworkStatus.Unreachable(address = validNetworkAddress),
)
val accountList = AccountList.empty(userWalletId = userWalletId, cryptoCurrencies = listOf(currency))
every { userWalletsListRepository.userWallets.value } returns listOf(multiUserWallet)
coEvery {
multiNetworkStatusSupplier.getSyncOrNull(params = MultiNetworkStatusProducer.Params(userWalletId), 1000)
} returns setOf(networkStatus)
coEvery {
singleAccountListSupplier.getSyncOrNull(
params = SingleAccountListProducer.Params(userWalletId = userWalletId),
)
} returns accountList
// Act — query with an EIP-55 checksummed (mixed/upper-case) variant of the same address
val actual = useCase(validAddress.uppercase())
// Assert
val expected = AccountCryptoCurrency(account = accountList.mainAccount, cryptoCurrency = currency)
assertSome(actual, expected)
}
private companion object Companion { private companion object Companion {
const val validAddress = "0x1234567890abcdef" const val validAddress = "0x1234567890abcdef"
val validNetworkAddress = NetworkAddress.Single( val validNetworkAddress = NetworkAddress.Single(

View file

@ -7,14 +7,16 @@ import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountStatus import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.PaymentAccountStatusValue import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.flow.PaymentAccountStatusProducer
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
import com.tangem.test.core.TestAppCoroutineScope
import com.tangem.test.core.getEmittedValues
import io.mockk.clearMocks import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.last import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Nested
@ -27,11 +29,6 @@ class IsAccountsModeEnabledUseCaseTest {
private val multiAccountListSupplier: MultiAccountListSupplier = mockk() private val multiAccountListSupplier: MultiAccountListSupplier = mockk()
private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier = mockk() private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier = mockk()
private val useCase = IsAccountsModeEnabledUseCase(
multiAccountListSupplier = multiAccountListSupplier,
paymentAccountStatusSupplier = paymentAccountStatusSupplier,
)
@AfterEach @AfterEach
fun tearDown() { fun tearDown() {
clearMocks(multiAccountListSupplier, paymentAccountStatusSupplier) clearMocks(multiAccountListSupplier, paymentAccountStatusSupplier)
@ -43,9 +40,9 @@ class IsAccountsModeEnabledUseCaseTest {
@Test @Test
fun `returns false when supplier emits empty list`() = runTest { fun `returns false when supplier emits empty list`() = runTest {
every { multiAccountListSupplier.invoke() } returns flowOf(emptyList()) every { multiAccountListSupplier.invoke() } returns MutableStateFlow(emptyList())
val actual = useCase.invoke().last() val actual = settledValue(createUseCase())
Truth.assertThat(actual).isFalse() Truth.assertThat(actual).isFalse()
} }
@ -53,9 +50,9 @@ class IsAccountsModeEnabledUseCaseTest {
@Test @Test
fun `returns false when single crypto portfolio account`() = runTest { fun `returns false when single crypto portfolio account`() = runTest {
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio())) val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio()))
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(list)) every { multiAccountListSupplier.invoke() } returns MutableStateFlow(listOf(list))
val actual = useCase.invoke().last() val actual = settledValue(createUseCase())
Truth.assertThat(actual).isFalse() Truth.assertThat(actual).isFalse()
} }
@ -63,9 +60,9 @@ class IsAccountsModeEnabledUseCaseTest {
@Test @Test
fun `returns true when two crypto portfolio accounts`() = runTest { fun `returns true when two crypto portfolio accounts`() = runTest {
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockCryptoPortfolio())) val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockCryptoPortfolio()))
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(list)) every { multiAccountListSupplier.invoke() } returns MutableStateFlow(listOf(list))
val actual = useCase.invoke().last() val actual = settledValue(createUseCase())
Truth.assertThat(actual).isTrue() Truth.assertThat(actual).isTrue()
} }
@ -73,10 +70,10 @@ class IsAccountsModeEnabledUseCaseTest {
@Test @Test
fun `returns true when payment account is Loaded`() = runTest { fun `returns true when payment account is Loaded`() = runTest {
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount())) val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(list)) every { multiAccountListSupplier.invoke() } returns MutableStateFlow(listOf(list))
mockPaymentStatus(WALLET_ID_1, mockk<PaymentAccountStatusValue.Loaded>()) mockPaymentStatus(WALLET_ID_1, mockk<PaymentAccountStatusValue.Loaded>())
val actual = useCase.invoke().last() val actual = settledValue(createUseCase())
Truth.assertThat(actual).isTrue() Truth.assertThat(actual).isTrue()
} }
@ -84,10 +81,10 @@ class IsAccountsModeEnabledUseCaseTest {
@Test @Test
fun `returns false when payment account is NotCreated`() = runTest { fun `returns false when payment account is NotCreated`() = runTest {
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount())) val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(list)) every { multiAccountListSupplier.invoke() } returns MutableStateFlow(listOf(list))
mockPaymentStatus(WALLET_ID_1, PaymentAccountStatusValue.NotCreated) mockPaymentStatus(WALLET_ID_1, PaymentAccountStatusValue.NotCreated)
val actual = useCase.invoke().last() val actual = settledValue(createUseCase())
Truth.assertThat(actual).isFalse() Truth.assertThat(actual).isFalse()
} }
@ -95,10 +92,10 @@ class IsAccountsModeEnabledUseCaseTest {
@Test @Test
fun `returns false when payment account is Empty`() = runTest { fun `returns false when payment account is Empty`() = runTest {
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount())) val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(list)) every { multiAccountListSupplier.invoke() } returns MutableStateFlow(listOf(list))
mockPaymentStatus(WALLET_ID_1, PaymentAccountStatusValue.Empty) mockPaymentStatus(WALLET_ID_1, PaymentAccountStatusValue.Empty)
val actual = useCase.invoke().last() val actual = settledValue(createUseCase())
Truth.assertThat(actual).isFalse() Truth.assertThat(actual).isFalse()
} }
@ -106,10 +103,10 @@ class IsAccountsModeEnabledUseCaseTest {
@Test @Test
fun `returns true when payment account is UnderReview`() = runTest { fun `returns true when payment account is UnderReview`() = runTest {
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount())) val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(list)) every { multiAccountListSupplier.invoke() } returns MutableStateFlow(listOf(list))
mockPaymentStatus(WALLET_ID_1, mockk<PaymentAccountStatusValue.UnderReview>()) mockPaymentStatus(WALLET_ID_1, mockk<PaymentAccountStatusValue.UnderReview>())
val actual = useCase.invoke().last() val actual = settledValue(createUseCase())
Truth.assertThat(actual).isTrue() Truth.assertThat(actual).isTrue()
} }
@ -117,10 +114,10 @@ class IsAccountsModeEnabledUseCaseTest {
@Test @Test
fun `returns true when payment account is IssuingCard`() = runTest { fun `returns true when payment account is IssuingCard`() = runTest {
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount())) val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(list)) every { multiAccountListSupplier.invoke() } returns MutableStateFlow(listOf(list))
mockPaymentStatus(WALLET_ID_1, mockk<PaymentAccountStatusValue.IssuingCard>()) mockPaymentStatus(WALLET_ID_1, mockk<PaymentAccountStatusValue.IssuingCard>())
val actual = useCase.invoke().last() val actual = settledValue(createUseCase())
Truth.assertThat(actual).isTrue() Truth.assertThat(actual).isTrue()
} }
@ -128,10 +125,10 @@ class IsAccountsModeEnabledUseCaseTest {
@Test @Test
fun `returns true when payment account is Loading`() = runTest { fun `returns true when payment account is Loading`() = runTest {
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount())) val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(list)) every { multiAccountListSupplier.invoke() } returns MutableStateFlow(listOf(list))
mockPaymentStatus(WALLET_ID_1, PaymentAccountStatusValue.Loading) mockPaymentStatus(WALLET_ID_1, PaymentAccountStatusValue.Loading)
val actual = useCase.invoke().last() val actual = settledValue(createUseCase())
Truth.assertThat(actual).isTrue() Truth.assertThat(actual).isTrue()
} }
@ -139,10 +136,10 @@ class IsAccountsModeEnabledUseCaseTest {
@Test @Test
fun `returns true when payment account is Deactivated`() = runTest { fun `returns true when payment account is Deactivated`() = runTest {
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount())) val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(list)) every { multiAccountListSupplier.invoke() } returns MutableStateFlow(listOf(list))
mockPaymentStatus(WALLET_ID_1, mockk<PaymentAccountStatusValue.Deactivated>()) mockPaymentStatus(WALLET_ID_1, mockk<PaymentAccountStatusValue.Deactivated>())
val actual = useCase.invoke().last() val actual = settledValue(createUseCase())
Truth.assertThat(actual).isTrue() Truth.assertThat(actual).isTrue()
} }
@ -151,9 +148,9 @@ class IsAccountsModeEnabledUseCaseTest {
fun `returns true when multiple wallets and one has two crypto portfolios`() = runTest { fun `returns true when multiple wallets and one has two crypto portfolios`() = runTest {
val list1 = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio())) val list1 = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio()))
val list2 = createAccountList(WALLET_ID_2, listOf(mockCryptoPortfolio(), mockCryptoPortfolio())) val list2 = createAccountList(WALLET_ID_2, listOf(mockCryptoPortfolio(), mockCryptoPortfolio()))
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(list1, list2)) every { multiAccountListSupplier.invoke() } returns MutableStateFlow(listOf(list1, list2))
val actual = useCase.invoke().last() val actual = settledValue(createUseCase())
Truth.assertThat(actual).isTrue() Truth.assertThat(actual).isTrue()
} }
@ -162,10 +159,10 @@ class IsAccountsModeEnabledUseCaseTest {
fun `returns true when multiple wallets and one has active payment`() = runTest { fun `returns true when multiple wallets and one has active payment`() = runTest {
val list1 = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio())) val list1 = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio()))
val list2 = createAccountList(WALLET_ID_2, listOf(mockCryptoPortfolio(), mockPaymentAccount())) val list2 = createAccountList(WALLET_ID_2, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
every { multiAccountListSupplier.invoke() } returns flowOf(listOf(list1, list2)) every { multiAccountListSupplier.invoke() } returns MutableStateFlow(listOf(list1, list2))
mockPaymentStatus(WALLET_ID_2, mockk<PaymentAccountStatusValue.Loaded>()) mockPaymentStatus(WALLET_ID_2, mockk<PaymentAccountStatusValue.Loaded>())
val actual = useCase.invoke().last() val actual = settledValue(createUseCase())
Truth.assertThat(actual).isTrue() Truth.assertThat(actual).isTrue()
} }
@ -176,29 +173,9 @@ class IsAccountsModeEnabledUseCaseTest {
inner class InvokeSync { inner class InvokeSync {
@Test @Test
fun `returns false when getSyncOrNull returns null`() = runTest { fun `returns false when supplier emits empty list`() = runTest {
coEvery { multiAccountListSupplier.getSyncOrNull(Unit, any()) } returns null every { multiAccountListSupplier.invoke() } returns MutableStateFlow(emptyList())
val actual = invokeSyncSettled(createUseCase())
val actual = useCase.invokeSync()
Truth.assertThat(actual).isFalse()
}
@Test
fun `returns false when getSyncOrNull returns empty list`() = runTest {
coEvery { multiAccountListSupplier.getSyncOrNull(Unit, any()) } returns emptyList()
val actual = useCase.invokeSync()
Truth.assertThat(actual).isFalse()
}
@Test
fun `returns false when single crypto portfolio account`() = runTest {
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio()))
coEvery { multiAccountListSupplier.getSyncOrNull(Unit, any()) } returns listOf(list)
val actual = useCase.invokeSync()
Truth.assertThat(actual).isFalse() Truth.assertThat(actual).isFalse()
} }
@ -206,9 +183,8 @@ class IsAccountsModeEnabledUseCaseTest {
@Test @Test
fun `returns true when two crypto portfolio accounts`() = runTest { fun `returns true when two crypto portfolio accounts`() = runTest {
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockCryptoPortfolio())) val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockCryptoPortfolio()))
coEvery { multiAccountListSupplier.getSyncOrNull(Unit, any()) } returns listOf(list) every { multiAccountListSupplier.invoke() } returns MutableStateFlow(listOf(list))
val actual = invokeSyncSettled(createUseCase())
val actual = useCase.invokeSync()
Truth.assertThat(actual).isTrue() Truth.assertThat(actual).isTrue()
} }
@ -216,69 +192,48 @@ class IsAccountsModeEnabledUseCaseTest {
@Test @Test
fun `returns true when payment account is Loaded`() = runTest { fun `returns true when payment account is Loaded`() = runTest {
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount())) val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
coEvery { multiAccountListSupplier.getSyncOrNull(Unit, any()) } returns listOf(list) every { multiAccountListSupplier.invoke() } returns MutableStateFlow(listOf(list))
mockPaymentStatusSync(WALLET_ID_1, mockk<PaymentAccountStatusValue.Loaded>()) mockPaymentStatus(WALLET_ID_1, mockk<PaymentAccountStatusValue.Loaded>())
val actual = invokeSyncSettled(createUseCase())
val actual = useCase.invokeSync()
Truth.assertThat(actual).isTrue() Truth.assertThat(actual).isTrue()
} }
@Test
fun `returns false when payment account is NotCreated`() = runTest {
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
coEvery { multiAccountListSupplier.getSyncOrNull(Unit, any()) } returns listOf(list)
mockPaymentStatusSync(WALLET_ID_1, PaymentAccountStatusValue.NotCreated)
val actual = useCase.invokeSync()
Truth.assertThat(actual).isFalse()
}
@Test @Test
fun `returns false when payment account is Empty`() = runTest { fun `returns false when payment account is Empty`() = runTest {
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount())) val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
coEvery { multiAccountListSupplier.getSyncOrNull(Unit, any()) } returns listOf(list) every { multiAccountListSupplier.invoke() } returns MutableStateFlow(listOf(list))
mockPaymentStatusSync(WALLET_ID_1, PaymentAccountStatusValue.Empty) mockPaymentStatus(WALLET_ID_1, PaymentAccountStatusValue.Empty)
val actual = invokeSyncSettled(createUseCase())
val actual = useCase.invokeSync()
Truth.assertThat(actual).isFalse() Truth.assertThat(actual).isFalse()
} }
}
@Test @OptIn(ExperimentalCoroutinesApi::class)
fun `returns true when payment account is UnderReview`() = runTest { private fun TestScope.createUseCase(): IsAccountsModeEnabledUseCase = IsAccountsModeEnabledUseCase(
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount())) multiAccountListSupplier = multiAccountListSupplier,
coEvery { multiAccountListSupplier.getSyncOrNull(Unit, any()) } returns listOf(list) paymentAccountStatusSupplier = paymentAccountStatusSupplier,
mockPaymentStatusSync(WALLET_ID_1, mockk<PaymentAccountStatusValue.UnderReview>()) appCoroutineScope = TestAppCoroutineScope(
backgroundScope.coroutineContext + UnconfinedTestDispatcher(testScheduler),
),
)
val actual = useCase.invokeSync() /**
* Returns the resolved value of the shared [IsAccountsModeEnabledUseCase.invoke] flow. The flow is
* a hot [kotlinx.coroutines.flow.SharedFlow] (replay = 1) that never completes, so we read the
* last value emitted while a subscriber is active.
*/
private fun TestScope.settledValue(useCase: IsAccountsModeEnabledUseCase): Boolean =
getEmittedValues(useCase.invoke()).last()
Truth.assertThat(actual).isTrue() /**
} * Samples via [IsAccountsModeEnabledUseCase.invokeSync] while a subscriber keeps the shared flow
* warm mirroring production, where a screen already observes the flow before invokeSync reads it.
@Test */
fun `returns true when payment account is Deactivated`() = runTest { private suspend fun TestScope.invokeSyncSettled(useCase: IsAccountsModeEnabledUseCase): Boolean {
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount())) getEmittedValues(useCase.invoke())
coEvery { multiAccountListSupplier.getSyncOrNull(Unit, any()) } returns listOf(list) return useCase.invokeSync()
mockPaymentStatusSync(WALLET_ID_1, mockk<PaymentAccountStatusValue.Deactivated>())
val actual = useCase.invokeSync()
Truth.assertThat(actual).isTrue()
}
@Test
fun `returns true when multiple wallets and one has loaded payment`() = runTest {
val list1 = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio()))
val list2 = createAccountList(WALLET_ID_2, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
coEvery { multiAccountListSupplier.getSyncOrNull(Unit, any()) } returns listOf(list1, list2)
mockPaymentStatusSync(WALLET_ID_2, mockk<PaymentAccountStatusValue.Loaded>())
val actual = useCase.invokeSync()
Truth.assertThat(actual).isTrue()
}
} }
private fun mockCryptoPortfolio(): Account.CryptoPortfolio = mockk() private fun mockCryptoPortfolio(): Account.CryptoPortfolio = mockk()
@ -287,14 +242,7 @@ class IsAccountsModeEnabledUseCaseTest {
private fun mockPaymentStatus(walletId: UserWalletId, value: PaymentAccountStatusValue) { private fun mockPaymentStatus(walletId: UserWalletId, value: PaymentAccountStatusValue) {
val status = mockk<AccountStatus.Payment> { every { this@mockk.value } returns value } val status = mockk<AccountStatus.Payment> { every { this@mockk.value } returns value }
every { paymentAccountStatusSupplier.invoke(walletId) } returns flowOf(status) every { paymentAccountStatusSupplier.invoke(walletId) } returns MutableStateFlow(status)
}
private fun mockPaymentStatusSync(walletId: UserWalletId, value: PaymentAccountStatusValue) {
val status = mockk<AccountStatus.Payment> { every { this@mockk.value } returns value }
coEvery {
paymentAccountStatusSupplier.getSyncOrNull(PaymentAccountStatusProducer.Params(walletId), any())
} returns status
} }
private fun createAccountList(walletId: UserWalletId, accounts: List<Account>): AccountList = mockk { private fun createAccountList(walletId: UserWalletId, accounts: List<Account>): AccountList = mockk {

View file

@ -2,4 +2,5 @@ package com.tangem.domain.appsflyer
enum class AppsFlyerDeeplinkSource { enum class AppsFlyerDeeplinkSource {
TangemPayHotWalletOnboarding, TangemPayHotWalletOnboarding,
Referral,
} }

View file

@ -4,5 +4,7 @@ import com.tangem.domain.appsflyer.AppsFlyerDeeplinkSource
interface AppsFlyerRepository { interface AppsFlyerRepository {
suspend fun getDeeplink(source: AppsFlyerDeeplinkSource): String?
suspend fun clearDeeplink(source: AppsFlyerDeeplinkSource) suspend fun clearDeeplink(source: AppsFlyerDeeplinkSource)
} }

View file

@ -0,0 +1,15 @@
package com.tangem.domain.appsflyer.usecase
import com.tangem.domain.appsflyer.AppsFlyerDeeplinkSource
import com.tangem.domain.appsflyer.repository.AppsFlyerRepository
/**
* Returns whether the app install was attributed to an AppsFlyer referral deep link.
*/
class IsReferralInstallUseCase(
private val appsFlyerRepository: AppsFlyerRepository,
) {
suspend operator fun invoke(): Boolean {
return appsFlyerRepository.getDeeplink(AppsFlyerDeeplinkSource.Referral) != null
}
}

View file

@ -31,6 +31,8 @@ interface CardTypesResolver {
fun isSingleWalletWithToken(): Boolean fun isSingleWalletWithToken(): Boolean
fun isSingleCurrency(): Boolean
fun isMultiwalletAllowed(): Boolean fun isMultiwalletAllowed(): Boolean
fun getBlockchain(): Blockchain fun getBlockchain(): Blockchain

View file

@ -65,6 +65,8 @@ internal class TangemCardTypesResolver(
override fun isSingleWalletWithToken(): Boolean = walletData?.token != null && !isMultiwalletAllowed() override fun isSingleWalletWithToken(): Boolean = walletData?.token != null && !isMultiwalletAllowed()
override fun isSingleCurrency(): Boolean = isSingleWallet() || isSingleWalletWithToken()
override fun isMultiwalletAllowed(): Boolean { override fun isMultiwalletAllowed(): Boolean {
return !isTangemTwins() && return !isTangemTwins() &&
!card.isStart2Coin && !card.isStart2Coin &&

View file

@ -68,6 +68,7 @@ data object Wallet2CardConfig : CardConfig {
Blockchain.BitcoinCashTestnet -> EllipticCurve.Secp256k1 Blockchain.BitcoinCashTestnet -> EllipticCurve.Secp256k1
Blockchain.Cardano -> EllipticCurve.Ed25519 Blockchain.Cardano -> EllipticCurve.Ed25519
Blockchain.Cosmos -> EllipticCurve.Secp256k1 Blockchain.Cosmos -> EllipticCurve.Secp256k1
Blockchain.Gonka -> EllipticCurve.Secp256k1
Blockchain.CosmosTestnet -> EllipticCurve.Secp256k1 Blockchain.CosmosTestnet -> EllipticCurve.Secp256k1
Blockchain.Dogecoin -> EllipticCurve.Secp256k1 Blockchain.Dogecoin -> EllipticCurve.Secp256k1
Blockchain.Ducatus -> EllipticCurve.Secp256k1 Blockchain.Ducatus -> EllipticCurve.Secp256k1

View file

@ -24,6 +24,7 @@ class Wallet2CardConfigTest {
Blockchain.BitcoinCashTestnet to EllipticCurve.Secp256k1, Blockchain.BitcoinCashTestnet to EllipticCurve.Secp256k1,
Blockchain.Cardano to EllipticCurve.Ed25519, Blockchain.Cardano to EllipticCurve.Ed25519,
Blockchain.Cosmos to EllipticCurve.Secp256k1, Blockchain.Cosmos to EllipticCurve.Secp256k1,
Blockchain.Gonka to EllipticCurve.Secp256k1,
Blockchain.CosmosTestnet to EllipticCurve.Secp256k1, Blockchain.CosmosTestnet to EllipticCurve.Secp256k1,
Blockchain.Dogecoin to EllipticCurve.Secp256k1, Blockchain.Dogecoin to EllipticCurve.Secp256k1,
Blockchain.Ducatus to EllipticCurve.Secp256k1, Blockchain.Ducatus to EllipticCurve.Secp256k1,

View file

@ -1,5 +1,6 @@
package com.tangem.domain.tokens.actions package com.tangem.domain.tokens.actions
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.exchange.RampStateManager import com.tangem.domain.exchange.RampStateManager
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus import com.tangem.domain.models.currency.CryptoCurrencyStatus
@ -65,6 +66,12 @@ internal open class BaseActionsFactory(
currency: CryptoCurrency, currency: CryptoCurrency,
requirementsDeferred: Deferred<AssetRequirementsCondition?>?, requirementsDeferred: Deferred<AssetRequirementsCondition?>?,
): ScenarioUnavailabilityReason { ): ScenarioUnavailabilityReason {
// Start2Coin (S2C) are legacy single-currency cards that do not support buying crypto in-app
// (historically only Receive/Send were offered for them).
if (userWallet is UserWallet.Cold && userWallet.cardTypesResolver.isStart2Coin()) {
return ScenarioUnavailabilityReason.BuyUnavailable(currency.name)
}
val onrampUnavailabilityReason = rampStateManager.availableForBuy( val onrampUnavailabilityReason = rampStateManager.availableForBuy(
userWallet = userWallet, userWallet = userWallet,
cryptoCurrency = currency, cryptoCurrency = currency,

View file

@ -10,11 +10,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.tangem.core.ui.R import com.tangem.core.ui.R
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.ds.image.TangemIconUM import com.tangem.core.ui.ds.image.TangemIconUM
import com.tangem.core.ui.ds.topbar.TangemTopBar import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds2.button.TangemButton import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.ds2.shimmers.ProvideTangemShimmer import com.tangem.core.ui.ds2.shimmers.ProvideTangemShimmer
import com.tangem.core.ui.ds2.shimmers.RectangleShimmer
import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign import com.tangem.core.ui.res.TangemThemePreviewRedesign

View file

@ -74,7 +74,7 @@ internal class AddTokenUiBuilder @Inject constructor(
isEnabled = isAvailableNetwork, isEnabled = isAvailableNetwork,
showProgress = false, showProgress = false,
isTangemIconVisible = isTangemIconVisible, isTangemIconVisible = isTangemIconVisible,
text = resourceReference(R.string.common_add), text = resourceReference(R.string.common_confirm),
onConfirmClick = onConfirmClick, onConfirmClick = onConfirmClick,
) )
val networkUM = createNetwork(selectedNetwork) val networkUM = createNetwork(selectedNetwork)

View file

@ -255,6 +255,7 @@ private fun WalletTabItem(state: WalletTabUM, modifier: Modifier = Modifier) {
.clip(RoundedCornerShape(percent = 50)) .clip(RoundedCornerShape(percent = 50))
.background(backgroundColor) .background(backgroundColor)
.clickable(onClick = state.onClick) .clickable(onClick = state.onClick)
.testTag(BuyTokenScreenTestTags.WALLET_TAB)
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,

View file

@ -190,8 +190,10 @@ internal class DefaultManageFundsComponent @AssistedInject constructor(
onBackClick: () -> Unit, onBackClick: () -> Unit,
onCloseClick: () -> Unit, onCloseClick: () -> Unit,
) { ) {
val spec = route.uiSpec(model.flowType)
TangemTopBar( TangemTopBar(
title = route.uiSpec(model.flowType).title, title = spec.title,
subtitle = spec.subtitle,
type = TangemTopBarType.BottomSheet, type = TangemTopBarType.BottomSheet,
startContent = if (canGoBack) { startContent = if (canGoBack) {
{ {

View file

@ -7,6 +7,7 @@ import com.tangem.features.commonfeatures.impl.R
internal data class ManageFundsRouteUiSpec( internal data class ManageFundsRouteUiSpec(
val title: TextReference, val title: TextReference,
val subtitle: TextReference?,
val shouldApplyHorizontalPadding: Boolean, val shouldApplyHorizontalPadding: Boolean,
val shouldFillHeight: Boolean, val shouldFillHeight: Boolean,
) )
@ -16,21 +17,25 @@ internal fun ManageFundsModel.UiRoute.uiSpec(flowType: ManageFundsComponent.Flow
return when (this) { return when (this) {
ManageFundsModel.UiRoute.Loading -> ManageFundsRouteUiSpec( ManageFundsModel.UiRoute.Loading -> ManageFundsRouteUiSpec(
title = resourceReference(if (isTransfer) R.string.common_choose_token else R.string.common_add_funds), title = resourceReference(if (isTransfer) R.string.common_choose_token else R.string.common_add_funds),
subtitle = null,
shouldApplyHorizontalPadding = false, shouldApplyHorizontalPadding = false,
shouldFillHeight = false, shouldFillHeight = false,
) )
ManageFundsModel.UiRoute.ChooseToken -> ManageFundsRouteUiSpec( ManageFundsModel.UiRoute.ChooseToken -> ManageFundsRouteUiSpec(
title = resourceReference(R.string.common_choose_token), title = resourceReference(R.string.common_choose_token),
subtitle = null,
shouldApplyHorizontalPadding = false, shouldApplyHorizontalPadding = false,
shouldFillHeight = true, shouldFillHeight = true,
) )
ManageFundsModel.UiRoute.UserPortfolio -> ManageFundsRouteUiSpec( ManageFundsModel.UiRoute.UserPortfolio -> ManageFundsRouteUiSpec(
title = resourceReference(R.string.common_add_funds), title = resourceReference(R.string.common_add_funds),
subtitle = resourceReference(R.string.common_choose_token),
shouldApplyHorizontalPadding = false, shouldApplyHorizontalPadding = false,
shouldFillHeight = false, shouldFillHeight = false,
) )
ManageFundsModel.UiRoute.TokenActions -> ManageFundsRouteUiSpec( ManageFundsModel.UiRoute.TokenActions -> ManageFundsRouteUiSpec(
title = resourceReference(if (isTransfer) R.string.common_transfer else R.string.common_get_token), title = resourceReference(if (isTransfer) R.string.common_transfer else R.string.common_get_token),
subtitle = null,
shouldApplyHorizontalPadding = true, shouldApplyHorizontalPadding = true,
shouldFillHeight = true, shouldFillHeight = true,
) )

View file

@ -130,7 +130,7 @@ private fun AddWalletButton(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
BlockCard( BlockCard(
modifier = modifier, modifier = modifier.testTag(DetailsScreenTestTags.ADD_WALLET_BUTTON),
onClick = onClick, onClick = onClick,
enabled = !isInProgress, enabled = !isInProgress,
) { ) {

View file

@ -218,7 +218,11 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
private fun onChildBack() { private fun onChildBack() {
if (stack.value.active.configuration !is FeedEntryChildFactory.Child.Feed) { if (stack.value.active.configuration !is FeedEntryChildFactory.Child.Feed) {
stackNavigation.pop() if (stack.value.backStack.isEmpty()) {
router.pop()
} else {
stackNavigation.pop()
}
} }
} }

View file

@ -1,19 +1,11 @@
package com.tangem.features.feed.components.market.details package com.tangem.features.feed.components.market.details
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.LifecycleStartEffect import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.ComponentContext
@ -27,32 +19,24 @@ import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.context.childByContext import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.DesignFeatureToggles import com.tangem.core.ui.DesignFeatureToggles
import com.tangem.core.ui.R
import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState 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.ComposableBottomSheetComponent
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent 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.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.PreselectedTokenDetailsSection import com.tangem.domain.markets.PreselectedTokenDetailsSection
import com.tangem.domain.markets.TokenMarketParams import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.models.currency.CryptoCurrency import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.commonfeatures.api.managefunds.ManageFundsComponent
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent
import com.tangem.features.commonfeatures.api.managefunds.ManageFundsComponent
import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent import com.tangem.features.feed.components.market.details.portfolio.api.MarketsPortfolioComponent
import com.tangem.features.feed.components.market.details.portfolioblock.PortfolioBlockComponent import com.tangem.features.feed.components.market.details.portfolioblock.PortfolioBlockComponent
import com.tangem.features.feed.components.market.details.portfolioblock.PortfolioBlockParentClickIntents import com.tangem.features.feed.components.market.details.portfolioblock.PortfolioBlockParentClickIntents
import com.tangem.features.feed.model.market.details.MarketsTokenDetailsModel import com.tangem.features.feed.model.market.details.MarketsTokenDetailsModel
import com.tangem.features.feed.model.market.details.analytics.MarketDetailsAnalyticsEvent import com.tangem.features.feed.model.market.details.analytics.MarketDetailsAnalyticsEvent
import com.tangem.features.feed.model.market.details.state.TokenNetworksState import com.tangem.features.feed.model.market.details.state.TokenNetworksState
import com.tangem.features.feed.ui.LocalIsOpenedInBottomSheet
import com.tangem.features.feed.ui.market.detailed.MarketsTokenDetailsContent import com.tangem.features.feed.ui.market.detailed.MarketsTokenDetailsContent
import com.tangem.features.feed.ui.market.detailed.MarketsTokenDetailsTopBar import com.tangem.features.feed.ui.market.detailed.MarketsTokenDetailsTitle
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@ -92,7 +76,7 @@ internal class DefaultMarketsTokenDetailsComponent(
} }
private val portfolioBlockComponent: PortfolioBlockComponent? = private val portfolioBlockComponent: PortfolioBlockComponent? =
if (updatedParams.shouldShowPortfolio && designFeatureToggles.isRedesignEnabled) { if (designFeatureToggles.isRedesignEnabled) {
portfolioBlockComponentFactory.create( portfolioBlockComponentFactory.create(
context = child("portfolio_block"), context = child("portfolio_block"),
params = PortfolioBlockComponent.Params(token = updatedParams.token), params = PortfolioBlockComponent.Params(token = updatedParams.token),
@ -187,58 +171,12 @@ internal class DefaultMarketsTokenDetailsComponent(
@Composable @Composable
override fun Title(bottomSheetState: State<BottomSheetState>) { override fun Title(bottomSheetState: State<BottomSheetState>) {
val state by model.state.collectAsStateWithLifecycle() val state by model.state.collectAsStateWithLifecycle()
val background = LocalMainBottomSheetColor.current.value MarketsTokenDetailsTitle(
if (LocalRedesignEnabled.current) { state = state,
TangemTopBar( backgroundColor = LocalMainBottomSheetColor.current.value,
startContent = { isBackButtonEnabled = bottomSheetState.value == BottomSheetState.EXPANDED,
Icon( onBackClick = { params.onBackClicked() },
imageVector = ImageVector.vectorResource(id = R.drawable.ic_arrow_back_28), )
contentDescription = null,
tint = TangemTheme.colors2.graphic.neutral.primary,
modifier = Modifier
.size(TangemTheme.dimens2.x11)
.clip(CircleShape)
.hazeEffectTangem { blurRadius = 8.dp }
.clickableSingle(
onClick = { params.onBackClicked() },
enabled = bottomSheetState.value == BottomSheetState.EXPANDED,
)
.padding(TangemTheme.dimens2.x2),
)
},
endContent = {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.ic_share_new_24),
contentDescription = null,
tint = TangemTheme.colors2.graphic.neutral.primary,
modifier = Modifier
.size(TangemTheme.dimens2.x11)
.clip(CircleShape)
.hazeEffectTangem { blurRadius = 8.dp }
.clickableSingle(
onClick = state.onShareClick,
enabled = bottomSheetState.value == BottomSheetState.EXPANDED,
)
.padding(TangemTheme.dimens2.x2_5),
)
},
type = if (LocalIsOpenedInBottomSheet.current) {
TangemTopBarType.BottomSheet
} else {
TangemTopBarType.Default
},
)
} else {
MarketsTokenDetailsTopBar(
onBackClick = { params.onBackClicked() },
isBackButtonEnabled = bottomSheetState.value == BottomSheetState.EXPANDED,
shouldShowPriceSubtitle = state.shouldShowPriceSubtitle,
tokenName = state.tokenName,
tokenPrice = state.priceText,
backgroundColor = background,
onShareClick = state.onShareClick,
)
}
} }
@Composable @Composable

View file

@ -47,7 +47,7 @@ internal class PortfolioBlockModel @Inject constructor(
) : Model() { ) : Model() {
val state: StateFlow<PortfolioBlockUM> val state: StateFlow<PortfolioBlockUM>
field = MutableStateFlow<PortfolioBlockUM>(PortfolioBlockUM.Loading) field = MutableStateFlow<PortfolioBlockUM>(PortfolioBlockUM.Hidden)
val cryptoCurrencyIdState: StateFlow<CryptoCurrency.ID?> val cryptoCurrencyIdState: StateFlow<CryptoCurrency.ID?>
field = MutableStateFlow(null) field = MutableStateFlow(null)
@ -86,7 +86,7 @@ internal class PortfolioBlockModel @Inject constructor(
private fun combineData(): Flow<PortfolioBlockUM> { private fun combineData(): Flow<PortfolioBlockUM> {
return availableNetworks.transformLatest { networks -> return availableNetworks.transformLatest { networks ->
if (networks.isEmpty()) { if (networks.isEmpty()) {
emit(PortfolioBlockUM.Hidden) emit(PortfolioBlockUM.Unsupported(tokenIcon = tokenIcon))
} else { } else {
emitAll(portfolioFlow().distinctUntilChanged()) emitAll(portfolioFlow().distinctUntilChanged())
} }
@ -145,7 +145,7 @@ internal class PortfolioBlockModel @Inject constructor(
onAddClick = { parentRouter?.openAddToPortfolioDirect() }, onAddClick = { parentRouter?.openAddToPortfolioDirect() },
) )
} else { } else {
PortfolioBlockUM.Hidden PortfolioBlockUM.Unsupported(tokenIcon = tokenIcon)
} }
} }

View file

@ -43,12 +43,11 @@ import com.tangem.features.feed.impl.R
@Composable @Composable
internal fun PortfolioBlock(state: PortfolioBlockUM, modifier: Modifier = Modifier) { internal fun PortfolioBlock(state: PortfolioBlockUM, modifier: Modifier = Modifier) {
val isVisible = state is PortfolioBlockUM.AddToken || state is PortfolioBlockUM.Content
val screenHeightPx = with(LocalDensity.current) { LocalWindowSize.current.height.toPx() } val screenHeightPx = with(LocalDensity.current) { LocalWindowSize.current.height.toPx() }
Box(modifier = modifier) { Box(modifier = modifier) {
AnimatedVisibility( AnimatedVisibility(
visible = isVisible, visible = state !is PortfolioBlockUM.Hidden,
enter = fadeIn(animationSpec = tween(durationMillis = 300)), enter = fadeIn(animationSpec = tween(durationMillis = 300)),
) { ) {
TangemFade( TangemFade(
@ -63,7 +62,7 @@ internal fun PortfolioBlock(state: PortfolioBlockUM, modifier: Modifier = Modifi
AnimatedVisibility( AnimatedVisibility(
modifier = Modifier.align(Alignment.BottomCenter), modifier = Modifier.align(Alignment.BottomCenter),
visible = isVisible, visible = state !is PortfolioBlockUM.Hidden,
enter = fadeIn(animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing)), enter = fadeIn(animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing)),
exit = fadeOut(animationSpec = tween(durationMillis = 300)), exit = fadeOut(animationSpec = tween(durationMillis = 300)),
) { ) {
@ -87,9 +86,8 @@ internal fun PortfolioBlock(state: PortfolioBlockUM, modifier: Modifier = Modifi
when (state) { when (state) {
is PortfolioBlockUM.AddToken -> AddTokenBlock(state) is PortfolioBlockUM.AddToken -> AddTokenBlock(state)
is PortfolioBlockUM.Content -> ContentBlock(state) is PortfolioBlockUM.Content -> ContentBlock(state)
is PortfolioBlockUM.Hidden, is PortfolioBlockUM.Unsupported -> UnsupportedTokenBlock(state.tokenIcon)
is PortfolioBlockUM.Loading, is PortfolioBlockUM.Hidden -> Unit
-> Unit
} }
} }
} }
@ -158,13 +156,19 @@ private fun AddTokenBlock(state: PortfolioBlockUM.AddToken, modifier: Modifier =
FloatingCard(modifier = modifier) { FloatingCard(modifier = modifier) {
Row( Row(
modifier = Modifier modifier = Modifier
.padding(TangemTheme.dimens2.x3) .padding(
vertical = 12.dp,
horizontal = 16.dp,
)
.clickableSingle(onClick = state.onAddClick), .clickableSingle(onClick = state.onAddClick),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
CurrencyIcon(state.tokenIcon) CurrencyIcon(
state = state.tokenIcon,
iconSize = 40.dp,
)
SpacerW(TangemTheme.dimens2.x3) SpacerW(8.dp)
Text( Text(
text = formatAnnotatedWithBoldColor( text = formatAnnotatedWithBoldColor(
@ -192,6 +196,39 @@ private fun AddTokenBlock(state: PortfolioBlockUM.AddToken, modifier: Modifier =
} }
} }
@Composable
private fun UnsupportedTokenBlock(icon: CurrencyIconState, modifier: Modifier = Modifier) {
FloatingCard(modifier = modifier) {
Row(
modifier = Modifier
.padding(
vertical = 12.dp,
horizontal = 16.dp,
)
.clickableSingle(onClick = {}), // just intercept
verticalAlignment = Alignment.CenterVertically,
) {
CurrencyIcon(
state = icon,
iconSize = 40.dp,
)
SpacerW(8.dp)
Text(
text = formatAnnotatedWithBoldColor(
rawString = stringResourceSafe(R.string.markets_portfolio_block_token_unsupported),
boldColor = TangemTheme.colors2.text.neutral.primary,
),
style = TangemTheme.typography2.captionMedium12,
color = TangemTheme.colors2.text.neutral.secondary,
modifier = Modifier.weight(1f),
maxLines = 2,
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun FloatingCard(modifier: Modifier = Modifier, content: @Composable () -> Unit) { private fun FloatingCard(modifier: Modifier = Modifier, content: @Composable () -> Unit) {

View file

@ -5,15 +5,14 @@ import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.extensions.TextReference import com.tangem.core.ui.extensions.TextReference
@Immutable @Immutable
internal sealed class PortfolioBlockUM { internal sealed interface PortfolioBlockUM {
data object Loading : PortfolioBlockUM() data object Hidden : PortfolioBlockUM
data object Hidden : PortfolioBlockUM()
data class AddToken( data class AddToken(
val tokenIcon: CurrencyIconState, val tokenIcon: CurrencyIconState,
val onAddClick: () -> Unit, val onAddClick: () -> Unit,
) : PortfolioBlockUM() ) : PortfolioBlockUM
data class Content( data class Content(
val totalBalance: TextReference, val totalBalance: TextReference,
@ -24,5 +23,9 @@ internal sealed class PortfolioBlockUM {
val isBalanceHidden: Boolean, val isBalanceHidden: Boolean,
val onRowClick: () -> Unit, val onRowClick: () -> Unit,
val onAddFundsClick: () -> Unit, val onAddFundsClick: () -> Unit,
) : PortfolioBlockUM() ) : PortfolioBlockUM
data class Unsupported(
val tokenIcon: CurrencyIconState,
) : PortfolioBlockUM
} }

View file

@ -12,6 +12,7 @@ import androidx.compose.runtime.State
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -24,6 +25,7 @@ import com.tangem.core.ui.components.bottomsheets.state.BottomSheetState
import com.tangem.core.ui.components.haze.hazeEffectTangem import com.tangem.core.ui.components.haze.hazeEffectTangem
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.core.ui.extensions.clickableSingle import com.tangem.core.ui.extensions.clickableSingle
import com.tangem.core.ui.extensions.conditional
import com.tangem.core.ui.res.LocalMainBottomSheetColor import com.tangem.core.ui.res.LocalMainBottomSheetColor
import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemTheme
@ -56,11 +58,13 @@ internal class DefaultMarketsTokenListComponent(
val bsState by bottomSheetState val bsState by bottomSheetState
if (LocalRedesignEnabled.current) { if (LocalRedesignEnabled.current) {
val background = LocalMainBottomSheetColor.current.value val bottomSheetColor = LocalMainBottomSheetColor.current.value
FeedSearchBar( FeedSearchBar(
isSearchBarClickable = bottomSheetState.value == BottomSheetState.EXPANDED, isSearchBarClickable = bottomSheetState.value == BottomSheetState.EXPANDED,
feedListSearchBar = state.feedListSearchBar, feedListSearchBar = state.feedListSearchBar,
modifier = Modifier.background(background.copy(alpha = .95f)), modifier = Modifier.conditional(bottomSheetColor.isSpecified) {
background(bottomSheetColor.copy(alpha = .95f))
},
startContent = { startContent = {
Icon( Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.ic_arrow_back_28), imageVector = ImageVector.vectorResource(id = R.drawable.ic_arrow_back_28),

View file

@ -21,6 +21,7 @@ internal class EarnTokenWithCurrencyToListItemUMConverter(
override fun convert(value: EarnTokenWithCurrency): EarnListItemUM { override fun convert(value: EarnTokenWithCurrency): EarnListItemUM {
return EarnListItemUM( return EarnListItemUM(
id = "${value.cryptoCurrency.id.value}_${value.earnToken.type}",
network = TextReference.Str(value.networkName), network = TextReference.Str(value.networkName),
symbol = TextReference.Str(value.earnToken.tokenSymbol), symbol = TextReference.Str(value.earnToken.tokenSymbol),
tokenName = TextReference.Str(value.earnToken.tokenName), tokenName = TextReference.Str(value.earnToken.tokenName),

View file

@ -289,6 +289,8 @@ internal class MarketsTokenDetailsModel @Inject constructor(
onScroll = {}, onScroll = {},
), ),
onShareClick = ::onShareClick, onShareClick = ::onShareClick,
isAddToPortfolioButtonVisible = false,
onAddToPortfolioClick = ::openAddToPortfolio,
), ),
) )
@ -344,6 +346,18 @@ internal class MarketsTokenDetailsModel @Inject constructor(
} }
} }
} }
if (isAddToPortfolioAvailable) {
addToPortfolioManager.setTokenParams(params.token)
addToPortfolioManager.state
.map { managerState ->
managerState is AddToPortfolioManager.State.Ready && managerState.isAvailableToAdd
}
.distinctUntilChanged()
.onEach { isVisible ->
state.update { it.copy(isAddToPortfolioButtonVisible = isVisible) }
}
.launchIn(modelScope)
}
addToPortfolioManager.onDismiss.receiveAsFlow() addToPortfolioManager.onDismiss.receiveAsFlow()
.onEach { addToPortfolioSheetNavigation.dismiss() } .onEach { addToPortfolioSheetNavigation.dismiss() }
.launchIn(modelScope) .launchIn(modelScope)

View file

@ -105,7 +105,7 @@ internal class UserAssetSearchItemConverter(
val entryCurrencyStatus = item.entries.first().currencyStatus val entryCurrencyStatus = item.entries.first().currencyStatus
return UserAssetItemUM.Grouped( return UserAssetItemUM.Grouped(
id = "grouped_${item.tokenName}_${item.tokenSymbol}", id = "grouped_${firstCurrency.id.value}",
icon = TangemIconUM.Currency( icon = TangemIconUM.Currency(
currencyIconState = CurrencyIconState.CoinIcon( currencyIconState = CurrencyIconState.CoinIcon(
url = entryCurrencyStatus.currency.iconUrl, url = entryCurrencyStatus.currency.iconUrl,

View file

@ -117,7 +117,7 @@ private fun MostlyUsedContent(state: EarnListUM, onScroll: () -> Unit) {
) { ) {
itemsIndexed( itemsIndexed(
items = animatedState.items, items = animatedState.items,
key = { _, item -> "${item.tokenName}-${item.network}" }, key = { _, item -> item.id },
) { index, item -> ) { index, item ->
val cardModifier = Modifier.conditional( val cardModifier = Modifier.conditional(
condition = index == FOURTH_ITEM_INDEX, condition = index == FOURTH_ITEM_INDEX,

View file

@ -36,4 +36,5 @@ internal data class EarnListItemUM(
val earnType: EarnType, val earnType: EarnType,
val earnTypeTitle: TextReference, val earnTypeTitle: TextReference,
val onItemClick: () -> Unit, val onItemClick: () -> Unit,
val id: String = "$tokenName-$symbol-$network-$earnType",
) )

View file

@ -154,7 +154,7 @@ private fun EarnContentBlock(items: ImmutableList<EarnListItemUM>) {
) { ) {
items( items(
items = items, items = items,
key = { item -> "${item.tokenName}-${item.network}" }, key = { item -> item.id },
) { item -> ) { item ->
MostlyUsedCard( MostlyUsedCard(
item = item, item = item,

View file

@ -18,7 +18,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import com.tangem.core.ui.test.MarketsTestTags import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
@ -41,6 +41,7 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.LocalRedesignEnabled import com.tangem.core.ui.res.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.MarketsTestTags
import com.tangem.domain.markets.PriceChangeInterval import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.features.feed.impl.R import com.tangem.features.feed.impl.R
import com.tangem.features.feed.ui.market.detailed.components.* import com.tangem.features.feed.ui.market.detailed.components.*
@ -267,14 +268,26 @@ private fun HeaderV2(state: MarketsTokenDetailsUM, modifier: Modifier = Modifier
horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1), horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens2.x1),
) { ) {
Text( Text(
modifier = Modifier.weight(weight = 1f, fill = false),
text = state.tokenName, text = state.tokenName,
style = TangemTheme.typography2.bodySemibold16, style = TangemTheme.typography2.bodySemibold16,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
color = TangemTheme.colors2.text.neutral.primary, color = TangemTheme.colors2.text.neutral.primary,
autoSize = TextAutoSize.StepBased(
minFontSize = TangemTheme.typography2.captionSemibold12.fontSize,
maxFontSize = TangemTheme.typography2.bodySemibold16.fontSize,
),
) )
Text( Text(
maxLines = 1,
text = state.symbol, text = state.symbol,
style = TangemTheme.typography2.captionMedium12, style = TangemTheme.typography2.captionMedium12,
color = TangemTheme.colors2.text.neutral.tertiary, color = TangemTheme.colors2.text.neutral.tertiary,
autoSize = TextAutoSize.StepBased(
minFontSize = TangemTheme.typography2.captionRegular11.fontSize,
maxFontSize = TangemTheme.typography2.captionMedium12.fontSize,
),
) )
} }
SpacerH(TangemTheme.dimens2.x1) SpacerH(TangemTheme.dimens2.x1)

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