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.status)
implementation(projects.domain.addressBook)
implementation(projects.domain.appsflyer)
implementation(projects.domain.models)
implementation(projects.domain.core)
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() {
val buttons = composeTestRule.onAllNodes(hasTestTag(BaseButtonTestTags.BUTTON))
val confirmButton = buttons[buttons.fetchSemanticsNodes().lastIndex]
confirmButton.performTouchInput { longClick(durationMillis = HOLD_DURATION_MS) }
composeTestRule.onNode(
hasTestTag(BaseButtonTestTags.BUTTON) and
hasText(getResourceString(CoreUiR.string.swapping_transfer_action)),
).performTouchInput { longClick(durationMillis = HOLD_DURATION_MS) }
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.lazylist.KLazyListNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
import androidx.compose.ui.test.hasText as withText
class BuyTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<BuyTokenPageObject>(semanticsProvider = semanticsProvider) {
@ -48,6 +49,19 @@ class BuyTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
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) =

View file

@ -32,6 +32,10 @@ class DetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
useUnmergedTree = true
}
val addWalletButton: KNode = child {
hasTestTag(DetailsScreenTestTags.ADD_WALLET_BUTTON)
}
val buyTangemButton: KNode = child {
hasTestTag(DetailsScreenTestTags.SCREEN_ITEM)
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 {
hasTestTag(MainScreenTestTags.SYNC_PROGRESS_TEXT)
useUnmergedTree = true

View file

@ -5,14 +5,18 @@ import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.extractText
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.TANGEM_PAY_ELIGIBILITY_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_VERY_LONG
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.core.ui.R as CoreUiR
import com.tangem.scenarios.*
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.WalletMockContent
import dagger.hilt.android.testing.HiltAndroidTest
import io.github.kakaocup.kakao.common.utilities.getResourceString
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.
@Ignore("[REDACTED_JIRA]")
@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.AppsFlyerStore
import com.tangem.domain.wallets.models.AppsFlyerConversionData
import com.tangem.feature.referral.domain.SetShouldShowMobileWalletPromoUseCase
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.contracts.ExperimentalContracts
@ -19,11 +16,9 @@ import kotlin.contracts.contract
@Singleton
class AppsFlyerReferralParamsHandler @Inject constructor(
private val appsFlyerStore: AppsFlyerStore,
private val setShouldShowMobileWalletPromoUseCase: SetShouldShowMobileWalletPromoUseCase,
private val coroutineScope: AppCoroutineScope,
) {
private val mutex = Mutex()
private val deepLinkDeferred = CompletableDeferred<String?>()
fun handle(params: Map<String?, Any?>) {
@ -48,15 +43,17 @@ class AppsFlyerReferralParamsHandler @Inject constructor(
}
suspend fun waitForDeeplink(deeplinkSource: AppsFlyerDeeplinkSource): String? {
val deeplinkFromCache = appsFlyerStore.getDeeplink(deeplinkSource)
return if (deeplinkFromCache == null) {
val value = when (deeplinkSource) {
appsFlyerStore.getDeeplink(deeplinkSource)?.let { return it }
val expectedValue = when (deeplinkSource) {
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?) {
@ -79,6 +76,10 @@ class AppsFlyerReferralParamsHandler @Inject constructor(
@Suppress("NullableToStringCall")
TangemLogger.i("refcode=$deepLinkSub1\ncampaign=$deepLinkSub2")
coroutineScope.launch {
appsFlyerStore.storeDeeplink(AppsFlyerDeeplinkSource.Referral, REFERRAL_DEEP_LINK_VALUE)
}
if (!isValidParam(deepLinkSub1)) {
TangemLogger.e("Deeplink conversion data is invalid")
return
@ -98,15 +99,11 @@ class AppsFlyerReferralParamsHandler @Inject constructor(
private fun storeConversionData(refcode: String, campaign: String?) {
coroutineScope.launch {
mutex.withLock {
setShouldShowMobileWalletPromoUseCase(true)
.onLeft { TangemLogger.e("Error", it) }
appsFlyerStore.storeIfAbsent(
value = AppsFlyerConversionData(refcode = refcode, campaign = campaign),
)
}
}
}
private companion object {

View file

@ -35,12 +35,15 @@ internal interface CardSdkModule {
sdkRepository: CardSdkConfigRepository,
@ApplicationContext context: Context,
): 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(
tangemApiBaseUrlProvider = { sdkRepository.sdk.config.tangemApiBaseUrl },
artworksDirectory = File(
context.getExternalFilesDir(null) ?: context.filesDir,
"card_artworks",
).apply { mkdirs() },
artworksDirectory = artworksDirectory,
)
}
}

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),
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(
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),
@ -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),
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(
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),
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
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),
@ -424,6 +458,34 @@ object WalletMockContent : MockContent {
parentFingerprint = byteArrayOf(0, 0, 0, 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.utils.TrackingContextProxy
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.UserWalletsListRepository
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.wallets.hot.HotWalletAccessCodeAttemptsRepository
import com.tangem.domain.wallets.hot.HotWalletPasswordRequester
import com.tangem.feature.referral.domain.MobileWalletPromoRepository
import com.tangem.hot.sdk.TangemHotSdk
import com.tangem.sdk.storage.AndroidSecureStorage
import com.tangem.sdk.storage.AndroidSecureStorageV2
@ -57,7 +57,7 @@ internal object UserWalletsListRepositoryModule {
trackingContextProxy: TrackingContextProxy,
analyticsEventHandler: AnalyticsEventHandler,
hotWalletRepository: HotWalletRepository,
mobileWalletPromoRepository: MobileWalletPromoRepository,
clearAppsFlyerDeeplinkUseCase: ClearAppsFlyerDeeplinkUseCase,
userWalletSelectedHandler: Lazy<UserWalletSelectedHandler>,
): UserWalletsListRepository {
val moshi = buildMoshi()
@ -109,7 +109,7 @@ internal object UserWalletsListRepositoryModule {
trackingContextProxy = trackingContextProxy,
analyticsEventHandler = analyticsEventHandler,
hotWalletRepository = hotWalletRepository,
mobileWalletPromoRepository = mobileWalletPromoRepository,
clearAppsFlyerDeeplinkUseCase = clearAppsFlyerDeeplinkUseCase,
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.PreferencesKeys
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.UserWalletTransformAction
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.hot.HotWalletAccessCodeAttemptsRepository
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.model.HotWalletId
import com.tangem.sdk.api.TangemSdkManager
@ -60,7 +61,7 @@ internal class DefaultUserWalletsListRepository(
private val trackingContextProxy: TrackingContextProxy,
private val analyticsEventHandler: AnalyticsEventHandler,
private val hotWalletRepository: HotWalletRepository,
private val mobileWalletPromoRepository: MobileWalletPromoRepository,
private val clearAppsFlyerDeeplinkUseCase: ClearAppsFlyerDeeplinkUseCase,
private val userWalletSelectedHandler: Lazy<UserWalletSelectedHandler>,
) : UserWalletsListRepository {
@ -620,14 +621,13 @@ internal class DefaultUserWalletsListRepository(
}
private suspend fun onFirstWalletCreated() {
// reset flag (that is set from AF deeplink) after creating a new wallet
mobileWalletPromoRepository.setShouldShowMobileWalletPromo(false)
// reset the referral attribution (set from AF deeplink) after creating a new wallet
clearAppsFlyerDeeplinkUseCase(AppsFlyerDeeplinkSource.Referral)
}
private suspend fun onAllWalletsDeleted() {
// reset flag (that is set from AF deeplink) after removing the last wallet
mobileWalletPromoRepository.setShouldShowMobileWalletPromo(false)
// wipe the Usedesk support-chat clientId so a fresh UUID is generated for the next wallet
// reset the referral attribution (set from AF deeplink) after removing the last wallet
clearAppsFlyerDeeplinkUseCase(AppsFlyerDeeplinkSource.Referral)
appPreferencesStore.editData { it.remove(PreferencesKeys.USEDESK_CLIENT_ID_KEY) }
}
}

View file

@ -166,4 +166,5 @@ internal val Blockchain.moonPaySupportedCurrency: MoonPaySupportedCurrency?
Adi, AdiTestnet -> null
SeiEvm, SeiEvmTestnet -> 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.NeverToInitiallyAskPermissionUseCase
import com.tangem.domain.settings.ShouldInitiallyAskPermissionUseCase
import com.tangem.feature.referral.domain.ShouldShowMobileWalletPromoUseCase
import com.tangem.features.hotwallet.HotAccessCodeRequestComponent
import com.tangem.features.hotwallet.accesscoderequest.proxy.HotWalletPasswordRequesterProxy
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.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.time.Duration.Companion.seconds
@ -102,7 +103,6 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
private val shouldInitiallyAskPermissionUseCase: ShouldInitiallyAskPermissionUseCase,
private val neverRequestPermissionUseCase: NeverRequestPermissionUseCase,
private val featureTogglesManager: FeatureTogglesManager,
private val shouldShowMobileWalletPromoUseCase: ShouldShowMobileWalletPromoUseCase,
) : RoutingComponent,
AppComponentContext by context,
SnackbarHandler {
@ -206,23 +206,8 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
}
private suspend fun navigateForEmptyWallets(): AppRoute {
val isHotWalletOnboardingEnabled = featureTogglesManager.isFeatureEnabled(
FeatureToggles.AND_15101_TANGEM_PAY_HOT_WALLET_ONBOARDING,
)
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 afterEmptyRoute = resolveAppsFlyerOnboardingRoute()
?: AppRoute.Home(launchMode = launchMode)
val shouldAskPushPermission = shouldInitiallyAskPermissionUseCase(PUSH_PERMISSION).getOrNull()
?: return afterEmptyRoute
@ -242,16 +227,41 @@ internal class DefaultRoutingComponent @AssistedInject constructor(
}
}
private suspend fun getDefaultRoute(): AppRoute {
val isHideStoriesForReferralEnabled = featureTogglesManager.isFeatureEnabled(
private suspend fun resolveAppsFlyerOnboardingRoute(): AppRoute? = coroutineScope {
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,
)
// Referral users skip the Home stories screen and land directly on the
// mobile wallet creation flow.
return if (isHideStoriesForReferralEnabled && shouldShowMobileWalletPromoUseCase()) {
if (!isEnabled) return null
val referralDeepLink = awaitAppsFlyerDeeplink(AppsFlyerDeeplinkSource.Referral)
return if (referralDeepLink != null) {
AppRoute.CreateWalletStart(mode = AppRoute.CreateWalletStart.Mode.HotWallet)
} 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.AppsFlyerStore
import com.tangem.domain.wallets.models.AppsFlyerConversionData
import com.tangem.feature.referral.domain.SetShouldShowMobileWalletPromoUseCase
import com.tangem.test.core.ProvideTestModels
import arrow.core.right
import com.tangem.test.core.TestAppCoroutineScope
import io.mockk.clearMocks
import io.mockk.coEvery
@ -28,13 +26,9 @@ import org.junit.jupiter.params.ParameterizedTest
class AppsFlyerReferralParamsHandlerTest {
private val appsFlyerStore: AppsFlyerStore = mockk(relaxUnitFun = true)
private val setShouldShowMobileWalletPromoUseCase: SetShouldShowMobileWalletPromoUseCase = mockk {
coEvery { this@mockk.invoke(true) } returns Unit.right()
}
private val handler = AppsFlyerReferralParamsHandler(
appsFlyerStore = appsFlyerStore,
coroutineScope = TestAppCoroutineScope(),
setShouldShowMobileWalletPromoUseCase = setShouldShowMobileWalletPromoUseCase,
)
@AfterEach
@ -175,7 +169,6 @@ class AppsFlyerReferralParamsHandlerTest {
private val localHandler = AppsFlyerReferralParamsHandler(
appsFlyerStore = localStore,
coroutineScope = TestAppCoroutineScope(),
setShouldShowMobileWalletPromoUseCase = mockk { coEvery { this@mockk.invoke(true) } returns Unit.right() },
)
@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 {
const val SUCCESS_REFCODE = "valid_refcode"
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.utils.TrackingContextProxy
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.hotwallet.repository.HotWalletRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.hot.HotWalletAccessCodeAttemptsRepository
import com.tangem.domain.wallets.hot.HotWalletPasswordRequester
import com.tangem.feature.referral.domain.MobileWalletPromoRepository
import com.tangem.hot.sdk.TangemHotSdk
import com.tangem.sdk.api.TangemSdkManager
import com.tangem.utils.Provider
@ -45,7 +45,7 @@ internal class DefaultUserWalletsListRepositoryTest {
private val trackingContextProxy: TrackingContextProxy = mockk(relaxed = true)
private val analyticsEventHandler: AnalyticsEventHandler = 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 walletA = MockUserWalletFactory.create().copy(walletId = UserWalletId("0011"), name = "Wallet A")
@ -61,7 +61,7 @@ internal class DefaultUserWalletsListRepositoryTest {
selectedUserWalletRepository,
userWalletEncryptionKeysRepository,
trackingContextProxy,
mobileWalletPromoRepository,
clearAppsFlyerDeeplinkUseCase,
userWalletSelectedHandler,
)
@ -82,7 +82,7 @@ internal class DefaultUserWalletsListRepositoryTest {
trackingContextProxy = trackingContextProxy,
analyticsEventHandler = analyticsEventHandler,
hotWalletRepository = hotWalletRepository,
mobileWalletPromoRepository = mobileWalletPromoRepository,
clearAppsFlyerDeeplinkUseCase = clearAppsFlyerDeeplinkUseCase,
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)
Blockchain.Gnosis,
-> 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.HederaTestnet,
-> 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),
)
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),
subtitle = resourceReference(
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),
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(

View file

@ -120,40 +120,66 @@ object NotificationsFactory {
}
}
@Suppress("LongParameterList")
fun MutableList<NotificationUM>.addReserveAmountErrorNotification(
reserveAmount: BigDecimal?,
sendingAmount: BigDecimal,
cryptoCurrency: CryptoCurrency,
feeCryptoCurrency: CryptoCurrency?,
isAccountFunded: Boolean,
hasRequiredTrustline: Boolean,
) {
val sendingCoinAmount = when (cryptoCurrency) {
is CryptoCurrency.Coin -> sendingAmount
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
return
} else if (!isAccountFunded && reserveAmount != null && reserveAmount > sendingCoinAmount) {
feeCryptoCurrency == null && cryptoCurrency is CryptoCurrency.Token -> Unit
// 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(
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)
add(
is CryptoCurrency.Coin -> add(
NotificationUM.Error.ReserveAmount(
reserveAmount.format {
crypto(feeCryptoCurrency ?: cryptoCurrency)
},
),
)
} 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))
is CryptoCurrency.Token -> {
checkNotNull(feeCryptoCurrency)
add(
NotificationUM.Error.NetworkAccountNotFunded(
coinName = feeCryptoCurrency.name,
reserveAmount = reserveAmount.format {
crypto(symbol = "", decimals = feeCryptoCurrency.decimals)
},
reserveSymbol = feeCryptoCurrency.symbol,
),
)
}
}
// TODO: check the RECEIVER account trustline before sending
}
private fun MutableList<NotificationUM>.addTrustlineRequiredNotification() {
add(NotificationUM.Error.RequiredTrustline)
}
fun MutableList<NotificationUM>.addMinimumAmountErrorNotification(

View file

@ -75,6 +75,7 @@ internal class BlockchainIconsTest {
Blockchain.Filecoin -> R.drawable.img_filecoin_22
Blockchain.Flare, Blockchain.FlareTestnet -> R.drawable.img_flare_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.Hyperliquid, Blockchain.HyperliquidTestnet -> R.drawable.img_hyperliquid_22
Blockchain.InternetComputer -> R.drawable.img_icp_22
@ -198,6 +199,7 @@ internal class BlockchainIconsTest {
Blockchain.Filecoin -> R.drawable.ic_filecoin_22
Blockchain.Flare, Blockchain.FlareTestnet -> R.drawable.ic_flare_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.Hyperliquid, Blockchain.HyperliquidTestnet -> R.drawable.ic_hyperliquid_22
Blockchain.InternetComputer -> R.drawable.ic_icp_22

View file

@ -1,6 +1,7 @@
package com.tangem.datasource.api.ethpool
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.P2PEthPoolTransactionRequest
import com.tangem.datasource.api.ethpool.models.response.*
@ -94,4 +95,20 @@ interface P2PEthPoolApi {
@Path("delegatorAddress") delegatorAddress: String,
@Path("vaultAddress") vaultAddress: String,
): 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")
val message: String, // Human-readable error message
@Json(name = "name")
val name: String, // Error name/type
val name: String?, // Error name/type
@Json(name = "errors")
val errors: List<String>? = null, // Optional validation errors array
)

View file

@ -23,9 +23,11 @@ interface AppsFlyerStore {
enum class AppsFlyerDeeplinkSource {
TangemPayHotWalletOnboarding,
Referral,
;
fun toStoreKey() = when (this) {
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,13 +83,17 @@ fun TangemMessage(
trailingContent = if (isIconLeading) null else icon,
contentColor = contentColor,
onCloseClick = messageUM.onCloseClick,
buttons = {
buttons = if (messageUM.buttonsUM.isEmpty()) {
null
} else {
{
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.TangemThemePreviewRedesign
import com.tangem.core.ui.test.TokenElementsTestTags
import org.burnoutcrew.reorderable.ReorderableLazyListState
/**
* UI model for header row component
*
* @param headerRowUM UI model for the header row
* @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
fun TangemHeaderRow(
headerRowUM: TangemHeaderRowUM,
modifier: Modifier = Modifier,
reorderableState: ReorderableLazyListState? = null,
dragHandleModifier: Modifier = Modifier,
isBalanceHidden: Boolean = false,
) {
TangemHeaderRow(
@ -48,7 +49,7 @@ fun TangemHeaderRow(
title = headerRowUM.title,
subtitle = headerRowUM.subtitle,
isBalanceHidden = isBalanceHidden,
reorderableState = reorderableState,
dragHandleModifier = dragHandleModifier,
modifier = modifier,
)
}
@ -129,7 +130,7 @@ fun TangemHeaderRow(
subtitle: TextReference? = null,
headTangemIconUM: TangemIconUM? = null,
tailUM: TangemRowTailUM = TangemRowTailUM.Empty,
reorderableState: ReorderableLazyListState? = null,
dragHandleModifier: Modifier = Modifier,
isEnabled: Boolean = false,
onItemClick: (() -> Unit)? = null,
) {
@ -181,7 +182,7 @@ fun TangemHeaderRow(
SpacerWMax()
TangemRowTail(
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.res.TangemTheme
import com.tangem.core.ui.test.OrganizeTokensScreenTestTags
import org.burnoutcrew.reorderable.ReorderableLazyListState
import org.burnoutcrew.reorderable.detectReorder
@Composable
fun TangemRowTail(
tangemRowTailUM: TangemRowTailUM,
modifier: Modifier = Modifier,
reorderableState: ReorderableLazyListState? = null,
dragHandleModifier: Modifier = Modifier,
) {
AnimatedContent(
targetState = tangemRowTailUM,
@ -39,7 +37,7 @@ fun TangemRowTail(
TangemRowTailUM.Empty -> Unit
is TangemRowTailUM.Draggable -> DraggableImage(
iconRes = animatedState.iconRes,
reorderableState = reorderableState,
dragHandleModifier = dragHandleModifier,
modifier = innerModifier,
)
is TangemRowTailUM.Text -> ContentText(text = animatedState.text, modifier = innerModifier)
@ -54,21 +52,11 @@ fun TangemRowTail(
}
@Composable
private fun DraggableImage(
@DrawableRes iconRes: Int,
reorderableState: ReorderableLazyListState?,
modifier: Modifier = Modifier,
) {
private fun DraggableImage(@DrawableRes iconRes: Int, dragHandleModifier: Modifier, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.size(size = TangemTheme.dimens2.x6)
.then(
other = if (reorderableState != null) {
Modifier.detectReorder(reorderableState)
} else {
Modifier
},
)
.then(dragHandleModifier)
.testTag(OrganizeTokensScreenTestTags.DRAGGABLE_IMAGE),
contentAlignment = Alignment.Center,
) {

View file

@ -21,7 +21,6 @@ import com.tangem.core.ui.extensions.clickableSingle
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import com.tangem.core.ui.test.TokenElementsTestTags
import org.burnoutcrew.reorderable.ReorderableLazyListState
/**
* Composable function that represents a Tangem token row in a list.
@ -30,15 +29,16 @@ import org.burnoutcrew.reorderable.ReorderableLazyListState
*
* @param tokenRowUM The user model containing the data for the token row.
* @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
* drag-handle modifier). Defaults to [Modifier] for non-reorderable rows.
* @param modifier The modifier to be applied to the row.
*/
@Composable
fun TangemTokenRow(
tokenRowUM: TangemTokenRowUM,
isBalanceHidden: Boolean,
reorderableState: ReorderableLazyListState?,
modifier: Modifier = Modifier,
dragHandleModifier: Modifier = Modifier,
) {
TangemRowContainer(
content = {
@ -91,7 +91,7 @@ fun TangemTokenRow(
TangemRowTail(
tangemRowTailUM = tokenRowUM.tailUM,
reorderableState = reorderableState,
dragHandleModifier = dragHandleModifier,
modifier = Modifier
.layoutId(layoutId = TangemRowLayoutId.TAIL)
.testTag(tag = TokenElementsTestTags.TOKEN_NON_FIAT_BLOCK),
@ -119,15 +119,16 @@ fun TangemTokenRow(
* @param headComponent The composable function representing the head component.
* @param titleComponent The composable function representing the title component.
* @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
* drag-handle modifier). Defaults to [Modifier] for non-reorderable rows.
* @param modifier The modifier to be applied to the row.
*/
@Composable
fun TangemTokenRow(
tokenRowUM: TangemTokenRowUM,
isBalanceHidden: Boolean,
reorderableState: ReorderableLazyListState?,
modifier: Modifier = Modifier,
dragHandleModifier: Modifier = Modifier,
headComponent: @Composable (Modifier) -> Unit,
titleComponent: @Composable (Modifier) -> Unit,
) {
@ -190,7 +191,7 @@ fun TangemTokenRow(
TangemRowTail(
tangemRowTailUM = tokenRowUM.tailUM,
reorderableState = reorderableState,
dragHandleModifier = dragHandleModifier,
modifier = Modifier
.layoutId(layoutId = TangemRowLayoutId.TAIL)
.testTag(tag = TokenElementsTestTags.TOKEN_NON_FIAT_BLOCK),
@ -210,7 +211,6 @@ private fun TangemTokenRow_Preview(
TangemTokenRow(
tokenRowUM = tokenRowUM,
isBalanceHidden = false,
reorderableState = null,
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.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.
* 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 {

View file

@ -4,9 +4,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.ui.Modifier
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.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.test.TopNavigationTestTags
@ -16,7 +17,8 @@ fun TangemButton.Back(modifier: Modifier = Modifier, onClick: () -> Unit) {
TangemButton(
modifier = modifier.testTag(TopNavigationTestTags.BACK_BUTTON),
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,
)
}
@ -27,7 +29,26 @@ fun TangemButton.Close(modifier: Modifier = Modifier, onClick: () -> Unit) {
TangemButton(
modifier = modifier,
variant = TangemButton.Variant.Material,
size = TangemButton.Size.X11,
iconStart = TangemIconUM.Icon(Icons.ic_cross_20),
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
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.*
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
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.foundation.layout.*
import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.TangemIconUM
import com.tangem.core.ui.ds2.loader.TangemLoader
import com.tangem.core.ui.extensions.ColorReference2
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.extensions.*
import com.tangem.core.ui.res.TangemTheme
/**
@ -168,6 +150,10 @@ private fun ContentRow(
maxLines = 1,
softWrap = false,
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(
modifier = Modifier.padding(end = 8.dp),
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,
)
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.shape.RoundedCornerShape
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.platform.LocalDensity
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.unit.Dp
import androidx.compose.ui.unit.dp
@ -24,25 +25,17 @@ import kotlin.math.cos
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)
*
* 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.
*/
@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 progress = LocalTangemShimmerProgress.current ?: rememberShimmerProgressInstance()
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
* typography preset selected by [style], plus the preset's vertical padding (top + bottom).
* Text-line shimmer placeholder, sized and styled after the typography line described by [style].
*
* @param text Text used to determine the shimmer's size. Not drawn.
* @param style Typography preset drives both the measurement style and the vertical padding.
* @param radius Corner radius of the rectangle.
* [Figma](https://www.figma.com/design/AsnJ5CPHib4Qxw12gszjMS/%F0%9F%92%A0-DS-Components?node-id=3398-625&p=f&m=dev)
*
* @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 textAlign Horizontal position of the block within the parent width.
*/
@Composable
fun TextShimmer(text: String, style: TextShimmerStyle, radius: Dp, modifier: Modifier = Modifier) {
val textStyle = style.toTextStyle()
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
val (widthDp, heightDp) = remember(text, textStyle, measurer, density) {
val measured = measurer.measure(text = text, style = textStyle)
with(density) { measured.size.width.toDp() to measured.size.height.toDp() }
fun TangemShimmer(style: TextStyle, modifier: Modifier = Modifier, textAlign: TextAlign = TextAlign.Start) {
val preset = TangemShimmer.TextPreset.forStyle(style)
val lineHeightDp = with(LocalDensity.current) { style.lineHeight.toDp() }
val alignment = when (textAlign) {
TextAlign.Center -> Alignment.Center
TextAlign.End -> Alignment.CenterEnd
else -> Alignment.CenterStart
}
RectangleShimmer(
modifier = modifier.size(
width = widthDp,
height = heightDp + style.verticalPadding * 2,
),
radius = radius,
Box(
modifier = modifier.fillMaxWidth(),
contentAlignment = alignment,
) {
TangemShimmer(
modifier = Modifier
.fillMaxWidth(preset.widthFraction)
.height(lineHeightDp)
.padding(vertical = preset.verticalPadding),
radius = preset.radius,
)
}
/**
* Typography preset for [TextShimmer]. Each preset maps to a [TangemTheme.typography3] style
* and contributes additional [verticalPadding] applied to both top and bottom the shimmer
* 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),
}
/** 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
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
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
}
}
}
}
}
/**
* 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).
* Wraps [content] so every [TangemShimmer] inside shares a single, in-phase animation driver
* use it around lists of shimmers. Safe to nest; safe to omit.
*/
@Composable
fun ProvideTangemShimmer(content: @Composable () -> Unit) {
@ -198,24 +204,16 @@ private fun TangemShimmerPreview() {
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
RectangleShimmer(
TangemShimmer(
modifier = Modifier.size(width = 200.dp, height = 24.dp),
radius = 6.dp,
)
RectangleShimmer(
TangemShimmer(
modifier = Modifier.size(width = 120.dp, height = 16.dp),
radius = 4.dp,
)
TextShimmer(
text = "Account balance",
style = TextShimmerStyle.BODY,
radius = 4.dp,
)
TextShimmer(
text = "$12,345.67",
style = TextShimmerStyle.HEADING_MEDIUM,
radius = 6.dp,
)
TangemShimmer(style = TangemTheme.typography3.body.medium)
TangemShimmer(style = TangemTheme.typography3.heading.medium)
}
}
}

View file

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

View file

@ -48,7 +48,7 @@ fun TangemNavigationText(
modifier = modifier,
color = navigationTextColor(role),
style = navigationTextStyle(role),
textAlign = TextAlign.Center,
textAlign = TextAlign.Start,
maxLines = maxLines,
overflow = overflow,
)
@ -73,7 +73,7 @@ fun TangemNavigationText(
modifier = modifier,
color = navigationTextColor(role),
style = navigationTextStyle(role),
textAlign = TextAlign.Center,
textAlign = TextAlign.Start,
maxLines = maxLines,
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 startButton Leading slot. Typically a back button (see [TangemButton.Back]).
* @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.
*/
@Suppress("LongMethod")
@ -88,7 +88,6 @@ fun TangemTopNavigation(
blur = blurBackground,
)
val groupSpacing = 8.dp
Layout(
modifier = Modifier
.fillMaxWidth()
@ -108,7 +107,7 @@ fun TangemTopNavigation(
Column(
modifier = Modifier
.padding(horizontal = 12.dp)
.padding(start = if (startButton != null) 12.dp else 0.dp, end = 12.dp)
.layoutId(SlotId.Content),
horizontalAlignment = when (contentAlign) {
TangemTopNavigation.ContentAlign.Start -> Alignment.Start
@ -148,7 +147,7 @@ fun TangemTopNavigation(
}
},
) { measurables, constraints ->
val groupSpacingPx = groupSpacing.roundToPx()
val groupSpacingPx = 8.dp.roundToPx()
val totalWidth = constraints.maxWidth
val startM = measurables.first { it.layoutId == SlotId.Start }
@ -161,13 +160,15 @@ fun TangemTopNavigation(
val endP = endM.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) {
// Symmetric band so the content can be visually centered within `totalWidth`
// without colliding with the start/end slots.
TangemTopNavigation.ContentAlign.Center ->
(totalWidth - 2 * maxOf(startP.width, endP.width)).coerceAtLeast(0)
(totalWidth - 2 * maxOf(startP.width, trailingWidth)).coerceAtLeast(0)
TangemTopNavigation.ContentAlign.Start ->
(totalWidth - startP.width - endP.width).coerceAtLeast(0)
(totalWidth - startP.width - trailingWidth).coerceAtLeast(0)
}
val contentP = contentM.measure(slotConstraints.copy(maxWidth = contentMaxWidth))
@ -183,15 +184,14 @@ fun TangemTopNavigation(
((totalWidth - contentP.width) / 2)
.coerceIn(
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))
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 - groupSpacingPx - groupP.width)
val groupX = (totalWidth - endP.width - endGap - groupP.width)
.coerceAtLeast(0)
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.runtime.*
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.darkColors3
import com.tangem.core.ui.res.generated.lightColors3
@ -48,6 +49,7 @@ fun TangemThemeRedesign(content: @Composable () -> Unit) {
CompositionLocalProvider(
LocalTextSelectionColors provides TangemTextSelectionColors2,
) {
ProvideTangemShimmer {
ProvideHaze {
content()
}
@ -55,6 +57,7 @@ fun TangemThemeRedesign(content: @Composable () -> Unit) {
}
}
}
}
private val TangemTextSelectionColors2: TextSelectionColors
@Composable

View file

@ -3,4 +3,5 @@ package com.tangem.core.ui.test
object BuyTokenScreenTestTags {
const val LAZY_LIST = "BUY_TOKEN_SCREEN_LAZY_LIST"
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 VERSION_NAME = "DETAILS_SCREEN_VERSION_NAME"
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"
android:width="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"
android:width="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:width="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:width="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"
android:width="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:width="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">
<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"
android:width="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"
xmlns:aapt="http://schemas.android.com/aapt"
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"
android:width="22dp"
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"
android:width="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.domain.models.wallet.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.withContext
@ -143,6 +144,11 @@ internal class DefaultWalletAccountsFetcher @Inject constructor(
apiResponse.bind().enrichByAccountId()
},
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)) {
throw error
}

View file

@ -59,6 +59,8 @@ internal class FetchWalletAccountsErrorHandler @Inject constructor(
storeWalletAccounts: suspend (UserWalletId, GetWalletAccountsResponse) -> Unit,
): FetchResult {
val isResponseUpToDate = error.isNetworkError(code = Code.NOT_MODIFIED)
val isNotFoundError = error.isNetworkError(code = Code.NOT_FOUND)
if (isResponseUpToDate) {
TangemLogger.e("ETag is up to date, no need to update accounts for wallet: $userWalletId")
val response = requireNotNull(savedAccountsResponse) {
@ -71,13 +73,16 @@ internal class FetchWalletAccountsErrorHandler @Inject constructor(
val response = savedAccountsResponse ?: createDefaultResponse(userWalletId)
val (accountDTOs, userTokensResponse) = response.accounts to response.toUserTokensResponse()
val isNotFoundError = error.isNetworkError(code = Code.NOT_FOUND)
if (isNotFoundError) {
val eTag = createWallet(userWalletId)
if (eTag != null) {
pushWalletAccounts(accountDTOs, eTag)
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? {
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()
} else {
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,
) : AppsFlyerRepository {
override suspend fun getDeeplink(source: AppsFlyerDeeplinkSource): String? {
return appsFlyerStore.getDeeplink(source.toStoreSource())
}
override suspend fun clearDeeplink(source: AppsFlyerDeeplinkSource) {
appsFlyerStore.clearDeeplink(source.toStoreSource())
}
private fun AppsFlyerDeeplinkSource.toStoreSource() = when (this) {
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.domain.appsflyer.repository.AppsFlyerRepository
import com.tangem.domain.appsflyer.usecase.ClearAppsFlyerDeeplinkUseCase
import com.tangem.domain.appsflyer.usecase.IsReferralInstallUseCase
import dagger.Binds
import dagger.Module
import dagger.Provides
@ -26,5 +27,10 @@ internal interface AppsFlyerDataModule {
): ClearAppsFlyerDeeplinkUseCase {
return ClearAppsFlyerDeeplinkUseCase(appsFlyerRepository)
}
@Provides
fun provideIsReferralInstallUseCase(appsFlyerRepository: AppsFlyerRepository): IsReferralInstallUseCase {
return IsReferralInstallUseCase(appsFlyerRepository)
}
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.data.common.wallet
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.converters.WalletIdBodyConverter
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.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.withContext
internal class DefaultWalletServerBinder(
@ -19,7 +21,12 @@ internal class DefaultWalletServerBinder(
) : WalletServerBinder {
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)
}
@ -31,6 +38,12 @@ internal class DefaultWalletServerBinder(
tangemTechApi.createWallet(
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.Dogecoin,
Blockchain.Ducatus,
Blockchain.Gonka,
Blockchain.Ethereum, Blockchain.EthereumTestnet,
Blockchain.EthereumClassic, Blockchain.EthereumClassicTestnet,
Blockchain.Fantom, Blockchain.FantomTestnet,

View file

@ -166,5 +166,6 @@ public val Blockchain.mercuryoNetwork: String?
Blockchain.Adi, Blockchain.AdiTestnet -> null
Blockchain.SeiEvm, Blockchain.SeiEvmTestnet -> 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.StakeKitBalancesStore
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.models.response.P2PEthPoolAccountResponse
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.isMultiCurrency
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.multi.MultiStakingBalanceFetcher
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
@ -65,6 +63,11 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor(
private val dispatchers: CoroutineDispatcherProvider,
) : MultiStakingBalanceFetcher {
private val p2pAccountsFetcher = P2PEthPoolAccountsFetcher(
p2pEthPoolApi = p2pEthPoolApi,
dispatchers = dispatchers,
)
override suspend fun invoke(params: MultiStakingBalanceFetcher.Params): Either<Throwable, Unit> {
TangemLogger.i("Start fetching staking balances for params:\n$params")
@ -195,50 +198,7 @@ internal class DefaultMultiStakingBalanceFetcher @Inject constructor(
vaults: List<P2PEthPoolVault>,
addresses: Set<String>,
): Set<P2PEthPoolAccountResponse> {
val responses = mutableSetOf<P2PEthPoolAccountResponse>()
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
return p2pAccountsFetcher.fetchBatch(vaults = vaults, addresses = addresses)
}
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.ApiResponseError
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.models.response.model.YieldBalanceWrapperDTO
import com.tangem.datasource.local.token.P2PEthPoolVaultsStore
import com.tangem.datasource.local.token.StakingYieldsStore
import com.tangem.domain.common.wallets.UserWalletsListRepository
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.test.core.assertEitherLeft
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.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
/**
[REDACTED_AUTHOR]
@ -54,7 +58,14 @@ internal class DefaultMultiStakingBalanceFetcherTest {
@BeforeEach
fun resetMocks() {
clearMocks(userWalletsListRepository, stakingYieldsStore, stakeKitBalancesStore, stakeKitApi)
clearMocks(
userWalletsListRepository,
stakingYieldsStore,
stakeKitBalancesStore,
stakeKitApi,
p2pEthPoolApi,
p2pEthPoolVaultsStore,
)
}
@Test
@ -359,6 +370,88 @@ internal class DefaultMultiStakingBalanceFetcherTest {
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 {
val userWallet = MockUserWalletFactory.create()
val userWalletId = userWallet.walletId
@ -370,5 +463,55 @@ internal class DefaultMultiStakingBalanceFetcherTest {
)
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.XRP -> memo.toLongOrNull()?.let { XrpTransactionBuilder.XrpTransactionExtras(it) }
Blockchain.Cosmos,
Blockchain.Gonka,
Blockchain.Sei,
Blockchain.TerraV1,
Blockchain.TerraV2,

View file

@ -57,7 +57,7 @@ internal class WcSolanaSignAndSendTransactionUseCase @AssistedInject constructor
val formattedHash = getFormattedHash(hash) // uses for flow sendLargeSolanaTransaction
if (context.session.wallet is UserWallet.Cold && isLargeHash(formattedHash)) {
// 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)
.fold(
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.card.IsWalletBackupProblematicUseCase
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
import com.tangem.domain.express.ExpressServiceFetcher
import com.tangem.domain.networks.multi.MultiNetworkStatusFetcher
import com.tangem.domain.networks.multi.MultiNetworkStatusSupplier
import com.tangem.domain.networks.utils.NetworksCleaner
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.staking.StakingIdFactory
import com.tangem.domain.staking.multi.MultiStakingBalanceFetcher
@ -71,10 +71,12 @@ internal object AccountStatusUseCaseModule {
fun provideIsAccountsModeEnabledUseCase(
multiAccountListSupplier: MultiAccountListSupplier,
paymentAccountStatusSupplier: PaymentAccountStatusSupplier,
appCoroutineScope: AppCoroutineScope,
): IsAccountsModeEnabledUseCase {
return IsAccountsModeEnabledUseCase(
multiAccountListSupplier = multiAccountListSupplier,
paymentAccountStatusSupplier = paymentAccountStatusSupplier,
appCoroutineScope = appCoroutineScope,
)
}

View file

@ -84,7 +84,7 @@ class GetAccountCurrencyByAddressUseCase(
params = MultiNetworkStatusProducer.Params(userWalletId = id),
timeMillis = 1000L,
)
?.firstOrNull { it.getAddress() == address }
?.firstOrNull { it.getAddress().equals(address, ignoreCase = true) }
if (networkStatus != null) {
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.models.account.Account
import com.tangem.domain.models.account.PaymentAccountStatusValue
import com.tangem.domain.pay.flow.PaymentAccountStatusProducer
import com.tangem.domain.pay.flow.PaymentAccountStatusSupplier
import com.tangem.utils.coroutines.AppCoroutineScope
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import java.util.concurrent.TimeUnit
/**
* Use case to determine if the accounts mode is enabled.
@ -24,10 +26,23 @@ import kotlinx.coroutines.flow.*
class IsAccountsModeEnabledUseCase(
private val multiAccountListSupplier: MultiAccountListSupplier,
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)
operator fun invoke(): Flow<Boolean> {
private fun createFlow(): Flow<Boolean> {
TangemLogger.i("$TAG: invoke() started")
val cryptoMode = multiAccountListSupplier.invoke()
@ -73,25 +88,14 @@ class IsAccountsModeEnabledUseCase(
TangemLogger.i("$TAG: final combine crypto=$crypto, payment=$payment, result=$isEnabled")
isEnabled
}
.distinctUntilChanged()
}
operator fun invoke(): Flow<Boolean> {
return flow
}
suspend fun invokeSync(): Boolean {
val accountLists = multiAccountListSupplier.getSyncOrNull(Unit).orEmpty()
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
}
return flow.first()
}
private fun AccountList.hasMultipleCryptoPortfolios(): Boolean =
@ -113,7 +117,6 @@ class IsAccountsModeEnabledUseCase(
}
private companion object {
const val PAYMENT_STATUS_SYNC_TIMEOUT_MS = 1_000L
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 {
const val validAddress = "0x1234567890abcdef"
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.PaymentAccountStatusValue
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.flow.PaymentAccountStatusProducer
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.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.last
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Nested
@ -27,11 +29,6 @@ class IsAccountsModeEnabledUseCaseTest {
private val multiAccountListSupplier: MultiAccountListSupplier = mockk()
private val paymentAccountStatusSupplier: PaymentAccountStatusSupplier = mockk()
private val useCase = IsAccountsModeEnabledUseCase(
multiAccountListSupplier = multiAccountListSupplier,
paymentAccountStatusSupplier = paymentAccountStatusSupplier,
)
@AfterEach
fun tearDown() {
clearMocks(multiAccountListSupplier, paymentAccountStatusSupplier)
@ -43,9 +40,9 @@ class IsAccountsModeEnabledUseCaseTest {
@Test
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()
}
@ -53,9 +50,9 @@ class IsAccountsModeEnabledUseCaseTest {
@Test
fun `returns false when single crypto portfolio account`() = runTest {
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()
}
@ -63,9 +60,9 @@ class IsAccountsModeEnabledUseCaseTest {
@Test
fun `returns true when two crypto portfolio accounts`() = runTest {
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()
}
@ -73,10 +70,10 @@ class IsAccountsModeEnabledUseCaseTest {
@Test
fun `returns true when payment account is Loaded`() = runTest {
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>())
val actual = useCase.invoke().last()
val actual = settledValue(createUseCase())
Truth.assertThat(actual).isTrue()
}
@ -84,10 +81,10 @@ class IsAccountsModeEnabledUseCaseTest {
@Test
fun `returns false when payment account is NotCreated`() = runTest {
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)
val actual = useCase.invoke().last()
val actual = settledValue(createUseCase())
Truth.assertThat(actual).isFalse()
}
@ -95,10 +92,10 @@ class IsAccountsModeEnabledUseCaseTest {
@Test
fun `returns false when payment account is Empty`() = runTest {
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)
val actual = useCase.invoke().last()
val actual = settledValue(createUseCase())
Truth.assertThat(actual).isFalse()
}
@ -106,10 +103,10 @@ class IsAccountsModeEnabledUseCaseTest {
@Test
fun `returns true when payment account is UnderReview`() = runTest {
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>())
val actual = useCase.invoke().last()
val actual = settledValue(createUseCase())
Truth.assertThat(actual).isTrue()
}
@ -117,10 +114,10 @@ class IsAccountsModeEnabledUseCaseTest {
@Test
fun `returns true when payment account is IssuingCard`() = runTest {
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>())
val actual = useCase.invoke().last()
val actual = settledValue(createUseCase())
Truth.assertThat(actual).isTrue()
}
@ -128,10 +125,10 @@ class IsAccountsModeEnabledUseCaseTest {
@Test
fun `returns true when payment account is Loading`() = runTest {
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)
val actual = useCase.invoke().last()
val actual = settledValue(createUseCase())
Truth.assertThat(actual).isTrue()
}
@ -139,10 +136,10 @@ class IsAccountsModeEnabledUseCaseTest {
@Test
fun `returns true when payment account is Deactivated`() = runTest {
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>())
val actual = useCase.invoke().last()
val actual = settledValue(createUseCase())
Truth.assertThat(actual).isTrue()
}
@ -151,9 +148,9 @@ class IsAccountsModeEnabledUseCaseTest {
fun `returns true when multiple wallets and one has two crypto portfolios`() = runTest {
val list1 = createAccountList(WALLET_ID_1, listOf(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()
}
@ -162,10 +159,10 @@ class IsAccountsModeEnabledUseCaseTest {
fun `returns true when multiple wallets and one has active payment`() = runTest {
val list1 = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio()))
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>())
val actual = useCase.invoke().last()
val actual = settledValue(createUseCase())
Truth.assertThat(actual).isTrue()
}
@ -176,29 +173,9 @@ class IsAccountsModeEnabledUseCaseTest {
inner class InvokeSync {
@Test
fun `returns false when getSyncOrNull returns null`() = runTest {
coEvery { multiAccountListSupplier.getSyncOrNull(Unit, any()) } returns null
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()
fun `returns false when supplier emits empty list`() = runTest {
every { multiAccountListSupplier.invoke() } returns MutableStateFlow(emptyList())
val actual = invokeSyncSettled(createUseCase())
Truth.assertThat(actual).isFalse()
}
@ -206,9 +183,8 @@ class IsAccountsModeEnabledUseCaseTest {
@Test
fun `returns true when two crypto portfolio accounts`() = runTest {
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockCryptoPortfolio()))
coEvery { multiAccountListSupplier.getSyncOrNull(Unit, any()) } returns listOf(list)
val actual = useCase.invokeSync()
every { multiAccountListSupplier.invoke() } returns MutableStateFlow(listOf(list))
val actual = invokeSyncSettled(createUseCase())
Truth.assertThat(actual).isTrue()
}
@ -216,69 +192,48 @@ class IsAccountsModeEnabledUseCaseTest {
@Test
fun `returns true when payment account is Loaded`() = runTest {
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
coEvery { multiAccountListSupplier.getSyncOrNull(Unit, any()) } returns listOf(list)
mockPaymentStatusSync(WALLET_ID_1, mockk<PaymentAccountStatusValue.Loaded>())
val actual = useCase.invokeSync()
every { multiAccountListSupplier.invoke() } returns MutableStateFlow(listOf(list))
mockPaymentStatus(WALLET_ID_1, mockk<PaymentAccountStatusValue.Loaded>())
val actual = invokeSyncSettled(createUseCase())
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
fun `returns false when payment account is Empty`() = runTest {
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
coEvery { multiAccountListSupplier.getSyncOrNull(Unit, any()) } returns listOf(list)
mockPaymentStatusSync(WALLET_ID_1, PaymentAccountStatusValue.Empty)
val actual = useCase.invokeSync()
every { multiAccountListSupplier.invoke() } returns MutableStateFlow(listOf(list))
mockPaymentStatus(WALLET_ID_1, PaymentAccountStatusValue.Empty)
val actual = invokeSyncSettled(createUseCase())
Truth.assertThat(actual).isFalse()
}
@Test
fun `returns true when payment account is UnderReview`() = runTest {
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
coEvery { multiAccountListSupplier.getSyncOrNull(Unit, any()) } returns listOf(list)
mockPaymentStatusSync(WALLET_ID_1, mockk<PaymentAccountStatusValue.UnderReview>())
val actual = useCase.invokeSync()
Truth.assertThat(actual).isTrue()
}
@Test
fun `returns true when payment account is Deactivated`() = runTest {
val list = createAccountList(WALLET_ID_1, listOf(mockCryptoPortfolio(), mockPaymentAccount()))
coEvery { multiAccountListSupplier.getSyncOrNull(Unit, any()) } returns listOf(list)
mockPaymentStatusSync(WALLET_ID_1, mockk<PaymentAccountStatusValue.Deactivated>())
@OptIn(ExperimentalCoroutinesApi::class)
private fun TestScope.createUseCase(): IsAccountsModeEnabledUseCase = IsAccountsModeEnabledUseCase(
multiAccountListSupplier = multiAccountListSupplier,
paymentAccountStatusSupplier = paymentAccountStatusSupplier,
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()
}
@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()
}
/**
* 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.
*/
private suspend fun TestScope.invokeSyncSettled(useCase: IsAccountsModeEnabledUseCase): Boolean {
getEmittedValues(useCase.invoke())
return useCase.invokeSync()
}
private fun mockCryptoPortfolio(): Account.CryptoPortfolio = mockk()
@ -287,14 +242,7 @@ class IsAccountsModeEnabledUseCaseTest {
private fun mockPaymentStatus(walletId: UserWalletId, value: PaymentAccountStatusValue) {
val status = mockk<AccountStatus.Payment> { every { this@mockk.value } returns value }
every { paymentAccountStatusSupplier.invoke(walletId) } returns flowOf(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
every { paymentAccountStatusSupplier.invoke(walletId) } returns MutableStateFlow(status)
}
private fun createAccountList(walletId: UserWalletId, accounts: List<Account>): AccountList = mockk {

View file

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

View file

@ -4,5 +4,7 @@ import com.tangem.domain.appsflyer.AppsFlyerDeeplinkSource
interface AppsFlyerRepository {
suspend fun getDeeplink(source: AppsFlyerDeeplinkSource): String?
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 isSingleCurrency(): Boolean
fun isMultiwalletAllowed(): Boolean
fun getBlockchain(): Blockchain

View file

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

View file

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

View file

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

View file

@ -1,5 +1,6 @@
package com.tangem.domain.tokens.actions
import com.tangem.domain.card.common.util.cardTypesResolver
import com.tangem.domain.exchange.RampStateManager
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
@ -65,6 +66,12 @@ internal open class BaseActionsFactory(
currency: CryptoCurrency,
requirementsDeferred: Deferred<AssetRequirementsCondition?>?,
): 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(
userWallet = userWallet,
cryptoCurrency = currency,

View file

@ -10,11 +10,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
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.topbar.TangemTopBar
import com.tangem.core.ui.ds2.button.TangemButton
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.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -218,9 +218,13 @@ internal class DefaultFeedEntryComponent @AssistedInject constructor(
private fun onChildBack() {
if (stack.value.active.configuration !is FeedEntryChildFactory.Child.Feed) {
if (stack.value.backStack.isEmpty()) {
router.pop()
} else {
stackNavigation.pop()
}
}
}
private fun mapEntryRouteToChild(entryRoute: FeedEntryRoute?): FeedEntryChildFactory.Child {
return when (entryRoute) {

View file

@ -1,19 +1,11 @@
package com.tangem.features.feed.components.market.details
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.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
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.collectAsStateWithLifecycle
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.model.getOrCreateModel
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.haze.hazeEffectTangem
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.decompose.ComposableModularBottomSheetContentComponent
import com.tangem.core.ui.ds.topbar.TangemTopBar
import com.tangem.core.ui.ds.topbar.TangemTopBarType
import com.tangem.core.ui.extensions.clickableSingle
import com.tangem.core.ui.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.markets.PreselectedTokenDetailsSection
import com.tangem.domain.markets.TokenMarketParams
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.managefunds.ManageFundsComponent
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.PortfolioBlockParentClickIntents
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.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.MarketsTokenDetailsTopBar
import com.tangem.features.feed.ui.market.detailed.MarketsTokenDetailsTitle
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
@ -92,7 +76,7 @@ internal class DefaultMarketsTokenDetailsComponent(
}
private val portfolioBlockComponent: PortfolioBlockComponent? =
if (updatedParams.shouldShowPortfolio && designFeatureToggles.isRedesignEnabled) {
if (designFeatureToggles.isRedesignEnabled) {
portfolioBlockComponentFactory.create(
context = child("portfolio_block"),
params = PortfolioBlockComponent.Params(token = updatedParams.token),
@ -187,59 +171,13 @@ internal class DefaultMarketsTokenDetailsComponent(
@Composable
override fun Title(bottomSheetState: State<BottomSheetState>) {
val state by model.state.collectAsStateWithLifecycle()
val background = LocalMainBottomSheetColor.current.value
if (LocalRedesignEnabled.current) {
TangemTopBar(
startContent = {
Icon(
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() },
MarketsTokenDetailsTitle(
state = state,
backgroundColor = LocalMainBottomSheetColor.current.value,
isBackButtonEnabled = bottomSheetState.value == BottomSheetState.EXPANDED,
shouldShowPriceSubtitle = state.shouldShowPriceSubtitle,
tokenName = state.tokenName,
tokenPrice = state.priceText,
backgroundColor = background,
onShareClick = state.onShareClick,
onBackClick = { params.onBackClicked() },
)
}
}
@Composable
override fun Content(

View file

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

View file

@ -43,12 +43,11 @@ import com.tangem.features.feed.impl.R
@Composable
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() }
Box(modifier = modifier) {
AnimatedVisibility(
visible = isVisible,
visible = state !is PortfolioBlockUM.Hidden,
enter = fadeIn(animationSpec = tween(durationMillis = 300)),
) {
TangemFade(
@ -63,7 +62,7 @@ internal fun PortfolioBlock(state: PortfolioBlockUM, modifier: Modifier = Modifi
AnimatedVisibility(
modifier = Modifier.align(Alignment.BottomCenter),
visible = isVisible,
visible = state !is PortfolioBlockUM.Hidden,
enter = fadeIn(animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing)),
exit = fadeOut(animationSpec = tween(durationMillis = 300)),
) {
@ -87,9 +86,8 @@ internal fun PortfolioBlock(state: PortfolioBlockUM, modifier: Modifier = Modifi
when (state) {
is PortfolioBlockUM.AddToken -> AddTokenBlock(state)
is PortfolioBlockUM.Content -> ContentBlock(state)
is PortfolioBlockUM.Hidden,
is PortfolioBlockUM.Loading,
-> Unit
is PortfolioBlockUM.Unsupported -> UnsupportedTokenBlock(state.tokenIcon)
is PortfolioBlockUM.Hidden -> Unit
}
}
}
@ -158,13 +156,19 @@ private fun AddTokenBlock(state: PortfolioBlockUM.AddToken, modifier: Modifier =
FloatingCard(modifier = modifier) {
Row(
modifier = Modifier
.padding(TangemTheme.dimens2.x3)
.padding(
vertical = 12.dp,
horizontal = 16.dp,
)
.clickableSingle(onClick = state.onAddClick),
verticalAlignment = Alignment.CenterVertically,
) {
CurrencyIcon(state.tokenIcon)
CurrencyIcon(
state = state.tokenIcon,
iconSize = 40.dp,
)
SpacerW(TangemTheme.dimens2.x3)
SpacerW(8.dp)
Text(
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)
@Composable
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
@Immutable
internal sealed class PortfolioBlockUM {
internal sealed interface PortfolioBlockUM {
data object Loading : PortfolioBlockUM()
data object Hidden : PortfolioBlockUM()
data object Hidden : PortfolioBlockUM
data class AddToken(
val tokenIcon: CurrencyIconState,
val onAddClick: () -> Unit,
) : PortfolioBlockUM()
) : PortfolioBlockUM
data class Content(
val totalBalance: TextReference,
@ -24,5 +23,9 @@ internal sealed class PortfolioBlockUM {
val isBalanceHidden: Boolean,
val onRowClick: () -> 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.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
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.decompose.ComposableModularBottomSheetContentComponent
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.LocalRedesignEnabled
import com.tangem.core.ui.res.TangemTheme
@ -56,11 +58,13 @@ internal class DefaultMarketsTokenListComponent(
val bsState by bottomSheetState
if (LocalRedesignEnabled.current) {
val background = LocalMainBottomSheetColor.current.value
val bottomSheetColor = LocalMainBottomSheetColor.current.value
FeedSearchBar(
isSearchBarClickable = bottomSheetState.value == BottomSheetState.EXPANDED,
feedListSearchBar = state.feedListSearchBar,
modifier = Modifier.background(background.copy(alpha = .95f)),
modifier = Modifier.conditional(bottomSheetColor.isSpecified) {
background(bottomSheetColor.copy(alpha = .95f))
},
startContent = {
Icon(
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 {
return EarnListItemUM(
id = "${value.cryptoCurrency.id.value}_${value.earnToken.type}",
network = TextReference.Str(value.networkName),
symbol = TextReference.Str(value.earnToken.tokenSymbol),
tokenName = TextReference.Str(value.earnToken.tokenName),

View file

@ -289,6 +289,8 @@ internal class MarketsTokenDetailsModel @Inject constructor(
onScroll = {},
),
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()
.onEach { addToPortfolioSheetNavigation.dismiss() }
.launchIn(modelScope)

View file

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

View file

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

View file

@ -36,4 +36,5 @@ internal data class EarnListItemUM(
val earnType: EarnType,
val earnTypeTitle: TextReference,
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,
key = { item -> "${item.tokenName}-${item.network}" },
key = { item -> item.id },
) { item ->
MostlyUsedCard(
item = item,

View file

@ -18,7 +18,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
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.PreviewParameter
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.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.MarketsTestTags
import com.tangem.domain.markets.PriceChangeInterval
import com.tangem.features.feed.impl.R
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),
) {
Text(
modifier = Modifier.weight(weight = 1f, fill = false),
text = state.tokenName,
style = TangemTheme.typography2.bodySemibold16,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
color = TangemTheme.colors2.text.neutral.primary,
autoSize = TextAutoSize.StepBased(
minFontSize = TangemTheme.typography2.captionSemibold12.fontSize,
maxFontSize = TangemTheme.typography2.bodySemibold16.fontSize,
),
)
Text(
maxLines = 1,
text = state.symbol,
style = TangemTheme.typography2.captionMedium12,
color = TangemTheme.colors2.text.neutral.tertiary,
autoSize = TextAutoSize.StepBased(
minFontSize = TangemTheme.typography2.captionRegular11.fontSize,
maxFontSize = TangemTheme.typography2.captionMedium12.fontSize,
),
)
}
SpacerH(TangemTheme.dimens2.x1)

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